@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.
- package/LICENSE +202 -0
- package/README.md +269 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts +2 -0
- package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/console.unit.test.d.ts +2 -0
- package/dist/__tests__/console.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execute-browser-safe.unit.test.d.ts +2 -0
- package/dist/__tests__/execute-browser-safe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execute-profiler-root.browser.test.d.ts +2 -0
- package/dist/__tests__/execute-profiler-root.browser.test.d.ts.map +1 -0
- package/dist/__tests__/execute.async.test.d.ts +2 -0
- package/dist/__tests__/execute.async.test.d.ts.map +1 -0
- package/dist/__tests__/execution-report-root.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-report-root.unit.test.d.ts.map +1 -0
- package/dist/__tests__/introspect-profiler.unit.test.d.ts +2 -0
- package/dist/__tests__/introspect-profiler.unit.test.d.ts.map +1 -0
- package/dist/__tests__/method-roster-profiler.test.d.ts +2 -0
- package/dist/__tests__/method-roster-profiler.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-capture-remote.integration.test.d.ts +2 -0
- package/dist/__tests__/rhi-capture-remote.integration.test.d.ts.map +1 -0
- package/dist/__tests__/server.unit.test.d.ts +2 -0
- package/dist/__tests__/server.unit.test.d.ts.map +1 -0
- package/dist/error-messages.d.ts +3 -0
- package/dist/error-messages.d.ts.map +1 -0
- package/dist/errors.d.ts +82 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +37 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/execute.d.ts +31 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.mjs +93 -0
- package/dist/execute.mjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +30 -0
- package/dist/index.mjs.map +1 -0
- package/dist/introspect.d.ts +21 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.mjs +173 -0
- package/dist/introspect.mjs.map +1 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.mjs +473 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +78 -0
- package/src/__tests__/asset-runtime-inspection.unit.test.ts +16 -0
- package/src/__tests__/console.unit.test.ts +12 -0
- package/src/__tests__/errors.unit.test.ts +102 -0
- package/src/__tests__/execute-browser-safe.unit.test.ts +43 -0
- package/src/__tests__/execute-profiler-root.browser.test.ts +41 -0
- package/src/__tests__/execute.async.test.ts +369 -0
- package/src/__tests__/execution-report-root.unit.test.ts +38 -0
- package/src/__tests__/introspect-profiler.unit.test.ts +55 -0
- package/src/__tests__/method-roster-profiler.test.ts +80 -0
- package/src/__tests__/rhi-capture-remote.integration.test.ts +58 -0
- package/src/__tests__/server.unit.test.ts +856 -0
- package/src/__tests__/vm-async-eval-verify.mjs +149 -0
- package/src/error-messages.ts +9 -0
- package/src/errors.ts +143 -0
- package/src/execute.ts +152 -0
- package/src/index.ts +19 -0
- package/src/introspect.ts +223 -0
- package/src/server.ts +331 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// biome-ignore-all lint/suspicious/noConsole: standalone D-1 verification CLI script; console is its diagnostic output channel
|
|
3
|
+
// @forgeax/engine-remote/src/__tests__/vm-async-eval-verify.mjs
|
|
4
|
+
// D-1 verification: route A (vm + importModuleDynamically) vs route B (host realm eval).
|
|
5
|
+
//
|
|
6
|
+
// Indicators:
|
|
7
|
+
// (a) vm.runInContext with importModuleDynamically: does await import resolve?
|
|
8
|
+
// (a') host realm eval via new Function: does await import resolve?
|
|
9
|
+
// (c) vm timeout watchdog: does it fire on sync infinite loop?
|
|
10
|
+
//
|
|
11
|
+
// Route B has NO timeout watchdog — infinite loops hang the thread.
|
|
12
|
+
//
|
|
13
|
+
// RESULT (2026-06-29, Node 24.15.0):
|
|
14
|
+
// ROUTE A FALSIFIED — vm.runInContext does NOT support importModuleDynamically.
|
|
15
|
+
// ROUTE B CONFIRMED — new Function resolves await import naturally.
|
|
16
|
+
// Timeout: vm watchdog PASS, but host eval has NO timeout.
|
|
17
|
+
|
|
18
|
+
import * as vm from 'node:vm';
|
|
19
|
+
|
|
20
|
+
let exitCode = 0;
|
|
21
|
+
const passes = [];
|
|
22
|
+
const fails = [];
|
|
23
|
+
|
|
24
|
+
function record(label, ok, detail) {
|
|
25
|
+
if (ok) {
|
|
26
|
+
passes.push(label);
|
|
27
|
+
console.log(`(a) vm importModuleDynamically: ${detail}`);
|
|
28
|
+
} else {
|
|
29
|
+
fails.push(label);
|
|
30
|
+
console.log(`(a) vm importModuleDynamically: FAIL — ${detail}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Indicator (a): vm route ─────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
const label = 'a';
|
|
38
|
+
const ok = false;
|
|
39
|
+
let detail = '';
|
|
40
|
+
const scriptBody = [
|
|
41
|
+
'(async () => {',
|
|
42
|
+
" const ecs = await import('@forgeax/engine-ecs');",
|
|
43
|
+
" return typeof ecs.World.prototype.query === 'function';",
|
|
44
|
+
'})()',
|
|
45
|
+
].join('\n');
|
|
46
|
+
try {
|
|
47
|
+
const ctx = vm.createContext(
|
|
48
|
+
{},
|
|
49
|
+
{ importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER },
|
|
50
|
+
);
|
|
51
|
+
const promise = vm.runInContext(scriptBody, ctx, { timeout: 10000, displayErrors: true });
|
|
52
|
+
if (typeof promise?.then === 'function') {
|
|
53
|
+
// We need to await it — but vm.runInContext is sync, so we get
|
|
54
|
+
// the Promise object back. However Node's vm Script does NOT
|
|
55
|
+
// execute the async IIFE — it evaluates the expression and returns
|
|
56
|
+
// the raw Promise without executing it. The Promise never settles.
|
|
57
|
+
// This is a known limitation.
|
|
58
|
+
detail =
|
|
59
|
+
'Promise returned but never settled — import() never called (vm Script cannot execute async IIFE)';
|
|
60
|
+
record(label, ok, detail);
|
|
61
|
+
} else {
|
|
62
|
+
detail = `returned ${typeof promise} (expected thenable)`;
|
|
63
|
+
record(label, ok, detail);
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
detail = e instanceof Error ? e.message : String(e);
|
|
67
|
+
record(label, ok, detail);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Indicator (a'): host realm eval route ───────────────────────────────────
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
const label = "a'";
|
|
75
|
+
let ok = false;
|
|
76
|
+
let detail = '';
|
|
77
|
+
try {
|
|
78
|
+
// Dynamically import in host realm, then new Function.
|
|
79
|
+
// The fact that new Function runs in host realm means import() works naturally.
|
|
80
|
+
const script = [
|
|
81
|
+
'return (async () => {',
|
|
82
|
+
" const ecs = await import('@forgeax/engine-ecs');",
|
|
83
|
+
" return typeof ecs.World.prototype.query === 'function';",
|
|
84
|
+
'})();',
|
|
85
|
+
].join('\n');
|
|
86
|
+
const fn = new Function(script);
|
|
87
|
+
const result = await fn();
|
|
88
|
+
ok = result === true;
|
|
89
|
+
detail = `import resolved, World.query is function = ${result}`;
|
|
90
|
+
record(label, ok, detail);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
detail = e instanceof Error ? e.message : String(e);
|
|
93
|
+
record(label, ok, detail);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Indicator (c): vm timeout watchdog ──────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
const label = 'c';
|
|
101
|
+
let ok = false;
|
|
102
|
+
let detail = '';
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
try {
|
|
105
|
+
const ctx = vm.createContext({});
|
|
106
|
+
vm.runInContext('while(true){}', ctx, { timeout: 200, displayErrors: false });
|
|
107
|
+
detail = `loop completed without interruption after ${Date.now() - start}ms`;
|
|
108
|
+
record(label, ok, detail);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const elapsed = Date.now() - start;
|
|
111
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
112
|
+
ok = msg.includes('timed out') || msg.includes('Script execution timed out');
|
|
113
|
+
detail = `timed out after ~${elapsed}ms`;
|
|
114
|
+
record(label, ok, detail);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Final verdict ───────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const routeBpass = passes.includes("a'");
|
|
121
|
+
const routeAfail = fails.includes('a');
|
|
122
|
+
|
|
123
|
+
console.log();
|
|
124
|
+
console.log('='.repeat(60));
|
|
125
|
+
console.log('D-1 VERDICT:');
|
|
126
|
+
|
|
127
|
+
if (routeAfail && routeBpass) {
|
|
128
|
+
console.log('ROUTE A (vm + importModuleDynamically): FALSIFIED');
|
|
129
|
+
console.log(' Reason: vm.runInContext does not honor importModuleDynamically');
|
|
130
|
+
console.log(' for Script execution. Only vm.SourceTextModule is supported.');
|
|
131
|
+
console.log();
|
|
132
|
+
console.log('ROUTE B (host realm eval via new Function): CONFIRMED');
|
|
133
|
+
console.log(' - await import resolves naturally in host realm');
|
|
134
|
+
console.log(' - NO timeout watchdog (known limitation, documented in R6)');
|
|
135
|
+
console.log();
|
|
136
|
+
console.log('IMPLEMENTATION IMPLICATIONS:');
|
|
137
|
+
console.log(' - w5: implement executeScript via new Function');
|
|
138
|
+
console.log(' - w5: remove scriptTimeoutMs & timeout logic (dead)');
|
|
139
|
+
console.log(' - w7: delete script-timeout from RemoteErrorCode');
|
|
140
|
+
console.log(' - Final error set: 5 members (no script-timeout)');
|
|
141
|
+
exitCode = 0;
|
|
142
|
+
} else {
|
|
143
|
+
console.log('UNEXPECTED RESULT:');
|
|
144
|
+
console.log(' Route A passes:', passes, 'fails:', fails);
|
|
145
|
+
console.log(' Route B passes:', passes, 'fails:', fails);
|
|
146
|
+
exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
process.exit(exitCode);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RemoteErrorCode } from './errors';
|
|
2
|
+
|
|
3
|
+
export const REMOTE_ERROR_MESSAGES: Readonly<Record<RemoteErrorCode, string>> = {
|
|
4
|
+
'script-syntax-error': 'Script syntax error',
|
|
5
|
+
'script-runtime-error': 'Script runtime error',
|
|
6
|
+
'server-startup-failed': 'Server startup failed',
|
|
7
|
+
'server-not-running': 'Server not reachable',
|
|
8
|
+
'eval-result-not-serializable': 'Eval result not serializable',
|
|
9
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @forgeax/engine-remote/src/errors - RemoteError runtime class + re-export of
|
|
2
|
+
// the closed `RemoteErrorCode` union; 5 members (feat-20260629-inspector-two-layer-model D-5).
|
|
3
|
+
//
|
|
4
|
+
// SSOT split: the **type alias** `RemoteErrorCode`
|
|
5
|
+
// + **structural interface** `RemoteError` live in `@forgeax/engine-types`
|
|
6
|
+
// (parallel to the existing `ShaderErrorCode` placement). This file owns
|
|
7
|
+
// the **runtime class** (`extends Error` + `toJSON()`) only; the class
|
|
8
|
+
// `implements` the type-side interface so the two sides cannot drift
|
|
9
|
+
// (architecture-principles #1 SSOT).
|
|
10
|
+
//
|
|
11
|
+
// Shape (mirrors @forgeax/engine-rhi/src/errors.ts RhiError 4-field surface for
|
|
12
|
+
// charter proposition 5 consistent abstraction):
|
|
13
|
+
// - `RemoteErrorCode` = closed union 5 members (re-exported from types).
|
|
14
|
+
// tsc strict-mode guards exhaustive switch completeness (charter
|
|
15
|
+
// proposition 4); AI users consume via `switch (err.code) { case '...': ... }`
|
|
16
|
+
// with NO default branch.
|
|
17
|
+
// - `RemoteError` class extends Error with three readonly fields .code /
|
|
18
|
+
// .expected / .hint (AGENTS.md "Errors are structured"). The constructor
|
|
19
|
+
// auto-composes a human-readable .message (`[RemoteError <code>]
|
|
20
|
+
// expected: <expected>; hint: <hint>`). The class implements the
|
|
21
|
+
// `RemoteError` interface from `@forgeax/engine-types` so callers may
|
|
22
|
+
// alternately type against the structural shape.
|
|
23
|
+
// - `toJSON()` opts into JSON.stringify serialisation so the JSON-RPC 2.0
|
|
24
|
+
// `error.data` payload carries .code / .expected / .hint / .message
|
|
25
|
+
// verbatim through the WebSocket transport.
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
RemoteErrorCode,
|
|
29
|
+
RemoteErrorDetail,
|
|
30
|
+
RemoteError as RemoteErrorShape,
|
|
31
|
+
} from '@forgeax/engine-types';
|
|
32
|
+
|
|
33
|
+
// Re-export the type-side alias verbatim so existing
|
|
34
|
+
// `import { type RemoteErrorCode } from '@forgeax/engine-remote'` call sites
|
|
35
|
+
// keep working (charter proposition 1 progressive disclosure — single
|
|
36
|
+
// entry point for AI users).
|
|
37
|
+
export type { RemoteErrorCode };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Structured remote error. Four core fields plus bounded detail, mirroring `@forgeax/engine-rhi`
|
|
41
|
+
* `RhiError` (charter proposition 5 consistent abstraction; AGENTS.md
|
|
42
|
+
* "Errors are structured"). The class `implements RemoteErrorShape`
|
|
43
|
+
* (the structural interface re-exported from `@forgeax/engine-types`) so the type
|
|
44
|
+
* SSOT and the runtime class cannot drift.
|
|
45
|
+
*
|
|
46
|
+
* - `.code` closed union member (L1 key signal; switch-able).
|
|
47
|
+
* - `.expected` expected-state description (L2 detail; ai-user-charter
|
|
48
|
+
* proposition 4 requires expected-state copy).
|
|
49
|
+
* - `.hint` actionable recovery guidance (L2 detail; charter
|
|
50
|
+
* proposition 3 machine-readable hint > prose).
|
|
51
|
+
* - `.message` auto-composed `[RemoteError <code>] expected: <expected>;
|
|
52
|
+
* hint: <hint>` so human stack traces still surface the
|
|
53
|
+
* triple. AI users prefer property access (charter
|
|
54
|
+
* proposition 4: no string parsing).
|
|
55
|
+
*
|
|
56
|
+
* Per-code `.expected` + `.hint` templates (requirements §10.2 SSOT):
|
|
57
|
+
*
|
|
58
|
+
* | code | `.expected` | `.hint` |
|
|
59
|
+
* |:--|:--|:--|
|
|
60
|
+
* | `'script-syntax-error'` | `'script body is valid JavaScript'` | `'check syntax position in errMessage; fix and resubmit'` |
|
|
61
|
+
* | `'script-runtime-error'` | `'script executes without throwing'` | `'inspect error; verify symbol availability; eval has full access to world/renderer/assets'` |
|
|
62
|
+
* | `'server-startup-failed'` | `'server starts successfully on requested port'` | `'check if port is already in use (default 5732); pass different port; or kill existing process holding the port'` |
|
|
63
|
+
* | `'server-not-running'` | `'server is reachable at ws://localhost:<port>'` | `'start the demo first; verify app.remote is wired; pass --port to override default 5732'` |
|
|
64
|
+
* | `'eval-result-not-serializable'` | `'eval result is JSON-serializable'` | `'return a JSON-safe value; BigInt and cyclic objects are unsupported over JSON-RPC'` |
|
|
65
|
+
*
|
|
66
|
+
* JSON-RPC 2.0 transport contract: `toJSON()` returns the structured plain
|
|
67
|
+
* object carried verbatim via `error.data` on the WebSocket envelope. The
|
|
68
|
+
* JSON-RPC server-error `.code` numeric segment -32001 ~ -32005 maps 1:1
|
|
69
|
+
* to the 5 members
|
|
70
|
+
* at the dispatch layer.
|
|
71
|
+
*
|
|
72
|
+
* @example AI-user exhaustive switch on the 5 remote-domain alternatives (no default fallback)
|
|
73
|
+
* ```ts
|
|
74
|
+
* import { RemoteError, type RemoteErrorCode } from '@forgeax/engine-remote';
|
|
75
|
+
*
|
|
76
|
+
* function recover(code: RemoteErrorCode): string {
|
|
77
|
+
* switch (code) {
|
|
78
|
+
* case 'script-syntax-error': return 'fix script body syntax and resubmit';
|
|
79
|
+
* case 'script-runtime-error': return 'inspect stack trace; verify symbol availability';
|
|
80
|
+
* case 'server-startup-failed': return 'pick a different port or free port 5732';
|
|
81
|
+
* case 'server-not-running': return 'start demo dev or wire app.remote';
|
|
82
|
+
* case 'eval-result-not-serializable': return 'return a JSON-safe eval result';
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export class RemoteError extends Error implements RemoteErrorShape {
|
|
88
|
+
readonly code: RemoteErrorCode;
|
|
89
|
+
readonly expected: string;
|
|
90
|
+
readonly hint: string;
|
|
91
|
+
readonly detail?: RemoteErrorDetail;
|
|
92
|
+
|
|
93
|
+
constructor(args: {
|
|
94
|
+
code: RemoteErrorCode;
|
|
95
|
+
expected: string;
|
|
96
|
+
hint: string;
|
|
97
|
+
detail?: RemoteErrorDetail;
|
|
98
|
+
}) {
|
|
99
|
+
super(`[RemoteError ${args.code}] expected: ${args.expected}; hint: ${args.hint}`);
|
|
100
|
+
this.name = 'RemoteError';
|
|
101
|
+
this.code = args.code;
|
|
102
|
+
this.expected = args.expected;
|
|
103
|
+
this.hint = args.hint;
|
|
104
|
+
if (args.detail !== undefined) {
|
|
105
|
+
this.detail = args.detail;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
toJSON(): {
|
|
110
|
+
readonly code: RemoteErrorCode;
|
|
111
|
+
readonly expected: string;
|
|
112
|
+
readonly hint: string;
|
|
113
|
+
readonly message: string;
|
|
114
|
+
readonly detail?: RemoteErrorDetail;
|
|
115
|
+
} {
|
|
116
|
+
const json = {
|
|
117
|
+
code: this.code,
|
|
118
|
+
expected: this.expected,
|
|
119
|
+
hint: this.hint,
|
|
120
|
+
message: this.message,
|
|
121
|
+
};
|
|
122
|
+
return this.detail === undefined ? json : { ...json, detail: this.detail };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* SSOT mapping `RemoteErrorCode` -> JSON-RPC `error.code` numeric segment
|
|
128
|
+
* (feat-20260629-inspector-two-layer-model D-5). The 5 remote P0
|
|
129
|
+
* members occupy the closed segment `-32001..-32005`.
|
|
130
|
+
*
|
|
131
|
+
* `server.ts` consumes this map at the JSON-RPC envelope edge so the wire
|
|
132
|
+
* always carries the lock-in numeric and a future drift in either direction
|
|
133
|
+
* raises a TypeScript completeness error (the `Record<RemoteErrorCode,
|
|
134
|
+
* number>` type guard requires every closed-union member to have a
|
|
135
|
+
* numeric).
|
|
136
|
+
*/
|
|
137
|
+
export const REMOTE_ERROR_CODE_TO_JSONRPC: Readonly<Record<RemoteErrorCode, number>> = {
|
|
138
|
+
'script-syntax-error': -32001,
|
|
139
|
+
'script-runtime-error': -32002,
|
|
140
|
+
'server-startup-failed': -32003,
|
|
141
|
+
'server-not-running': -32004,
|
|
142
|
+
'eval-result-not-serializable': -32005,
|
|
143
|
+
};
|
package/src/execute.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @forgeax/engine-remote/src/execute — async host-realm eval.
|
|
2
|
+
//
|
|
3
|
+
// D-1 route B (2026-06-29): vm.runInContext does not honor
|
|
4
|
+
// importModuleDynamically for Script execution. Host realm compilation via
|
|
5
|
+
// the AsyncFunction constructor resolves `await import` naturally, as long
|
|
6
|
+
// as the import function from the calling module scope is injected.
|
|
7
|
+
//
|
|
8
|
+
// CONTRACT (the one an AI user holds): the script IS the body of an async
|
|
9
|
+
// function with `world` / `renderer` / `assets` / `rhiCapture` / `simulation` / `_import`
|
|
10
|
+
// in scope. So all of these Just Work, un-wrapped:
|
|
11
|
+
// - a bare expression: `renderer.backend` -> auto-returned
|
|
12
|
+
// - top-level await: `await _import('@forgeax/engine-ecs')`
|
|
13
|
+
// - top-level return: `return world.inspect().entityCount`
|
|
14
|
+
// - multi-statement + return: `const m = await _import(...); return m.x`
|
|
15
|
+
// (the historical `(async () => { ... })()` IIFE form still works too — its
|
|
16
|
+
// returned Promise is awaited.)
|
|
17
|
+
//
|
|
18
|
+
// Implementation (mirrors a REPL, two construction-time-checked attempts):
|
|
19
|
+
// 1. expression mode: compile `return (<script>)` — auto-returns a lone
|
|
20
|
+
// expression (incl. an await-expression), preserving last-expression value.
|
|
21
|
+
// 2. on SyntaxError from (1)'s CONSTRUCTION: statement mode — compile
|
|
22
|
+
// `<script>` as the async body directly, legalizing top-level return +
|
|
23
|
+
// await + arbitrary statements.
|
|
24
|
+
// Only a construction-time SyntaxError advances attempt 1 -> 2, so user code is
|
|
25
|
+
// compiled once and executed at most once (no double side effects). We never use
|
|
26
|
+
// `eval`: indirect eval ran the body as a global program, which is precisely
|
|
27
|
+
// what banned top-level return/await and produced the doc-vs-reality gap.
|
|
28
|
+
//
|
|
29
|
+
// try/catch maps errors:
|
|
30
|
+
// SyntaxError (both attempts fail to compile) -> 'script-syntax-error'
|
|
31
|
+
// RemoteError (re-thrown) -> verbatim
|
|
32
|
+
// anything else -> 'script-runtime-error'
|
|
33
|
+
//
|
|
34
|
+
// The sandbox is dismantled — eval is full-access, no wrapReadOnly.
|
|
35
|
+
// Timeout is removed (route B has no interrupt mechanism; see R6).
|
|
36
|
+
|
|
37
|
+
import { RemoteError } from './errors';
|
|
38
|
+
|
|
39
|
+
// AsyncFunction constructor (not a global binding). An async body is what
|
|
40
|
+
// legalizes top-level `await` AND top-level `return` simultaneously.
|
|
41
|
+
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as FunctionConstructor;
|
|
42
|
+
|
|
43
|
+
export type ExecuteContext = {
|
|
44
|
+
readonly world: unknown;
|
|
45
|
+
readonly renderer: unknown;
|
|
46
|
+
readonly assets: unknown;
|
|
47
|
+
readonly rhiCapture?: unknown;
|
|
48
|
+
readonly profiler?: unknown;
|
|
49
|
+
readonly execution?: unknown;
|
|
50
|
+
readonly importModule?: (specifier: string) => Promise<unknown>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ExecuteResult = { ok: true; value: unknown } | { ok: false; error: RemoteError };
|
|
54
|
+
|
|
55
|
+
// Capture import at module load time. This is the host realm's dynamic
|
|
56
|
+
// import() — when injected into new Function, it resolves module
|
|
57
|
+
// specifiers relative to the module that called executeScript. The remote
|
|
58
|
+
// package stays package-neutral: a host may inject a capability projection
|
|
59
|
+
// through ExecuteContext.importModule, but the transport does not own any
|
|
60
|
+
// engine package vocabulary.
|
|
61
|
+
const _import = async (specifier: string): Promise<unknown> => import(specifier);
|
|
62
|
+
|
|
63
|
+
// Compile the script as an async function body. Tries expression mode first
|
|
64
|
+
// (auto-return a lone expression), falling back to statement mode on a
|
|
65
|
+
// construction-time SyntaxError. Returns the compiled fn, or throws the
|
|
66
|
+
// statement-mode SyntaxError if BOTH modes fail to parse.
|
|
67
|
+
function compile(script: string): FunctionConstructor['prototype'] {
|
|
68
|
+
const params = [
|
|
69
|
+
'world',
|
|
70
|
+
'renderer',
|
|
71
|
+
'assets',
|
|
72
|
+
'rhiCapture',
|
|
73
|
+
'profiler',
|
|
74
|
+
'execution',
|
|
75
|
+
'_import',
|
|
76
|
+
] as const;
|
|
77
|
+
try {
|
|
78
|
+
// Expression mode: `return (<expr>)` auto-returns a lone expression
|
|
79
|
+
// (including an await-expression), preserving last-expression-value.
|
|
80
|
+
// Trailing semicolons/whitespace are trimmed so `renderer.backend;`
|
|
81
|
+
// still returns its value (the old indirect-eval completion-value
|
|
82
|
+
// behavior) instead of parsing as a statement that returns undefined.
|
|
83
|
+
const expr = script.replace(/[\s;]+$/, '');
|
|
84
|
+
return new AsyncFunction(...params, `return (${expr}\n)`);
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (!(e instanceof SyntaxError)) throw e;
|
|
87
|
+
// Statement mode: the script IS the async body — legalizes top-level
|
|
88
|
+
// return + await + arbitrary statements. If this also fails to parse,
|
|
89
|
+
// its SyntaxError is the authoritative one to surface.
|
|
90
|
+
return new AsyncFunction(...params, script);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Evaluate a JavaScript script against the host engine context.
|
|
96
|
+
*
|
|
97
|
+
* Route B (D-1): host-realm compilation via the AsyncFunction constructor with
|
|
98
|
+
* injected _import. The script is the body of an async function; a lone
|
|
99
|
+
* expression is auto-returned, and top-level `await` / `return` are legal.
|
|
100
|
+
* - _import is available as a parameter for dynamic ESM imports.
|
|
101
|
+
* - rhiCapture is available as a 4th eval-scope root for the RHI capture
|
|
102
|
+
* capability (plan-strategy D-4).
|
|
103
|
+
* - No sandbox — full access reads and writes.
|
|
104
|
+
* - No timeout — host realm eval cannot be interrupted (see R6).
|
|
105
|
+
*/
|
|
106
|
+
export async function executeScript(script: string, ctx: ExecuteContext): Promise<ExecuteResult> {
|
|
107
|
+
try {
|
|
108
|
+
const fn = compile(script);
|
|
109
|
+
// AsyncFunction always returns a Promise; await resolves the value and
|
|
110
|
+
// surfaces any runtime throw into this catch.
|
|
111
|
+
const value: unknown = await fn(
|
|
112
|
+
ctx.world,
|
|
113
|
+
ctx.renderer,
|
|
114
|
+
ctx.assets,
|
|
115
|
+
ctx.rhiCapture,
|
|
116
|
+
ctx.profiler,
|
|
117
|
+
ctx.execution,
|
|
118
|
+
ctx.importModule ?? _import,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return { ok: true, value };
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// 1. RemoteError re-thrown from within the script surfaces verbatim.
|
|
124
|
+
if (e instanceof RemoteError) {
|
|
125
|
+
return { ok: false, error: e };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. SyntaxError: both compile() attempts failed to parse the script.
|
|
129
|
+
if (e instanceof SyntaxError) {
|
|
130
|
+
const msg = e.message;
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: new RemoteError({
|
|
134
|
+
code: 'script-syntax-error',
|
|
135
|
+
expected: 'script body is valid JavaScript',
|
|
136
|
+
hint: `check syntax near: ${msg}; fix and resubmit`,
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Runtime error (throws during function execution).
|
|
142
|
+
const rawMessage = e instanceof Error ? e.message : String(e);
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: new RemoteError({
|
|
146
|
+
code: 'script-runtime-error',
|
|
147
|
+
expected: 'script executes without throwing',
|
|
148
|
+
hint: `inspect error; verify symbol availability; eval has full access to world/renderer/assets (errMessage: ${rawMessage})`,
|
|
149
|
+
}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @forgeax/engine-remote - inspector P0 server + eval core.
|
|
2
|
+
//
|
|
3
|
+
// Single entry facade (charter proposition 1 progressive disclosure). The
|
|
4
|
+
// runtime server lives under the ./server sub-path. AI users import the shared
|
|
5
|
+
// error model from this top entry:
|
|
6
|
+
//
|
|
7
|
+
// import { RemoteError, type RemoteErrorCode } from '@forgeax/engine-remote';
|
|
8
|
+
//
|
|
9
|
+
// The error model SSOT (5-member closed RemoteErrorCode union) lives in
|
|
10
|
+
// src/errors.ts and is also exposed via the ./errors sub-path for callers that
|
|
11
|
+
// want type-only imports without pulling the runtime surface (D-P4 bundle
|
|
12
|
+
// isolation + AGENTS.md "Inspector / Console" evolution contract minor
|
|
13
|
+
// add-only).
|
|
14
|
+
//
|
|
15
|
+
// M2 w8: routing layer (Registry / sandbox / wireDefaultInspectors /
|
|
16
|
+
// register-plugin-inspector / discoverPlugins) deleted; eval is the sole
|
|
17
|
+
// command channel.
|
|
18
|
+
|
|
19
|
+
export { RemoteError, type RemoteErrorCode } from './errors';
|