@eryx-org/eryx 0.0.0 → 0.15.0

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 (63) hide show
  1. package/README.md +281 -4
  2. package/eryx-sandbox.conditional-host-import-0.wasm +0 -0
  3. package/eryx-sandbox.conditional-host-import-1.wasm +0 -0
  4. package/eryx-sandbox.conditional-suspending-1-i32-to-i32.wasm +0 -0
  5. package/eryx-sandbox.core.wasm +0 -0
  6. package/eryx-sandbox.core10.wasm +0 -0
  7. package/eryx-sandbox.core11.wasm +0 -0
  8. package/eryx-sandbox.core12.wasm +0 -0
  9. package/eryx-sandbox.core13.wasm +0 -0
  10. package/eryx-sandbox.core2.wasm +0 -0
  11. package/eryx-sandbox.core3.wasm +0 -0
  12. package/eryx-sandbox.core4.wasm +0 -0
  13. package/eryx-sandbox.core5.wasm +0 -0
  14. package/eryx-sandbox.core6.wasm +0 -0
  15. package/eryx-sandbox.core7.wasm +0 -0
  16. package/eryx-sandbox.core8.wasm +0 -0
  17. package/eryx-sandbox.core9.wasm +0 -0
  18. package/eryx-sandbox.d.ts +163 -0
  19. package/eryx-sandbox.js +43541 -0
  20. package/index.d.ts +331 -0
  21. package/index.js +698 -0
  22. package/instance.js +174 -0
  23. package/interfaces/eryx-net-tcp.d.ts +30 -0
  24. package/interfaces/eryx-net-tls.d.ts +24 -0
  25. package/interfaces/wasi-cli-environment.d.ts +3 -0
  26. package/interfaces/wasi-cli-exit.d.ts +3 -0
  27. package/interfaces/wasi-cli-stderr.d.ts +3 -0
  28. package/interfaces/wasi-cli-stdin.d.ts +3 -0
  29. package/interfaces/wasi-cli-stdout.d.ts +3 -0
  30. package/interfaces/wasi-cli-terminal-input.d.ts +8 -0
  31. package/interfaces/wasi-cli-terminal-output.d.ts +8 -0
  32. package/interfaces/wasi-cli-terminal-stderr.d.ts +3 -0
  33. package/interfaces/wasi-cli-terminal-stdin.d.ts +3 -0
  34. package/interfaces/wasi-cli-terminal-stdout.d.ts +3 -0
  35. package/interfaces/wasi-clocks-monotonic-clock.d.ts +8 -0
  36. package/interfaces/wasi-clocks-wall-clock.d.ts +7 -0
  37. package/interfaces/wasi-filesystem-preopens.d.ts +3 -0
  38. package/interfaces/wasi-filesystem-types.d.ts +207 -0
  39. package/interfaces/wasi-io-error.d.ts +8 -0
  40. package/interfaces/wasi-io-poll.d.ts +10 -0
  41. package/interfaces/wasi-io-streams.d.ts +33 -0
  42. package/interfaces/wasi-random-random.d.ts +2 -0
  43. package/interfaces/wasi-sockets-instance-network.d.ts +3 -0
  44. package/interfaces/wasi-sockets-ip-name-lookup.d.ts +15 -0
  45. package/interfaces/wasi-sockets-network.d.ts +92 -0
  46. package/interfaces/wasi-sockets-tcp-create-socket.d.ts +5 -0
  47. package/interfaces/wasi-sockets-tcp.d.ts +52 -0
  48. package/interfaces/wasi-sockets-udp-create-socket.d.ts +5 -0
  49. package/interfaces/wasi-sockets-udp.d.ts +51 -0
  50. package/package.json +69 -3
  51. package/python-stdlib.tar.gz +0 -0
  52. package/shims/callbacks.d.ts +45 -0
  53. package/shims/callbacks.js +469 -0
  54. package/shims/ip.js +46 -0
  55. package/shims/net-fetch.js +264 -0
  56. package/shims/net-node.js +236 -0
  57. package/shims/net.d.ts +84 -0
  58. package/shims/net.js +255 -0
  59. package/shims/preview2-node.js +35 -0
  60. package/shims/raw-json.js +111 -0
  61. package/shims/sockets.js +53 -0
  62. package/stdlib-loader.js +174 -0
  63. package/worker.js +100 -0
