sandboxedjs 0.1.26 → 0.1.27

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/README.md CHANGED
@@ -577,8 +577,8 @@ its kill signal.
577
577
 
578
578
  ## Running in a browser
579
579
 
580
- This package targets Node today, and that is what is tested. If your goal is a browser IDE, here
581
- is exactly where things stand.
580
+ The same `createContainer()` API runs under Node and in modern browsers. Browser execution is
581
+ tested under Vite 8; the remaining browser-specific limits are listed below.
582
582
 
583
583
  **Done — the package no longer hard-depends on Node at import time.** Compression and hashing
584
584
  resolve their implementation at call time (`node:zlib`/`node:crypto` on Node,
@@ -607,7 +607,24 @@ $ node -p process.version v22.12.0
607
607
  $ cat /etc/os-release SandboxedJS 1.0 (sandbox)
608
608
  ```
609
609
 
610
- **Not done three things stand between this and a browser IDE.**
610
+ **Vite 8 works through Rolldown's official WASI binding.** Because that binding uses shared
611
+ WebAssembly memory and workers, the page must be cross-origin isolated. For example:
612
+
613
+ ```ts
614
+ // vite.config.ts
615
+ export default {
616
+ server: { headers: {
617
+ "Cross-Origin-Opener-Policy": "same-origin",
618
+ "Cross-Origin-Embedder-Policy": "require-corp",
619
+ } },
620
+ };
621
+ ```
622
+
623
+ Production hosting must return the same two headers. Without them SandboxedJS reports a direct
624
+ configuration error when Vite/Rolldown starts. The compiler is loaded only when an installed
625
+ project actually contains Rolldown, so ordinary container boot does not pay its WASM startup cost.
626
+
627
+ **Not done — these things still stand between this and a complete browser IDE.**
611
628
 
612
629
  - *A service worker.* A preview iframe needs a real URL, and giving it one means a worker on your
613
630
  own origin that intercepts requests and routes them into the container. `request()` works
@@ -617,11 +634,10 @@ $ cat /etc/os-release SandboxedJS 1.0 (sandbox)
617
634
  - *Worker isolation.* The runtime executes in whichever realm you boot it from, which in a browser
618
635
  is the main thread — so a long build blocks the UI, and container code can reach page globals.
619
636
  `comlink` is a dependency in anticipation of this and is not used yet.
620
- - *esbuild.* The runtime cannot execute any build of esbuild itself: one dlopens a compiled addon,
637
+ - *esbuild-dependent tools.* The runtime cannot execute any build of esbuild itself: one dlopens a compiled addon,
621
638
  the other drives a Go program through facilities the sandbox does not have. On Node it borrows
622
- the host's `esbuild-wasm`, which is why Vite's dev server works there. In a browser there is
623
- nothing to borrow, so anything routed through esbuild fails until esbuild-wasm runs *inside*
624
- the sandbox.
639
+ the host's `esbuild-wasm`. Vite 8's Rolldown path works in a browser, but tools which call
640
+ esbuild directly still fail until esbuild-wasm runs *inside* the sandbox.
625
641
 
626
642
  Vite prints two warnings about `util` being externalized. They come from `readable-stream`, which
627
643
  declares `"util": false` for browsers and falls back on its own; nothing in this package imports
@@ -637,7 +653,6 @@ built on `fetch`, `acorn`, `resolve.exports`, `@noble/hashes` and `pako`.
637
653
  - `copyIn()` / `copyOut()` — they read and write the host filesystem, which does not exist.
638
654
  - `expose()` — it opens a real `node:http` listener. Use `request()` to reach an in-container
639
655
  server instead of a host port.
640
- - `python3` — see below.
641
656
  - The `sandboxedjs` CLI, obviously.
642
657
 
643
658
  Those use dynamic imports, so they only fail if you call them.
@@ -750,9 +765,11 @@ Honest list of what does not work:
750
765
  - **Compiled native addons.** A `.node` file cannot be loaded, so a package that ships one has to
751
766
  have a JavaScript or WebAssembly build to fall back on. `rollup` and `esbuild` do, and the
752
767
  runtime redirects those two names to `@rollup/wasm-node` and `esbuild-wasm` automatically when
753
- they are installed. **Vite 8 does not work** for this reason: it builds on Rolldown, whose only
754
- sandbox-viable binding needs WASI and `worker_threads`. Vite 7 runs, dev server included, as do
755
- Express, Koa, Fastify-style apps and plain `http` servers.
768
+ they are installed. Vite 8 is supported through Rolldown's official WASI build (with the browser
769
+ isolation headers above). Express, Koa, Fastify-style apps and plain `http` servers also run.
770
+ - **Concurrent browser Rolldown projects need distinct absolute working directories.** The
771
+ official binding owns one WASI memfs per page; SandboxedJS mirrors each project into it before
772
+ startup. Two live projects using the same path such as `/workspace` can overwrite that mirror.
756
773
  - **`child_process` is asynchronous only.** `spawn`, `exec` and `execFile` run through the kernel,
757
774
  so a child sees the same filesystem and coreutils as the shell. `execSync`, `spawnSync` and
758
775
  `execFileSync` throw `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`: blocking the JavaScript thread on
package/dist/index.cjs CHANGED
@@ -6823,7 +6823,7 @@ var NetworkStack = class {
6823
6823
  }
6824
6824
  return [...out.values()].sort((a, b) => a.port - b.port);
6825
6825
  }
6826
- /** Ports Nodepod's proxy has registered for this instance. */
6826
+ /** Ports the pod's proxy has registered for this instance. */
6827
6827
  knownPodPorts() {
6828
6828
  try {
6829
6829
  return this.pod.proxy.activePorts(this.pod.instanceId) ?? [];
@@ -18180,7 +18180,33 @@ function ffmpegCommands() {
18180
18180
  return [ffmpeg, ffprobe];
18181
18181
  }
18182
18182
  var platformBuffer = globalThis.Buffer;
18183
- var Buffer2 = platformBuffer ?? index_js.Buffer;
18183
+ var Buffer2 = platformBuffer ?? addBase64UrlSupport(index_js.Buffer);
18184
+ function addBase64UrlSupport(BufferClass) {
18185
+ const target = BufferClass;
18186
+ if (target.__sandboxedBase64Url) return BufferClass;
18187
+ Object.defineProperty(target, "__sandboxedBase64Url", { value: true });
18188
+ const from = target.from.bind(target);
18189
+ target.from = (value, encodingOrOffset, length) => from(value, normalizeEncoding(encodingOrOffset), length);
18190
+ const byteLength = target.byteLength.bind(target);
18191
+ target.byteLength = (value, encoding) => byteLength(value, normalizeEncoding(encoding));
18192
+ const isEncoding = target.isEncoding?.bind(target);
18193
+ if (isEncoding) target.isEncoding = (encoding) => encoding.toLowerCase() === "base64url" || isEncoding(encoding);
18194
+ const toString = target.prototype.toString;
18195
+ target.prototype.toString = function(encoding, start2, end) {
18196
+ if (encoding?.toLowerCase() !== "base64url") return toString.call(this, encoding, start2, end);
18197
+ return toString.call(this, "base64", start2, end).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
18198
+ };
18199
+ const write = target.prototype.write;
18200
+ target.prototype.write = function(...args) {
18201
+ const index = typeof args[1] === "string" ? 1 : typeof args[2] === "string" ? 2 : 3;
18202
+ if (typeof args[index] === "string") args[index] = normalizeEncoding(args[index]);
18203
+ return write.apply(this, args);
18204
+ };
18205
+ return BufferClass;
18206
+ }
18207
+ function normalizeEncoding(value) {
18208
+ return typeof value === "string" && value.toLowerCase() === "base64url" ? "base64" : value;
18209
+ }
18184
18210
 
18185
18211
  // src/pkg/clean-installer.ts
18186
18212
  init_path();
@@ -18222,6 +18248,9 @@ var CleanPackageInstaller = class _CleanPackageInstaller {
18222
18248
  const version = resolveVersion(metadata, range);
18223
18249
  const manifest = metadata.versions[version];
18224
18250
  if (!manifest) throw new Error(`No matching version found for ${name}@${range}`);
18251
+ if (!supportsPlatform(manifest)) {
18252
+ throw new Error(`${name}@${version} is not compatible with linux/x64/glibc`);
18253
+ }
18225
18254
  const identity = `${name}@${version}`;
18226
18255
  const target = join(modulesRoot, name);
18227
18256
  const installed2 = this.tryReadJson(join(target, "package.json"));
@@ -18316,6 +18345,15 @@ var CleanPackageInstaller = class _CleanPackageInstaller {
18316
18345
  }
18317
18346
  }
18318
18347
  };
18348
+ function supportsPlatform(manifest) {
18349
+ return !manifest.main?.endsWith(".node") && platformListAllows(manifest.os, "linux") && platformListAllows(manifest.cpu, "x64") && platformListAllows(manifest.libc, "glibc");
18350
+ }
18351
+ function platformListAllows(values, current) {
18352
+ if (!values?.length) return true;
18353
+ if (values.includes(`!${current}`)) return false;
18354
+ const positive = values.filter((value) => !value.startsWith("!"));
18355
+ return positive.length === 0 || positive.includes(current) || positive.includes("any");
18356
+ }
18319
18357
  function resolveVersion(metadata, range) {
18320
18358
  const tag2 = metadata["dist-tags"]?.[range];
18321
18359
  if (tag2) return tag2;
@@ -22276,6 +22314,7 @@ function createCoreModules(options) {
22276
22314
  globals,
22277
22315
  process: processObject,
22278
22316
  pendingHandles: timers.pending,
22317
+ pendingUnrefed: timers.pendingUnrefed,
22279
22318
  writeStdin: (data) => {
22280
22319
  if (options.interactiveStdin) stdin.write(data);
22281
22320
  },
@@ -22871,33 +22910,78 @@ var ERRNO_CONSTANTS = {
22871
22910
  };
22872
22911
  function createTrackedTimers() {
22873
22912
  const live = /* @__PURE__ */ new Set();
22874
- const track = (handle, repeating) => {
22913
+ const unrefed = /* @__PURE__ */ new Set();
22914
+ const states = /* @__PURE__ */ new WeakMap();
22915
+ const track = (native) => {
22916
+ const state = { native, active: true, referenced: true };
22917
+ const handle = {
22918
+ ref() {
22919
+ state.referenced = true;
22920
+ unrefed.delete(handle);
22921
+ if (state.active) live.add(handle);
22922
+ state.native?.ref?.();
22923
+ return handle;
22924
+ },
22925
+ unref() {
22926
+ state.referenced = false;
22927
+ live.delete(handle);
22928
+ if (state.active) unrefed.add(handle);
22929
+ state.native?.unref?.();
22930
+ return handle;
22931
+ },
22932
+ hasRef: () => state.referenced,
22933
+ refresh() {
22934
+ state.native?.refresh?.();
22935
+ return handle;
22936
+ },
22937
+ [Symbol.toPrimitive]: () => Number(state.native)
22938
+ };
22939
+ states.set(handle, state);
22875
22940
  live.add(handle);
22876
22941
  return handle;
22877
22942
  };
22943
+ const complete = (handle) => {
22944
+ const state = states.get(handle);
22945
+ if (state) state.active = false;
22946
+ live.delete(handle);
22947
+ unrefed.delete(handle);
22948
+ };
22878
22949
  const setTimeoutTracked = (fn, delay, ...args) => {
22879
- const handle = setTimeout(
22950
+ let handle;
22951
+ const native = setTimeout(
22880
22952
  (...inner) => {
22881
- live.delete(handle);
22953
+ complete(handle);
22882
22954
  fn(...inner);
22883
22955
  },
22884
22956
  delay,
22885
22957
  ...args
22886
22958
  );
22887
- live.add(handle);
22959
+ handle = track(native);
22888
22960
  return handle;
22889
22961
  };
22890
22962
  const setIntervalTracked = (fn, delay, ...args) => track(setInterval(fn, delay, ...args));
22891
22963
  const hostSetImmediate = globalThis.setImmediate;
22892
22964
  const setImmediateTracked = (fn, ...args) => {
22893
- const handle = hostSetImmediate ? hostSetImmediate((...inner) => {
22894
- live.delete(handle);
22965
+ if (!hostSetImmediate) return setTimeoutTracked(fn, 0, ...args);
22966
+ let handle;
22967
+ const native = hostSetImmediate((...inner) => {
22968
+ complete(handle);
22895
22969
  fn(...inner);
22896
- }, ...args) : setTimeoutTracked(fn, 0, ...args);
22897
- live.add(handle);
22970
+ }, ...args);
22971
+ handle = track(native);
22898
22972
  return handle;
22899
22973
  };
22900
22974
  const clear2 = (handle, native) => {
22975
+ if (typeof handle === "object" && handle !== null) {
22976
+ const state = states.get(handle);
22977
+ if (state) {
22978
+ state.active = false;
22979
+ live.delete(handle);
22980
+ unrefed.delete(handle);
22981
+ native(state.native);
22982
+ return;
22983
+ }
22984
+ }
22901
22985
  live.delete(handle);
22902
22986
  native(handle);
22903
22987
  };
@@ -22910,7 +22994,8 @@ function createTrackedTimers() {
22910
22994
  clearInterval: (handle) => clear2(handle, clearInterval),
22911
22995
  clearImmediate: (handle) => clear2(handle, globalThis.clearImmediate ?? clearTimeout)
22912
22996
  },
22913
- pending: () => live.size
22997
+ pending: () => live.size,
22998
+ pendingUnrefed: () => unrefed.size
22914
22999
  };
22915
23000
  }
22916
23001
  function createTimerPromises() {
@@ -23432,7 +23517,26 @@ function ensureProcessGlobal() {
23432
23517
  });
23433
23518
  }
23434
23519
 
23520
+ // src/runtime/host-rolldown.ts
23521
+ async function loadHostRolldownBinding() {
23522
+ if (typeof window !== "undefined" && globalThis.crossOriginIsolated !== true) {
23523
+ throw new Error(
23524
+ "Vite 8/Rolldown requires cross-origin isolation. Serve the app with Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers."
23525
+ );
23526
+ }
23527
+ try {
23528
+ const loaded = await import('@rolldown/binding-wasm32-wasi');
23529
+ return "__fs" in loaded ? { ...loaded } : loaded.default ?? loaded;
23530
+ } catch (error) {
23531
+ if (typeof process !== "undefined" && process.env?.SANDBOXEDJS_DEBUG) {
23532
+ console.error("[sandboxedjs] Rolldown WASI binding unavailable:", error);
23533
+ }
23534
+ throw new Error("The optional Rolldown WASI binding could not be loaded.", { cause: error });
23535
+ }
23536
+ }
23537
+
23435
23538
  // src/runtime/local-runtime-pod.ts
23539
+ init_path();
23436
23540
  var WASM_ALIASES = {
23437
23541
  esbuild: "esbuild-wasm",
23438
23542
  rollup: "@rollup/wasm-node"
@@ -23683,6 +23787,7 @@ var LocalRuntimePod = class _LocalRuntimePod {
23683
23787
  aliases;
23684
23788
  modules;
23685
23789
  esbuild;
23790
+ rolldownBinding;
23686
23791
  constructor(options) {
23687
23792
  ensureProcessGlobal();
23688
23793
  this.workdir = options.workdir ?? "/";
@@ -23716,6 +23821,7 @@ var LocalRuntimePod = class _LocalRuntimePod {
23716
23821
  const env2 = { ...this.env, ...isRecord(options.env) ? options.env : {} };
23717
23822
  const owner = `${this.instanceId}:${Math.random().toString(36).slice(2)}`;
23718
23823
  return new LocalProcess(async (proc) => {
23824
+ await this.prepareRolldown(cwd, env2);
23719
23825
  const untrack = trackProcess(proc);
23720
23826
  let requestedExit = 0;
23721
23827
  let engine;
@@ -23752,7 +23858,7 @@ var LocalRuntimePod = class _LocalRuntimePod {
23752
23858
  });
23753
23859
  try {
23754
23860
  await engine.run(script);
23755
- await this.settle(owner, core.pendingHandles, core.readingStdin);
23861
+ await this.settle(owner, core.pendingHandles, core.pendingUnrefed, core.readingStdin);
23756
23862
  if (this.router.activePorts(owner).length) {
23757
23863
  await proc.waitForKill();
23758
23864
  return 137;
@@ -23764,6 +23870,25 @@ var LocalRuntimePod = class _LocalRuntimePod {
23764
23870
  }
23765
23871
  });
23766
23872
  }
23873
+ /**
23874
+ * Rolldown's JavaScript API synchronously requires its compiled binding.
23875
+ * When a project contains Rolldown, preload the official WASI build in the
23876
+ * host and expose it through the module override table before evaluation.
23877
+ * Keeping this demand-driven avoids adding WASM startup cost to ordinary
23878
+ * shells and Node programs.
23879
+ */
23880
+ async prepareRolldown(cwd, env2) {
23881
+ const specifier = "@rolldown/binding-wasm32-wasi";
23882
+ if (!this.modules[specifier] && packageInstalled(this.volume, cwd, "rolldown")) {
23883
+ this.rolldownBinding ??= loadHostRolldownBinding();
23884
+ const binding = await this.rolldownBinding;
23885
+ if (binding) this.modules[specifier] = binding;
23886
+ }
23887
+ if (this.modules[specifier]) {
23888
+ syncRolldownFileSystem(this.modules[specifier], this.volume, cwd);
23889
+ env2.NAPI_RS_FORCE_WASI ??= "true";
23890
+ }
23891
+ }
23767
23892
  /**
23768
23893
  * Wait until the process has either started serving or genuinely run out of
23769
23894
  * work.
@@ -23779,11 +23904,17 @@ var LocalRuntimePod = class _LocalRuntimePod {
23779
23904
  * A plain script that has genuinely finished falls straight through both,
23780
23905
  * costing a handful of empty turns.
23781
23906
  */
23782
- async settle(owner, pendingHandles, readingStdin) {
23907
+ async settle(owner, pendingHandles, pendingUnrefed, readingStdin) {
23783
23908
  for (let turn = 0; turn < DRAIN_TURNS; turn++) {
23784
23909
  if (this.router.activePorts(owner).length) return;
23785
23910
  await new Promise((resolve2) => setTimeout(resolve2, 0));
23786
23911
  }
23912
+ if (pendingHandles() === 0 && pendingUnrefed() > 0 && !readingStdin()) {
23913
+ const deadline = Date.now() + 1e3;
23914
+ while (!this.router.activePorts(owner).length && Date.now() < deadline) {
23915
+ await new Promise((resolve2) => setTimeout(resolve2, 5));
23916
+ }
23917
+ }
23787
23918
  while (!this.router.activePorts(owner).length && (pendingHandles() > 0 || readingStdin())) {
23788
23919
  await new Promise((resolve2) => setTimeout(resolve2, 5));
23789
23920
  }
@@ -23836,6 +23967,60 @@ function formatError(error) {
23836
23967
  function isRecord(value) {
23837
23968
  return typeof value === "object" && value !== null && !Array.isArray(value);
23838
23969
  }
23970
+ function packageInstalled(volume, cwd, wanted) {
23971
+ for (let dir3 = clean(cwd); ; dir3 = dirname(dir3)) {
23972
+ if (packageTreeContains(volume, join(dir3, "node_modules"), wanted, /* @__PURE__ */ new Set())) return true;
23973
+ if (dir3 === "/") return false;
23974
+ }
23975
+ }
23976
+ function packageTreeContains(volume, modulesRoot, wanted, visited) {
23977
+ if (visited.has(modulesRoot) || !directory(volume, modulesRoot)) return false;
23978
+ visited.add(modulesRoot);
23979
+ if (directory(volume, join(modulesRoot, wanted))) return true;
23980
+ for (const entry of volume.readdirSync(modulesRoot)) {
23981
+ if (entry === ".bin") continue;
23982
+ const first = join(modulesRoot, entry);
23983
+ const packages = entry.startsWith("@") && directory(volume, first) ? volume.readdirSync(first).map((name) => join(first, name)) : [first];
23984
+ for (const root of packages) {
23985
+ if (directory(volume, root) && packageTreeContains(volume, join(root, "node_modules"), wanted, visited)) {
23986
+ return true;
23987
+ }
23988
+ }
23989
+ }
23990
+ return false;
23991
+ }
23992
+ function directory(volume, path) {
23993
+ try {
23994
+ return volume.lstatSync(path).isDirectory();
23995
+ } catch {
23996
+ return false;
23997
+ }
23998
+ }
23999
+ function syncRolldownFileSystem(binding, volume, root) {
24000
+ const fs = binding?.__fs;
24001
+ if (!fs?.mkdirSync || !fs?.writeFileSync) return;
24002
+ try {
24003
+ fs.rmSync?.(root, { recursive: true, force: true });
24004
+ } catch {
24005
+ }
24006
+ const copy = (path) => {
24007
+ const stat2 = volume.lstatSync(path);
24008
+ if (stat2.isDirectory()) {
24009
+ fs.mkdirSync(path, { recursive: true });
24010
+ for (const name of volume.readdirSync(path)) copy(join(path, name));
24011
+ } else if (stat2.isSymbolicLink()) {
24012
+ fs.mkdirSync(dirname(path), { recursive: true });
24013
+ try {
24014
+ fs.symlinkSync(volume.readlinkSync(path), path);
24015
+ } catch {
24016
+ }
24017
+ } else if (!path.endsWith(".node")) {
24018
+ fs.mkdirSync(dirname(path), { recursive: true });
24019
+ fs.writeFileSync(path, volume.readFileSync(path));
24020
+ }
24021
+ };
24022
+ copy(clean(root));
24023
+ }
23839
24024
 
23840
24025
  // src/container/container.ts
23841
24026
  var Container = class _Container {
@@ -24179,7 +24364,7 @@ var Container = class _Container {
24179
24364
  /**
24180
24365
  * Deliver a request whose body is bytes, without letting them become text.
24181
24366
  *
24182
- * Nodepod's public `request()` runs the body through `toString("utf8")` on
24367
+ * A RuntimePod's public `request()` may run the body through `toString("utf8")` on
24183
24368
  * its way in, so anything above `0x7f` is replaced: a five-byte payload
24184
24369
  * containing `0x89` and `0xff` arrives as nine. That silently destroys every
24185
24370
  * upload — an image or a video reaches the server the wrong size and no
@@ -24281,7 +24466,7 @@ var Container = class _Container {
24281
24466
  assertActive() {
24282
24467
  if (this.disposed) throw new Error("container has been disposed");
24283
24468
  }
24284
- /** Tear down every process and release the Nodepod instance. */
24469
+ /** Tear down every process and release the runtime pod. */
24285
24470
  dispose() {
24286
24471
  if (this.disposed) return;
24287
24472
  this.disposed = true;