@forgeax/engine-remote 0.1.4 → 0.1.6

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 (42) hide show
  1. package/README.md +3 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/__tests__/cli-defaults.unit.test.d.ts +2 -0
  4. package/dist/__tests__/cli-defaults.unit.test.d.ts.map +1 -0
  5. package/dist/__tests__/simulation-inspect.integration.test.d.ts +2 -0
  6. package/dist/__tests__/simulation-inspect.integration.test.d.ts.map +1 -0
  7. package/dist/cli.d.ts +15 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.mjs +336 -0
  10. package/dist/cli.mjs.map +1 -0
  11. package/dist/defineSubcommand.d.ts +40 -0
  12. package/dist/defineSubcommand.d.ts.map +1 -0
  13. package/dist/execute.d.ts +1 -0
  14. package/dist/execute.d.ts.map +1 -1
  15. package/dist/execute.mjs +2 -0
  16. package/dist/execute.mjs.map +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/introspect.d.ts +2 -0
  19. package/dist/introspect.d.ts.map +1 -1
  20. package/dist/introspect.mjs +5 -8
  21. package/dist/introspect.mjs.map +1 -1
  22. package/dist/server.d.ts +2 -2
  23. package/dist/server.d.ts.map +1 -1
  24. package/dist/server.mjs +20 -58
  25. package/dist/server.mjs.map +1 -1
  26. package/package.json +6 -3
  27. package/src/__tests__/cli-defaults.unit.test.ts +69 -0
  28. package/src/__tests__/console.unit.test.ts +676 -9
  29. package/src/__tests__/errors.unit.test.ts +3 -17
  30. package/src/__tests__/execute.async.test.ts +17 -13
  31. package/src/__tests__/server.unit.test.ts +19 -262
  32. package/src/__tests__/simulation-inspect.integration.test.ts +67 -0
  33. package/src/__tests__/vm-async-eval-verify.mjs +1 -1
  34. package/src/cli.ts +333 -0
  35. package/src/defineSubcommand.ts +169 -0
  36. package/src/execute.ts +3 -0
  37. package/src/index.ts +5 -4
  38. package/src/introspect.ts +10 -15
  39. package/src/server.ts +14 -60
  40. package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts +0 -2
  41. package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts.map +0 -1
  42. package/src/__tests__/asset-runtime-inspection.unit.test.ts +0 -16
@@ -1,5 +1,5 @@
1
1
  // w11: RemoteErrorCode completeness unit test (5-member closed union).
2
- // Verifies that the error-code family has exactly 5 members after
2
+ // Verifies that the error-code family has exactly 4 members after
3
3
  // deleting inspector-write-denied + script-timeout in w7.
4
4
 
5
5
  import { describe, expect, it } from 'vitest';
@@ -39,7 +39,7 @@ describe('RemoteErrorCode closed union — 5-member completeness', () => {
39
39
  });
40
40
 
