@forgeax/engine-remote 0.1.2

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 (66) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +269 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts +2 -0
  5. package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/console.unit.test.d.ts +2 -0
  7. package/dist/__tests__/console.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/errors.unit.test.d.ts +2 -0
  9. package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/execute-browser-safe.unit.test.d.ts +2 -0
  11. package/dist/__tests__/execute-browser-safe.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/execute-profiler-root.browser.test.d.ts +2 -0
  13. package/dist/__tests__/execute-profiler-root.browser.test.d.ts.map +1 -0
  14. package/dist/__tests__/execute.async.test.d.ts +2 -0
  15. package/dist/__tests__/execute.async.test.d.ts.map +1 -0
  16. package/dist/__tests__/execution-report-root.unit.test.d.ts +2 -0
  17. package/dist/__tests__/execution-report-root.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/introspect-profiler.unit.test.d.ts +2 -0
  19. package/dist/__tests__/introspect-profiler.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/method-roster-profiler.test.d.ts +2 -0
  21. package/dist/__tests__/method-roster-profiler.test.d.ts.map +1 -0
  22. package/dist/__tests__/rhi-capture-remote.integration.test.d.ts +2 -0
  23. package/dist/__tests__/rhi-capture-remote.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/server.unit.test.d.ts +2 -0
  25. package/dist/__tests__/server.unit.test.d.ts.map +1 -0
  26. package/dist/error-messages.d.ts +3 -0
  27. package/dist/error-messages.d.ts.map +1 -0
  28. package/dist/errors.d.ts +82 -0
  29. package/dist/errors.d.ts.map +1 -0
  30. package/dist/errors.mjs +37 -0
  31. package/dist/errors.mjs.map +1 -0
  32. package/dist/execute.d.ts +31 -0
  33. package/dist/execute.d.ts.map +1 -0
  34. package/dist/execute.mjs +93 -0
  35. package/dist/execute.mjs.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.mjs +30 -0
  39. package/dist/index.mjs.map +1 -0
  40. package/dist/introspect.d.ts +21 -0
  41. package/dist/introspect.d.ts.map +1 -0
  42. package/dist/introspect.mjs +173 -0
  43. package/dist/introspect.mjs.map +1 -0
  44. package/dist/server.d.ts +35 -0
  45. package/dist/server.d.ts.map +1 -0
  46. package/dist/server.mjs +473 -0
  47. package/dist/server.mjs.map +1 -0
  48. package/package.json +78 -0
  49. package/src/__tests__/asset-runtime-inspection.unit.test.ts +16 -0
  50. package/src/__tests__/console.unit.test.ts +12 -0
  51. package/src/__tests__/errors.unit.test.ts +102 -0
  52. package/src/__tests__/execute-browser-safe.unit.test.ts +43 -0
  53. package/src/__tests__/execute-profiler-root.browser.test.ts +41 -0
  54. package/src/__tests__/execute.async.test.ts +369 -0
  55. package/src/__tests__/execution-report-root.unit.test.ts +38 -0
  56. package/src/__tests__/introspect-profiler.unit.test.ts +55 -0
  57. package/src/__tests__/method-roster-profiler.test.ts +80 -0
  58. package/src/__tests__/rhi-capture-remote.integration.test.ts +58 -0
  59. package/src/__tests__/server.unit.test.ts +856 -0
  60. package/src/__tests__/vm-async-eval-verify.mjs +149 -0
  61. package/src/error-messages.ts +9 -0
  62. package/src/errors.ts +143 -0
  63. package/src/execute.ts +152 -0
  64. package/src/index.ts +19 -0
  65. package/src/introspect.ts +223 -0
  66. package/src/server.ts +331 -0
