sandboxedjs 0.2.11 → 0.2.13

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 (48) hide show
  1. package/README.md +21 -9
  2. package/assets/logo.png +0 -0
  3. package/bin/sandboxedjs-egress.mjs +25 -10
  4. package/dist/index.cjs +87 -3
  5. package/dist/index.js +87 -3
  6. package/dist/python/python.data +140 -141
  7. package/dist/python/python.js +1 -1
  8. package/dist/python/python.wasm +0 -0
  9. package/dist/python/runtime.json +3 -3
  10. package/dist/python-worker.js +9 -0
  11. package/dist/service-worker.js +3 -2
  12. package/docs/agent/COMMANDS.md +85 -0
  13. package/docs/agent/DECISION-TREE.md +84 -0
  14. package/docs/agent/INVARIANTS.md +40 -0
  15. package/docs/agent/LAUNCH-PROMPT.md +37 -0
  16. package/docs/agent/LOOP.md +84 -0
  17. package/docs/agent/README.md +77 -0
  18. package/docs/agent/ROADMAP.md +37 -0
  19. package/docs/agent/STATE.md +93 -0
  20. package/docs/agent/tasks/00-verify-inherited-work.md +36 -0
  21. package/docs/agent/tasks/01-authoritative-metadata.md +34 -0
  22. package/docs/agent/tasks/02-native-dependencies.md +35 -0
  23. package/docs/agent/tasks/03-reproducible-inputs.md +27 -0
  24. package/docs/agent/tasks/04-build-frontends.md +27 -0
  25. package/docs/agent/tasks/05-registry-integration.md +27 -0
  26. package/docs/agent/tasks/06-package-cohorts.md +42 -0
  27. package/docs/agent/tasks/07-build-on-miss-boundary.md +31 -0
  28. package/docs/browser-runtime-architecture.md +132 -0
  29. package/docs/compatibility-implementation-plan.md +98 -0
  30. package/docs/developer-tool-packs.md +134 -0
  31. package/docs/frontend-automation.md +49 -0
  32. package/docs/fullstack-deployment.md +163 -0
  33. package/docs/handoff.md +275 -0
  34. package/docs/original-x64.md +39 -0
  35. package/docs/platform-hardening.md +49 -0
  36. package/docs/python/abi.md +97 -0
  37. package/docs/python/architecture.md +94 -0
  38. package/docs/python/baseline-inventory.md +54 -0
  39. package/docs/python/build-on-miss.md +198 -0
  40. package/docs/python/compatibility.md +206 -0
  41. package/docs/python/cross-build.md +354 -0
  42. package/docs/python/extensions.md +282 -0
  43. package/docs/python/release-gates.md +46 -0
  44. package/docs/python/virtual-sockets-plan.md +331 -0
  45. package/docs/runtime-lifecycle-fixes.md +39 -0
  46. package/docs/server-previews.md +268 -0
  47. package/docs/virtual-browser.md +120 -0
  48. package/package.json +6 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="assets/logo.png" alt="SandboxedJS" width="140" />
3
+ <img src="https://cdn.jsdelivr.net/npm/sandboxedjs@latest/assets/logo.png" alt="SandboxedJS" width="140" />
4
4
 
5
5
  # SandboxedJS
6
6
 
@@ -117,7 +117,9 @@ terminal.start();
117
117
  This is the case most people arrive for: a Vite frontend and a Python backend, in one container, in
118
118
  a tab — the frontend calling `http://localhost:8000`, the backend calling a real API.
119
119
 
120
- Two different problems hide in that sentence, and SandboxedJS solves them in two different ways.
120
+ The frontend-to-backend hop and the backend-to-internet hop have different requirements.
121
+ See the [full-stack deployment guide](docs/fullstack-deployment.md) for production asset staging,
122
+ Cloudflare Pages, Vercel, Node/serverless lifetimes, and troubleshooting.
121
123
 
122
124
  ### 1. Frontend → backend: nothing to configure
123
125
 
