browser-commander 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The one dispatcher behind `serve --stdio` and `run` (issue #104).
3
+ *
4
+ * It owns the handle table, the sessions and the event subscriptions of one
5
+ * connection, and maps a JSON-RPC method name and params to a result or a
6
+ * thrown error. Transports (the stdio server and the script runner) only
7
+ * frame messages around it.
8
+ */
9
+ import { HANDLE_METHODS, removeSubscription } from './handle-methods.js';
10
+ import { HandleTable } from './handles.js';
11
+ import { HIGH_LEVEL_METHODS } from './high-level-methods.js';
12
+ import { DEFAULT_DEPENDENCIES } from './modules.js';
13
+ import { RPC_ERROR, RpcError, invalidParams } from './rpc-error.js';
14
+ import { SessionTable } from './sessions.js';
15
+
16
+ const METHODS = Object.freeze({ ...HIGH_LEVEL_METHODS, ...HANDLE_METHODS });
17
+
18
+ /** Every method name the dispatcher answers. */
19
+ export const METHOD_NAMES = Object.freeze(Object.keys(METHODS).sort());
20
+
21
+ /**
22
+ * Create a dispatcher.
23
+ *
24
+ * @param {Object} [options]
25
+ * @param {Object} [options.dependencies] - Replacements for {@link DEFAULT_DEPENDENCIES}
26
+ * @param {function(string, Object): void} [options.notify] - Sends a server notification (`events.emit`)
27
+ * @returns {{dispatch: function(string, Object=): Promise<unknown>, close: function(): Promise<void>, state: Object}}
28
+ */
29
+ export function createDispatcher({
30
+ dependencies = {},
31
+ notify = () => {},
32
+ } = {}) {
33
+ const state = {
34
+ dependencies: { ...DEFAULT_DEPENDENCIES, ...dependencies },
35
+ handles: new HandleTable(),
36
+ sessions: new SessionTable(),
37
+ subscriptions: new Map(),
38
+ nextSubscription: 1,
39
+ relay: null,
40
+ notify,
41
+ };
42
+
43
+ async function dispatch(method, params = {}) {
44
+ if (typeof method !== 'string' || !Object.hasOwn(METHODS, method)) {
45
+ throw new RpcError(
46
+ RPC_ERROR.METHOD_NOT_FOUND,
47
+ `Unknown method: ${method}`
48
+ );
49
+ }
50
+ if (
51
+ params === null ||
52
+ typeof params !== 'object' ||
53
+ Array.isArray(params)
54
+ ) {
55
+ throw invalidParams('params must be an object');
56
+ }
57
+ return await METHODS[method](state, params);
58
+ }
59
+
60
+ /** Remove every subscription, close every session and the relay. */
61
+ async function close() {
62
+ for (const subscription of [...state.subscriptions.keys()]) {
63
+ removeSubscription(state, subscription);
64
+ }
65
+ const { relay } = state;
66
+ state.relay = null;
67
+ await Promise.all([
68
+ state.sessions.closeAll(),
69
+ relay?.close().catch(() => {}),
70
+ ]);
71
+ }
72
+
73
+ return { dispatch, close, state };
74
+ }
75
+
76
+ /**
77
+ * Replace `"$session"` anywhere in params with the latest session id.
78
+ *
79
+ * @param {unknown} value - Step params
80
+ * @param {string|null} session - Latest session id
81
+ * @returns {unknown}
82
+ */
83
+ export function substituteSession(value, session) {
84
+ if (value === '$session') {
85
+ return session ?? value;
86
+ }
87
+ if (Array.isArray(value)) {
88
+ return value.map((item) => substituteSession(item, session));
89
+ }
90
+ if (value && typeof value === 'object') {
91
+ return Object.fromEntries(
92
+ Object.entries(value).map(([key, item]) => [
93
+ key,
94
+ substituteSession(item, session),
95
+ ])
96
+ );
97
+ }
98
+ return value;
99
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Generic handle methods (docs/cli-and-bridge.md, "Generic handle methods").
3
+ *
4
+ * They reach every public method of Playwright and Puppeteer through remote
5
+ * object handles, so a Rust or Python program - or a shell script - can call
6
+ * `page.route()`, `locator.boundingBox()` or `cdpSession.send()` without a
7
+ * native binding for each of them.
8
+ */
9
+ import { decodeValue, describeObject, encodeValue } from './handles.js';
10
+ import { invalidParams, requireString } from './rpc-error.js';
11
+
12
+ const ENGINE_ROOTS = Object.freeze(['playwright', 'puppeteer']);
13
+
14
+ function targetOf(state, params) {
15
+ const handle = params.handle;
16
+ const id = typeof handle === 'object' && handle ? handle.$handle : handle;
17
+ return state.handles.get(requireString({ id }, 'id'));
18
+ }
19
+
20
+ /** Roots are always handles, even when the engine object is a plain object. */
21
+ function handleOrNull(state, value) {
22
+ return value === null || value === undefined
23
+ ? null
24
+ : state.handles.register(value);
25
+ }
26
+
27
+ /**
28
+ * `playwright` and `puppeteer` resolve to the engine's entry object;
29
+ * `session:<id>` to the session's browser, context and page.
30
+ */
31
+ async function root(state, params) {
32
+ const name = requireString(params, 'name');
33
+ if (name.startsWith('session:')) {
34
+ const session = state.sessions.get(name.slice('session:'.length));
35
+ return {
36
+ browser: handleOrNull(state, session.browser),
37
+ context: handleOrNull(state, session.context),
38
+ page: handleOrNull(state, session.page),
39
+ };
40
+ }
41
+ if (!ENGINE_ROOTS.includes(name)) {
42
+ throw invalidParams(
43
+ `Unknown root "${name}"; expected playwright, puppeteer or session:<id>`
44
+ );
45
+ }
46
+ const module = await state.dependencies.loadEngine(name);
47
+ return state.handles.register(module.default ?? module);
48
+ }
49
+
50
+ async function call(state, params) {
51
+ const target = targetOf(state, params);
52
+ const method = requireString(params, 'method');
53
+ const args = params.args ?? [];
54
+ if (!Array.isArray(args)) {
55
+ throw invalidParams('args must be an array');
56
+ }
57
+ if (typeof target?.[method] !== 'function') {
58
+ throw invalidParams(`${method} is not a method of this handle`);
59
+ }
60
+ const result = await target[method](...decodeValue(args, state.handles));
61
+ return encodeValue(result, state.handles);
62
+ }
63
+
64
+ async function get(state, params) {
65
+ const target = targetOf(state, params);
66
+ const property = requireString(params, 'property');
67
+ const value = target?.[property];
68
+ return encodeValue(
69
+ value instanceof Promise ? await value : value,
70
+ state.handles
71
+ );
72
+ }
73
+
74
+ function dispose(state, params) {
75
+ const id =
76
+ typeof params.handle === 'object' && params.handle
77
+ ? params.handle.$handle
78
+ : params.handle;
79
+ if (!state.handles.dispose(requireString({ id }, 'id'))) {
80
+ throw invalidParams(`Unknown handle: ${id}`);
81
+ }
82
+ return { disposed: true };
83
+ }
84
+
85
+ function describe(state, params) {
86
+ return describeObject(targetOf(state, params));
87
+ }
88
+
89
+ function subscribe(state, params) {
90
+ const target = targetOf(state, params);
91
+ const event = requireString(params, 'event');
92
+ if (typeof target?.on !== 'function') {
93
+ throw invalidParams('This handle does not emit events');
94
+ }
95
+ const subscription = `e${state.nextSubscription++}`;
96
+ const listener = (...args) =>
97
+ state.notify('events.emit', {
98
+ subscription,
99
+ args: args.map((arg) => encodeValue(arg, state.handles)),
100
+ });
101
+ target.on(event, listener);
102
+ state.subscriptions.set(subscription, { target, event, listener });
103
+ return { subscription };
104
+ }
105
+
106
+ /** Remove one subscription's listener. */
107
+ export function removeSubscription(state, subscription) {
108
+ const entry = state.subscriptions.get(subscription);
109
+ if (!entry) {
110
+ return false;
111
+ }
112
+ state.subscriptions.delete(subscription);
113
+ const { target, event, listener } = entry;
114
+ const off = target.off ?? target.removeListener;
115
+ off?.call(target, event, listener);
116
+ return true;
117
+ }
118
+
119
+ function unsubscribe(state, params) {
120
+ const subscription = requireString(params, 'subscription');
121
+ if (!removeSubscription(state, subscription)) {
122
+ throw invalidParams(`Unknown subscription: ${subscription}`);
123
+ }
124
+ return { unsubscribed: true };
125
+ }
126
+
127
+ /** Method name to implementation. */
128
+ export const HANDLE_METHODS = Object.freeze({
129
+ 'handle.root': root,
130
+ 'handle.call': call,
131
+ 'handle.get': get,
132
+ 'handle.dispose': dispose,
133
+ 'handle.describe': describe,
134
+ 'events.subscribe': subscribe,
135
+ 'events.unsubscribe': unsubscribe,
136
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Remote object handles and the value encoding of the `serve --stdio` bridge
3
+ * (issue #104, docs/cli-and-bridge.md "Value encoding").
4
+ *
5
+ * JSON values cross the bridge unchanged. Anything else - an engine object, a
6
+ * function, a buffer, a date - is tagged, so a client in any language can hold
7
+ * a Playwright `Page` or a Puppeteer `ElementHandle` as `{"$handle": "h3"}`
8
+ * and pass it back as an argument.
9
+ */
10
+ import { invalidParams } from './rpc-error.js';
11
+
12
+ const TAGS = Object.freeze([
13
+ '$handle',
14
+ '$binary',
15
+ '$function',
16
+ '$undefined',
17
+ '$date',
18
+ '$regexp',
19
+ '$bigint',
20
+ '$error',
21
+ ]);
22
+
23
+ function isModuleNamespace(value) {
24
+ return Object.prototype.toString.call(value) === '[object Module]';
25
+ }
26
+
27
+ /** True for `{}` literals and null-prototype records, not class instances. */
28
+ export function isPlainObject(value) {
29
+ if (value === null || typeof value !== 'object' || isModuleNamespace(value)) {
30
+ return false;
31
+ }
32
+ const prototype = Object.getPrototypeOf(value);
33
+ return prototype === Object.prototype || prototype === null;
34
+ }
35
+
36
+ function isBinary(value) {
37
+ return value instanceof Uint8Array || value instanceof ArrayBuffer;
38
+ }
39
+
40
+ /**
41
+ * The class name a client sees for a handle.
42
+ *
43
+ * Bundlers rename colliding classes (`Page2`), so a trailing number after a
44
+ * lowercase letter is dropped to keep the public name.
45
+ */
46
+ export function typeName(value) {
47
+ if (typeof value === 'function') {
48
+ return 'Function';
49
+ }
50
+ const name = value?.constructor?.name;
51
+ if (!name || isModuleNamespace(value)) {
52
+ return 'Object';
53
+ }
54
+ return name.replace(/(?<=[a-z])\d+$/u, '');
55
+ }
56
+
57
+ /**
58
+ * A table of remote objects with deterministic ids (`h1`, `h2`, …).
59
+ *
60
+ * The same object always gets the same id while it is registered, so a page
61
+ * returned by two calls is one handle.
62
+ */
63
+ export class HandleTable {
64
+ constructor() {
65
+ this.nextId = 1;
66
+ this.byId = new Map();
67
+ this.idOf = new Map();
68
+ }
69
+
70
+ /** Register an object (or reuse its id) and return the encoded handle. */
71
+ register(value) {
72
+ let id = this.idOf.get(value);
73
+ if (!id) {
74
+ id = `h${this.nextId++}`;
75
+ this.byId.set(id, value);
76
+ this.idOf.set(value, id);
77
+ }
78
+ return { $handle: id, type: typeName(value) };
79
+ }
80
+
81
+ /** The object behind an id, or an invalid-params error. */
82
+ get(id) {
83
+ if (!this.byId.has(id)) {
84
+ throw invalidParams(`Unknown handle: ${id}`);
85
+ }
86
+ return this.byId.get(id);
87
+ }
88
+
89
+ /** Forget a handle. Returns whether it existed. */
90
+ dispose(id) {
91
+ if (!this.byId.has(id)) {
92
+ return false;
93
+ }
94
+ this.idOf.delete(this.byId.get(id));
95
+ this.byId.delete(id);
96
+ return true;
97
+ }
98
+ }
99
+
100
+ function encodeError(error) {
101
+ return { $error: { name: error.name, message: error.message } };
102
+ }
103
+
104
+ /**
105
+ * Encode a value for the wire.
106
+ *
107
+ * @param {unknown} value
108
+ * @param {HandleTable} handles - Where non-JSON objects are registered
109
+ * @param {Set<Object>} [seen] - Cycle guard for plain objects and arrays
110
+ * @returns {unknown} JSON-compatible value
111
+ */
112
+ export function encodeValue(value, handles, seen = new Set()) {
113
+ if (value === undefined) {
114
+ return { $undefined: true };
115
+ }
116
+ if (value === null || ['boolean', 'string'].includes(typeof value)) {
117
+ return value;
118
+ }
119
+ if (typeof value === 'number') {
120
+ return Number.isFinite(value) ? value : null;
121
+ }
122
+ if (typeof value === 'bigint') {
123
+ return { $bigint: value.toString() };
124
+ }
125
+ if (typeof value === 'symbol') {
126
+ return value.toString();
127
+ }
128
+ if (isBinary(value)) {
129
+ return { $binary: Buffer.from(value).toString('base64') };
130
+ }
131
+ if (value instanceof Date) {
132
+ return { $date: value.toISOString() };
133
+ }
134
+ if (value instanceof RegExp) {
135
+ return { $regexp: { source: value.source, flags: value.flags } };
136
+ }
137
+ if (value instanceof Error) {
138
+ return encodeError(value);
139
+ }
140
+ const container = Array.isArray(value) || isPlainObject(value);
141
+ if (!container || seen.has(value)) {
142
+ return handles.register(value);
143
+ }
144
+ seen.add(value);
145
+ const encoded = Array.isArray(value)
146
+ ? value.map((item) => encodeValue(item, handles, seen))
147
+ : Object.fromEntries(
148
+ Object.entries(value).map(([key, item]) => [
149
+ key,
150
+ encodeValue(item, handles, seen),
151
+ ])
152
+ );
153
+ seen.delete(value);
154
+ return encoded;
155
+ }
156
+
157
+ /**
158
+ * Compile a `$function` source on the server.
159
+ *
160
+ * The result's `toString()` is the original source, which is what both
161
+ * engines serialize when the function is evaluated in the page.
162
+ */
163
+ export function compileFunction(source) {
164
+ if (typeof source !== 'string' || source.trim() === '') {
165
+ throw invalidParams('$function must be a function source string');
166
+ }
167
+ let compiled;
168
+ try {
169
+ compiled = (0, eval)(`(${source})`);
170
+ } catch (error) {
171
+ throw invalidParams(`Invalid $function source: ${error.message}`);
172
+ }
173
+ if (typeof compiled !== 'function') {
174
+ throw invalidParams('$function source did not evaluate to a function');
175
+ }
176
+ return compiled;
177
+ }
178
+
179
+ function decodeError({ name = 'Error', message = '' } = {}) {
180
+ const error = new Error(message);
181
+ error.name = name;
182
+ return error;
183
+ }
184
+
185
+ const DECODERS = {
186
+ $handle: (tag, handles) => handles.get(tag),
187
+ $binary: (tag) => Buffer.from(String(tag), 'base64'),
188
+ $function: (tag) => compileFunction(tag),
189
+ $undefined: () => undefined,
190
+ $date: (tag) => new Date(tag),
191
+ $regexp: (tag) => new RegExp(tag?.source ?? '', tag?.flags ?? ''),
192
+ $bigint: (tag) => BigInt(tag),
193
+ $error: (tag) => decodeError(tag),
194
+ };
195
+
196
+ function tagOf(value) {
197
+ return TAGS.find((tag) => Object.hasOwn(value, tag));
198
+ }
199
+
200
+ /**
201
+ * Decode a value received from a client.
202
+ *
203
+ * @param {unknown} value
204
+ * @param {HandleTable} handles - Resolves `$handle` references
205
+ * @returns {unknown}
206
+ */
207
+ export function decodeValue(value, handles) {
208
+ if (Array.isArray(value)) {
209
+ return value.map((item) => decodeValue(item, handles));
210
+ }
211
+ if (value === null || typeof value !== 'object') {
212
+ return value;
213
+ }
214
+ const tag = tagOf(value);
215
+ if (tag) {
216
+ return DECODERS[tag](value[tag], handles);
217
+ }
218
+ return Object.fromEntries(
219
+ Object.entries(value).map(([key, item]) => [
220
+ key,
221
+ decodeValue(item, handles),
222
+ ])
223
+ );
224
+ }
225
+
226
+ function isPublicName(name) {
227
+ return (
228
+ typeof name === 'string' && name !== 'constructor' && !name.startsWith('_')
229
+ );
230
+ }
231
+
232
+ function collectMembers(target) {
233
+ const methods = new Set();
234
+ const properties = new Set();
235
+ let current = target;
236
+ while (current && current !== Object.prototype) {
237
+ for (const name of Object.getOwnPropertyNames(current)) {
238
+ if (!isPublicName(name) || (current === target && name === 'prototype')) {
239
+ continue;
240
+ }
241
+ const descriptor = Object.getOwnPropertyDescriptor(current, name);
242
+ if (typeof descriptor.value === 'function') {
243
+ methods.add(name);
244
+ } else {
245
+ properties.add(name);
246
+ }
247
+ }
248
+ current = Object.getPrototypeOf(current);
249
+ }
250
+ for (const name of methods) {
251
+ properties.delete(name);
252
+ }
253
+ return { methods, properties };
254
+ }
255
+
256
+ /**
257
+ * Describe a remote object: every callable member on the object and its
258
+ * prototype chain, and its other public properties.
259
+ *
260
+ * @param {unknown} target
261
+ * @returns {{type: string, methods: string[], properties: string[]}}
262
+ */
263
+ export function describeObject(target) {
264
+ const { methods, properties } =
265
+ target !== null && ['object', 'function'].includes(typeof target)
266
+ ? collectMembers(target)
267
+ : { methods: new Set(), properties: new Set() };
268
+ return {
269
+ type: typeName(target),
270
+ methods: [...methods].sort(),
271
+ properties: [...properties].sort(),
272
+ };
273
+ }