41
41
  describe('RemoteError construction + toJSON', () => {
42
- it('constructs with the core structured fields', () => {
42
+ it('constructs with all 4 fields', () => {
43
43
  const err = new RemoteError({
44
44
  code: 'script-syntax-error',
45
45
  expected: 'valid JS',
@@ -51,7 +51,7 @@ describe('RemoteError construction + toJSON', () => {
51
51
  expect(err.message).toContain('[RemoteError script-syntax-error]');
52
52
  });
53
53
 
54
- it('toJSON() returns the core plain object when detail is absent', () => {
54
+ it('toJSON() returns 4-field plain object', () => {
55
55
  const err = new RemoteError({
56
56
  code: 'server-not-running',
57
57
  expected: 'server reachable',
@@ -85,18 +85,4 @@ describe('RemoteError construction + toJSON', () => {
85
85
  expect(err.code).toBe('script-runtime-error');
86
86
  expect(err.expected).toBe('script executes');
87
87
  });
88
-
89
- it('toJSON retains only the bounded result-shape detail', () => {
90
- const err = new RemoteError({
91
- code: 'eval-result-not-serializable',
92
- expected: 'eval result is JSON-serializable',
93
- hint: 'return a JSON-safe value',
94
- detail: { code: 'eval-result-not-serializable', shape: 'cyclic-object' },
95
- });
96
- expect(err.toJSON()).toMatchObject({
97
- code: 'eval-result-not-serializable',
98
- detail: { code: 'eval-result-not-serializable', shape: 'cyclic-object' },
99
- });
100
- expect(JSON.stringify(err)).not.toContain('privateMarker');
101
- });
102
88
  });
@@ -18,7 +18,21 @@ import { executeScript } from '../execute';
18
18
 
19
19
  function makeMockWorld() {
20
20
  const state: unknown[] = [];
21
+ // Archetype shape retained for scripts that inspect the mock World internals:
22
+ // - columns: Map<compId, Map<fieldName, { length: number }>>
23
+ // The Entity component (id=0) has field 'self' with Uint32Array length.
24
+ function mkArchetype(id: number) {
25
+ const selfField = new Map([['self', { length: 3 }]]);
26
+ const columns = new Map([[0, selfField]]);
27
+ return { id, componentIds: [0], columns };
28
+ }
21
29
  return {
30
+ _getGraph() {
31
+ return {
32
+ generation: 1,
33
+ archetypes: [mkArchetype(0), mkArchetype(1)],
34
+ };
35
+ },
22
36
  inspect(): { entityCount: number } {
23
37
  return { entityCount: state.length + 5 };
24
38
  },
@@ -41,17 +55,7 @@ const mockRenderer = {
41
55
  /* no-op */
42
56
  },
43
57
  };
44
- const mockAssets = {
45
- load(guid: string, expectedKind: string) {
46
- return Promise.resolve({ ok: true, value: { guid, kind: expectedKind } });
47
- },
48
- snapshot() {
49
- return { scopeId: 'test-scope', generation: 1, entries: [] };
50
- },
51
- subscribe() {
52
- return () => {};
53
- },
54
- };
58
+ const mockAssets = { HANDLE_CUBE: 1, HANDLE_TRIANGLE: 2 };
55
59
 
56
60
  function makeCtx() {
57
61
  return {
@@ -89,9 +93,9 @@ describe('executeScript async - read (w1)', () => {
89
93
  }
90
94
  });
91
95
 
92
- it('reads the AssetRegistry snapshot', async () => {
96
+ it('reads asset handle constants', async () => {
93
97
  const ctx = makeCtx();
94
- const result = await executeScript('assets.snapshot().generation', ctx);
98
+ const result = await executeScript('assets.HANDLE_CUBE', ctx);
95
99
  expect(result.ok).toBe(true);
96
100
  if (result.ok) {
97
101
  expect(result.value).toBe(1);
@@ -8,7 +8,6 @@
8
8
  // source as ancestorTitles[0]. Top-level imports merged + deduped.
9
9
 
10
10
  import { createProfiler } from '@forgeax/engine-profiler';
11
- import { defaultConnect } from '@forgeax/engine-types/inspector-client';
12
11
  import { describe, expect, it } from 'vitest';
13
12
  import { WebSocket } from 'ws';
14
13
  import { RemoteError } from '../errors';
@@ -37,7 +36,6 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
37
36
  expected: string;
38
37
  hint: string;
39
38
  message?: string;
40
- detail?: { code: string; shape: string };
41
39
  };
42
40
  };
43
41
  id: number | string | null;
@@ -68,22 +66,6 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
68
66
  });
69
67
  }
70
68
 
71
- async function sendRaw(ws: WebSocket, raw: string): Promise<JsonRpcResponse> {
72
- return new Promise<JsonRpcResponse>((resolve, reject) => {
73
- const handler = (message: WebSocket.RawData): void => {
74
- ws.off('message', handler);
75
- try {
76
- const parsed = JSON.parse(message.toString()) as JsonRpcResponse;
77
- resolve(parsed);
78
- } catch (e) {
79
- reject(e);
80
- }
81
- };
82
- ws.on('message', handler);
83
- ws.send(raw);
84
- });
85
- }
86
-
87
69
  async function withServer(
88
70
  fn: (handle: ConsoleHandle) => Promise<void>,
89
71
  opts: {
@@ -92,7 +74,6 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
92
74
  renderer?: unknown;
93
75
  assets?: unknown;
94
76
  profiler?: unknown;
95
- importModule?: (specifier: string) => Promise<unknown>;
96
77
  } = {},
97
78
  ): Promise<void> {
98
79
  const startResult = await startServer({
@@ -102,7 +83,6 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
102
83
  renderer: opts.renderer,
103
84
  assets: opts.assets,
104
85
  profiler: opts.profiler,
105
- ...(opts.importModule === undefined ? {} : { importModule: opts.importModule }),
106
86
  });
107
87
  if (!startResult.ok) {
108
88
  throw startResult.error;
@@ -123,39 +103,9 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
123
103
  expect(typeof handle.close).toBe('function');
124
104
  });
125
105
  });
126
-
127
- it('close is idempotent', async () => {
128
- await withServer(async (handle) => {
129
- await handle.close();
130
- await handle.close();
131
- });
132
- });
133
106
  });
134
107
 
135
108
  describe('JSON-RPC envelope shape', () => {
136
- it('uses the host import resolver without coupling remote to engine packages', async () => {
137
- await withServer(
138
- async (handle) => {
139
- const ws = await connect(handle.port);
140
- const resp = await send(ws, {
141
- jsonrpc: '2.0',
142
- method: 'eval',
143
- params: {
144
- script: "const module = await _import('virtual:host-module'); return module.value",
145
- },
146
- id: 11,
147
- });
148
- expect(resp.result).toBe('host-resolved');
149
- expect(resp.error).toBeUndefined();
150
- ws.close();
151
- },
152
- {
153
- importModule: async (specifier) =>
154
- specifier === 'virtual:host-module' ? { value: 'host-resolved' } : {},
155
- },
156
- );
157
- });
158
-
159
109
  it('response carries jsonrpc + id and either result or error (mutually exclusive)', async () => {
160
110
  await withServer(async (handle) => {
161
111
  const ws = await connect(handle.port);
@@ -208,58 +158,6 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
208
158
  ws.close();
209
159
  });
210
160
  });
211
-
212
- it('null envelope returns -32600 without root execution and preserves the connection', async () => {
213
- let worldReads = 0;
214
- const world = new Proxy(
215
- {},
216
- {
217
- get(_target, property) {
218
- worldReads += 1;
219
- throw new Error(`unexpected world read: ${String(property)}`);
220
- },
221
- },
222
- );
223
-
224
- await withServer(
225
- async (handle) => {
226
- const ws = await connect(handle.port);
227
- let closeCount = 0;
228
- ws.on('close', () => {
229
- closeCount += 1;
230
- });
231
-
232
- const invalid = await sendRaw(ws, 'null');
233
- expect(invalid).toEqual({
234
- jsonrpc: '2.0',
235
- id: null,
236
- error: { code: -32600, message: 'Invalid Request' },
237
- });
238
- expect(worldReads).toBe(0);
239
-
240
- const introspect = await send(ws, { jsonrpc: '2.0', method: 'introspect', id: 1 });
241
- expect(introspect.jsonrpc).toBe('2.0');
242
- expect(introspect.id).toBe(1);
243
- expect(introspect.result).toBeDefined();
244
-
245
- const evaluated = await send(ws, {
246
- jsonrpc: '2.0',
247
- method: 'eval',
248
- params: { script: 'return 7' },
249
- id: 2,
250
- });
251
- expect(evaluated).toEqual({ jsonrpc: '2.0', id: 2, result: 7 });
252
-
253
- const repeated = await sendRaw(ws, 'null');
254
- expect(repeated).toEqual(invalid);
255
- expect(worldReads).toBe(0);
256
- expect(ws.readyState).toBe(ws.OPEN);
257
- expect(closeCount).toBe(0);
258
- ws.close();
259
- },
260
- { world },
261
- );
262
- });
263
161
  });
264
162
 
265
163
  describe('introspect() OpenRPC L2 subset', () => {
@@ -497,14 +395,18 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
497
395
  });
498
396
 
499
397
  it('renderer read returns real field value', async () => {
500
- const stubRenderer = { backend: 'webgpu', isReady: true };
398
+ const stubRenderer = {
399
+ backend: 'webgpu',
400
+ isReady: true,
401
+ inspect: () => ({ capabilities: { backendKind: 'webgpu' } }),
402
+ };
501
403
  await withServer(
502
404
  async (handle) => {
503
405
  const ws = await connect(handle.port);
504
406
  const resp = await send(ws, {
505
407
  jsonrpc: '2.0',
506
408
  method: 'eval',
507
- params: { script: 'renderer.backend' },
409
+ params: { script: 'renderer.inspect().capabilities.backendKind' },
508
410
  id: 21,
509
411
  });
510
412
  expect(resp.result).toBe('webgpu');
@@ -545,157 +447,6 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
545
447
  });
546
448
  });
547
449
 
548
- it('successful non-JSON results return a structured error and preserve the socket', async () => {
549
- const world = {
550
- count: 0,
551
- bump() {
552
- this.count += 1;
553
- },
554
- inspect() {
555
- return { count: this.count, stable: true };
556
- },
557
- };
558
- const renderer = {
559
- inspect() {
560
- return { renderer: 'stable' };
561
- },
562
- };
563
- await withServer(
564
- async (handle) => {
565
- const ws = await connect(handle.port);
566
- const cyclic = await send(ws, {
567
- jsonrpc: '2.0',
568
- method: 'eval',
569
- params: {
570
- script:
571
- "world.bump(); const value = { privateMarker: 'must-not-cross-wire' }; value.self = value; return value;",
572
- },
573
- id: 'cyclic-result',
574
- });
575
- expect(cyclic).toMatchObject({
576
- jsonrpc: '2.0',
577
- id: 'cyclic-result',
578
- error: {
579
- code: -32005,
580
- message: 'Eval result not serializable',
581
- data: {
582
- code: 'eval-result-not-serializable',
583
- expected: 'eval result is JSON-serializable',
584
- hint: 'return a JSON-safe value; BigInt and cyclic objects are unsupported over JSON-RPC',
585
- detail: {
586
- code: 'eval-result-not-serializable',
587
- shape: 'cyclic-object',
588
- },
589
- },
590
- },
591
- });
592
- expect(JSON.stringify(cyclic)).not.toContain('privateMarker');
593
-
594
- const afterCyclic = await send(ws, {
595
- jsonrpc: '2.0',
596
- method: 'eval',
597
- params: { script: 'world.inspect()' },
598
- id: 'after-cyclic',
599
- });
600
- expect(afterCyclic).toEqual({
601
- jsonrpc: '2.0',
602
- id: 'after-cyclic',
603
- result: { count: 1, stable: true },
604
- });
605
-
606
- let notificationReceived = false;
607
- const notificationHandler = () => {
608
- notificationReceived = true;
609
- };
610
- ws.on('message', notificationHandler);
611
- ws.send(
612
- JSON.stringify({
613
- jsonrpc: '2.0',
614
- method: 'eval',
615
- params: { script: 'world.bump(); return 7' },
616
- }),
617
- );
618
- await new Promise((resolve) => setTimeout(resolve, 50));
619
- ws.off('message', notificationHandler);
620
- expect(notificationReceived).toBe(false);
621
-
622
- const afterNotification = await send(ws, {
623
- jsonrpc: '2.0',
624
- method: 'eval',
625
- params: { script: 'renderer.inspect()' },
626
- id: 'after-notification',
627
- });
628
- expect(afterNotification.result).toEqual({ renderer: 'stable' });
629
-
630
- const bigint = await send(ws, {
631
- jsonrpc: '2.0',
632
- method: 'eval',
633
- params: { script: 'world.bump(); return 1n' },
634
- id: 'bigint-result',
635
- });
636
- expect(bigint).toMatchObject({
637
- jsonrpc: '2.0',
638
- id: 'bigint-result',
639
- error: {
640
- code: -32005,
641
- message: 'Eval result not serializable',
642
- data: {
643
- code: 'eval-result-not-serializable',
644
- detail: { code: 'eval-result-not-serializable', shape: 'bigint' },
645
- },
646
- },
647
- });
648
-
649
- const afterBigInt = await send(ws, {
650
- jsonrpc: '2.0',
651
- method: 'eval',
652
- params: { script: 'world.inspect()' },
653
- id: 'after-bigint',
654
- });
655
- expect(afterBigInt).toEqual({
656
- jsonrpc: '2.0',
657
- id: 'after-bigint',
658
- result: { count: 3, stable: true },
659
- });
660
- ws.close();
661
- },
662
- { world, renderer },
663
- );
664
- });
665
-
666
- it('shared inspector client preserves structured serialization errors and recovers', async () => {
667
- const world = {
668
- count: 0,
669
- bump() {
670
- this.count += 1;
671
- },
672
- inspect() {
673
- return { count: this.count, stable: true };
674
- },
675
- };
676
- await withServer(
677
- async (handle) => {
678
- const connected = await defaultConnect(`ws://127.0.0.1:${handle.port}/inspector`);
679
- expect(connected.ok).toBe(true);
680
- if (!connected.ok) return;
681
- const client = connected.value;
682
- await expect(
683
- client.eval(
684
- "world.bump(); const value = { secret: 'hidden' }; value.self = value; return value",
685
- ),
686
- ).rejects.toMatchObject({
687
- code: 'eval-result-not-serializable',
688
- expected: 'eval result is JSON-serializable',
689
- detail: { code: 'eval-result-not-serializable', shape: 'cyclic-object' },
690
- });
691
- await expect(client.eval('world.inspect()')).resolves.toEqual({ count: 1, stable: true });
692
- await client.dispose();
693
- await client.dispose();
694
- },
695
- { world },
696
- );
697
- });
698
-
699
450
  it('JSON-RPC error envelopes preserve exact human messages', async () => {
700
451
  await withServer(async (handle) => {
701
452
  const ws = await connect(handle.port);
@@ -771,10 +522,10 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
771
522
  );
772
523
  });
773
524
 
774
- it('assets.load() succeeds (full-access, no sandbox)', async () => {
525
+ it('assets.register() succeeds (full-access, no sandbox)', async () => {
775
526
  const stubAssets = {
776
- load(guid: string, kind: string): Promise<unknown> {
777
- return Promise.resolve({ ok: true, value: { guid, kind } });
527
+ register(_a: unknown): number {
528
+ return 42;
778
529
  },
779
530
  };
780
531
  await withServer(
@@ -783,10 +534,10 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
783
534
  const resp = await send(ws, {
784
535
  jsonrpc: '2.0',
785
536
  method: 'eval',
786
- params: { script: 'await assets.load("asset-guid", "mesh")' },
537
+ params: { script: 'assets.register({ kind: "mesh" })' },
787
538
  id: 51,
788
539
  });
789
- expect(resp.result).toEqual({ ok: true, value: { guid: 'asset-guid', kind: 'mesh' } });
540
+ expect(resp.result).toBe(42);
790
541
  ws.close();
791
542
  },
792
543
  { world: {}, assets: stubAssets },
@@ -800,13 +551,19 @@ import { type ComponentIntrospectionDescriptor, type ConsoleHandle, startServer
800
551
  const resp = await send(ws, {
801
552
  jsonrpc: '2.0',
802
553
  method: 'eval',
803
- params: { script: 'renderer.backend' },
554
+ params: { script: 'renderer.inspect().capabilities.backendKind' },
804
555
  id: 52,
805
556
  });
806
557
  expect(resp.result).toBe('webgpu');
807
558
  ws.close();
808
559
  },
809
- { world: {}, renderer: { backend: 'webgpu' } },
560
+ {
561
+ world: {},
562
+ renderer: {
563
+ backend: 'webgpu',
564
+ inspect: () => ({ capabilities: { backendKind: 'webgpu' } }),
565
+ },
566
+ },
810
567
  );
811
568
  });
812
569
  });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildIntrospectDoc } from '../introspect';
4
+ import { startServer } from '../server';
5
+
6
+ describe('remote simulation inspection projection', () => {
7
+ it('projects the existing simulation read root without adding remote actions', () => {
8
+ const doc = buildIntrospectDoc('127.0.0.1', 5732, {
9
+ world: {},
10
+ renderer: {},
11
+ assets: {},
12
+ simulation: {
13
+ inspect: () => ({
14
+ formatVersion: 1,
15
+ recordOwner: '@forgeax/engine-ecs',
16
+ schemaOwner: '@forgeax/engine-ecs',
17
+ participants: [],
18
+ trace: { recordTick: 0, sampleCount: 0 },
19
+ report: { verdict: 'match', entries: [] },
20
+ }),
21
+ },
22
+ }) as {
23
+ roots: Record<string, { type: string; description: string }>;
24
+ methods: readonly { name: string }[];
25
+ };
26
+
27
+ expect(doc.roots.simulation).toMatchObject({
28
+ available: true,
29
+ type: 'SimulationInspection',
30
+ });
31
+ expect(doc.methods.map((method) => method.name)).toEqual(['eval', 'introspect']);
32
+ });
33
+
34
+ it('keeps simulation inspection on the existing eval transport', async () => {
35
+ const started = await startServer({
36
+ port: 0,
37
+ host: '127.0.0.1',
38
+ world: {},
39
+ simulation: {
40
+ inspect: () => ({
41
+ formatVersion: 1,
42
+ recordOwner: '@forgeax/engine-ecs',
43
+ schemaOwner: '@forgeax/engine-ecs',
44
+ participants: [],
45
+ trace: { recordTick: 0, sampleCount: 0 },
46
+ report: { verdict: 'match', entries: [] },
47
+ }),
48
+ },
49
+ });
50
+ expect(started.ok).toBe(true);
51
+ if (!started.ok) return;
52
+ try {
53
+ const { defaultConnect } = await import('@forgeax/engine-types/inspector-client');
54
+ const connected = await defaultConnect(`ws://127.0.0.1:${started.value.port}/inspector`);
55
+ expect(connected.ok).toBe(true);
56
+ if (!connected.ok) return;
57
+ const value = await connected.value.eval('simulation.inspect()');
58
+ expect(value).toMatchObject({
59
+ formatVersion: 1,
60
+ recordOwner: '@forgeax/engine-ecs',
61
+ });
62
+ await connected.value.dispose();
63
+ } finally {
64
+ await started.value.close();
65
+ }
66
+ });
67
+ });
@@ -137,7 +137,7 @@ if (routeAfail && routeBpass) {
137
137
  console.log(' - w5: implement executeScript via new Function');
138
138
  console.log(' - w5: remove scriptTimeoutMs & timeout logic (dead)');
139
139
  console.log(' - w7: delete script-timeout from RemoteErrorCode');
140
- console.log(' - Final error set: 5 members (no script-timeout)');
140
+ console.log(' - Final error set: 4 members (no script-timeout)');
141
141
  exitCode = 0;
142
142
  } else {
143
143
  console.log('UNEXPECTED RESULT:');