@zackbart/connecta 0.7.1 → 0.7.4

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 (96) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +5 -2
  3. package/dist/connector-scope.d.ts +7 -4
  4. package/dist/connector-scope.d.ts.map +1 -1
  5. package/dist/connector-scope.js +36 -16
  6. package/dist/connector-scope.js.map +1 -1
  7. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  8. package/dist/connectors/remote-mcp.js +164 -95
  9. package/dist/connectors/remote-mcp.js.map +1 -1
  10. package/dist/credential-health.d.ts +11 -7
  11. package/dist/credential-health.d.ts.map +1 -1
  12. package/dist/credential-health.js +26 -19
  13. package/dist/credential-health.js.map +1 -1
  14. package/dist/execute.d.ts +3 -0
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +70 -29
  17. package/dist/execute.js.map +1 -1
  18. package/dist/executor-admission.d.ts +53 -0
  19. package/dist/executor-admission.d.ts.map +1 -0
  20. package/dist/executor-admission.js +151 -0
  21. package/dist/executor-admission.js.map +1 -0
  22. package/dist/executor-result.d.ts +13 -0
  23. package/dist/executor-result.d.ts.map +1 -0
  24. package/dist/executor-result.js +57 -0
  25. package/dist/executor-result.js.map +1 -0
  26. package/dist/executors/quickjs-child.d.ts +2 -0
  27. package/dist/executors/quickjs-child.d.ts.map +1 -0
  28. package/dist/executors/quickjs-child.js +131 -0
  29. package/dist/executors/quickjs-child.js.map +1 -0
  30. package/dist/executors/quickjs-protocol.d.ts +55 -0
  31. package/dist/executors/quickjs-protocol.d.ts.map +1 -0
  32. package/dist/executors/quickjs-protocol.js +22 -0
  33. package/dist/executors/quickjs-protocol.js.map +1 -0
  34. package/dist/executors/quickjs-runtime.d.ts +26 -0
  35. package/dist/executors/quickjs-runtime.d.ts.map +1 -0
  36. package/dist/executors/quickjs-runtime.js +383 -0
  37. package/dist/executors/quickjs-runtime.js.map +1 -0
  38. package/dist/executors/quickjs.d.ts +20 -15
  39. package/dist/executors/quickjs.d.ts.map +1 -1
  40. package/dist/executors/quickjs.js +465 -265
  41. package/dist/executors/quickjs.js.map +1 -1
  42. package/dist/index.d.ts +15 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -0
  45. package/dist/index.js.map +1 -1
  46. package/dist/meta-tools.d.ts +4 -0
  47. package/dist/meta-tools.d.ts.map +1 -1
  48. package/dist/meta-tools.js +49 -12
  49. package/dist/meta-tools.js.map +1 -1
  50. package/dist/node.d.ts.map +1 -1
  51. package/dist/node.js +36 -3
  52. package/dist/node.js.map +1 -1
  53. package/dist/registry.d.ts +21 -24
  54. package/dist/registry.d.ts.map +1 -1
  55. package/dist/registry.js +37 -25
  56. package/dist/registry.js.map +1 -1
  57. package/dist/server.d.ts.map +1 -1
  58. package/dist/server.js +19 -9
  59. package/dist/server.js.map +1 -1
  60. package/dist/timeout.d.ts +9 -4
  61. package/dist/timeout.d.ts.map +1 -1
  62. package/dist/timeout.js +34 -4
  63. package/dist/timeout.js.map +1 -1
  64. package/dist/toolkits.d.ts +8 -0
  65. package/dist/toolkits.d.ts.map +1 -1
  66. package/dist/toolkits.js +3 -0
  67. package/dist/toolkits.js.map +1 -1
  68. package/dist/types.d.ts +19 -2
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/ui.d.ts +13 -1
  71. package/dist/ui.d.ts.map +1 -1
  72. package/dist/ui.js +188 -7
  73. package/dist/ui.js.map +1 -1
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/package.json +1 -1
  77. package/src/connector-scope.ts +43 -18
  78. package/src/connectors/remote-mcp.ts +184 -102
  79. package/src/credential-health.ts +33 -17
  80. package/src/execute.ts +93 -35
  81. package/src/executor-admission.ts +229 -0
  82. package/src/executor-result.ts +63 -0
  83. package/src/executors/quickjs-child.ts +168 -0
  84. package/src/executors/quickjs-protocol.ts +77 -0
  85. package/src/executors/quickjs-runtime.ts +440 -0
  86. package/src/executors/quickjs.ts +669 -302
  87. package/src/index.ts +30 -5
  88. package/src/meta-tools.ts +84 -27
  89. package/src/node.ts +36 -2
  90. package/src/registry.ts +53 -22
  91. package/src/server.ts +21 -10
  92. package/src/timeout.ts +41 -4
  93. package/src/toolkits.ts +11 -0
  94. package/src/types.ts +19 -2
  95. package/src/ui.ts +218 -12
  96. package/src/version.ts +1 -1
