From 9c222ef1cec1a857ac7ac62a3877806d640198a5 Mon Sep 17 00:00:00 2001 From: Aleksei Nagovitsyn Date: Sun, 22 Dec 2024 04:38:19 +0500 Subject: [PATCH 1/2] Fix `repl` can be called as a side effect causing infinite recursion --- .../jsrepl/tests/playwright/repl-eval.test.ts | 59 +++++++++++++++++++ packages/preview-entry/src/repl.ts | 23 +++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/packages/jsrepl/tests/playwright/repl-eval.test.ts b/packages/jsrepl/tests/playwright/repl-eval.test.ts index 62f2857..66b86cb 100644 --- a/packages/jsrepl/tests/playwright/repl-eval.test.ts +++ b/packages/jsrepl/tests/playwright/repl-eval.test.ts @@ -336,3 +336,62 @@ test('multiline decor', async ({ page }) => { ` ) }) + +test( + 'repl is not called as a side effect of transformPayloadResult', + { + annotation: { + type: 'issue', + description: 'https://github.com/jsrepl/jsrepl.io/issues/3', + }, + }, + async ({ page }) => { + await visitPlayground(page, { + openedModels: ['/test.ts'], + activeModel: '/test.ts', + showPreview: false, + fs: new ReplFS.FS({ + kind: ReplFS.Kind.Directory, + children: { + 'test.ts': { + kind: ReplFS.Kind.File, + content: dedent` + const obj2 = { + get foo() { + return obj2; + }, + }; + + const obj = { a: 20 }; + + const proxy = new Proxy(obj, { + get(target, p) { + + } + }) + `, + }, + }, + }), + }) + + await assertMonacoContentsWithDecors( + page, + dedent` + const obj2 = { // → obj2 = [ref *1] {foo: [Circular *1]} + get foo() { + return obj2; + }, + }; + + const obj = { a: 20 }; // → obj = {a: 20} + + const proxy = new Proxy(obj, { // → proxy = {a: undefined} + get(target, p) { + + } + }) + ` + ) + } +) diff --git a/packages/preview-entry/src/repl.ts b/packages/preview-entry/src/repl.ts index 4c37923..23e4bc9 100644 --- a/packages/preview-entry/src/repl.ts +++ b/packages/preview-entry/src/repl.ts @@ -10,6 +10,15 @@ import { transformPayloadResult } from './payload' import { postMessage } from './post-message' import type { PreviewWindow } from './types' +// Traversing the object props in `transformPayloadResult` can cause calling `repl` +// again in some cases, which is not desired and may cause infinite recursion which +// is not handled by circular reference prevention mechanism in `transformPayloadResult`. +// Although that mechanism can be extended to the entire event loop stack, these +// side-effects are not the intended behavior anyway: `repl` is intended to be +// called by user-code only, and not during traversing the object props within `transformPayloadResult`. +// See https://github.com/jsrepl/jsrepl.io/issues/3 for the reference. +let skipReplAsSideEffect = false + export function setupRepl(win: PreviewWindow, token: number) { win[identifierNameRepl] = repl.bind({ token, win }) win[identifierNameFunctionMeta] = () => {} @@ -20,8 +29,18 @@ export function setupRepl(win: PreviewWindow, token: number) { } function repl(this: { token: number; win: PreviewWindow }, ctxId: string | number, value: unknown) { - const { token, win } = this - postMessageRepl(token, win, value, false, ctxId) + if (!skipReplAsSideEffect) { + skipReplAsSideEffect = true + try { + const { token, win } = this + postMessageRepl(token, win, value, false, ctxId) + } catch (err) { + console.error('JSRepl Error: repl failed', err) + } finally { + skipReplAsSideEffect = false + } + } + return value } From 9b84e045f2aed8b3a7cc2f7a19332c8d8cc8eac2 Mon Sep 17 00:00:00 2001 From: Aleksei Nagovitsyn Date: Sun, 22 Dec 2024 08:57:06 +0500 Subject: [PATCH 2/2] Support repl decorations for proxy objects --- packages/jsrepl/package.json | 1 + .../lib/bundler/babel/repl-plugin/index.ts | 4 +- .../src/lib/repl-payload/parse-function.ts | 140 ++++++++++++++++++ .../src/lib/repl-payload/payload-utils.ts | 5 + .../src/lib/repl-payload/render-json.ts | 7 + .../lib/repl-payload/render-mock-object.ts | 13 ++ .../jsrepl/src/lib/repl-payload/stringify.ts | 134 ++++------------- .../jsrepl/tests/playwright/repl-eval.test.ts | 57 ++++++- packages/preview-entry/src/payload.test.ts | 5 +- packages/preview-entry/src/payload.ts | 13 ++ packages/preview-entry/src/post-message.ts | 30 ++++ packages/preview-entry/src/repl.ts | 82 +--------- .../preview-entry/src/repl/proxy-proxy.ts | 26 ++++ .../preview-entry/src/repl/window-error.ts | 46 ++++++ packages/preview-entry/src/types.ts | 8 +- packages/shared-types/marshalled.ts | 9 ++ packages/shared-types/repl-meta.ts | 6 + pnpm-lock.yaml | 68 ++++++--- 18 files changed, 447 insertions(+), 207 deletions(-) create mode 100644 packages/jsrepl/src/lib/repl-payload/parse-function.ts create mode 100644 packages/preview-entry/src/repl/proxy-proxy.ts create mode 100644 packages/preview-entry/src/repl/window-error.ts diff --git a/packages/jsrepl/package.json b/packages/jsrepl/package.json index c01b081..2d59418 100644 --- a/packages/jsrepl/package.json +++ b/packages/jsrepl/package.json @@ -56,6 +56,7 @@ }, "devDependencies": { "@babel/parser": "^7.25.6", + "@babel/types": "^7.26.3", "@chromatic-com/storybook": "^1.9.0", "@iconify-json/mdi": "^1.2.0", "@iconify-json/simple-icons": "^1.2.2", diff --git a/packages/jsrepl/src/lib/bundler/babel/repl-plugin/index.ts b/packages/jsrepl/src/lib/bundler/babel/repl-plugin/index.ts index 26abe55..fe6debd 100644 --- a/packages/jsrepl/src/lib/bundler/babel/repl-plugin/index.ts +++ b/packages/jsrepl/src/lib/bundler/babel/repl-plugin/index.ts @@ -336,7 +336,9 @@ export function replPlugin({ types: t }: { types: typeof types }): PluginObj { let fnId = t.isFunctionDeclaration(path.node) || t.isFunctionExpression(path.node) ? path.node.id - : null + : t.isObjectMethod(path.node) && !path.node.computed && t.isIdentifier(path.node.key) + ? path.node.key + : null // Infer function id from variable id if ( diff --git a/packages/jsrepl/src/lib/repl-payload/parse-function.ts b/packages/jsrepl/src/lib/repl-payload/parse-function.ts new file mode 100644 index 0000000..103db72 --- /dev/null +++ b/packages/jsrepl/src/lib/repl-payload/parse-function.ts @@ -0,0 +1,140 @@ +import type Parser from '@babel/parser' +import type { + ArrowFunctionExpression, + FunctionExpression, + ObjectExpression, + ObjectMethod, +} from '@babel/types' +import { identifierNameFunctionMeta } from '@jsrepl/shared-types' +import { getBabel } from '../get-babel' + +// Let babel to parse this madness. +// - function (arg1, arg2) {} +// - async function (arg1, arg2) {} +// - function name(arg1, arg2) {} +// - async function name(arg1, arg2) {} +// - function name(arg1, arg2 = 123, ...args) {} +// - () => {} +// - async () => {} +// - args1 => {} +// - async args1 => {} +// - (args1, args2) => {} +// - async (args1, args2) => {} +// - function ({ jhkhj, asdad = 123 } = {}) {} +// - () => 7 +// - function (asd = adsasd({})) { ... } +// - get() { return 123 } // method, obj property +// - set(value) { this.value = value } // method, obj property +// - foo(value) {} // method, obj property +// - async foo(value) {} // async method, obj property +// - var obj = { foo: async function bar () {} } // async method, obj property, but defined with function keyword and name +export function parseFunction( + str: string, + _isOriginalSource = false +): { + name: string + args: string + isAsync: boolean + isArrow: boolean + isMethod: boolean + origSource: string | null +} | null { + const babel = getBabel()[0].value! + + // @ts-expect-error Babel standalone: https://babeljs.io/docs/babel-standalone#internal-packages + const { parser } = babel.packages as { parser: typeof Parser } + + let ast: ArrowFunctionExpression | FunctionExpression | ObjectMethod + + try { + // ArrowFunctionExpression | FunctionExpression + ast = parser.parseExpression(str) as ArrowFunctionExpression | FunctionExpression + } catch { + try { + // ObjectMethod? + str = `{${str}}` + const objExpr = parser.parseExpression(str) as ObjectExpression + if ( + objExpr.type === 'ObjectExpression' && + objExpr.properties.length === 1 && + objExpr.properties[0]!.type === 'ObjectMethod' + ) { + ast = objExpr.properties[0]! as ObjectMethod + } else { + return null + } + } catch { + return null + } + } + + let origSource: string | null = null + + if (!_isOriginalSource) { + origSource = getFunctionOriginalSource(ast) + if (origSource) { + return parseFunction(origSource, true) + } + } else { + origSource = str + } + + if (ast.type === 'ArrowFunctionExpression') { + return { + name: '', + args: ast.params.map((param) => str.slice(param.start!, param.end!)).join(', '), + isAsync: ast.async, + isArrow: true, + isMethod: false, + origSource, + } + } + + if (ast.type === 'FunctionExpression') { + return { + name: ast.id?.name ?? '', + args: ast.params.map((param) => str.slice(param.start!, param.end!)).join(', '), + isAsync: ast.async, + isArrow: false, + isMethod: false, + origSource, + } + } + + if (ast.type === 'ObjectMethod') { + return { + name: ast.computed ? '' : ast.key.type === 'Identifier' ? ast.key.name : '', + args: ast.params.map((param) => str.slice(param.start!, param.end!)).join(', '), + isAsync: ast.async, + isArrow: false, + isMethod: true, + origSource, + } + } + + return null +} + +function getFunctionOriginalSource( + ast: ArrowFunctionExpression | FunctionExpression | ObjectMethod +): string | null { + if ( + (ast.type === 'ArrowFunctionExpression' || + ast.type === 'FunctionExpression' || + ast.type === 'ObjectMethod') && + ast.body.type === 'BlockStatement' + ) { + const node = ast.body.body[0] + if ( + node?.type === 'ExpressionStatement' && + node.expression.type === 'CallExpression' && + node.expression.callee.type === 'Identifier' && + node.expression.callee.name === identifierNameFunctionMeta && + node.expression.arguments[0]?.type === 'StringLiteral' + ) { + return node.expression.arguments[0].value + } + } + + return null +} diff --git a/packages/jsrepl/src/lib/repl-payload/payload-utils.ts b/packages/jsrepl/src/lib/repl-payload/payload-utils.ts index 2e44944..4789af4 100644 --- a/packages/jsrepl/src/lib/repl-payload/payload-utils.ts +++ b/packages/jsrepl/src/lib/repl-payload/payload-utils.ts @@ -3,6 +3,7 @@ import { MarshalledFunction, MarshalledObject, MarshalledPromise, + MarshalledProxy, MarshalledSymbol, MarshalledType, MarshalledWeakMap, @@ -42,6 +43,10 @@ export function isMarshalledPromise(result: object): result is MarshalledPromise return getMarshalledType(result) === MarshalledType.Promise } +export function isMarshalledProxy(result: object): result is MarshalledProxy { + return getMarshalledType(result) === MarshalledType.Proxy +} + export function getMarshalledType(result: object): MarshalledType | null { return '__meta__' in result && result.__meta__ !== null && diff --git a/packages/jsrepl/src/lib/repl-payload/render-json.ts b/packages/jsrepl/src/lib/repl-payload/render-json.ts index f36e1c4..248cd90 100644 --- a/packages/jsrepl/src/lib/repl-payload/render-json.ts +++ b/packages/jsrepl/src/lib/repl-payload/render-json.ts @@ -47,6 +47,13 @@ function replacer(this: unknown, key: string, value: unknown): unknown { finally: 'function finally() { [native code] }', } + case utils.isMarshalledProxy(value): { + const { target } = value.__meta__ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { __meta__, ...props } = target + return props + } + case utils.isMarshalledObject(value): { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { __meta__, ...props } = value diff --git a/packages/jsrepl/src/lib/repl-payload/render-mock-object.ts b/packages/jsrepl/src/lib/repl-payload/render-mock-object.ts index b0a3e01..863318a 100644 --- a/packages/jsrepl/src/lib/repl-payload/render-mock-object.ts +++ b/packages/jsrepl/src/lib/repl-payload/render-mock-object.ts @@ -34,6 +34,7 @@ function revive( return doc.head.firstChild ?? doc.body.firstChild } + // TODO: support original source, see parse-function.ts case utils.isMarshalledFunction(value): { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type let fn: Function @@ -42,6 +43,11 @@ function revive( fn = new Function(`return ${value.serialized}`)() } catch {} + try { + // Object method? - e.g., get() {}, async foo(value) {} + fn = new Function(`return Object.values({ ${value.serialized} })[0]`)() + } catch {} + try { // Some reserved keywords are not allowed in function names, // for example `catch`, `finally`, `do`, etc. @@ -70,6 +76,13 @@ function revive( case utils.isMarshalledPromise(value): return new Promise(() => {}) + case utils.isMarshalledProxy(value): { + const { target, handler } = value.__meta__ + const revivedTarget = revive(target, context) as object + const revivedHandler = revive(handler, context) as ProxyHandler + return new Proxy(revivedTarget, revivedHandler) + } + case utils.isMarshalledObject(value): { const { __meta__: meta, ...props } = value diff --git a/packages/jsrepl/src/lib/repl-payload/stringify.ts b/packages/jsrepl/src/lib/repl-payload/stringify.ts index 1d92079..55d8776 100644 --- a/packages/jsrepl/src/lib/repl-payload/stringify.ts +++ b/packages/jsrepl/src/lib/repl-payload/stringify.ts @@ -1,9 +1,5 @@ -import { - type MarshalledDomNode, - type ReplPayload, - identifierNameFunctionMeta, -} from '@jsrepl/shared-types' -import { getBabel } from '../get-babel' +import { type MarshalledDomNode, type ReplPayload } from '@jsrepl/shared-types' +import { parseFunction } from './parse-function' import * as utils from './payload-utils' const MAX_NESTING_LEVEL = 20 @@ -57,8 +53,12 @@ function _stringifyResult( return data?.caught ? `[ref *${data.index}] ` : '' } - function next(result: ReplPayload['result'], target: StringifyResultTarget): StringifyResult { - return _stringifyResult(result, target, nestingLevel + 1, refs) + function next( + result: ReplPayload['result'], + target: StringifyResultTarget, + customNestingLevel?: number + ): StringifyResult { + return _stringifyResult(result, target, customNestingLevel ?? nestingLevel + 1, refs) } function t(str: string, relativeIndexLevel: number) { @@ -331,6 +331,29 @@ ${t('', 0)}}` return { value, type: 'promise', lang: 'js' } } + if (utils.isMarshalledProxy(result)) { + const { target: targetObj, handler } = result.__meta__ + let value: string + if (target === 'decor') { + const targetStr = next(targetObj, target, nestingLevel).value + const handlerStr = next(handler, target).value + value = `Proxy(${targetStr}) ${handlerStr}` + } else { + const targetStr = next(targetObj, target).value + const handlerStr = next(handler, target).value + value = `Proxy { +${t('', 1)}[[Target]]: ${targetStr}, +${t('', 1)}[[Handler]]: ${handlerStr} +${t('', 0)}}` + } + + return { + value, + type: 'proxy', + lang: 'js', + } + } + if (utils.isMarshalledObject(result)) { const releaseRef = putRef(result) const { __meta__: meta, ...props } = result @@ -388,101 +411,6 @@ ${t('', 0)}}` } } -// Let babel to parse this madness. -// - function (arg1, arg2) {} -// - async function (arg1, arg2) {} -// - function name(arg1, arg2) {} -// - async function name(arg1, arg2) {} -// - function name(arg1, arg2 = 123, ...args) {} -// - () => {} -// - async () => {} -// - args1 => {} -// - async args1 => {} -// - (args1, args2) => {} -// - async (args1, args2) => {} -// - function ({ jhkhj, asdad = 123 } = {}) {} -// - () => 7 -// - function (asd = adsasd({})) { ... } -function parseFunction( - str: string, - _isOriginalSource = false -): { - name: string - args: string - isAsync: boolean - isArrow: boolean - origSource: string | null -} | null { - const babel = getBabel()[0].value! - - // @ts-expect-error Babel standalone: https://babeljs.io/docs/babel-standalone#internal-packages - const { parser } = babel.packages as { parser: typeof import('@babel/parser') } - - let ast: ReturnType - - try { - // ArrowFunctionExpression | FunctionExpression - ast = parser.parseExpression(str) - } catch { - return null - } - - let origSource: string | null = null - - if (!_isOriginalSource) { - origSource = getFunctionOriginalSource(ast) - if (origSource) { - return parseFunction(origSource, true) - } - } else { - origSource = str - } - - if (ast.type === 'ArrowFunctionExpression') { - return { - name: '', - args: ast.params.map((param) => str.slice(param.start!, param.end!)).join(', '), - isAsync: ast.async, - isArrow: true, - origSource, - } - } - - if (ast.type === 'FunctionExpression') { - return { - name: ast.id?.name ?? '', - args: ast.params.map((param) => str.slice(param.start!, param.end!)).join(', '), - isAsync: ast.async, - isArrow: false, - origSource, - } - } - - return null -} - -function getFunctionOriginalSource( - ast: ReturnType -): string | null { - if ( - (ast.type === 'ArrowFunctionExpression' || ast.type === 'FunctionExpression') && - ast.body.type === 'BlockStatement' - ) { - const node = ast.body.body[0] - if ( - node?.type === 'ExpressionStatement' && - node.expression.type === 'CallExpression' && - node.expression.callee.type === 'Identifier' && - node.expression.callee.name === identifierNameFunctionMeta && - node.expression.arguments[0]?.type === 'StringLiteral' - ) { - return node.expression.arguments[0].value - } - } - - return null -} - function stringifyDomNodeShort(result: MarshalledDomNode): string { const meta = result.__meta__ const idAttr = meta.attributes.find((attr) => attr.name === 'id') diff --git a/packages/jsrepl/tests/playwright/repl-eval.test.ts b/packages/jsrepl/tests/playwright/repl-eval.test.ts index 66b86cb..a7897e0 100644 --- a/packages/jsrepl/tests/playwright/repl-eval.test.ts +++ b/packages/jsrepl/tests/playwright/repl-eval.test.ts @@ -367,7 +367,7 @@ test( const proxy = new Proxy(obj, { get(target, p) { - } + } }) `, }, @@ -386,12 +386,63 @@ test( const obj = { a: 20 }; // → obj = {a: 20} - const proxy = new Proxy(obj, { // → proxy = {a: undefined} + const proxy = new Proxy(obj, { // → proxy = Proxy({a: 20}) {…} get(target, p) { - } + } }) ` ) } ) + +test( + 'proxy decorations support', + { + annotation: { + type: 'issue', + description: 'https://github.com/jsrepl/jsrepl.io/issues/3', + }, + }, + async ({ page }) => { + await visitPlayground(page, { + openedModels: ['/test.ts'], + activeModel: '/test.ts', + showPreview: false, + fs: new ReplFS.FS({ + kind: ReplFS.Kind.Directory, + children: { + 'test.ts': { + kind: ReplFS.Kind.File, + content: dedent` + const obj = { a: 20 }; + const proxy = new Proxy(obj, { + get(target, p) { + return 5; + }, + }); + Proxy; + proxy.a; + proxy; + `, + }, + }, + }), + }) + + await assertMonacoContentsWithDecors( + page, + dedent` + const obj = { a: 20 }; // → obj = {a: 20} + const proxy = new Proxy(obj, { // → proxy = Proxy({a: 20}) {…} + get(target, p) { // → ƒƒ get({…}, "a", Proxy({…}) {…}), target = {a: 20}, p = "a" + return 5; // → ƒƒ => 5 + }, + }); + Proxy; // → ƒ Proxy() + proxy.a; // → 5 + proxy; // → Proxy({a: 20}) {…} + ` + ) + } +) diff --git a/packages/preview-entry/src/payload.test.ts b/packages/preview-entry/src/payload.test.ts index 982f845..e587e0a 100644 --- a/packages/preview-entry/src/payload.test.ts +++ b/packages/preview-entry/src/payload.test.ts @@ -11,6 +11,7 @@ import { } from '@jsrepl/shared-types' import { expect, test } from 'vitest' import { transformPayloadResult } from './payload' +import { setupProxyProxy } from './repl/proxy-proxy' import type { PreviewWindow } from './types' // title, rawResult, expected result from `transformPayloadResult` @@ -230,7 +231,9 @@ const testCases: [string, unknown, unknown][] = [ testCases.forEach(([desc, rawResult, expectedResult]) => { test(desc, () => { - const result = transformPayloadResult(window as PreviewWindow, rawResult) + const win = window as PreviewWindow + setupProxyProxy(win) + const result = transformPayloadResult(win, rawResult) expect(result).toEqual(expectedResult) }) }) diff --git a/packages/preview-entry/src/payload.ts b/packages/preview-entry/src/payload.ts index ff10b30..fdbd14a 100644 --- a/packages/preview-entry/src/payload.ts +++ b/packages/preview-entry/src/payload.ts @@ -3,12 +3,14 @@ import { MarshalledFunction, MarshalledObject, MarshalledPromise, + MarshalledProxy, MarshalledSymbol, MarshalledType, MarshalledWeakMap, MarshalledWeakRef, MarshalledWeakSet, } from '@jsrepl/shared-types' +import { getProxyMetadata } from './repl/proxy-proxy' import type { PreviewWindow } from './types' // Traverse everything and replace non-clonable stuff for structured clone algorithm, @@ -122,6 +124,17 @@ function transformResult( } as MarshalledPromise } + const proxyMetadata = getProxyMetadata(win, result) + if (proxyMetadata) { + return { + __meta__: { + type: MarshalledType.Proxy, + target: transformResult(win, proxyMetadata.target, refs), + handler: transformResult(win, proxyMetadata.handler, refs), + }, + } as MarshalledProxy + } + // TODO: support more built-in known transferable objects: // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects#supported_objects // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#javascript_types diff --git a/packages/preview-entry/src/post-message.ts b/packages/preview-entry/src/post-message.ts index f689deb..ee4e321 100644 --- a/packages/preview-entry/src/post-message.ts +++ b/packages/preview-entry/src/post-message.ts @@ -1,9 +1,13 @@ import { + ReplPayloadContext, + ReplPayloadContextId, ReplPayloadContextMessageData, ReplPayloadMessageData, ReplStatusMessageData, } from '@jsrepl/shared-types' +import { transformPayloadResult } from './payload' import { previewId } from './preview-id' +import { PreviewWindow } from './types' const JSREPL_ORIGIN = __JSREPL_ORIGIN__ @@ -40,3 +44,29 @@ export function postMessage( console.error('JSRepl Error: unknown error on postMessage', err) } } + +export function postMessageRepl( + token: number, + win: PreviewWindow, + result: unknown, + isError: boolean, + ctxId: ReplPayloadContextId +) { + postMessage(token, { + type: 'repl-payload', + payload: { + id: crypto.randomUUID(), + isError, + result: transformPayloadResult(win, result), + timestamp: Date.now(), + ctxId, + }, + }) +} + +export function postMessageReplContext(token: number, ctx: ReplPayloadContext) { + postMessage(token, { + type: 'repl-payload-context', + ctx, + }) +} diff --git a/packages/preview-entry/src/repl.ts b/packages/preview-entry/src/repl.ts index 23e4bc9..343dfc2 100644 --- a/packages/preview-entry/src/repl.ts +++ b/packages/preview-entry/src/repl.ts @@ -1,13 +1,7 @@ -import { - ReplPayloadContext, - ReplPayloadContextId, - ReplPayloadContextKind, - ReplPayloadContextWindowError, - identifierNameFunctionMeta, - identifierNameRepl, -} from '@jsrepl/shared-types' -import { transformPayloadResult } from './payload' -import { postMessage } from './post-message' +import { identifierNameFunctionMeta, identifierNameRepl } from '@jsrepl/shared-types' +import { postMessageRepl } from './post-message' +import { setupProxyProxy } from './repl/proxy-proxy' +import { setupWindowErrorHandler } from './repl/window-error' import type { PreviewWindow } from './types' // Traversing the object props in `transformPayloadResult` can cause calling `repl` @@ -22,10 +16,8 @@ let skipReplAsSideEffect = false export function setupRepl(win: PreviewWindow, token: number) { win[identifierNameRepl] = repl.bind({ token, win }) win[identifierNameFunctionMeta] = () => {} - - win.addEventListener('error', (event) => { - onWindowError(event, token) - }) + setupWindowErrorHandler(win, token) + setupProxyProxy(win) } function repl(this: { token: number; win: PreviewWindow }, ctxId: string | number, value: unknown) { @@ -43,65 +35,3 @@ function repl(this: { token: number; win: PreviewWindow }, ctxId: string | numbe return value } - -function postMessageRepl( - token: number, - win: PreviewWindow, - result: unknown, - isError: boolean, - ctxId: ReplPayloadContextId -) { - postMessage(token, { - type: 'repl-payload', - payload: { - id: crypto.randomUUID(), - isError, - result: transformPayloadResult(win, result), - timestamp: Date.now(), - ctxId, - }, - }) -} - -function postMessageReplContext(token: number, ctx: ReplPayloadContext) { - postMessage(token, { - type: 'repl-payload-context', - ctx, - }) -} - -function onWindowError(event: ErrorEvent, token: number) { - const error = event.error - const win = event.target as PreviewWindow - - let filePath = event.filename - if (filePath.startsWith('data:')) { - // base64 url -> bundle output file path - filePath = - Array.from(win.document.scripts).find((script) => script.src === event.filename)?.dataset - .path ?? '' - } else if (filePath === 'about:srcdoc') { - filePath = '/index.html' - } - - const ctx: ReplPayloadContextWindowError = { - id: `window-error-${event.filename}-${event.lineno}:${event.colno}`, - // Normally lineno and colno start with 1. - // There are edge cases where they might appear as zero, which usually indicates that the browser couldn't - // determine the exact location of the error. For example, SecurityError case when trying to evaluate - // "window.top.location.href", in that case lineno is 0, colno is 0 (Google Chrome). - // Later they will be resolved to the original position taking into account - // the sourcemap (see the handler for the kind 'window-error'). - lineStart: event.lineno === 0 ? 1 : event.lineno, - lineEnd: event.lineno === 0 ? 1 : event.lineno, - colStart: event.colno === 0 ? 1 : event.colno, - colEnd: event.colno === 0 ? 1 : event.colno, - source: '', - // Will be resolved to the original filePath taking into account the sourcemap. - filePath, - kind: ReplPayloadContextKind.WindowError, - } - - postMessageReplContext(token, ctx) - postMessageRepl(token, win, error, true, ctx.id) -} diff --git a/packages/preview-entry/src/repl/proxy-proxy.ts b/packages/preview-entry/src/repl/proxy-proxy.ts new file mode 100644 index 0000000..fb943ee --- /dev/null +++ b/packages/preview-entry/src/repl/proxy-proxy.ts @@ -0,0 +1,26 @@ +import { ProxyMetadata, identifierNameProxyMap } from '@jsrepl/shared-types' +import { PreviewWindow } from '../types' + +export function setupProxyProxy(win: PreviewWindow) { + win[identifierNameProxyMap] = new WeakMap() + + win.Proxy = new win.Proxy(win.Proxy, { + construct(target, args: [object, ProxyHandler]) { + const proxy = new target(...args) + win[identifierNameProxyMap].set(proxy, { target: args[0], handler: args[1] }) + return proxy + }, + }) +} + +/** + * Returns a proxy metadata if the value is a proxy object + * and it has been catched by overriden `Proxy` constructor. + * + * `undefined` otherwise. + */ +export function getProxyMetadata(win: PreviewWindow, value: unknown): ProxyMetadata | undefined { + return typeof value === 'object' && value !== null + ? win[identifierNameProxyMap]?.get(value) + : undefined +} diff --git a/packages/preview-entry/src/repl/window-error.ts b/packages/preview-entry/src/repl/window-error.ts new file mode 100644 index 0000000..7c381f7 --- /dev/null +++ b/packages/preview-entry/src/repl/window-error.ts @@ -0,0 +1,46 @@ +import { ReplPayloadContextKind } from '@jsrepl/shared-types' +import { ReplPayloadContextWindowError } from '@jsrepl/shared-types' +import { postMessageRepl, postMessageReplContext } from '../post-message' +import { PreviewWindow } from '../types' + +export function setupWindowErrorHandler(win: PreviewWindow, token: number) { + win.addEventListener('error', (event) => { + onWindowError(event, token) + }) +} + +function onWindowError(event: ErrorEvent, token: number) { + const error = event.error + const win = event.target as PreviewWindow + + let filePath = event.filename + if (filePath.startsWith('data:')) { + // base64 url -> bundle output file path + filePath = + Array.from(win.document.scripts).find((script) => script.src === event.filename)?.dataset + .path ?? '' + } else if (filePath === 'about:srcdoc') { + filePath = '/index.html' + } + + const ctx: ReplPayloadContextWindowError = { + id: `window-error-${event.filename}-${event.lineno}:${event.colno}`, + // Normally lineno and colno start with 1. + // There are edge cases where they might appear as zero, which usually indicates that the browser couldn't + // determine the exact location of the error. For example, SecurityError case when trying to evaluate + // "window.top.location.href", in that case lineno is 0, colno is 0 (Google Chrome). + // Later they will be resolved to the original position taking into account + // the sourcemap (see the handler for the kind 'window-error'). + lineStart: event.lineno === 0 ? 1 : event.lineno, + lineEnd: event.lineno === 0 ? 1 : event.lineno, + colStart: event.colno === 0 ? 1 : event.colno, + colEnd: event.colno === 0 ? 1 : event.colno, + source: '', + // Will be resolved to the original filePath taking into account the sourcemap. + filePath, + kind: ReplPayloadContextKind.WindowError, + } + + postMessageReplContext(token, ctx) + postMessageRepl(token, win, error, true, ctx.id) +} diff --git a/packages/preview-entry/src/types.ts b/packages/preview-entry/src/types.ts index 25cc62a..28f7e62 100644 --- a/packages/preview-entry/src/types.ts +++ b/packages/preview-entry/src/types.ts @@ -1,4 +1,9 @@ -import { identifierNameFunctionMeta, identifierNameRepl } from '@jsrepl/shared-types' +import { + ProxyMetadata, + identifierNameFunctionMeta, + identifierNameProxyMap, + identifierNameRepl, +} from '@jsrepl/shared-types' declare global { const __JSREPL_ORIGIN__: string @@ -15,4 +20,5 @@ export type PreviewWindow = Window & [identifierNameRepl]: Function // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type [identifierNameFunctionMeta]: Function + [identifierNameProxyMap]: WeakMap } diff --git a/packages/shared-types/marshalled.ts b/packages/shared-types/marshalled.ts index 109d2d9..1335c73 100644 --- a/packages/shared-types/marshalled.ts +++ b/packages/shared-types/marshalled.ts @@ -7,6 +7,7 @@ export enum MarshalledType { WeakRef = 'weak-ref', // non-cloneable Object = 'object', // prototype chain is not preserved in structured clone Promise = 'promise', // non-cloneable. Handled differently from MarshalledObject. + Proxy = 'proxy', // proxy objects are non-cloneable } export type MarshalledDomNode = { @@ -67,3 +68,11 @@ export type MarshalledPromise = { type: MarshalledType.Promise } } + +export type MarshalledProxy = { + __meta__: { + type: MarshalledType.Proxy + target: MarshalledObject + handler: MarshalledObject + } +} diff --git a/packages/shared-types/repl-meta.ts b/packages/shared-types/repl-meta.ts index 8b0263f..a00604c 100644 --- a/packages/shared-types/repl-meta.ts +++ b/packages/shared-types/repl-meta.ts @@ -4,5 +4,11 @@ export type ReplMeta = { ctxMap: Map } +export type ProxyMetadata = { + target: object + handler: ProxyHandler +} + export const identifierNameRepl = '__repl' export const identifierNameFunctionMeta = '__repl_fn' +export const identifierNameProxyMap = '__repl_proxy_map' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7233477..9722b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@babel/parser': specifier: ^7.25.6 version: 7.25.6 + '@babel/types': + specifier: ^7.26.3 + version: 7.26.3 '@chromatic-com/storybook': specifier: ^1.9.0 version: 1.9.0(react@18.3.1) @@ -455,10 +458,18 @@ packages: resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.7': resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.24.8': resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} engines: {node: '>=6.9.0'} @@ -1032,6 +1043,10 @@ packages: resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} engines: {node: '>=6.9.0'} + '@babel/types@7.26.3': + resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} + engines: {node: '>=6.9.0'} + '@base2/pretty-print-object@1.0.1': resolution: {integrity: sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==} @@ -6814,7 +6829,7 @@ snapshots: '@babel/parser': 7.25.6 '@babel/template': 7.25.0 '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 convert-source-map: 2.0.0 debug: 4.3.7 gensync: 1.0.0-beta.2 @@ -6831,19 +6846,19 @@ snapshots: '@babel/generator@7.25.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 2.5.2 '@babel/helper-annotate-as-pure@7.24.7': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@babel/helper-builder-binary-assignment-operator-visitor@7.24.7': dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color @@ -6902,14 +6917,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.24.8': dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.24.7': dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color @@ -6925,7 +6940,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.24.7': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@babel/helper-plugin-utils@7.24.8': {} @@ -6950,14 +6965,14 @@ snapshots: '@babel/helper-simple-access@7.24.7': dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.24.7': dependencies: '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color @@ -6967,22 +6982,26 @@ snapshots: '@babel/helper-string-parser@7.24.8': {} + '@babel/helper-string-parser@7.25.9': {} + '@babel/helper-validator-identifier@7.24.7': {} + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-option@7.24.8': {} '@babel/helper-wrap-function@7.25.0': dependencies: '@babel/template': 7.25.0 '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color '@babel/helpers@7.25.6': dependencies: '@babel/template': 7.25.0 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@babel/highlight@7.24.7': dependencies: @@ -6993,7 +7012,7 @@ snapshots: '@babel/parser@7.25.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.3(@babel/core@7.25.2)': dependencies: @@ -7444,7 +7463,7 @@ snapshots: '@babel/helper-module-imports': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 transitivePeerDependencies: - supports-color @@ -7632,7 +7651,7 @@ snapshots: dependencies: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.24.8 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 esutils: 2.0.3 '@babel/preset-react@7.24.7(@babel/core@7.25.2)': @@ -7670,7 +7689,7 @@ snapshots: dependencies: '@babel/code-frame': 7.24.7 '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@babel/traverse@7.23.2': dependencies: @@ -7693,7 +7712,7 @@ snapshots: '@babel/generator': 7.25.6 '@babel/parser': 7.25.6 '@babel/template': 7.25.0 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 debug: 4.3.7 globals: 11.12.0 transitivePeerDependencies: @@ -7710,6 +7729,11 @@ snapshots: '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 + '@babel/types@7.26.3': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@base2/pretty-print-object@1.0.1': {} '@chromatic-com/storybook@1.9.0(react@18.3.1)': @@ -9197,7 +9221,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.6.2))': @@ -9283,14 +9307,14 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@types/babel__standalone@7.1.7': dependencies: @@ -9299,11 +9323,11 @@ snapshots: '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@types/body-parser@1.19.5': dependencies: @@ -13086,7 +13110,7 @@ snapshots: dependencies: '@babel/core': 7.25.2 '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.26.3 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.20.6 '@types/doctrine': 0.0.9