package/index.js ADDED
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Eryx - JavaScript API
3
+ *
4
+ * This module provides a high-level API for the eryx Python sandbox
5
+ * WebAssembly component.
6
+ *
7
+ * Usage:
8
+ * ```js
9
+ * import { Sandbox } from '@eryx-org/eryx';
10
+ * const sandbox = new Sandbox();
11
+ * const result = await sandbox.execute('print("hello")');
12
+ * console.log(result.stdout); // "hello\n"
13
+ * ```
14
+ */
15
+
16
+ import {
17
+ ExecutionError,
18
+ PythonExceptionError,
19
+ TimeoutError,
20
+ } from "@eryx-org/eryx-run/shared";
21
+ import { createCallbackHost } from "./shims/callbacks.js";
22
+ import { createNet, setNetConfig as setDefaultNetConfig } from "./shims/net.js";
23
+ import {
24
+ _fileTree,
25
+ decodeError,
26
+ encodeError,
27
+ forkTree,
28
+ getCoreModule,
29
+ instantiateSandbox,
30
+ isNode,
31
+ } from "./instance.js";
32
+
33
+ export { _fileTree } from "./instance.js";
34
+
35
+ // The errors a run throws, shared with eryx.run's client (@eryx-org/eryx-run),
36
+ // so one `instanceof` handles both
37
+ export {
38
+ EryxError,
39
+ ExecutionError,
40
+ PythonExceptionError,
41
+ ResourceLimitError,
42
+ TimeoutError,
43
+ } from "@eryx-org/eryx-run/shared";
44
+
45
+ /**
46
+ * Start a sandbox instance in its own Worker and return a proxy with the
47
+ * instance's methods, plus `terminate()`. The handlers, journal and network
48
+ * stay on this thread; the Worker forwards to them (see worker.js).
49
+ * @param {ReturnType<typeof createCallbackHost>} host
50
+ * @param {ReturnType<typeof createNet>} net - The sandbox's connection table
51
+ */
52
+ async function instantiateInWorker(host, net) {
53
+ const url = new URL("./worker.js", import.meta.url);
54
+ const worker = isNode
55
+ ? // Not the parent's execArgv, which can hold `-e`/`--input-type` a Worker
56
+ // rejects. V8 flags such as --experimental-wasm-jspi are process-wide,
57
+ // so the Worker keeps them.
58
+ new (await import(/* webpackIgnore: true */ "node:worker_threads")).Worker(url, {
59
+ execArgv: [],
60
+ })
61
+ : new Worker(url, { type: "module" });
62
+ const targets = {
63
+ module: getCoreModule,
64
+ invoke: host.imports.invoke,
65
+ reportTrace: host.imports.reportTrace,
66
+ reportOutput: host.imports.reportOutput,
67
+ "tcp.connect": net.tcp.connect,
68
+ "tcp.read": net.tcp.read,
69
+ "tcp.write": net.tcp.write,
70
+ "tcp.close": net.tcp.close,
71
+ "tls.upgrade": net.tls.upgrade,
72
+ "tls.read": net.tls.read,
73
+ "tls.write": net.tls.write,
74
+ "tls.close": net.tls.close,
75
+ };
76
+
77
+ let nextId = 0;
78
+ const pending = new Map();
79
+ // An idle Worker mustn't keep Node running, but a busy one must.
80
+ const busy = (yes) => isNode && (yes ? worker.ref() : worker.unref());
81
+ let failure = null;
82
+ let ready;
83
+ const started = new Promise((resolve, reject) => (ready = { resolve, reject }));
84
+ // Once the Worker is gone, nothing will close what its code left open.
85
+ const fail = (error) => {
86
+ if (failure) return;
87
+ failure = error;
88
+ net.closeConnections();
89
+ ready.reject(failure);
90
+ for (const { reject } of pending.values()) reject(failure);
91
+ pending.clear();
92
+ };
93
+
94
+ const onMessage = async (message) => {
95
+ switch (message.type) {
96
+ case "ready":
97
+ busy(false);
98
+ ready.resolve();
99
+ break;
100
+ case "failed":
101
+ fail(decodeError(message));
102
+ break;
103
+ case "result": {
104
+ const call = pending.get(message.id);
105
+ pending.delete(message.id);
106
+ if (pending.size === 0) busy(false);
107
+ if ("value" in message) call?.resolve(message.value);
108
+ else call?.reject(decodeError(message));
109
+ break;
110
+ }
111
+ case "notify":
112
+ if (!failure) targets[message.target](...message.args);
113
+ break;
114
+ case "request":
115
+ // A dead Worker's queued requests mustn't reach handlers or the network.
116
+ if (failure) break;
117
+ try {
118
+ const value = await targets[message.target](...message.args);
119
+ if (failure) {
120
+ // Opened after the Worker was stopped: nothing would ever close it.
121
+ if (message.target === "tcp.connect") net.tcp.close(value);
122
+ else if (message.target === "tls.upgrade") net.tls.close(value);
123
+ break;
124
+ }
125
+ worker.postMessage({ type: "reply", id: message.id, value });
126
+ } catch (e) {
127
+ if (!failure) worker.postMessage({ type: "reply", id: message.id, ...encodeError(e) });
128
+ }
129
+ break;
130
+ }
131
+ };
132
+ const onError = (e) => fail(e instanceof Error ? e : new Error(e.message ?? "sandbox worker failed"));
133
+ if (isNode) {
134
+ worker.on("message", onMessage);
135
+ worker.on("error", onError);
136
+ worker.on("exit", () => fail(new Error("sandbox worker exited")));
137
+ } else {
138
+ worker.addEventListener("message", (e) => onMessage(e.data));
139
+ worker.addEventListener("error", onError);
140
+ }
141
+
142
+ const call = (method, ...args) => {
143
+ if (failure) return Promise.reject(failure);
144
+ const id = nextId++;
145
+ busy(true);
146
+ worker.postMessage({
147
+ type: "call",
148
+ id,
149
+ method,
150
+ args,
151
+ callbacks: host.imports.listCallbacks(),
152
+ options: host.imports.getExecutionOptions(),
153
+ });
154
+ return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
155
+ };
156
+
157
+ await started;
158
+ return {
159
+ execute: (code) => call("execute", code),
160
+ snapshotState: () => call("snapshotState"),
161
+ restoreState: (data) => call("restoreState", data),
162
+ clearState: () => call("clearState"),
163
+ setResultVariable: (name) => call("setResultVariable", name),
164
+ /** Stop the Worker, rejecting anything still pending with `error`. */
165
+ terminate(error) {
166
+ fail(error);
167
+ worker.terminate();
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Terminates the Worker of a `timeoutMs` sandbox that was garbage collected
174
+ * without close(). Holds only the sandbox's Worker slot, never the sandbox.
175
+ * @type {FinalizationRegistry<{worker: any, closed: Error | null}>}
176
+ */
177
+ const _workerReaper = new FinalizationRegistry((slot) => {
178
+ slot.closed ??= new Error("sandbox was garbage collected");
179
+ slot.worker?.terminate(slot.closed);
180
+ });
181
+
182
+ // Re-export callback configuration
183
+ export {
184
+ setTraceHandler,
185
+ setOutputHandler,
186
+ SuspendCallback,
187
+ } from "./shims/callbacks.js";
188
+
189
+ // Re-export network policy defaults
190
+ export { DEFAULT_BLOCKED_HOSTS, DEFAULT_BLOCKED_NETWORKS } from "./shims/net.js";
191
+
192
+ /**
193
+ * Result from executing Python code in the sandbox: eryx.run's
194
+ * `ExecuteResult`, so code handles results from either the same way. `result`
195
+ * keeps integers outside the safe range exact by parsing them as BigInt (see
196
+ * {@link setResultVariable} to capture another variable), `stateWasReset` says
197
+ * an earlier call hit `timeoutMs` and replaced the Worker (reported once, by
198
+ * the next successful run), and `fuelConsumed`, `peakMemoryBytes`,
199
+ * `networkBytes` and `traceId` are undefined.
200
+ * @typedef {import("@eryx-org/eryx-run/shared").ExecuteResult} ExecuteResult
201
+ */
202
+
203
+ /**
204
+ * A captured Python session state, from {@link Sandbox#snapshotState}.
205
+ * @typedef {Object} StateSnapshot
206
+ * @property {Uint8Array} data - The serialized state, accepted by restoreState()
207
+ * @property {string[]} skippedGlobals - Names of the globals left out because
208
+ * they could not be serialized (e.g. open sockets, generators)
209
+ */
210
+
211
+ /**
212
+ * Parse the captured result JSON, promoting integers outside the IEEE-754 safe
213
+ * range to BigInt so they round-trip without precision loss.
214
+ *
215
+ * Uses the ES2023 reviver `context.source` (the raw source text of the value) to
216
+ * distinguish a true integer literal from a float. When the runtime doesn't
217
+ * provide it (older engines), falls back to a plain (lossy) parse.
218
+ * @param {string} json
219
+ * @returns {*}
220
+ */
221
+ function _parseResultJson(json) {
222
+ return JSON.parse(json, (_key, value, context) => {
223
+ if (
224
+ typeof value === "number" &&
225
+ Number.isInteger(value) &&
226
+ !Number.isSafeInteger(value) &&
227
+ context &&
228
+ typeof context.source === "string" &&
229
+ /^-?\d+$/.test(context.source)
230
+ ) {
231
+ return BigInt(context.source);
232
+ }
233
+ return value;
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Map a raw jco execute-output record into the public ExecuteResult shape,
239
+ * parsing the captured result variable from JSON.
240
+ * @param {Object} output
241
+ * @param {boolean} stateWasReset
242
+ * @returns {ExecuteResult}
243
+ */
244
+ const _decoder = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true });
245
+
246
+ function _toResult(output, stateWasReset, stats) {
247
+ return {
248
+ stdout: _decoder.decode(output.stdout),
249
+ stderr: _decoder.decode(output.stderr),
250
+ result: output.resultJson ? _parseResultJson(output.resultJson) : undefined,
251
+ resultJson: output.resultJson || undefined,
252
+ resultError: output.resultError || undefined,
253
+ stateWasReset,
254
+ unsavedVariables: [],
255
+ ...stats,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * The outcome of executeWithJournal().
261
+ * @typedef {Object} ReplayOutcome
262
+ * @property {ExecuteResult} [result] - The execution result, or undefined if it failed
263
+ * @property {Error} [error] - Why execution failed (Python exception, or the
264
+ * SuspendCallback that halted it), or undefined on success
265
+ * @property {string} journal - Callbacks completed during this run, serialized
266
+ * as JSON in the same shape as the Rust `CallbackJournal`. Always present, even
267
+ * on error. Store it as-is and pass it back via `options.journal`.
268
+ * @property {number} replayedCallbacks - How many callbacks were served from
269
+ * the supplied journal instead of running live
270
+ * @property {{name: string, argsJson: string, reason: string}} [suspended] -
271
+ * Set if a callback threw SuspendCallback
272
+ */
273
+
274
+ /**
275
+ * Options for a {@link Sandbox}. `traceHandler` and `outputHandler` override,
276
+ * for this sandbox only, the module-wide default set by the setter of the
277
+ * same name; an omitted one follows that default.
278
+ * @typedef {Object} SandboxOptions
279
+ * @property {import("@eryx-org/eryx-run/shared").Callbacks} [callbacks] -
280
+ * Functions the code can `await`, by name, as for eryx.run's client: each a
281
+ * function of the code's keyword arguments, or `{ handler, description }`.
282
+ * What it returns must be JSON; if it throws, the call fails in Python.
283
+ * @property {((lineno: number, eventJson: string, contextJson: string) => void) | null} [traceHandler]
284
+ * @property {((stream: number, data: string) => void) | null} [outputHandler]
285
+ * @property {import("./shims/net.js").NetConfig | null} [netConfig] - This
286
+ * sandbox's network policy (`null` disables networking). Omitted, the
287
+ * sandbox copies the default set with {@link setNetConfig} at creation.
288
+ * @property {number} [timeoutMs] - Run the sandbox in its own Worker and
289
+ * reject any call that takes longer than this many milliseconds with a
290
+ * {@link TimeoutError}. The Worker is then terminated and replaced, so the
291
+ * sandbox's Python state is lost. The deadline is wall-clock time, including
292
+ * callbacks; instantiation doesn't count. The Worker costs about 70 MB.
293
+ * Without it, nothing interrupts a call.
294
+ */
295
+
296
+ /**
297
+ * A Python sandbox powered by WebAssembly.
298
+ *
299
+ * Each Sandbox is its own WebAssembly instance, with its own Python
300
+ * interpreter, filesystem, network connections, callback handlers and
301
+ * journal, so code run in one can't see or change another's state. Python
302
+ * state (variables, imports, etc.) persists across execute() calls on the same
303
+ * Sandbox; connections are closed when each call finishes.
304
+ *
305
+ * The compiled code is shared, but every instance has its own linear memory
306
+ * (the pre-initialized interpreter image plus whatever the code allocates), so
307
+ * each live Sandbox costs tens of megabytes (about 20 MB idle on Node.js 24).
308
+ * One created with `timeoutMs` also runs in its own Worker, which adds about
309
+ * 70 MB. Call close() (or use `using`) when you're done with a sandbox: it
310
+ * stops the Worker at once and closes the sandbox's connections. A sandbox
311
+ * that is only dropped is freed whenever the garbage collector gets to it.
312
+ *
313
+ * @example
314
+ * const sandbox = new Sandbox();
315
+ *
316
+ * // Variables persist across calls
317
+ * await sandbox.execute('x = 42');
318
+ * const result = await sandbox.execute('print(x)');
319
+ * console.log(result.stdout); // "42"
320
+ *
321
+ * // Reset state
322
+ * await sandbox.clearState();
323
+ */
324
+ export class Sandbox {
325
+ /** @type {ReturnType<typeof createCallbackHost>} */
326
+ #host;
327
+ /** @type {ReturnType<typeof createNet>} */
328
+ #net = createNet();
329
+ /** @type {number | undefined} */
330
+ #timeoutMs;
331
+ /** @type {Promise<any>} */
332
+ #instance;
333
+ /** The Worker of a `timeoutMs` sandbox, and why the sandbox was closed. */
334
+ #slot = { worker: null, closed: /** @type {Error | null} */ (null) };
335
+ /** The last name passed to setResultVariable(), re-applied after a restart. */
336
+ #resultVariable;
337
+ /** Set when a timeout restarts the Worker; reported by the next execution. */
338
+ #stateWasReset = false;
339
+ // The tail of the queue of calls into this instance. Each call starts only
340
+ // after the previous one settles, so executions never overlap: the journal
341
+ // and the streaming output decoders belong to the instance, and jco can't
342
+ // run two calls on one instance at once.
343
+ #queue = Promise.resolve();
344
+
345
+ /**
346
+ * Create a sandbox. Instantiation starts right away and finishes in the
347
+ * background; the first call waits for it, and rejects if it failed.
348
+ *
349
+ * @param {SandboxOptions} [options]
350
+ */
351
+ constructor(options = {}) {
352
+ const { timeoutMs } = options;
353
+ if (timeoutMs !== undefined && !(Number.isFinite(timeoutMs) && timeoutMs > 0)) {
354
+ throw new TypeError("timeoutMs must be a positive number of milliseconds");
355
+ }
356
+ this.#timeoutMs = timeoutMs;
357
+ /**
358
+ * This sandbox's own file tree (a copy of the module's `_fileTree`).
359
+ * Changes to it are visible to the sandbox's next filesystem access.
360
+ * Undefined for a sandbox with `timeoutMs`, whose files live in its Worker.
361
+ * @internal For the demo app and advanced integrations.
362
+ */
363
+ this._fileTree = timeoutMs === undefined ? forkTree(_fileTree) : undefined;
364
+ if ("callbackHandler" in options) {
365
+ throw new TypeError(
366
+ "callbackHandler is gone: pass callbacks as an object of functions by name, " +
367
+ "e.g. { get_user: async ({ id }) => ... }",
368
+ );
369
+ }
370
+ this.#host = createCallbackHost(options);
371
+ if (options.netConfig !== undefined) this.#net.setNetConfig(options.netConfig);
372
+ if (timeoutMs !== undefined) _workerReaper.register(this, this.#slot, this.#slot);
373
+ this.#start();
374
+ }
375
+
376
+ #start() {
377
+ if (this.#timeoutMs === undefined) {
378
+ this.#instance = instantiateSandbox(this._fileTree, this.#host.imports, this.#net);
379
+ } else {
380
+ const slot = this.#slot;
381
+ const name = this.#resultVariable;
382
+ this.#instance = instantiateInWorker(this.#host, this.#net).then(async (worker) => {
383
+ slot.worker = worker;
384
+ if (slot.closed) worker.terminate(slot.closed);
385
+ // A restarted Worker starts with the default result variable.
386
+ else if (name !== undefined) await worker.setResultVariable(name);
387
+ return worker;
388
+ });
389
+ }
390
+ // Surfaced by the first call instead.
391
+ this.#instance.catch(() => {});
392
+ }
393
+
394
+ /**
395
+ * Stop using this sandbox. Its open connections are closed, and every later
396
+ * call rejects. For a `timeoutMs` sandbox the Worker is terminated at once,
397
+ * so a call in flight rejects too; without `timeoutMs` a call in flight
398
+ * runs to the end. Calling it again does nothing.
399
+ */
400
+ close() {
401
+ if (this.#slot.closed) return;
402
+ this.#slot.closed = new Error("sandbox is closed");
403
+ _workerReaper.unregister(this.#slot);
404
+ this.#slot.worker?.terminate(this.#slot.closed);
405
+ this.#net.closeConnections();
406
+ }
407
+
408
+ /**
409
+ * Replace this sandbox's network policy (`null` disables networking). It
410
+ * applies to connections opened after the call, and doesn't change the
411
+ * default set with the module-level {@link setNetConfig} or other sandboxes.
412
+ *
413
+ * Throws a TypeError if a network isn't a valid CIDR or `maxConnections`
414
+ * isn't a non-negative integer.
415
+ *
416
+ * @param {import("./shims/net.js").NetConfig | null} netConfig
417
+ */
418
+ setNetConfig(netConfig) {
419
+ this.#net.setNetConfig(netConfig);
420
+ }
421
+
422
+ /**
423
+ * Run `fn` on the instance once every call queued before it has settled.
424
+ * @template T
425
+ * @param {(instance: any) => Promise<T>} fn
426
+ * @returns {Promise<T>}
427
+ */
428
+ #serialized(fn) {
429
+ const run = this.#queue.then(async () => {
430
+ if (this.#slot.closed) throw this.#slot.closed;
431
+ try {
432
+ return await this.#call(fn);
433
+ } finally {
434
+ // Connections don't outlive the call that opened them.
435
+ this.#net.closeConnections();
436
+ }
437
+ });
438
+ this.#queue = run.catch(() => {});
439
+ return run;
440
+ }
441
+
442
+ /**
443
+ * Run `fn` on the instance, under the deadline if there is one. On timeout,
444
+ * the Worker is terminated and a fresh one started for later calls.
445
+ * @template T
446
+ * @param {(instance: any) => Promise<T>} fn
447
+ * @returns {Promise<T>}
448
+ */
449
+ async #call(fn) {
450
+ const instance = await this.#instance;
451
+ if (this.#timeoutMs === undefined) return fn(instance);
452
+ // Terminating the Worker rejects the call in flight with the TimeoutError,
453
+ // which executeWithJournal() then reports like any other failure.
454
+ const timer = setTimeout(() => {
455
+ instance.terminate(new TimeoutError(`execution timed out after ${this.#timeoutMs} ms`));
456
+ this.#stateWasReset = true;
457
+ this.#start();
458
+ }, this.#timeoutMs);
459
+ try {
460
+ return await fn(instance);
461
+ } finally {
462
+ clearTimeout(timer);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Run code, flushing streamed output when it finishes. Callers must hold the
468
+ * queue, so the flush can't cut into another execution's output.
469
+ * @param {any} instance
470
+ * @param {string} code
471
+ * @returns {Promise<ExecuteResult>}
472
+ */
473
+ async #run(instance, code) {
474
+ const start = performance.now();
475
+ this.#host.takeInvocations();
476
+ const stats = () => ({
477
+ durationMs: performance.now() - start,
478
+ callbackInvocations: this.#host.takeInvocations(),
479
+ });
480
+ try {
481
+ // Frame the code with an empty preamble (see the guest's PREAMBLE_HEADER),
482
+ // so a leading header-like line in user code isn't misparsed.
483
+ const output = await instance.execute(`#eryx-preamble:0\n${code}`);
484
+ const result = _toResult(output, this.#stateWasReset, stats());
485
+ this.#stateWasReset = false;
486
+ return result;
487
+ } catch (e) {
488
+ // A failed run's payload is an execute-error record: surface its
489
+ // traceback as the message, with the output captured before it failed.
490
+ const payload = e?.payload;
491
+ if (payload && typeof payload.message === "string") {
492
+ // As eryx.run's client tells them apart: an exception in the code has
493
+ // its traceback as the message
494
+ const failed = payload.message.startsWith("Traceback (most recent call last)")
495
+ ? Object.assign(new PythonExceptionError(payload.message, { cause: e }), {
496
+ stdout: _decoder.decode(payload.stdout),
497
+ stderr: _decoder.decode(payload.stderr),
498
+ })
499
+ : new ExecutionError(payload.message, { cause: e });
500
+ throw Object.assign(failed, stats());
501
+ }
502
+ throw e;
503
+ } finally {
504
+ this.#host.flushOutput();
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Execute Python code in the sandbox.
510
+ *
511
+ * The code runs in the sandboxed Python interpreter. Output to stdout
512
+ * and stderr is captured and returned. Variables and imports persist
513
+ * across calls on the same Sandbox instance.
514
+ *
515
+ * Calls on one Sandbox run one at a time, in call order; an execute() made
516
+ * while another call on it is running waits for it to finish. Don't await
517
+ * execute() on the same Sandbox from inside its callback handler: it would
518
+ * wait for the execution that invoked the handler, which never finishes.
519
+ *
520
+ * @param {string} code - Python source code to execute
521
+ * @returns {Promise<ExecuteResult>} Captured stdout and stderr
522
+ * @throws {Error} If the Python code raises an unhandled exception
523
+ *
524
+ * @example
525
+ * const result = await sandbox.execute('print("hello")');
526
+ * console.log(result.stdout); // "hello\n"
527
+ */
528
+ async execute(code) {
529
+ return this.#serialized((instance) => this.#run(instance, code));
530
+ }
531
+
532
+ /**
533
+ * Execute Python code, journaling callback results so a later run can replay
534
+ * them instead of re-invoking the callbacks.
535
+ *
536
+ * Callbacks matching an entry of `options.journal` (by name and canonical
537
+ * arguments, FIFO) return the recorded result without calling the handler;
538
+ * the first miss switches the rest of the run to live calls. A handler can
539
+ * throw SuspendCallback to halt execution; the outcome then has `suspended`
540
+ * set and a journal to resume from.
541
+ *
542
+ * Never throws for script failures: check `suspended`, then `error`. Like
543
+ * every call on this Sandbox, it waits for earlier calls to finish first.
544
+ *
545
+ * @param {string} code - Python source code to execute
546
+ * @param {{journal?: string}} [options] - A journal from a previous run to replay
547
+ * @returns {Promise<ReplayOutcome>}
548
+ */
549
+ async executeWithJournal(code, options) {
550
+ return this.#serialized(async (instance) => {
551
+ this.#host.beginReplay(options?.journal);
552
+ let result;
553
+ let error;
554
+ try {
555
+ result = await this.#run(instance, code);
556
+ } catch (e) {
557
+ error = e;
558
+ }
559
+ return { result, error, ...this.#host.endReplay(code) };
560
+ });
561
+ }
562
+
563
+ /**
564
+ * Set the name of the variable captured as the structured result.
565
+ *
566
+ * After each execute(), the variable with this name is read from the
567
+ * script's namespace, JSON-serialized, and returned as
568
+ * `ExecuteResult.result`. Defaults to "result". Takes effect for calls made
569
+ * after it.
570
+ *
571
+ * @param {string} name - The variable name to capture
572
+ * @returns {Promise<void>}
573
+ */
574
+ async setResultVariable(name) {
575
+ await this.#serialized((instance) => instance.setResultVariable(name));
576
+ this.#resultVariable = name;
577
+ }
578
+
579
+ /**
580
+ * Capture a snapshot of the current Python session state.
581
+ *
582
+ * Returns serialized state (via pickle) that can be restored later
583
+ * with restoreState(). This captures all user-defined variables that can
584
+ * be serialized, and names the others in `skippedGlobals`.
585
+ *
586
+ * @returns {Promise<StateSnapshot>} Serialized Python state
587
+ * @throws {Error} If serialization fails
588
+ */
589
+ async snapshotState() {
590
+ return this.#serialized((instance) => instance.snapshotState());
591
+ }
592
+
593
+ /**
594
+ * Restore Python session state from a previously captured snapshot.
595
+ *
596
+ * After restore, subsequent execute() calls will have access to all
597
+ * variables that were present when the snapshot was taken.
598
+ *
599
+ * @param {StateSnapshot | Uint8Array} snapshot - A snapshot from
600
+ * snapshotState(), or its `data`
601
+ * @throws {Error} If deserialization fails
602
+ */
603
+ async restoreState(snapshot) {
604
+ const data = snapshot instanceof Uint8Array ? snapshot : snapshot.data;
605
+ await this.#serialized((instance) => instance.restoreState(data));
606
+ }
607
+
608
+ /**
609
+ * Clear all persistent state from the session.
610
+ *
611
+ * After clear, subsequent execute() calls will start with a fresh
612
+ * namespace (no user-defined variables from previous calls).
613
+ */
614
+ async clearState() {
615
+ await this.#serialized((instance) => instance.clearState());
616
+ }
617
+ }
618
+
619
+ // `using sandbox = new Sandbox()` closes it at the end of the scope, where the
620
+ // runtime has explicit resource management.
621
+ for (const symbol of [Symbol.dispose, Symbol.asyncDispose]) {
622
+ if (symbol) {
623
+ Object.defineProperty(Sandbox.prototype, symbol, {
624
+ value() {
625
+ this.close();
626
+ },
627
+ configurable: true,
628
+ writable: true,
629
+ });
630
+ }
631
+ }
632
+
633
+ /** @type {Sandbox | undefined} */
634
+ let _defaultSandbox;
635
+
636
+ /** The sandbox behind the module-level helpers, created on first use. */
637
+ function _default() {
638
+ return (_defaultSandbox ??= new Sandbox());
639
+ }
640
+
641
+ /**
642
+ * Set the default network policy (`null` disables networking, the initial
643
+ * default). Each Sandbox copies it when it is created, unless given its own
644
+ * `netConfig`; sandboxes that already exist keep their policy. It also
645
+ * applies to the default sandbox behind the module-level helpers.
646
+ *
647
+ * Throws a TypeError if a network isn't a valid CIDR or `maxConnections`
648
+ * isn't a non-negative integer.
649
+ *
650
+ * @param {import("./shims/net.js").NetConfig | null} netConfig
651
+ */
652
+ export function setNetConfig(netConfig) {
653
+ setDefaultNetConfig(netConfig);
654
+ _defaultSandbox?.setNetConfig(netConfig);
655
+ }
656
+
657
+ /**
658
+ * Execute Python code in the default sandbox.
659
+ *
660
+ * This is a convenience function backed by one module-wide Sandbox, created
661
+ * on first use, so state from previous execute() calls persists. It shares
662
+ * nothing with Sandbox instances you create yourself; use one of those for
663
+ * isolated execution.
664
+ *
665
+ * @param {string} code - Python source code to execute
666
+ * @returns {Promise<ExecuteResult>} Captured stdout and stderr
667
+ * @throws {Error} If the Python code raises an unhandled exception
668
+ *
669
+ * @example
670
+ * const result = await execute('print("hello")');
671
+ * console.log(result.stdout); // "hello"
672
+ */
673
+ export async function execute(code) {
674
+ return _default().execute(code);
675
+ }
676
+
677
+ /**
678
+ * Execute Python code in the default sandbox, journaling callback results.
679
+ * See {@link Sandbox#executeWithJournal}.
680
+ *
681
+ * @param {string} code - Python source code to execute
682
+ * @param {{journal?: string}} [options] - A journal from a previous run to replay
683
+ * @returns {Promise<ReplayOutcome>}
684
+ */
685
+ export async function executeWithJournal(code, options) {
686
+ return _default().executeWithJournal(code, options);
687
+ }
688
+
689
+ /**
690
+ * Set the name of the variable captured as the structured result in the
691
+ * default sandbox. See {@link Sandbox#setResultVariable}.
692
+ *
693
+ * @param {string} name - The variable name to capture
694
+ * @returns {Promise<void>}
695
+ */
696
+ export function setResultVariable(name) {
697
+ return _default().setResultVariable(name);
698
+ }