package/src/execute.ts CHANGED
@@ -8,10 +8,18 @@ import {
8
8
  discoverySearchLimit,
9
9
  errorResult,
10
10
  jsonResult,
11
- serializeResultText,
12
11
  type ToolResult,
13
12
  } from "./meta-tools.js";
13
+ import {
14
+ guardExecuteResultValue,
15
+ MAX_EXECUTE_LOG_CHARS,
16
+ truncateExecuteText,
17
+ } from "./executor-result.js";
14
18
  import { classifyCallError, ConnectorCallError } from "./errors.js";
19
+ import {
20
+ ExecutorAdmissionError,
21
+ isAdmittingExecutor,
22
+ } from "./executor-admission.js";
15
23
  import { unwrapMcpResult } from "./mcp-result.js";
16
24
  import type { RegistryView } from "./registry.js";
17
25
  import type {
@@ -22,9 +30,6 @@ import type {
22
30
  ToolDef,
23
31
  } from "./types.js";
24
32
 
25
- /** ~6k tokens. Sandbox code should filter data down before returning. */
26
- const MAX_RESULT_CHARS = 24_000;
27
- const MAX_LOG_CHARS = 4_000;
28
33
  /** Keep one model-written program from amplifying into an unbounded fan-out. */
29
34
  export const EXECUTE_MAX_HOST_CALLS = 20;
30
35
  export const EXECUTE_MAX_BATCH_CALLS = 10;
@@ -136,6 +141,7 @@ export async function buildSandboxProviders(
136
141
  "connecta",
137
142
  "console",
138
143
  "__invoke",
144
+ "__namespace",
139
145
  "__call",
140
146
  "__log",
141
147
  ]);