@@ -149,10 +151,14 @@ await box.exec("npm install", { cwd: "/app/frontend", timeoutMs: 600_000 });
149
151
 
150
152
  box.spawn("fastapi run", { cwd: "/app/backend" });
151
153
  box.spawn("npm run dev", { cwd: "/app/frontend" });
152
- await box.waitForPort(3000, { timeoutMs: 60_000 });
153
-
154
- const preview = await createPreview(box); // null where service workers are unavailable
155
- iframe.src = preview!.urlFor(3000);
154
+ if (!(await box.waitForPort(8000, { timeoutMs: 60_000 })) ||
155
+ !(await box.waitForPort(3000, { timeoutMs: 60_000 }))) {
156
+ throw new Error("Check backend and frontend startup logs");
157
+ }
158
+
159
+ const preview = await createPreview(box);
160
+ if (!preview) throw new Error("Preview requires a working service worker");
161
+ iframe.src = preview.urlFor(3000);
156
162
  ```
157
163
 
158
164
  It works between preview pages and from another tab of the same browser while the owner page is
@@ -172,13 +178,14 @@ browser's rule about _pages_, not a container limit, and the only way through it
172
178
  somewhere a page is not. So add one to the project you already deploy:
173
179
 
174
180
  ```bash
175
- npx sandboxedjs-egress init --allow ollama.com,api.openai.com
181
+ npx sandboxedjs-egress init --target cloudflare --allow ollama.com,api.openai.com
176
182
  ```
177
183
 
178
184
  That writes a single function file at the path your host serves — Cloudflare Pages, Vercel and
179
185
  Netlify are detected, `--target` names one. Nothing else changes: a container in a browser probes
180
- its own origin for that proxy before giving up, so **no project passes a `proxy` option and no
181
- project is configured twice.**
186
+ its own origin for that proxy before giving up. The guest project needs no proxy setting;
187
+ the host must deploy the generated function. For a separate relay, set `network.proxy`.
188
+ A host serving only static files cannot relay APIs that reject browser requests.
182
189
 
183
190
  | Where you are | What to do |
184
191
  | --------------------------------- | ----------------------------------------------------------------------------------- |
@@ -249,6 +256,11 @@ from source where a wheel does not exist; browsers have no compiler, so they ask
249
256
  `npx sandboxedjs-build-wheels 4180`. See [docs/python/build-on-miss.md](docs/python/build-on-miss.md)
250
257
  and [compatibility](docs/python/compatibility.md).
251
258
 
259
+ **Python thread offloads work in the bundled runtime:** `asyncio.to_thread`,
260
+ `run_in_executor`, and synchronous FastAPI routes use real worker threads.
261
+ For browser deployments, update `python-worker.js` and the entire `python/`
262
+ directory together. See [threading architecture and limits](docs/browser-runtime-architecture.md#python-thread-offloads).
263
+
252
264
  **What does not work in a browser:** `copyIn()` / `copyOut()` and `expose()` (they need a real
253
265
  filesystem and a real socket), and the CLI. They fail only if you call them.
254
266
 
Binary file
@@ -45,7 +45,7 @@ const TARGETS = {
45
45
  ${allow}
46
46
  /* Cloudflare Pages serves this file at /__sandboxedjs__/egress, which is where
47
47
  * a container in this site's pages looks for its way out. */
48
- export const onRequest = ({ request }) => handleEgressRequest(request, options);
48
+ export const onRequest = ({ request }: { request: Request }) => handleEgressRequest(request, options);
49
49
  `,
50
50
  },
51
51
  vercel: {
@@ -57,7 +57,7 @@ export const config = { runtime: "edge" };
57
57
 
58
58
  /* Vercel serves this file at /api/__sandboxedjs__/egress, which is one of the
59
59
  * paths a container in this site's pages probes for its way out. */
60
- export default (request) => handleEgressRequest(request, options);
60
+ export default (request: Request) => handleEgressRequest(request, options);
61
61
  `,
62
62
  },
63
63
  netlify: {
@@ -67,7 +67,7 @@ export default (request) => handleEgressRequest(request, options);
67
67
  ${allow}
68
68
  export const config = { path: "/.netlify/functions/sandboxedjs-egress" };
69
69
 
70
- export default (request) => handleEgressRequest(request, options);
70
+ export default (request: Request) => handleEgressRequest(request, options);
71
71
  `,
72
72
  },
