@zackbart/connecta 0.7.3 → 0.7.5
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/CHANGELOG.md +68 -0
- package/README.md +6 -3
- package/dist/connector-scope.d.ts +7 -4
- package/dist/connector-scope.d.ts.map +1 -1
- package/dist/connector-scope.js +36 -16
- package/dist/connector-scope.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +47 -16
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credential-health.d.ts +3 -2
- package/dist/credential-health.d.ts.map +1 -1
- package/dist/credential-health.js +9 -9
- package/dist/credential-health.js.map +1 -1
- package/dist/execute.d.ts +3 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +70 -29
- package/dist/execute.js.map +1 -1
- package/dist/executor-admission.d.ts +53 -0
- package/dist/executor-admission.d.ts.map +1 -0
- package/dist/executor-admission.js +151 -0
- package/dist/executor-admission.js.map +1 -0
- package/dist/executor-result.d.ts +13 -0
- package/dist/executor-result.d.ts.map +1 -0
- package/dist/executor-result.js +57 -0
- package/dist/executor-result.js.map +1 -0
- package/dist/executors/quickjs-child.d.ts +2 -0
- package/dist/executors/quickjs-child.d.ts.map +1 -0
- package/dist/executors/quickjs-child.js +131 -0
- package/dist/executors/quickjs-child.js.map +1 -0
- package/dist/executors/quickjs-protocol.d.ts +55 -0
- package/dist/executors/quickjs-protocol.d.ts.map +1 -0
- package/dist/executors/quickjs-protocol.js +22 -0
- package/dist/executors/quickjs-protocol.js.map +1 -0
- package/dist/executors/quickjs-runtime.d.ts +26 -0
- package/dist/executors/quickjs-runtime.d.ts.map +1 -0
- package/dist/executors/quickjs-runtime.js +383 -0
- package/dist/executors/quickjs-runtime.js.map +1 -0
- package/dist/executors/quickjs.d.ts +20 -15
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +459 -311
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +4 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +3 -2
- package/dist/meta-tools.js.map +1 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +36 -3
- package/dist/node.js.map +1 -1
- package/dist/registry.d.ts +3 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -4
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +2 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +3 -3
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/connector-scope.ts +43 -18
- package/src/connectors/remote-mcp.ts +56 -19
- package/src/credential-health.ts +20 -6
- package/src/execute.ts +93 -35
- package/src/executor-admission.ts +229 -0
- package/src/executor-result.ts +63 -0
- package/src/executors/quickjs-child.ts +168 -0
- package/src/executors/quickjs-protocol.ts +77 -0
- package/src/executors/quickjs-runtime.ts +440 -0
- package/src/executors/quickjs.ts +660 -353
- package/src/index.ts +28 -4
- package/src/meta-tools.ts +9 -1
- package/src/node.ts +36 -2
- package/src/registry.ts +5 -2
- package/src/server.ts +10 -2
- package/src/types.ts +17 -0
- package/src/ui.ts +6 -1
- package/src/version.ts +1 -1
|
@@ -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" });
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ExecuteResult } from "../types.js";
|
|
2
|
+
import type { QuickJsRuntimeOptions } from "./quickjs-runtime.js";
|
|
3
|
+
|
|
4
|
+
// The guest result is shaped to 24k characters before transport and QuickJS
|
|
5
|
+
// capture already bounds logs to 256k characters. Every complete message is
|
|
6
|
+
// measured against this ceiling before process.send; one MiB leaves room for
|
|
7
|
+
// multibyte log text and envelope escaping while remaining a hard IPC bound.
|
|
8
|
+
export const MAX_QUICKJS_IPC_BYTES = 1024 * 1024;
|
|
9
|
+
/** Preserve #84's stopgap before a host value enters the child/WASM process. */
|
|
10
|
+
export const MAX_QUICKJS_HOST_RPC_BYTES = 256 * 1024;
|
|
11
|
+
|
|
12
|
+
export interface RunPayload {
|
|
13
|
+
id: number;
|
|
14
|
+
code: string;
|
|
15
|
+
providerNames: string[];
|
|
16
|
+
options: QuickJsRuntimeOptions;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ParentToChildMessage =
|
|
20
|
+
| { type: "run"; payloadJson: string }
|
|
21
|
+
| {
|
|
22
|
+
type: "host-result";
|
|
23
|
+
jobId: number;
|
|
24
|
+
callId: number;
|
|
25
|
+
payloadJson: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ChildToParentMessage =
|
|
29
|
+
| { type: "ready" }
|
|
30
|
+
| {
|
|
31
|
+
type: "host-call";
|
|
32
|
+
jobId: number;
|
|
33
|
+
callId: number;
|
|
34
|
+
payloadJson: string;
|
|
35
|
+
}
|
|
36
|
+
| { type: "result"; jobId: number; payloadJson: string };
|
|
37
|
+
|
|
38
|
+
export interface HostCallPayload {
|
|
39
|
+
namespace: string;
|
|
40
|
+
functionName: string;
|
|
41
|
+
args: unknown[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type HostResultPayload =
|
|
45
|
+
| { ok: true; value: unknown }
|
|
46
|
+
| { ok: false; error: string };
|
|
47
|
+
|
|
48
|
+
export interface ExecutionPayload {
|
|
49
|
+
outcome: ExecuteResult;
|
|
50
|
+
/**
|
|
51
|
+
* Set by the runtime on wall-clock expiry. The parent recycles the child on
|
|
52
|
+
* this flag, never on error text a guest can fabricate.
|
|
53
|
+
*/
|
|
54
|
+
timedOut?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function serializedBytes(text: string): number {
|
|
58
|
+
return new TextEncoder().encode(text).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function stringifyBounded(
|
|
62
|
+
value: unknown,
|
|
63
|
+
label: string,
|
|
64
|
+
limit = MAX_QUICKJS_IPC_BYTES,
|
|
65
|
+
): string {
|
|
66
|
+
const json = JSON.stringify(value);
|
|
67
|
+
if (json === undefined) {
|
|
68
|
+
throw new TypeError(`${label} is not JSON-serializable.`);
|
|
69
|
+
}
|
|
70
|
+
const bytes = serializedBytes(json);
|
|
71
|
+
if (bytes > limit) {
|
|
72
|
+
throw new RangeError(
|
|
73
|
+
`${label} is ${bytes} UTF-8 bytes, over the ${limit}-byte IPC limit.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return json;
|
|
77
|
+
}
|