@@ -0,0 +1,223 @@
1
+ import { REMOTE_ERROR_MESSAGES } from './error-messages';
2
+ import { REMOTE_ERROR_CODE_TO_JSONRPC, type RemoteErrorCode } from './errors';
3
+
4
+ export interface ComponentIntrospectionDescriptor {
5
+ readonly name: string;
6
+ readonly schema: Readonly<Record<string, string>>;
7
+ readonly fields: Readonly<Record<string, unknown>>;
8
+ readonly meta: Readonly<Record<string, unknown>>;
9
+ }
10
+
11
+ export interface RemoteRootValues {
12
+ readonly world: unknown;
13
+ readonly renderer: unknown;
14
+ readonly assets: unknown;
15
+ readonly rhiCapture?: unknown;
16
+ readonly profiler?: unknown;
17
+ readonly execution?: unknown;
18
+ readonly introspection?: readonly ComponentIntrospectionDescriptor[];
19
+ }
20
+
21
+ type RootProjection = {
22
+ readonly available: true;
23
+ readonly type: string;
24
+ readonly description: string;
25
+ readonly capability?: string;
26
+ readonly phaseCatalog?: unknown;
27
+ readonly operations?: {
28
+ readonly startCapture?: string;
29
+ readonly latestCapture?: string;
30
+ readonly load?: string;
31
+ readonly snapshot?: string;
32
+ readonly subscribe?: string;
33
+ };
34
+ };
35
+
36
+ export function isProfilerRoot(value: unknown): boolean {
37
+ if (value === null || typeof value !== 'object') return false;
38
+ const root = value as {
39
+ startCapture?: unknown;
40
+ latestCapture?: unknown;
41
+ activeSession?: unknown;
42
+ phaseCatalog?: unknown;
43
+ };
44
+ return (
45
+ typeof root.startCapture === 'function' &&
46
+ typeof root.latestCapture === 'function' &&
47
+ typeof root.activeSession === 'function' &&
48
+ root.phaseCatalog !== undefined
49
+ );
50
+ }
51
+
52
+ export function isExecutionRoot(value: unknown): value is { report(): unknown } {
53
+ return (
54
+ value !== null &&
55
+ typeof value === 'object' &&
56
+ typeof (value as { report?: unknown }).report === 'function'
57
+ );
58
+ }
59
+
60
+ function profilerPhaseCatalog(value: unknown): unknown {
61
+ if (value === null || typeof value !== 'object') return undefined;
62
+ const catalog = (value as { phaseCatalog?: unknown }).phaseCatalog;
63
+ return catalog === undefined ? undefined : catalog;
64
+ }
65
+
66
+ function projectRoot(name: string, value: unknown): RootProjection {
67
+ const descriptions: Record<string, { type: string; description: string }> = {
68
+ world: { type: 'World', description: 'The host World instance.' },
69
+ renderer: { type: 'Renderer', description: 'The host Renderer instance.' },
70
+ assets: { type: 'AssetRegistry', description: 'The host AssetRegistry instance.' },
71
+ rhiCapture: {
72
+ type: 'RhiCapture',
73
+ description: 'The opt-in RHI frame capture capability.',
74
+ },
75
+ profiler: {
76
+ type: 'Profiler',
77
+ description: 'The opt-in CPU profiler for bounded App and Render capture.',
78
+ },
79
+ execution: {
80
+ type: 'ExecutionReportProvider',
81
+ description: 'The host execution report provider for tier, health, performance, and fault.',
82
+ },
83
+ };
84
+ const descriptor = descriptions[name] ?? { type: 'unknown', description: 'A live eval root.' };
85
+ return {
86
+ available: true,
87
+ ...descriptor,
88
+ ...(name === 'assets'
89
+ ? {
90
+ operations: {
91
+ load: 'assets.load(guid, expectedKind)',
92
+ snapshot: 'assets.snapshot()',
93
+ subscribe: 'assets.subscribe(listener)',
94
+ },
95
+ }
96
+ : {}),
97
+ ...(name === 'profiler'
98
+ ? {
99
+ capability: 'cpu-profile-v1',
100
+ operations: {
101
+ startCapture: 'profiler.startCapture({ frameLimit, eventLimit })',
102
+ latestCapture: 'profiler.latestCapture() after the host reaches the frame boundary',
103
+ },
104
+ ...(profilerPhaseCatalog(value) === undefined
105
+ ? {}
106
+ : { phaseCatalog: profilerPhaseCatalog(value) }),
107
+ }
108
+ : name === 'execution'
109
+ ? {
110
+ capability: 'execution-report-v1',
111
+ }
112
+ : name === 'rhiCapture'
113
+ ? {
114
+ capability: 'rhi-capture-v1',
115
+ }
116
+ : {}),
117
+ };
118
+ }
119
+
120
+ function projectRoots(roots: RemoteRootValues): Record<string, RootProjection> {
121
+ const projected: Record<string, RootProjection> = {};
122
+ for (const [name, value] of Object.entries(roots)) {
123
+ if (
124
+ value !== undefined &&
125
+ (name !== 'profiler' || isProfilerRoot(value)) &&
126
+ (name !== 'execution' || isExecutionRoot(value))
127
+ ) {
128
+ projected[name] = projectRoot(name, value);
129
+ }
130
+ }
131
+ return projected;
132
+ }
133
+
134
+ function profilerCapability(roots: Record<string, RootProjection>): Record<string, unknown> {
135
+ if (roots.profiler !== undefined) {
136
+ return {
137
+ enabled: true,
138
+ capability: 'cpu-profile-v1',
139
+ limits: {
140
+ frameLimit: 'positive-safe-integer',
141
+ eventLimit: 'positive-safe-integer',
142
+ },
143
+ };
144
+ }
145
+ return {
146
+ enabled: false,
147
+ code: 'profiler-not-enabled',
148
+ expected: 'an explicitly opted-in profiler root',
149
+ hint: 'Pass profiler: createProfiler() to createApp or startServer in development, then retry.',
150
+ detail: { enabled: false },
151
+ limits: {
152
+ frameLimit: 'positive-safe-integer',
153
+ eventLimit: 'positive-safe-integer',
154
+ },
155
+ };
156
+ }
157
+
158
+ function buildErrorProjection(): Record<string, { code: number; message: string }> {
159
+ const errors: Record<string, { code: number; message: string }> = {};
160
+ for (const [code, numericCode] of Object.entries(REMOTE_ERROR_CODE_TO_JSONRPC) as Array<
161
+ [RemoteErrorCode, number]
162
+ >) {
163
+ errors[code] = { code: numericCode, message: REMOTE_ERROR_MESSAGES[code] };
164
+ }
165
+ return errors;
166
+ }
167
+
168
+ export function buildIntrospectDoc(host: string, port: number, roots: RemoteRootValues): unknown {
169
+ const projectedRoots = projectRoots(roots);
170
+ const schemas: Record<string, unknown> = {
171
+ World: { type: 'object', description: 'The host World instance.' },
172
+ Renderer: { type: 'object', description: 'The host Renderer instance.' },
173
+ Assets: { type: 'object', description: 'The host AssetRegistry instance.' },
174
+ };
175
+ for (const [name, root] of Object.entries(projectedRoots)) {
176
+ schemas[root.type] = { type: 'object', description: root.description };
177
+ if (name === 'profiler') {
178
+ schemas.ProfilerCapture = {
179
+ type: 'object',
180
+ description: 'A bounded ProfileCapture v1 artifact returned through eval.',
181
+ };
182
+ }
183
+ }
184
+ for (const descriptor of roots.introspection ?? []) {
185
+ schemas[descriptor.name] = descriptor;
186
+ }
187
+ return {
188
+ openrpc: '1.3.2',
189
+ info: {
190
+ title: '@forgeax/engine-remote remote eval',
191
+ version: '0.0.0',
192
+ description:
193
+ 'Remote eval server. Methods: eval / introspect. Errors map to JSON-RPC -32001..-32005.',
194
+ },
195
+ servers: [{ name: 'in-process', url: `ws://${host}:${port}/inspector` }],
196
+ methods: [
197
+ {
198
+ name: 'eval',
199
+ summary: 'Evaluate a JavaScript script against live eval roots.',
200
+ params: [
201
+ {
202
+ name: 'script',
203
+ required: true,
204
+ schema: { type: 'string' },
205
+ },
206
+ ],
207
+ result: { name: 'value', schema: { type: 'object' } },
208
+ },
209
+ {
210
+ name: 'introspect',
211
+ summary: 'Return this OpenRPC L2 subset document.',
212
+ params: [],
213
+ result: { name: 'document', schema: { type: 'object' } },
214
+ },
215
+ ],
216
+ roots: projectedRoots,
217
+ capabilities: { profiler: profilerCapability(projectedRoots) },
218
+ components: {
219
+ schemas,
220
+ errors: buildErrorProjection(),
221
+ },
222
+ };
223
+ }
package/src/server.ts ADDED
@@ -0,0 +1,331 @@
1
+ // @forgeax/engine-remote/src/server — in-process remote eval server.
2
+ //
3
+ // Wire format: JSON-RPC 2.0 with two methods:
4
+ // - introspect() -> OpenRPC L2 subset doc
5
+ // - eval({script}) -> eval the script against world/renderer/assets
6
+ //
7
+ // Lifecycle:
8
+ // startServer({ port, host?, world, renderer?, assets? })
9
+ // -> Promise<Result<ConsoleHandle, RemoteError>>
10
+ //
11
+ // The sandbox layer is dismantled — eval is full-access (route B).
12
+
13
+ import { err, ok, type Result } from '@forgeax/engine-types';
14
+ import type { WebSocket } from 'ws';
15
+ import { WebSocketServer } from 'ws';
16
+ import { REMOTE_ERROR_MESSAGES } from './error-messages';
17
+ import { REMOTE_ERROR_CODE_TO_JSONRPC, RemoteError } from './errors';
18
+ import { executeScript } from './execute';
19
+ import {
20
+ buildIntrospectDoc,
21
+ type ComponentIntrospectionDescriptor,
22
+ isExecutionRoot,
23
+ isProfilerRoot,
24
+ } from './introspect';
25
+
26
+ export type { ComponentIntrospectionDescriptor } from './introspect';
27
+
28
+ const REMOTE_TO_JSONRPC = REMOTE_ERROR_CODE_TO_JSONRPC;
29
+
30
+ export type { Result };
31
+
32
+ export type ConsoleHandle = {
33
+ readonly port: number;
34
+ readonly close: () => Promise<void>;
35
+ };
36
+
37
+ export type StartServerOptions = {
38
+ readonly port: number;
39
+ readonly host?: string;
40
+ readonly world: unknown;
41
+ readonly renderer?: unknown;
42
+ readonly assets?: unknown;
43
+ /** JSON-safe host reflection; the remote package does not know component owners. */
44
+ readonly introspection?: readonly ComponentIntrospectionDescriptor[];
45
+ /** Explicit CPU profiler capability; omitted unless the host opts in. */
46
+ readonly profiler?: unknown;
47
+ /** Structural report provider; remote never imports the App owner. */
48
+ readonly execution?: unknown;
49
+ /** Host-owned dynamic module resolver; omitted for package-neutral eval. */
50
+ readonly importModule?: (specifier: string) => Promise<unknown>;
51
+ /**
52
+ * Host-owned RHI capture capability for eval-scope injection (plan-strategy D-4).
53
+ * It is exposed as the single `rhiCapture` eval root.
54
+ * Undefined when FORGEAX_ENGINE_RHI_DEBUG !== '1'.
55
+ */
56
+ readonly rhiCapture?: unknown;
57
+ };
58
+
59
+ type JsonRpcRequest = {
60
+ jsonrpc?: unknown;
61
+ method?: unknown;
62
+ params?: unknown;
63
+ id?: unknown;
64
+ };
65
+
66
+ type JsonRpcError = {
67
+ code: number;
68
+ message: string;
69
+ data?: unknown;
70
+ };
71
+
72
+ type JsonRpcResponse = {
73
+ jsonrpc: '2.0';
74
+ id: number | string | null;
75
+ } & ({ result: unknown } | { error: JsonRpcError });
76
+
77
+ function inspectorErrorToJsonRpc(e: RemoteError): JsonRpcError {
78
+ const data: Record<string, unknown> = {
79
+ code: e.code,
80
+ expected: e.expected,
81
+ hint: e.hint,
82
+ message: e.message,
83
+ };
84
+ if (e.detail !== undefined) data.detail = e.detail;
85
+ return {
86
+ code: REMOTE_TO_JSONRPC[e.code],
87
+ message: REMOTE_ERROR_MESSAGES[e.code],
88
+ data,
89
+ };
90
+ }
91
+
92
+ function respondError(
93
+ id: number | string | null,
94
+ code: number,
95
+ message: string,
96
+ data?: unknown,
97
+ ): JsonRpcResponse {
98
+ const error: JsonRpcError = { code, message };
99
+ if (data !== undefined) {
100
+ error.data = data;
101
+ }
102
+ return { jsonrpc: '2.0', id, error };
103
+ }
104
+
105
+ function respondOk(id: number | string | null, result: unknown): JsonRpcResponse {
106
+ return { jsonrpc: '2.0', id, result };
107
+ }
108
+
109
+ type EvalResultShape = 'bigint' | 'cyclic-object' | 'unsupported';
110
+
111
+ const EVAL_RESULT_NOT_SERIALIZABLE_EXPECTED = 'eval result is JSON-serializable';
112
+ const EVAL_RESULT_NOT_SERIALIZABLE_HINT =
113
+ 'return a JSON-safe value; BigInt and cyclic objects are unsupported over JSON-RPC';
114
+
115
+ function classifyEvalResultShape(value: unknown): EvalResultShape {
116
+ if (typeof value === 'bigint') return 'bigint';
117
+ if (value !== null && typeof value === 'object') return 'cyclic-object';
118
+ return 'unsupported';
119
+ }
120
+
121
+ function serializeResponse(response: JsonRpcResponse): string {
122
+ try {
123
+ const serialized = JSON.stringify(response);
124
+ if (serialized !== undefined) return serialized;
125
+ } catch {
126
+ // A successful eval result can be a valid JavaScript value that JSON-RPC
127
+ // cannot carry. Convert that transport-only failure below.
128
+ }
129
+
130
+ if (!('result' in response)) {
131
+ return JSON.stringify(respondError(response.id, -32603, 'Internal error')) as string;
132
+ }
133
+
134
+ const error = new RemoteError({
135
+ code: 'eval-result-not-serializable',
136
+ expected: EVAL_RESULT_NOT_SERIALIZABLE_EXPECTED,
137
+ hint: EVAL_RESULT_NOT_SERIALIZABLE_HINT,
138
+ detail: {
139
+ code: 'eval-result-not-serializable',
140
+ shape: classifyEvalResultShape(response.result),
141
+ },
142
+ });
143
+ return JSON.stringify({
144
+ jsonrpc: '2.0',
145
+ id: response.id,
146
+ error: inspectorErrorToJsonRpc(error),
147
+ }) as string;
148
+ }
149
+
150
+ function isValidId(v: unknown): v is number | string | null {
151
+ return typeof v === 'number' || typeof v === 'string' || v === null;
152
+ }
153
+
154
+ async function handleEnvelope(
155
+ raw: string,
156
+ ctx: {
157
+ world: unknown;
158
+ renderer: unknown;
159
+ assets: unknown;
160
+ rhiCapture: unknown | undefined;
161
+ introspection: readonly ComponentIntrospectionDescriptor[];
162
+ profiler: unknown | undefined;
163
+ execution: unknown | undefined;
164
+ importModule: ((specifier: string) => Promise<unknown>) | undefined;
165
+ host: string;
166
+ port: number;
167
+ },
168
+ ): Promise<JsonRpcResponse | null> {
169
+ let parsed: JsonRpcRequest | null;
170
+ try {
171
+ parsed = JSON.parse(raw) as JsonRpcRequest;
172
+ } catch {
173
+ return respondError(null, -32700, 'Parse error');
174
+ }
175
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
176
+ return respondError(null, -32600, 'Invalid Request');
177
+ }
178
+ if (typeof parsed.method !== 'string') {
179
+ const id = isValidId(parsed.id) ? parsed.id : null;
180
+ return respondError(id, -32600, 'Invalid Request');
181
+ }
182
+ const isNotification = !('id' in parsed);
183
+ const id = isValidId(parsed.id) ? parsed.id : null;
184
+
185
+ let response: JsonRpcResponse;
186
+ if (parsed.method === 'introspect') {
187
+ response = respondOk(
188
+ id,
189
+ buildIntrospectDoc(ctx.host, ctx.port, {
190
+ world: ctx.world,
191
+ renderer: ctx.renderer,
192
+ assets: ctx.assets,
193
+ ...(ctx.rhiCapture !== undefined ? { rhiCapture: ctx.rhiCapture } : {}),
194
+ ...(ctx.profiler !== undefined ? { profiler: ctx.profiler } : {}),
195
+ ...(ctx.execution !== undefined ? { execution: ctx.execution } : {}),
196
+ ...(ctx.introspection.length > 0 ? { introspection: ctx.introspection } : {}),
197
+ }),
198
+ );
199
+ } else if (parsed.method === 'eval') {
200
+ const params = parsed.params as { script?: unknown } | undefined;
201
+ const script = params?.script;
202
+ if (typeof script !== 'string') {
203
+ response = respondError(id, -32602, 'Invalid params: eval requires { script: string }');
204
+ } else {
205
+ const result = await executeScript(script, {
206
+ world: ctx.world,
207
+ renderer: ctx.renderer,
208
+ assets: ctx.assets,
209
+ rhiCapture: ctx.rhiCapture,
210
+ profiler: ctx.profiler,
211
+ execution: ctx.execution,
212
+ ...(ctx.importModule === undefined ? {} : { importModule: ctx.importModule }),
213
+ });
214
+ if (result.ok) {
215
+ response = respondOk(id, result.value);
216
+ } else {
217
+ response = { jsonrpc: '2.0', id, error: inspectorErrorToJsonRpc(result.error) };
218
+ }
219
+ }
220
+ } else {
221
+ response = respondError(id, -32601, `Method not found: ${parsed.method}`);
222
+ }
223
+
224
+ return isNotification ? null : response;
225
+ }
226
+
227
+ /**
228
+ * Start the remote eval WebSocket server.
229
+ */
230
+ export function startServer(opts: StartServerOptions): Promise<Result<ConsoleHandle, RemoteError>> {
231
+ return new Promise<Result<ConsoleHandle, RemoteError>>((resolve) => {
232
+ const host = opts.host ?? '127.0.0.1';
233
+ if (host !== '127.0.0.1' && host !== 'localhost') {
234
+ console.warn(
235
+ `[@forgeax/engine-remote] WARNING: binding on non-loopback host '${host}'. P0 trusts localhost only.`,
236
+ );
237
+ }
238
+ const wss = new WebSocketServer({
239
+ host,
240
+ port: opts.port,
241
+ path: '/inspector',
242
+ maxPayload: 1 << 20,
243
+ perMessageDeflate: false,
244
+ });
245
+
246
+ const world = opts.world;
247
+ const renderer = opts.renderer ?? {};
248
+ const assets = opts.assets ?? {};
249
+ const rhiCapture = opts.rhiCapture;
250
+ const introspection = opts.introspection ?? [];
251
+ const profiler = isProfilerRoot(opts.profiler) ? opts.profiler : undefined;
252
+ const execution = isExecutionRoot(opts.execution) ? opts.execution : undefined;
253
+ const importModule = opts.importModule;
254
+ let settled = false;
255
+ let boundPort = opts.port;
256
+
257
+ wss.on('error', (e: NodeJS.ErrnoException) => {
258
+ if (settled) {
259
+ console.error('[@forgeax/engine-remote] post-startup error:', e);
260
+ return;
261
+ }
262
+ settled = true;
263
+ const errno = e.code ?? 'unknown';
264
+ resolve(
265
+ err(
266
+ new RemoteError({
267
+ code: 'server-startup-failed',
268
+ expected: 'server starts successfully on requested port',
269
+ hint:
270
+ errno === 'EADDRINUSE'
271
+ ? `port ${opts.port} is already in use; lsof -i :${opts.port} or pick a different port`
272
+ : `listen failed with errno ${errno}; check host '${host}' on this machine; port=${opts.port}`,
273
+ }),
274
+ ),
275
+ );
276
+ });
277
+
278
+ wss.on('connection', (ws: WebSocket) => {
279
+ ws.on('message', (raw) => {
280
+ const text = typeof raw === 'string' ? raw : raw.toString();
281
+ handleEnvelope(text, {
282
+ world,
283
+ renderer,
284
+ assets,
285
+ rhiCapture,
286
+ introspection,
287
+ profiler,
288
+ execution,
289
+ importModule,
290
+ host,
291
+ port: boundPort,
292
+ })
293
+ .then((response) => {
294
+ if (response !== null && ws.readyState === ws.OPEN) {
295
+ ws.send(serializeResponse(response));
296
+ }
297
+ })
298
+ .catch((e: unknown) => {
299
+ if (ws.readyState === ws.OPEN) {
300
+ ws.send(JSON.stringify(respondError(null, -32603, `Internal error: ${String(e)}`)));
301
+ }
302
+ });
303
+ });
304
+ });
305
+
306
+ wss.on('listening', () => {
307
+ if (settled) {
308
+ return;
309
+ }
310
+ settled = true;
311
+ const address = wss.address();
312
+ const port = typeof address === 'object' && address !== null ? address.port : opts.port;
313
+ let closePromise: Promise<void> | undefined;
314
+ boundPort = port;
315
+ const handle: ConsoleHandle = {
316
+ port,
317
+ close: () => {
318
+ if (closePromise !== undefined) return closePromise;
319
+ closePromise = new Promise<void>((closeResolve) => {
320
+ for (const client of wss.clients) {
321
+ client.terminate();
322
+ }
323
+ wss.close(() => closeResolve());
324
+ });
325
+ return closePromise;
326
+ },
327
+ };
328
+ resolve(ok(handle));
329
+ });
330
+ });
331
+ }