73
73
  };
@@ -90,13 +90,28 @@ async function detectTarget(root) {
90
90
  }
91
91
 
92
92
  async function init(argv) {
93
- const root = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
94
- const at = argv.indexOf("--target");
95
- const allowAt = argv.indexOf("--allow");
96
- const allowed = allowAt === -1
97
- ? null
98
- : (argv[allowAt + 1] ?? "").split(",").map((h) => h.trim()).filter(Boolean);
99
- const name = at === -1 ? await detectTarget(root) : argv[at + 1];
93
+ let directory;
94
+ let targetName;
95
+ let allowed = null;
96
+ for (let index = 0; index < argv.length; index++) {
97
+ const argument = argv[index];
98
+ if (argument === "--target" || argument === "--allow") {
99
+ const value = argv[++index];
100
+ if (!value || value.startsWith("--")) {
101
+ console.error(`sandboxedjs-egress init: ${argument} requires a value`);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ if (argument === "--target") targetName = value;
106
+ else allowed = value.split(",").map((host) => host.trim()).filter(Boolean);
107
+ } else if (argument.startsWith("-") || directory !== undefined) {
108
+ console.error(`sandboxedjs-egress init: unexpected argument ${argument}`);
109
+ process.exitCode = 1;
110
+ return;
111
+ } else directory = argument;
112
+ }
113
+ const root = directory ?? process.cwd();
114
+ const name = targetName ?? await detectTarget(root);
100
115
  if (!name || !TARGETS[name]) {
101
116
  console.error(
102
117
  name
package/dist/index.cjs CHANGED
@@ -20027,7 +20027,7 @@ var CAPABILITIES = ["files", "descriptors", "pipes", "readiness", "time", "ident
20027
20027
  var OP_NAMES = Object.fromEntries(
20028
20028
  Object.entries(Op).map(([name, code]) => [code, name])
20029
20029
  );
20030
- Object.fromEntries(
20030
+ var ERRNO_NAMES = Object.fromEntries(
20031
20031
  Object.entries(Errno).map(([name, code]) => [code, name])
20032
20032
  );
20033
20033
 
@@ -21103,7 +21103,7 @@ var wheels_default = {
21103
21103
 
21104
21104
  // package.json
21105
21105
  var package_default = {
21106
- version: "0.2.11"};
21106
+ version: "0.2.13"};
21107
21107
 
21108
21108
  // src/python/extension-abi.ts
21109
21109
  var EXTENSION_ABI = {
@@ -21863,6 +21863,59 @@ var FileService = class {
21863
21863
  init_signals();
21864
21864
  init_binary();
21865
21865
 
21866
+ // src/python/thread-transport.ts
21867
+ init_binary();
21868
+ async function createThreadTransport(generation, handle) {
21869
+ const crypto2 = globalThis.crypto ?? (await nodeBuiltin("crypto")).webcrypto;
21870
+ const name = `sbx-python-${crypto2.randomUUID()}`;
21871
+ const channel = new BroadcastChannel(name);
21872
+ const pending = /* @__PURE__ */ new Set();
21873
+ let closed = false;
21874
+ let memory;
21875
+ let memoryReady;
21876
+ const ready = new Promise((resolve3) => {
21877
+ memoryReady = resolve3;
21878
+ });
21879
+ channel.onmessage = async ({ data }) => {
21880
+ if (closed || !(data?.request instanceof Uint8Array) || data.request.length < 16 || !Number.isSafeInteger(data.offset) || data.offset < 0 || data.offset % 4 || !Number.isSafeInteger(data.capacity) || data.capacity < 84) return;
21881
+ await ready;
21882
+ if (closed || !memory || data.offset + data.capacity > memory.buffer.byteLength) return;
21883
+ const control = new Int32Array(memory.buffer, data.offset, 1);
21884
+ const output = new Uint8Array(memory.buffer, data.offset, data.capacity);
21885
+ pending.add(control);
21886
+ try {
21887
+ const request = data.request.slice();
21888
+ new DataView(request.buffer).setUint32(8, generation, true);
21889
+ const response = await handle(request);
21890
+ if (closed) return;
21891
+ if (response.length > output.length - 64) throw new Error("thread response exceeds capacity");
21892
+ output.set(response, 64);
21893
+ const status = new DataView(response.buffer, response.byteOffset).getInt32(12, true);
21894
+ if (status < 0) output.set(new TextEncoder().encode(ERRNO_NAMES[-status] ?? "EIO"), 4);
21895
+ Atomics.store(control, 0, response.length);
21896
+ } catch {
21897
+ Atomics.store(control, 0, -1);
21898
+ } finally {
21899
+ pending.delete(control);
21900
+ Atomics.notify(control, 0);
21901
+ }
21902
+ };
21903
+ return { name, setMemory(value) {
21904
+ if (!(value.buffer instanceof SharedArrayBuffer)) throw new Error("Python thread memory must be shared");
21905
+ memory = value;
21906
+ memoryReady();
21907
+ }, close() {
21908
+ closed = true;
21909
+ memoryReady();
21910
+ channel.close();
21911
+ for (const control of pending) {
21912
+ Atomics.store(control, 0, -1);
21913
+ Atomics.notify(control, 0);
21914
+ }
21915
+ pending.clear();
21916
+ } };
21917
+ }
21918
+
21866
21919
  // src/python/protocol.ts
21867
21920
  var ProtocolError = class extends Error {
21868
21921
  code = "ERR_SBX_ABI_PROTOCOL";
@@ -23109,6 +23162,7 @@ async function startPythonProcess(options) {
23109
23162
  sockets: options.sockets,
23110
23163
  processes: options.processes
23111
23164
  });
23165
+ const threads = await createThreadTransport(generation, handle);
23112
23166
  const buffers = createHostTransportBuffers();
23113
23167
  let fault = null;
23114
23168
  const server = new HostCallServer(buffers, handle, (error) => {
@@ -23127,8 +23181,10 @@ async function startPythonProcess(options) {
23127
23181
  const cleanup = () => {
23128
23182
  if (done) return;
23129
23183
  done = true;
23184
+ controller.abort();
23130
23185
  options.signal.removeEventListener("abort", abort);
23131
23186
  server.close();
23187
+ threads.close();
23132
23188
  table.closeAll();
23133
23189
  try {
23134
23190
  worker?.terminate();
@@ -23143,6 +23199,10 @@ async function startPythonProcess(options) {
23143
23199
  }
23144
23200
  worker.onMessage((message) => {
23145
23201
  const detail = message;
23202
+ if (detail?.type === "thread-memory" && detail.memory) {
23203
+ threads.setMemory(detail.memory);
23204
+ return;
23205
+ }
23146
23206
  if (detail?.type === "wake") {
23147
23207
  void server.pump();
23148
23208
  return;
@@ -23175,6 +23235,7 @@ async function startPythonProcess(options) {
23175
23235
  type: "start",
23176
23236
  buffers,
23177
23237
  generation,
23238
+ threadChannel: threads.name,
23178
23239
  moduleUrl: options.manifest.artifacts.moduleUrl,
23179
23240
  argv: options.argv,
23180
23241
  env: options.env,
@@ -25301,7 +25362,30 @@ if _egress:
25301
25362
  sys.meta_path.insert(0, _SbxHttpxHook())
25302
25363
 
25303
25364
 
25304
- if os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25365
+ if os.environ.get("SBX_THREAD_HOST_CALLS") == "1":
25366
+ import selectors as _selectors
25367
+ import threading as _threading
25368
+ import time as _time
25369
+
25370
+ class _SbxThreadSelector(_selectors.SelectSelector):
25371
+ """Let Emscripten service filesystem calls proxied by Python threads.
25372
+
25373
+ Native socket calls have an independent channel per request. Filesystem
25374
+ calls still use the main runtime thread, so it must periodically leave
25375
+ its host poll and yield whenever other Python threads are alive. Keep
25376
+ the standard asyncio executor, context propagation and cancellation.
25377
+ """
25378
+ def select(self, timeout=None):
25379
+ threaded = _threading.active_count() > 1
25380
+ if threaded:
25381
+ timeout = 0.01 if timeout is None else min(max(timeout, 0), 0.01)
25382
+ ready = super().select(timeout)
25383
+ if threaded:
25384
+ _time.sleep(0.001)
25385
+ return ready
25386
+
25387
+ _selectors.DefaultSelector = _SbxThreadSelector
25388
+ elif os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25305
25389
  import asyncio
25306
25390
 
25307
25391
  async def _to_thread(func, /, *args, **kwargs):
package/dist/index.js CHANGED
@@ -20010,7 +20010,7 @@ var CAPABILITIES = ["files", "descriptors", "pipes", "readiness", "time", "ident
20010
20010
  var OP_NAMES = Object.fromEntries(
20011
20011
  Object.entries(Op).map(([name, code]) => [code, name])
20012
20012
  );
20013
- Object.fromEntries(
20013
+ var ERRNO_NAMES = Object.fromEntries(
20014
20014
  Object.entries(Errno).map(([name, code]) => [code, name])
20015
20015
  );
20016
20016
 
@@ -21086,7 +21086,7 @@ var wheels_default = {
21086
21086
 
21087
21087
  // package.json
21088
21088
  var package_default = {
21089
- version: "0.2.11"};
21089
+ version: "0.2.13"};
21090
21090
 
21091
21091
  // src/python/extension-abi.ts
21092
21092
  var EXTENSION_ABI = {
@@ -21846,6 +21846,59 @@ var FileService = class {
21846
21846
  init_signals();
21847
21847
  init_binary();
21848
21848
 
21849
+ // src/python/thread-transport.ts
21850
+ init_binary();
21851
+ async function createThreadTransport(generation, handle) {
21852
+ const crypto2 = globalThis.crypto ?? (await nodeBuiltin("crypto")).webcrypto;
21853
+ const name = `sbx-python-${crypto2.randomUUID()}`;
21854
+ const channel = new BroadcastChannel(name);
21855
+ const pending = /* @__PURE__ */ new Set();
21856
+ let closed = false;
21857
+ let memory;
21858
+ let memoryReady;
21859
+ const ready = new Promise((resolve3) => {
21860
+ memoryReady = resolve3;
21861
+ });
21862
+ channel.onmessage = async ({ data }) => {
21863
+ if (closed || !(data?.request instanceof Uint8Array) || data.request.length < 16 || !Number.isSafeInteger(data.offset) || data.offset < 0 || data.offset % 4 || !Number.isSafeInteger(data.capacity) || data.capacity < 84) return;
21864
+ await ready;
21865
+ if (closed || !memory || data.offset + data.capacity > memory.buffer.byteLength) return;
21866
+ const control = new Int32Array(memory.buffer, data.offset, 1);
21867
+ const output = new Uint8Array(memory.buffer, data.offset, data.capacity);
21868
+ pending.add(control);
21869
+ try {
21870
+ const request = data.request.slice();
21871
+ new DataView(request.buffer).setUint32(8, generation, true);
21872
+ const response = await handle(request);
21873
+ if (closed) return;
21874
+ if (response.length > output.length - 64) throw new Error("thread response exceeds capacity");
21875
+ output.set(response, 64);
21876
+ const status = new DataView(response.buffer, response.byteOffset).getInt32(12, true);
21877
+ if (status < 0) output.set(new TextEncoder().encode(ERRNO_NAMES[-status] ?? "EIO"), 4);
21878
+ Atomics.store(control, 0, response.length);
21879
+ } catch {
21880
+ Atomics.store(control, 0, -1);
21881
+ } finally {
21882
+ pending.delete(control);
21883
+ Atomics.notify(control, 0);
21884
+ }
21885
+ };
21886
+ return { name, setMemory(value) {
21887
+ if (!(value.buffer instanceof SharedArrayBuffer)) throw new Error("Python thread memory must be shared");
21888
+ memory = value;
21889
+ memoryReady();
21890
+ }, close() {
21891
+ closed = true;
21892
+ memoryReady();
21893
+ channel.close();
21894
+ for (const control of pending) {
21895
+ Atomics.store(control, 0, -1);
21896
+ Atomics.notify(control, 0);
21897
+ }
21898
+ pending.clear();
21899
+ } };
21900
+ }
21901
+
21849
21902
  // src/python/protocol.ts
21850
21903
  var ProtocolError = class extends Error {
21851
21904
  code = "ERR_SBX_ABI_PROTOCOL";
@@ -23092,6 +23145,7 @@ async function startPythonProcess(options) {
23092
23145
  sockets: options.sockets,
23093
23146
  processes: options.processes
23094
23147
  });
23148
+ const threads = await createThreadTransport(generation, handle);
23095
23149
  const buffers = createHostTransportBuffers();
23096
23150
  let fault = null;
23097
23151
  const server = new HostCallServer(buffers, handle, (error) => {
@@ -23110,8 +23164,10 @@ async function startPythonProcess(options) {
23110
23164
  const cleanup = () => {
23111
23165
  if (done) return;
23112
23166
  done = true;
23167
+ controller.abort();
23113
23168
  options.signal.removeEventListener("abort", abort);
23114
23169
  server.close();
23170
+ threads.close();
23115
23171
  table.closeAll();
23116
23172
  try {
23117
23173
  worker?.terminate();
@@ -23126,6 +23182,10 @@ async function startPythonProcess(options) {
23126
23182
  }
23127
23183
  worker.onMessage((message) => {
23128
23184
  const detail = message;
23185
+ if (detail?.type === "thread-memory" && detail.memory) {
23186
+ threads.setMemory(detail.memory);
23187
+ return;
23188
+ }
23129
23189
  if (detail?.type === "wake") {
23130
23190
  void server.pump();
23131
23191
  return;
@@ -23158,6 +23218,7 @@ async function startPythonProcess(options) {
23158
23218
  type: "start",
23159
23219
  buffers,
23160
23220
  generation,
23221
+ threadChannel: threads.name,
23161
23222
  moduleUrl: options.manifest.artifacts.moduleUrl,
23162
23223
  argv: options.argv,
23163
23224
  env: options.env,
@@ -25284,7 +25345,30 @@ if _egress:
25284
25345
  sys.meta_path.insert(0, _SbxHttpxHook())
25285
25346
 
25286
25347
 
25287
- if os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25348
+ if os.environ.get("SBX_THREAD_HOST_CALLS") == "1":
25349
+ import selectors as _selectors
25350
+ import threading as _threading
25351
+ import time as _time
25352
+
25353
+ class _SbxThreadSelector(_selectors.SelectSelector):
25354
+ """Let Emscripten service filesystem calls proxied by Python threads.
25355
+
25356
+ Native socket calls have an independent channel per request. Filesystem
25357
+ calls still use the main runtime thread, so it must periodically leave
25358
+ its host poll and yield whenever other Python threads are alive. Keep
25359
+ the standard asyncio executor, context propagation and cancellation.
25360
+ """
25361
+ def select(self, timeout=None):
25362
+ threaded = _threading.active_count() > 1
25363
+ if threaded:
25364
+ timeout = 0.01 if timeout is None else min(max(timeout, 0), 0.01)
25365
+ ready = super().select(timeout)
25366
+ if threaded:
25367
+ _time.sleep(0.001)
25368
+ return ready
25369
+
25370
+ _selectors.DefaultSelector = _SbxThreadSelector
25371
+ elif os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25288
25372
  import asyncio
25289
25373
 
25290
25374
  async def _to_thread(func, /, *args, **kwargs):