diff --git a/src/WS/WSServer.ts b/src/WS/WSServer.ts index 58350ec..155e0b9 100644 --- a/src/WS/WSServer.ts +++ b/src/WS/WSServer.ts @@ -1,12 +1,7 @@ import type http from 'node:http'; import WebSocket, { WebSocketServer } from 'ws'; import type playwright from 'playwright'; -import { - parseEvaluateExpression, - parseSerializedValue, - serializeValue, - SerializedValue, -} from './handle/serializer.js'; +import * as Serializer from '../serializer/index.js'; import { RouteClientMeta, HandleClientMeta, @@ -235,9 +230,9 @@ export default class WSServer { try { switch (meta.action) { case 'evaluate': { - const fn = parseEvaluateExpression(meta.fn); - const arg = parseSerializedValue( - JSON.parse(meta.arg) as SerializedValue, + const fn = Serializer.parseEvaluateExpression(meta.fn); + const arg = Serializer.parseSerializedValue( + JSON.parse(meta.arg) as Serializer.SerializedValue, this.handleTargets, ); const returned: unknown = await (typeof fn === 'function' ? fn(target, arg) : fn); @@ -281,7 +276,7 @@ export default class WSServer { action: 'resolve', id, resolveID, - result: JSON.stringify(serializeValue(result, null)), + result: JSON.stringify(Serializer.serializeValue(result, null)), error, })); } diff --git a/src/WS/handle/NodeHandle.ts b/src/WS/handle/NodeHandle.ts index d7e4f9a..8b3aa62 100644 --- a/src/WS/handle/NodeHandle.ts +++ b/src/WS/handle/NodeHandle.ts @@ -4,12 +4,7 @@ import { HandleMetaBase, HandleClientMeta, } from '../message.js'; -import Handle from './Handle.js'; -import { - serializeValue, - parseSerializedValue, - SerializedValue, -} from './serializer.js'; +import * as Serializer from '../../serializer/index.js'; export type Unboxed = Arg extends URL @@ -52,7 +47,7 @@ const finalizationRegistry = new FinalizationRegistry(({ id, ws }: { })); }); -export default class NodeHandle extends Handle { +export default class NodeHandle extends Serializer.Handle { /** * Share the handle ID with an object so that the matching handles will keep referencing the node * target until all ID users are disposed or garbage-collected. @@ -100,7 +95,9 @@ export default class NodeHandle extends Handle { || meta.id !== id || meta.resolveID !== resolveID) return; controller.abort(); - const result = parseSerializedValue(JSON.parse(meta.result) as SerializedValue); + const result = Serializer.parseSerializedValue( + JSON.parse(meta.result) as Serializer.SerializedValue, + ); if (meta.error) { reject(result); } else { @@ -128,7 +125,7 @@ export default class NodeHandle extends Handle { return this.act({ action: 'evaluate', fn: String(nodeFunction), - arg: JSON.stringify(serializeValue(arg)), + arg: JSON.stringify(Serializer.serializeValue(arg)), h: createHandle, }, (result) => { if (createHandle) return new NodeHandle(result as number, this.ws); diff --git a/src/WS/handle/serializer.ts b/src/WS/handle/serializer.ts deleted file mode 100644 index d44dc46..0000000 --- a/src/WS/handle/serializer.ts +++ /dev/null @@ -1,211 +0,0 @@ -import Handle from './Handle.js'; - -export type SerializableValue = - | number | boolean | string | null | undefined | bigint | URL | Date | Error | RegExp | Handle - | SerializableValue[] | { [K: string]: SerializableValue }; - -export interface SerializedValue { - i: number; // ID - n?: number | boolean | string | null; - v?: 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0'; - b?: string; // BigInt - u?: string; // URL - d?: string; // Date - r?: { - p: string; - f: string; - }, // RegExp - h?: number; // Handle ID - e?: { - n: string; - m: string; - c?: SerializedValue | undefined; - s?: string | undefined; - }; // Error - a?: SerializedValue[]; // Array - o?: { - k: string; - v: SerializedValue; - }[]; // Object -} - -const innerParseSerializedValue = ( - value: SerializedValue, - handleTargets: unknown[], - refs: Map, -): unknown => { - const { i } = value; - if (refs.has(i)) return refs.get(i); - - if (value.a !== undefined) { - const arr: unknown[] = []; - refs.set(i, arr); - value.a.forEach((e) => { - arr.push(innerParseSerializedValue(e, handleTargets, refs)); - }); - return arr; - } - if (value.o !== undefined) { - const obj: { [K: string]: unknown } = {}; - refs.set(i, obj); - value.o.forEach(({ k, v }) => { - obj[k] = innerParseSerializedValue(v, handleTargets, refs); - }); - return obj; - } - if (value.e !== undefined) { - const error = new Error(value.e.m); - refs.set(i, error); - error.name = value.e.n; - if (value.e.c !== undefined) { - error.cause = innerParseSerializedValue(value.e.c, handleTargets, refs) as Error; - } - if (value.e.s !== undefined) error.stack = value.e.s; - return error; - } - - let parsed: unknown; - - if (value.n !== undefined) { - parsed = value.n; - } else if (value.v !== undefined) { - switch (value.v) { - case 'undefined': - parsed = undefined; - break; - case 'NaN': - parsed = NaN; - break; - case 'Infinity': - parsed = Infinity; - break; - case '-Infinity': - parsed = -Infinity; - break; - case '-0': - parsed = -0; - break; - default: - throw new Error('Unexpected value.v'); - } - } else if (value.b !== undefined) { - parsed = BigInt(value.b); - } else if (value.u !== undefined) { - parsed = new URL(value.u); - } else if (value.d !== undefined) { - parsed = new Date(value.d); - } else if (value.r !== undefined) { - parsed = new RegExp(value.r.p, value.r.f); - } else if (value.h !== undefined) { - if (!(value.h in handleTargets)) { - throw new Error('Unexpected handle'); - } - parsed = handleTargets[value.h]; - } else { - throw new Error('Unexpected value'); - } - - refs.set(i, parsed); - return parsed; -}; - -export const parseSerializedValue = ( - value: SerializedValue, - handleTargets: unknown[] = [], -) => ( - innerParseSerializedValue(value, handleTargets, new Map()) -); - -function isURL(obj: unknown, objStr: string): obj is URL { - return obj instanceof URL || objStr === '[object URL]'; -} - -function isDate(obj: unknown, objStr: string): obj is Date { - return obj instanceof Date || objStr === '[object Date]'; -} - -function isRegExp(obj: unknown, objStr: string): obj is RegExp { - return obj instanceof RegExp || objStr === '[object RegExp]'; -} - -function isError(obj: unknown, objStr: string): obj is Error { - return obj instanceof Error || objStr === '[object Error]'; -} - -export const noFallback = Symbol('indicate no fallback value on serialize'); - -export const innerSerializeValue = ( - value: unknown, - fallback: SerializableValue | typeof noFallback, - visited: unknown[], -): SerializedValue => { - const visitedIndex = visited.findIndex((v) => Object.is(value, v)); - if (visitedIndex !== -1) return { i: visitedIndex }; - const i = visited.length; - visited.push(value); - - if (Object.is(value, -0)) return { i, v: '-0' }; - if (Object.is(value, NaN)) return { i, v: 'NaN' }; - if (typeof value === 'symbol' && value !== noFallback) return { i, v: 'undefined' }; - if (value === undefined) return { i, v: 'undefined' }; - if (value === Infinity) return { i, v: 'Infinity' }; - if (value === -Infinity) return { i, v: '-Infinity' }; - if (value === null) return { i, n: null }; - if (typeof value === 'number') return { i, n: value }; - if (typeof value === 'boolean') return { i, n: value }; - if (typeof value === 'string') return { i, n: value }; - if (typeof value === 'bigint') return { i, b: value.toString() }; - if (value instanceof Handle) return { i, h: value.id }; - const valueObjStr = Object.prototype.toString.call(value); - if (isURL(value, valueObjStr)) return { i, u: value.toJSON() }; - if (isDate(value, valueObjStr)) return { i, d: value.toJSON() }; - if (isRegExp(value, valueObjStr)) return { i, r: { p: value.source, f: value.flags } }; - if (isError(value, valueObjStr)) { - return { - i, - e: { - n: value.name, - m: value.message, - c: innerSerializeValue(value.cause, fallback, visited), - s: value.stack, - }, - }; - } - if (Array.isArray(value)) { - return { i, a: value.map((e) => innerSerializeValue(e, fallback, visited)) }; - } - if (typeof value === 'object') { - return { - i, - o: Object.entries(value).map(([k, v]) => ( - { k, v: innerSerializeValue(v, fallback, visited) } - )), - }; - } - if (fallback !== noFallback) { - return innerSerializeValue(fallback, noFallback, visited); - } - throw new Error(`Unexpected value: ${String(value)}`); -}; - -export const serializeValue = ( - value: unknown, - fallback: SerializableValue | typeof noFallback = noFallback, -) => ( - innerSerializeValue(value, fallback, []) -); - -export const parseEvaluateExpression = (expression: string): unknown => { - const exp = expression.trim(); - try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval - return new Function(`return (${exp})`)(); - } catch { - try { - // eslint-disable-next-line @typescript-eslint/no-implied-eval - return new Function(`return (${exp.replace(/^(async )?/, '$1function ')})`)(); - } catch { - throw new Error('Passed function is not well-serializable!'); - } - } -}; diff --git a/src/WS/handle/Handle.ts b/src/serializer/Handle.ts similarity index 100% rename from src/WS/handle/Handle.ts rename to src/serializer/Handle.ts diff --git a/src/serializer/Serializable.ts b/src/serializer/Serializable.ts new file mode 100644 index 0000000..5478980 --- /dev/null +++ b/src/serializer/Serializable.ts @@ -0,0 +1,30 @@ +import Handle from './Handle.js'; + +export type SerializableValue = + | number | boolean | string | null | undefined | bigint | URL | Date | Error | RegExp | Handle + | SerializableValue[] | { [K: string]: SerializableValue }; + +export interface SerializedValue { + i: number; // ID + n?: number | boolean | string | null; + v?: 'undefined' | 'NaN' | 'Infinity' | '-Infinity' | '-0'; + b?: string; // BigInt + u?: string; // URL + d?: string; // Date + r?: { + p: string; + f: string; + }, // RegExp + h?: number; // Handle ID + e?: { + n: string; + m: string; + c?: SerializedValue | undefined; + s?: string | undefined; + }; // Error + a?: SerializedValue[]; // Array + o?: { + k: string; + v: SerializedValue; + }[]; // Object +} diff --git a/src/serializer/index.ts b/src/serializer/index.ts new file mode 100644 index 0000000..1c3cf4b --- /dev/null +++ b/src/serializer/index.ts @@ -0,0 +1,13 @@ +import Handle from './Handle.js'; +import serializeValue, { noFallback } from './serializeValue.js'; +import parseSerializedValue from './parseSerializedValue.js'; +import parseEvaluateExpression from './parseEvaluateExpression.js'; + +export * from './Serializable.js'; +export { + parseEvaluateExpression, + parseSerializedValue, + serializeValue, + noFallback, + Handle, +}; diff --git a/src/serializer/parseEvaluateExpression.ts b/src/serializer/parseEvaluateExpression.ts new file mode 100644 index 0000000..d29053c --- /dev/null +++ b/src/serializer/parseEvaluateExpression.ts @@ -0,0 +1,14 @@ +export default function parseEvaluateExpression(expression: string): unknown { + const exp = expression.trim(); + try { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + return new Function(`return (${exp})`)(); + } catch { + try { + // eslint-disable-next-line @typescript-eslint/no-implied-eval + return new Function(`return (${exp.replace(/^(async )?/, '$1function ')})`)(); + } catch { + throw new Error('Passed function is not well-serializable!'); + } + } +} diff --git a/src/serializer/parseSerializedValue.ts b/src/serializer/parseSerializedValue.ts new file mode 100644 index 0000000..02f0b7b --- /dev/null +++ b/src/serializer/parseSerializedValue.ts @@ -0,0 +1,88 @@ +import { SerializedValue } from './Serializable.js'; + +const innerParseSerializedValue = ( + value: SerializedValue, + handleTargets: unknown[], + refs: Map, +): unknown => { + const { i } = value; + if (refs.has(i)) return refs.get(i); + + if (value.a !== undefined) { + const arr: unknown[] = []; + refs.set(i, arr); + value.a.forEach((e) => { + arr.push(innerParseSerializedValue(e, handleTargets, refs)); + }); + return arr; + } + if (value.o !== undefined) { + const obj: { [K: string]: unknown } = {}; + refs.set(i, obj); + value.o.forEach(({ k, v }) => { + obj[k] = innerParseSerializedValue(v, handleTargets, refs); + }); + return obj; + } + if (value.e !== undefined) { + const error = new Error(value.e.m); + refs.set(i, error); + error.name = value.e.n; + if (value.e.c !== undefined) { + error.cause = innerParseSerializedValue(value.e.c, handleTargets, refs) as Error; + } + if (value.e.s !== undefined) error.stack = value.e.s; + return error; + } + + let parsed: unknown; + + if (value.n !== undefined) { + parsed = value.n; + } else if (value.v !== undefined) { + switch (value.v) { + case 'undefined': + parsed = undefined; + break; + case 'NaN': + parsed = NaN; + break; + case 'Infinity': + parsed = Infinity; + break; + case '-Infinity': + parsed = -Infinity; + break; + case '-0': + parsed = -0; + break; + default: + throw new Error('Unexpected value.v'); + } + } else if (value.b !== undefined) { + parsed = BigInt(value.b); + } else if (value.u !== undefined) { + parsed = new URL(value.u); + } else if (value.d !== undefined) { + parsed = new Date(value.d); + } else if (value.r !== undefined) { + parsed = new RegExp(value.r.p, value.r.f); + } else if (value.h !== undefined) { + if (!(value.h in handleTargets)) { + throw new Error('Unexpected handle'); + } + parsed = handleTargets[value.h]; + } else { + throw new Error('Unexpected value'); + } + + refs.set(i, parsed); + return parsed; +}; + +export default function parseSerializedValue( + value: SerializedValue, + handleTargets: unknown[] = [], +) { + return innerParseSerializedValue(value, handleTargets, new Map()); +} diff --git a/src/serializer/serializeValue.ts b/src/serializer/serializeValue.ts new file mode 100644 index 0000000..0a8297f --- /dev/null +++ b/src/serializer/serializeValue.ts @@ -0,0 +1,81 @@ +import Handle from './Handle.js'; +import { SerializableValue, SerializedValue } from './Serializable.js'; + +function isURL(obj: unknown, objStr: string): obj is URL { + return obj instanceof URL || objStr === '[object URL]'; +} + +function isDate(obj: unknown, objStr: string): obj is Date { + return obj instanceof Date || objStr === '[object Date]'; +} + +function isRegExp(obj: unknown, objStr: string): obj is RegExp { + return obj instanceof RegExp || objStr === '[object RegExp]'; +} + +function isError(obj: unknown, objStr: string): obj is Error { + return obj instanceof Error || objStr === '[object Error]'; +} + +export const noFallback = Symbol('indicate no fallback value on serialize'); + +const innerSerializeValue = ( + value: unknown, + fallback: SerializableValue | typeof noFallback, + visited: unknown[], +): SerializedValue => { + const visitedIndex = visited.findIndex((v) => Object.is(value, v)); + if (visitedIndex !== -1) return { i: visitedIndex }; + const i = visited.length; + visited.push(value); + + if (Object.is(value, -0)) return { i, v: '-0' }; + if (Object.is(value, NaN)) return { i, v: 'NaN' }; + if (typeof value === 'symbol' && value !== noFallback) return { i, v: 'undefined' }; + if (value === undefined) return { i, v: 'undefined' }; + if (value === Infinity) return { i, v: 'Infinity' }; + if (value === -Infinity) return { i, v: '-Infinity' }; + if (value === null) return { i, n: null }; + if (typeof value === 'number') return { i, n: value }; + if (typeof value === 'boolean') return { i, n: value }; + if (typeof value === 'string') return { i, n: value }; + if (typeof value === 'bigint') return { i, b: value.toString() }; + if (value instanceof Handle) return { i, h: value.id }; + const valueObjStr = Object.prototype.toString.call(value); + if (isURL(value, valueObjStr)) return { i, u: value.toJSON() }; + if (isDate(value, valueObjStr)) return { i, d: value.toJSON() }; + if (isRegExp(value, valueObjStr)) return { i, r: { p: value.source, f: value.flags } }; + if (isError(value, valueObjStr)) { + return { + i, + e: { + n: value.name, + m: value.message, + c: innerSerializeValue(value.cause, fallback, visited), + s: value.stack, + }, + }; + } + if (Array.isArray(value)) { + return { i, a: value.map((e) => innerSerializeValue(e, fallback, visited)) }; + } + if (typeof value === 'object') { + return { + i, + o: Object.entries(value).map(([k, v]) => ( + { k, v: innerSerializeValue(v, fallback, visited) } + )), + }; + } + if (fallback !== noFallback) { + return innerSerializeValue(fallback, noFallback, visited); + } + throw new Error(`Unexpected value: ${String(value)}`); +}; + +export default function serializeValue( + value: unknown, + fallback: SerializableValue | typeof noFallback = noFallback, +) { + return innerSerializeValue(value, fallback, []); +} diff --git a/test/handle/serializer.test.ts b/test/serializer/basic.test.ts similarity index 97% rename from test/handle/serializer.test.ts rename to test/serializer/basic.test.ts index 39e43f3..eaf4139 100644 --- a/test/handle/serializer.test.ts +++ b/test/serializer/basic.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from '../default.setup.js'; -import Handle from '../../src/WS/handle/Handle.js'; +import Handle from '../../src/serializer/Handle.js'; import { - noFallback, - serializeValue, - parseSerializedValue, SerializableValue, SerializedValue, -} from '../../src/WS/handle/serializer.js'; + serializeValue, + noFallback, + parseSerializedValue, +} from '../../src/serializer/index.js'; describe('serializer', () => { function parse(