@@ -143,9 +149,17 @@ export async function buildSandboxProviders(
143
149
  const catalogStarted = Date.now();
144
150
  const loaded = await Promise.allSettled(
145
151
  connectors.map((connector) =>
146
- registry.getTools(connector.id, baseUrl, requestScope),
152
+ registry.getTools(connector.id, baseUrl, requestScope, {
153
+ signal: limits.signal,
154
+ }),
147
155
  ),
148
156
  );
157
+ if (limits.signal?.aborted) {
158
+ throw new ExecutorAdmissionError(
159
+ "executor_cancelled",
160
+ "Execution was cancelled during catalog construction.",
161
+ );
162
+ }
149
163
  const catalogs = new Map<string, ToolDef[]>();
150
164
  const callAddress = async (address: unknown, args: unknown) => {
151
165
  const resolved = registry.resolveAddress(String(address));
@@ -474,24 +488,6 @@ export async function buildSandboxProviders(
474
488
  return providers;
475
489
  }
476
490
 
477
- function truncate(text: string, max: number): string {
478
- if (text.length <= max) return text;
479
- return `${text.slice(0, max)}\n--- TRUNCATED (${text.length} chars total) — filter/map/slice data inside your code and return only what you need ---`;
480
- }
481
-
482
- function guardResultValue(value: unknown): unknown {
483
- // Same serialization the call_tool guards measure, so a program returning
484
- // nothing is rendered one way across every result path (issue #42).
485
- const text = serializeResultText(value);
486
- if (text.length <= MAX_RESULT_CHARS) return value;
487
- return {
488
- truncated: true,
489
- preview: text.slice(0, MAX_RESULT_CHARS),
490
- totalChars: text.length,
491
- hint: "filter/map/slice data inside execute_code and return only what you need",
492
- };
493
- }
494
-
495
491
  /** The execute_code handler. Exported for direct testing. */
496
492
  export function createExecuteTool(
497
493
  registry: RegistryView,
@@ -500,28 +496,69 @@ export function createExecuteTool(
500
496
  logger: Logger,
501
497
  activity?: ActivityRequestContext,
502
498
  ) {
503
- return async ({ code }: { code: string }): Promise<ToolResult> => {
499
+ return async (
500
+ { code }: { code: string },
501
+ options: { signal?: AbortSignal } = {},
502
+ ): Promise<ToolResult> => {
504
503
  const controller = new AbortController();
505
- const providers = await buildSandboxProviders(
506
- registry,
507
- baseUrl,
508
- logger,
509
- activity,
510
- { signal: controller.signal },
511
- );
504
+ const forwardAbort = () => controller.abort(options.signal?.reason);
505
+ if (options.signal?.aborted) forwardAbort();
506
+ else {
507
+ options.signal?.addEventListener("abort", forwardAbort, { once: true });
508
+ }
509
+ let lease;
512
510
  let outcome;
513
511
  try {
514
- outcome = await executor.execute(code, providers);
512
+ // Admission comes before provider construction: queued calls retain no
513
+ // catalogs, request scopes, or one-closure-per-tool provider arrays.
514
+ if (isAdmittingExecutor(executor)) {
515
+ lease = await executor.acquire({ signal: controller.signal });
516
+ }
517
+ const providers = await buildSandboxProviders(
518
+ registry,
519
+ baseUrl,
520
+ logger,
521
+ activity,
522
+ { signal: controller.signal },
523
+ );
524
+ if (controller.signal.aborted) {
525
+ throw new ExecutorAdmissionError(
526
+ "executor_cancelled",
527
+ "Execution was cancelled during sandbox setup.",
528
+ );
529
+ }
530
+ outcome = lease
531
+ ? await lease.execute(code, providers)
532
+ : await executor.execute(code, providers);
515
533
  } catch (err) {
534
+ if (err instanceof ExecutorAdmissionError) {
535
+ const result = jsonResult({
536
+ error: {
537
+ code: err.code,
538
+ message: err.message,
539
+ retryable: err.retryable,
540
+ ...(err.retryAfterMs !== undefined
541
+ ? { retryAfterMs: err.retryAfterMs }
542
+ : {}),
543
+ },
544
+ });
545
+ result.isError = true;
546
+ return result;
547
+ }
516
548
  return errorResult(`Executor failed: ${msg(err)}`);
517
549
  } finally {
518
550
  // A sandbox timeout or early return must also release any outstanding
519
551
  // host waits and signal cooperative connectors to stop their work.
520
552
  controller.abort();
553
+ lease?.release();
554
+ options.signal?.removeEventListener("abort", forwardAbort);
521
555
  }
522
556
  const logs =
523
557
  outcome.logs && outcome.logs.length > 0
524
- ? truncate(outcome.logs.join("\n"), MAX_LOG_CHARS)
558
+ ? truncateExecuteText(
559
+ outcome.logs.join("\n"),
560
+ MAX_EXECUTE_LOG_CHARS,
561
+ )
525
562
  : undefined;
526
563
  if (outcome.error) {
527
564
  return errorResult(
@@ -533,7 +570,7 @@ export function createExecuteTool(
533
570
  // error path so captured logs survive instead of a raw SDK 500.
534
571
  let result: unknown;
535
572
  try {
536
- result = guardResultValue(outcome.result);
573
+ result = guardExecuteResultValue(outcome.result);
537
574
  } catch (err) {
538
575
  return errorResult(
539
576
  `Error: result is not JSON-serializable: ${msg(err)}${logs ? `\n\nLogs:\n${logs}` : ""}`,
@@ -568,6 +605,7 @@ export function registerExecuteTool(
568
605
  executor: Executor;
569
606
  logger: Logger;
570
607
  activity?: ActivityRequestContext;
608
+ requestSignal?: AbortSignal;
571
609
  },
572
610
  ): void {
573
611
  const handler = createExecuteTool(
@@ -594,6 +632,26 @@ export function registerExecuteTool(
594
632
  openWorldHint: true,
595
633
  },
596
634
  },
597
- async (args) => handler(args as { code: string }),
635
+ async (args, extra) => {
636
+ const controller = new AbortController();
637
+ const signals = [extra.signal, ctx.requestSignal].filter(
638
+ (signal): signal is AbortSignal => signal !== undefined,
639
+ );
640
+ const forwarders = signals.map((signal) => {
641
+ const forward = () => controller.abort(signal.reason);
642
+ if (signal.aborted) forward();
643
+ else signal.addEventListener("abort", forward, { once: true });
644
+ return { signal, forward };
645
+ });
646
+ try {
647
+ return await handler(args as { code: string }, {
648
+ signal: controller.signal,
649
+ });
650
+ } finally {
651
+ for (const { signal, forward } of forwarders) {
652
+ signal.removeEventListener("abort", forward);
653
+ }
654
+ }
655
+ },
598
656
  );
599
657
  }
@@ -0,0 +1,229 @@
1
+ import type {
2
+ AdmittingExecutor,
3
+ Executor,
4
+ } from "./types.js";
5
+
6
+ export type ExecutorAdmissionErrorCode =
7
+ | "executor_overloaded"
8
+ | "executor_cancelled"
9
+ | "executor_closed";
10
+
11
+ /**
12
+ * A stable, machine-readable admission failure. Overload is retryable; caller
13
+ * cancellation and shutdown are terminal for this invocation.
14
+ */
15
+ export class ExecutorAdmissionError extends Error {
16
+ readonly retryable: boolean;
17
+ readonly retryAfterMs?: number;
18
+
19
+ constructor(
20
+ readonly code: ExecutorAdmissionErrorCode,
21
+ message: string,
22
+ opts: { retryAfterMs?: number } = {},
23
+ ) {
24
+ super(message);
25
+ this.name = "ExecutorAdmissionError";
26
+ this.retryable = code === "executor_overloaded";
27
+ if (
28
+ opts.retryAfterMs !== undefined &&
29
+ Number.isFinite(opts.retryAfterMs) &&
30
+ opts.retryAfterMs >= 0
31
+ ) {
32
+ this.retryAfterMs = Math.trunc(opts.retryAfterMs);
33
+ }
34
+ }
35
+ }
36
+
37
+ export interface AdmissionLease {
38
+ release(): void;
39
+ }
40
+
41
+ interface Waiter {
42
+ resolve: (lease: AdmissionLease) => void;
43
+ reject: (error: ExecutorAdmissionError) => void;
44
+ signal?: AbortSignal;
45
+ onAbort?: () => void;
46
+ timer?: ReturnType<typeof setTimeout>;
47
+ }
48
+
49
+ export interface AdmissionControllerOptions {
50
+ concurrency: number;
51
+ maxQueueSize: number;
52
+ queueTimeoutMs: number;
53
+ /** Suggested delay exposed with retryable overload failures. */
54
+ retryAfterMs?: number;
55
+ }
56
+
57
+ function positiveWhole(value: number, name: string): number {
58
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1) {
59
+ throw new TypeError(`${name} must be a positive whole number.`);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function nonNegativeWhole(value: number, name: string): number {
65
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
66
+ throw new TypeError(`${name} must be a non-negative whole number.`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * Runtime-portable bounded admission. It deliberately owns no executor or
73
+ * request state: queued requests retain only a resolver and AbortSignal, while
74
+ * provider catalogs are constructed after the returned lease is granted.
75
+ */
76
+ export class AdmissionController {
77
+ readonly concurrency: number;
78
+ readonly maxQueueSize: number;
79
+ readonly queueTimeoutMs: number;
80
+ readonly retryAfterMs: number;
81
+
82
+ private active = 0;
83
+ private closed = false;
84
+ private readonly waiters: Waiter[] = [];
85
+
86
+ constructor(options: AdmissionControllerOptions) {
87
+ this.concurrency = positiveWhole(options.concurrency, "concurrency");
88
+ this.maxQueueSize = nonNegativeWhole(
89
+ options.maxQueueSize,
90
+ "maxQueueSize",
91
+ );
92
+ this.queueTimeoutMs = positiveWhole(
93
+ options.queueTimeoutMs,
94
+ "queueTimeoutMs",
95
+ );
96
+ this.retryAfterMs = nonNegativeWhole(
97
+ options.retryAfterMs ?? this.queueTimeoutMs,
98
+ "retryAfterMs",
99
+ );
100
+ }
101
+
102
+ get activeCount(): number {
103
+ return this.active;
104
+ }
105
+
106
+ get queuedCount(): number {
107
+ return this.waiters.length;
108
+ }
109
+
110
+ acquire(options: { signal?: AbortSignal } = {}): Promise<AdmissionLease> {
111
+ if (this.closed) {
112
+ return Promise.reject(
113
+ new ExecutorAdmissionError(
114
+ "executor_closed",
115
+ "Executor is shutting down.",
116
+ ),
117
+ );
118
+ }
119
+ if (options.signal?.aborted) {
120
+ return Promise.reject(
121
+ new ExecutorAdmissionError(
122
+ "executor_cancelled",
123
+ "Execution was cancelled before admission.",
124
+ ),
125
+ );
126
+ }
127
+ if (this.active < this.concurrency) {
128
+ this.active++;
129
+ return Promise.resolve(this.makeLease());
130
+ }
131
+ if (this.waiters.length >= this.maxQueueSize) {
132
+ return Promise.reject(this.overloaded("Executor queue is full."));
133
+ }
134
+
135
+ return new Promise<AdmissionLease>((resolve, reject) => {
136
+ const waiter: Waiter = {
137
+ resolve,
138
+ reject,
139
+ ...(options.signal ? { signal: options.signal } : {}),
140
+ };
141
+ waiter.onAbort = () => {
142
+ if (!this.remove(waiter)) return;
143
+ this.cleanup(waiter);
144
+ reject(
145
+ new ExecutorAdmissionError(
146
+ "executor_cancelled",
147
+ "Execution was cancelled while queued.",
148
+ ),
149
+ );
150
+ };
151
+ options.signal?.addEventListener("abort", waiter.onAbort, { once: true });
152
+ waiter.timer = setTimeout(() => {
153
+ if (!this.remove(waiter)) return;
154
+ this.cleanup(waiter);
155
+ reject(
156
+ this.overloaded(
157
+ `Executor admission timed out after ${this.queueTimeoutMs}ms.`,
158
+ ),
159
+ );
160
+ }, this.queueTimeoutMs);
161
+ this.waiters.push(waiter);
162
+ });
163
+ }
164
+
165
+ close(): void {
166
+ if (this.closed) return;
167
+ this.closed = true;
168
+ for (const waiter of this.waiters.splice(0)) {
169
+ this.cleanup(waiter);
170
+ waiter.reject(
171
+ new ExecutorAdmissionError(
172
+ "executor_closed",
173
+ "Executor is shutting down.",
174
+ ),
175
+ );
176
+ }
177
+ }
178
+
179
+ private overloaded(message: string): ExecutorAdmissionError {
180
+ return new ExecutorAdmissionError("executor_overloaded", message, {
181
+ retryAfterMs: this.retryAfterMs,
182
+ });
183
+ }
184
+
185
+ private makeLease(): AdmissionLease {
186
+ let released = false;
187
+ return {
188
+ release: () => {
189
+ if (released) return;
190
+ released = true;
191
+ this.release();
192
+ },
193
+ };
194
+ }
195
+
196
+ private release(): void {
197
+ if (this.active > 0) this.active--;
198
+ if (this.closed) return;
199
+ const waiter = this.waiters.shift();
200
+ if (!waiter) return;
201
+ this.cleanup(waiter);
202
+ this.active++;
203
+ waiter.resolve(this.makeLease());
204
+ }
205
+
206
+ private remove(waiter: Waiter): boolean {
207
+ const index = this.waiters.indexOf(waiter);
208
+ if (index < 0) return false;
209
+ this.waiters.splice(index, 1);
210
+ return true;
211
+ }
212
+
213
+ private cleanup(waiter: Waiter): void {
214
+ if (waiter.timer !== undefined) clearTimeout(waiter.timer);
215
+ if (waiter.onAbort) {
216
+ waiter.signal?.removeEventListener("abort", waiter.onAbort);
217
+ }
218
+ }
219
+ }
220
+
221
+ /** Preserve the one-method Workers seam while recognizing richer Node executors. */
222
+ export function isAdmittingExecutor(
223
+ executor: Executor,
224
+ ): executor is AdmittingExecutor {
225
+ return (
226
+ "acquire" in executor &&
227
+ typeof (executor as { acquire?: unknown }).acquire === "function"
228
+ );
229
+ }
@@ -0,0 +1,63 @@
1
+ import type { ExecuteResult } from "./types.js";
2
+
3
+ /** ~6k tokens. Sandbox code should filter data down before returning. */
4
+ export const MAX_EXECUTE_RESULT_CHARS = 24_000;
5
+ export const MAX_EXECUTE_LOG_CHARS = 4_000;
6
+
7
+ function msg(err: unknown): string {
8
+ return err instanceof Error ? err.message : String(err);
9
+ }
10
+
11
+ export function serializeExecuteValue(value: unknown): string {
12
+ const serialized = JSON.stringify(value, null, 2);
13
+ return serialized === undefined ? String(value) : serialized;
14
+ }
15
+
16
+ export function guardExecuteResultValue(value: unknown): unknown {
17
+ const text = serializeExecuteValue(value);
18
+ if (text.length <= MAX_EXECUTE_RESULT_CHARS) return value;
19
+ return {
20
+ truncated: true,
21
+ preview: text.slice(0, MAX_EXECUTE_RESULT_CHARS),
22
+ totalChars: text.length,
23
+ hint: "filter/map/slice data inside execute_code and return only what you need",
24
+ };
25
+ }
26
+
27
+ export function truncateExecuteText(text: string, max: number): string {
28
+ if (text.length <= max) return text;
29
+ return `${text.slice(0, max)}\n--- TRUNCATED (${text.length} chars total) — filter/map/slice data inside your code and return only what you need ---`;
30
+ }
31
+
32
+ /**
33
+ * Apply the public execute_code result/log policy before a child result enters
34
+ * IPC. The parent repeats the guard for third-party Executor implementations.
35
+ */
36
+ export function prepareExecuteResultForTransport(
37
+ outcome: ExecuteResult,
38
+ ): ExecuteResult {
39
+ // QuickJS already bounds captured logs at source (entries + cumulative
40
+ // characters). Preserve that Executor-level shape; createExecuteTool applies
41
+ // the smaller model-facing 4k presentation cap in the parent.
42
+ const logs =
43
+ outcome.logs && outcome.logs.length > 0 ? outcome.logs : undefined;
44
+ if (outcome.error) {
45
+ return {
46
+ result: undefined,
47
+ error: outcome.error,
48
+ ...(logs ? { logs } : {}),
49
+ };
50
+ }
51
+ try {
52
+ return {
53
+ result: guardExecuteResultValue(outcome.result),
54
+ ...(logs ? { logs } : {}),
55
+ };
56
+ } catch (err) {
57
+ return {
58
+ result: undefined,
59
+ error: `result is not JSON-serializable: ${msg(err)}`,
60
+ ...(logs ? { logs } : {}),
61
+ };
62
+ }
63
+ }
@@ -0,0 +1,168 @@
1
+ import { prepareExecuteResultForTransport } from "../executor-result.js";
2
+ import type { ExecutorProvider } from "../types.js";
3
+ import {
4
+ MAX_QUICKJS_IPC_BYTES,
5
+ MAX_QUICKJS_HOST_RPC_BYTES,
6
+ type ChildToParentMessage,
7
+ type ExecutionPayload,
8
+ type HostCallPayload,
9
+ type HostResultPayload,
10
+ type ParentToChildMessage,
11
+ type RunPayload,
12
+ serializedBytes,
13
+ stringifyBounded,
14
+ } from "./quickjs-protocol.js";
15
+ import { executeQuickJs, prepareQuickJs } from "./quickjs-runtime.js";
16
+
17
+ let activeJobId: number | undefined;
18
+ let nextCallId = 1;
19
+ const pending = new Map<
20
+ number,
21
+ {
22
+ resolve: (value: unknown) => void;
23
+ reject: (error: Error) => void;
24
+ }
25
+ >();
26
+
27
+ function msg(err: unknown): string {
28
+ return err instanceof Error ? err.message : String(err);
29
+ }
30
+
31
+ function send(message: ChildToParentMessage): void {
32
+ if (!process.send) throw new Error("QuickJS child IPC channel is unavailable.");
33
+ // Bound the actual process.send envelope, not only its nested payloadJson.
34
+ // Guest-controlled strings must be rejected while they are still isolated
35
+ // in this disposable process, before Node materializes them in the parent.
36
+ stringifyBounded(message, "QuickJS child IPC envelope");
37
+ process.send(message);
38
+ }
39
+
40
+ function provider(name: string, jobId: number): ExecutorProvider {
41
+ const functions = new Proxy(
42
+ Object.create(null) as ExecutorProvider["fns"],
43
+ {
44
+ get: (_target, key) => {
45
+ if (typeof key !== "string") return undefined;
46
+ return async (...args: unknown[]): Promise<unknown> => {
47
+ const callId = nextCallId++;
48
+ const payloadJson = stringifyBounded(
49
+ {
50
+ namespace: name,
51
+ functionName: key,
52
+ args,
53
+ } satisfies HostCallPayload,
54
+ "Host call payload",
55
+ MAX_QUICKJS_HOST_RPC_BYTES,
56
+ );
57
+ const result = new Promise<unknown>((resolve, reject) => {
58
+ pending.set(callId, { resolve, reject });
59
+ });
60
+ send({
61
+ type: "host-call",
62
+ jobId,
63
+ callId,
64
+ payloadJson,
65
+ });
66
+ return result;
67
+ };
68
+ },
69
+ getOwnPropertyDescriptor: (_target, key) =>
70
+ typeof key === "string"
71
+ ? { configurable: true, enumerable: false }
72
+ : undefined,
73
+ },
74
+ );
75
+ return { name, fns: functions };
76
+ }
77
+
78
+ function fixedTransportFailure(message: string): ExecutionPayload {
79
+ return {
80
+ outcome: {
81
+ result: undefined,
82
+ error: message.slice(0, 4_000),
83
+ },
84
+ };
85
+ }
86
+
87
+ async function run(payload: RunPayload): Promise<void> {
88
+ if (activeJobId !== undefined) {
89
+ throw new Error("QuickJS child received overlapping executions.");
90
+ }
91
+ activeJobId = payload.id;
92
+ try {
93
+ const providers = payload.providerNames.map((name) =>
94
+ provider(name, payload.id),
95
+ );
96
+ const raw = await executeQuickJs(
97
+ payload.code,
98
+ providers,
99
+ payload.options,
100
+ );
101
+ const prepared: ExecutionPayload = {
102
+ outcome: prepareExecuteResultForTransport(raw),
103
+ ...(raw.timedOut ? { timedOut: true } : {}),
104
+ };
105
+ let payloadJson: string;
106
+ try {
107
+ payloadJson = stringifyBounded(prepared, "QuickJS execution result");
108
+ } catch (err) {
109
+ payloadJson = JSON.stringify(
110
+ fixedTransportFailure(
111
+ `QuickJS execution result exceeded the ${MAX_QUICKJS_IPC_BYTES}-byte IPC limit: ${msg(err)}`,
112
+ ),
113
+ );
114
+ }
115
+ send({ type: "result", jobId: payload.id, payloadJson });
116
+ } finally {
117
+ activeJobId = undefined;
118
+ for (const request of pending.values()) {
119
+ request.reject(new Error("Execution ended before the host call settled."));
120
+ }
121
+ pending.clear();
122
+ }
123
+ }
124
+
125
+ process.on("message", (message: ParentToChildMessage) => {
126
+ if (!message || typeof message !== "object") return;
127
+ if (message.type === "host-result") {
128
+ if (message.jobId !== activeJobId) return;
129
+ const request = pending.get(message.callId);
130
+ if (!request) return;
131
+ pending.delete(message.callId);
132
+ if (
133
+ serializedBytes(message.payloadJson) > MAX_QUICKJS_HOST_RPC_BYTES
134
+ ) {
135
+ request.reject(new Error("Host result exceeded the IPC limit."));
136
+ return;
137
+ }
138
+ try {
139
+ const result = JSON.parse(message.payloadJson) as HostResultPayload;
140
+ if (result.ok) request.resolve(result.value);
141
+ else request.reject(new Error(result.error));
142
+ } catch (err) {
143
+ request.reject(new Error(`Invalid host result: ${msg(err)}`));
144
+ }
145
+ return;
146
+ }
147
+ if (message.type !== "run") return;
148
+ if (serializedBytes(message.payloadJson) > MAX_QUICKJS_IPC_BYTES) {
149
+ throw new Error("Run payload exceeded the IPC limit.");
150
+ }
151
+ const payload = JSON.parse(message.payloadJson) as RunPayload;
152
+ void run(payload).catch((err) => {
153
+ const payloadJson = JSON.stringify(
154
+ fixedTransportFailure(`QuickJS child failed: ${msg(err)}`),
155
+ );
156
+ send({ type: "result", jobId: payload.id, payloadJson });
157
+ activeJobId = undefined;
158
+ });
159
+ });
160
+
161
+ process.on("disconnect", () => process.exit(0));
162
+
163
+ // The parent does not start an execution's wall budget until the trusted WASM
164
+ // module is loaded. This keeps cold-start latency from being misclassified as
165
+ // guest CPU/wall failure while the parent's startup timeout still bounds a
166
+ // child that never becomes ready.
167
+ await prepareQuickJs();
168
+ send({ type: "ready" });