@johnhenry/andbox 0.0.1 → 0.0.3

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
@@ -1,5 +1,11 @@
1
1
  # andbox
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fandbox.svg)](https://www.npmjs.com/package/@johnhenry/andbox)
4
+ [![CI](https://github.com/johnhenry/andbox/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/andbox/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fandbox.svg)](LICENSE)
6
+
7
+ Full documentation: [opensource.johnhenry.me/andbox](https://opensource.johnhenry.me/andbox/)
8
+
3
9
  A separate-context JavaScript runtime with Worker isolation, RPC capabilities, import maps, and timeouts.
4
10
 
5
11
  andbox runs JavaScript in a dedicated Web Worker with a structured bridge back to the host. Code in that Worker can call host-provided "capabilities" via RPC, use import-mapped packages, and define virtual modules -- all with configurable rate limits, timeouts, and hard-kill semantics.
@@ -8,6 +14,17 @@ andbox runs JavaScript in a dedicated Web Worker with a structured bridge back t
8
14
 
9
15
  Zero dependencies. Uses only Web Workers and standard browser APIs.
10
16
 
17
+ ## Contents
18
+
19
+ - [Install](#install)
20
+ - [Quick Start](#quick-start)
21
+ - [Sandbox Modes](#sandbox-modes)
22
+ - [API](#api)
23
+ - [Execution model](#execution-model)
24
+ - [Security model](#security-model)
25
+ - [Family](#family)
26
+ - [License](#license)
27
+
11
28
  ## Install
12
29
 
13
30
  ```bash
@@ -68,11 +85,12 @@ await sandbox.dispose();
68
85
 
69
86
  ## Sandbox Modes
70
87
 
71
- andbox supports three execution modes:
88
+ andbox supports four execution modes:
72
89
 
73
90
  - **`worker`** (default) -- Runs in a dedicated Worker with an RPC bridge, import maps, virtual modules, and hard-kill timeout semantics. See [Security model](#security-model) for what this does and doesn't protect against.
74
91
  - **`inline`** -- Same-thread execution via AsyncFunction. Lighter weight, no Worker overhead, no isolation at all -- code runs with full access to the calling context. Only for code you already trust.
75
92
  - **`data-uri`** -- Dynamic `import()` via Blob URL. Module-level separation without a Worker. Supports globals injection.
93
+ - **`service-worker`** -- Not code execution at all: registers a Service Worker that serves an in-memory `path → content` map with real HTTP-shaped fetch/navigation semantics. For hosting a small virtual multi-file site (HTML/CSS/JS, arbitrary paths), not for running JS in isolation. See [andbox#14](https://github.com/johnhenry/andbox/issues/14) and [Security model](#security-model) -- this mode does **not** provide isolation by merely existing.
76
94
 
77
95
  ```js
78
96
  // Inline mode (no Worker)
@@ -82,6 +100,15 @@ const result = await inline.execute('return math.sqrt(16)');
82
100
  // Data-URI mode
83
101
  const dataUri = createSandbox({ mode: 'data-uri', globals: { x: 42 } });
84
102
  const result = await dataUri.execute('print(x)');
103
+
104
+ // Service-Worker mode (browser only -- see examples/06-service-worker-mode/)
105
+ const site = await createSandbox({
106
+ mode: 'service-worker',
107
+ scriptURL: '/andbox-sw.js', // you serve makeServiceWorkerSource()'s output here
108
+ scope: '/virtual/',
109
+ files: { '/virtual/hello.html': '<h1>Hello</h1>' },
110
+ });
111
+ // Only navigate into scope *after* this resolves -- see Security model.
85
112
  ```
86
113
 
87
114
  ## API
@@ -153,6 +180,28 @@ const { gated, stats } = gateCapabilities(
153
180
 
154
181
  Resolves a module specifier against an import map, following the browser import map algorithm.
155
182
 
183
+ ### `createVirtualModuleRegistry(files?, options?)`
184
+
185
+ Takes a `path → source` map and mints one real `blob:` URL per entry (the same `new Blob([...]) + URL.createObjectURL()` pattern `data-uri` mode uses, generalized to a whole file table). Returns a registry for a multi-file tree that references itself by relative path -- an entry module `import`-ing `./util.js`, which itself imports `../shared/x.js`, and so on.
186
+
187
+ ```js
188
+ import { createVirtualModuleRegistry } from '@johnhenry/andbox';
189
+
190
+ const registry = createVirtualModuleRegistry({
191
+ 'index.js': 'import { add } from "./util.js"; export const result = add(2, 3);',
192
+ 'util.js': 'export function add(a, b) { return a + b; }',
193
+ });
194
+
195
+ registry.resolve('index.js'); // blob: URL, or null if unknown
196
+ registry.resolveSpecifier('./util.js', 'index.js'); // resolves relative to the importing file
197
+ registry.define('extra.js', 'export const x = 1;'); // register (or replace) a file at runtime
198
+ registry.dispose(); // revokes every blob URL it ever minted
199
+ ```
200
+
201
+ `resolveSpecifier(specifier, parentPath?)` checks, in order: (1) import-map resolution via `resolveWithImportMap()` -- not reimplemented, just delegated, with `parentPath`'s own blob URL passed through as `parentURL` so `scopes` apply; (2) a relative-path (`./`, `../`) fallback looked up against the registry's own file table, which `resolveWithImportMap()` alone has no notion of; (3) `null` if neither matched, e.g. a genuine external bare specifier -- left for the caller to handle, not swallowed.
202
+
203
+ **Honest caveat:** this only solves module *resolution* (the URL a given specifier should point at), not automatic rewriting of `import` statements inside the source text, and not general asset URLs (`<img src>`, CSS `url()`) or navigation. A `blob:` URL has no hierarchical path of its own, so a literal `import "./util.js"` statement inside blob-served source will **not** resolve on its own in a browser -- call `resolveSpecifier()` yourself with the specifier your loader saw and use the URL it returns (or rewrite the specifier to that URL before creating the blob). See [andbox#13](https://github.com/johnhenry/andbox/issues/13) for the full design discussion, and [andbox#14](https://github.com/johnhenry/andbox/issues/14) for the harder "make it behave like a real server" problem this deliberately does not attempt to solve.
204
+
156
205
  ### `createNetworkFetch(allowedHosts?, fetchFn?)`
157
206
 
158
207
  Creates a fetch function that checks the request hostname against an allowlist before calling through. Useful for keeping cooperative code pointed at the hosts you intend -- **not redirect-safe** (see [Security model](#security-model)): an allowlisted host that responds with a redirect is followed without re-checking the final URL.
@@ -177,6 +226,51 @@ Promise and error utilities used internally, also available for consumers.
177
226
 
178
227
  Returns the Worker script source code as a string (useful for custom Worker setups).
179
228
 
229
+ ### `createSandbox({ mode: 'service-worker', ... })`
230
+
231
+ Registers a Service Worker that backs a `path → content` map with real, same-origin, HTTP-shaped fetch and navigation semantics -- see [andbox#14](https://github.com/johnhenry/andbox/issues/14). Unlike the other three modes, this isn't code execution: it's for hosting a small virtual multi-file site (an in-memory archive of HTML/CSS/JS/anything) so that relative links, absolute-path links, runtime-computed `fetch()` calls, and `pushState`-based routing all work exactly as they would against a real server, because it *is* real HTTP-shaped navigation, transparently intercepted.
232
+
233
+ **Options:**
234
+
235
+ | Option | Type | Description |
236
+ |--------|------|-------------|
237
+ | `scriptURL` | `string` | **Required.** A real same-origin `http(s)` URL where you are already serving `makeServiceWorkerSource()`'s output. Service Worker registration requires a real `http(s)` scriptURL -- unlike `worker`/`data-uri` mode, a `blob:` URL is not accepted by the platform. |
238
+ | `scope` | `string` | Path prefix to register the Service Worker under. Defaults to `scriptURL`'s own directory. |
239
+ | `files` | `Record<string, string \| { body, contentType?, status?, headers? }>` | Initial served-file map. |
240
+ | `registry` | `VirtualModuleRegistry` | A [`createVirtualModuleRegistry()`](#createvirtualmoduleregistryfiles-options) instance to pull additional served content from -- its known paths' real blob content is fetched and merged in, reusing its path/blob bookkeeping instead of a second one. |
241
+ | `timeoutMs` | `number` | Max time to wait for the registration to become active before rejecting. Defaults to `DEFAULT_TIMEOUT_MS`. |
242
+
243
+ **Returns:** `Promise<{ scriptURL, scope, define(path, source, opts?), remove(path), dispose(), isDisposed() }>`. The promise resolves once the registration is **active**, rejects if it instead becomes `'redundant'` (its install/activate threw, or it failed to parse) or if `timeoutMs` elapses first -- see the ordering note in [Security model](#security-model) below before navigating anything into `scope`.
244
+
245
+ ```js
246
+ import { createSandbox } from '@johnhenry/andbox';
247
+
248
+ const site = await createSandbox({
249
+ mode: 'service-worker',
250
+ scriptURL: '/andbox-sw.js', // you serve makeServiceWorkerSource()'s output here
251
+ scope: '/virtual/',
252
+ files: {
253
+ '/virtual/hello.html': { body: '<h1>Hello</h1>', contentType: 'text/html' },
254
+ },
255
+ });
256
+
257
+ // Only now -- with the registration active -- point something at the scope:
258
+ iframe.src = '/virtual/hello.html';
259
+
260
+ await site.define('/virtual/about.html', '<h1>About</h1>');
261
+ await site.dispose(); // unregisters the Service Worker
262
+ ```
263
+
264
+ See `examples/06-service-worker-mode/` for a complete, runnable (browser-only) demo, including the dev server that serves the generated script.
265
+
266
+ ### `makeServiceWorkerSource()`
267
+
268
+ Returns the Service Worker script source code as a string, for `mode: 'service-worker'`. Unlike `makeWorkerSource()`'s output (turned into a `blob:` URL for `new Worker(...)`), this string must be served as a real same-origin file -- Service Worker registration doesn't accept `blob:` scriptURLs.
269
+
270
+ ### `resolveServiceWorkerResponse(pathname, files)`
271
+
272
+ Given a request pathname and a served-file map (`Map` or plain object), returns the `Response` the Service Worker's `fetch` handler would produce for an in-scope request, or `null` if it should fall through to the network. The pure logic factored out of the generated script, for testing and for embedding the same matching behavior elsewhere.
273
+
180
274
  ## Execution model
181
275
 
182
276
  Code runs inside a Web Worker created from a Blob URL. This gets you, for free, against code that isn't specifically trying to defeat it:
@@ -189,18 +283,41 @@ Code runs inside a Web Worker created from a Blob URL. This gets you, for free,
189
283
 
190
284
  ## Security model
191
285
 
192
- andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section -- every item below is a confirmed way sandboxed code can act outside what `capabilities`/`policy`/`createNetworkFetch` appear to allow:
286
+ andbox is **not** a boundary against code that is actively trying to escape it. If you're running code you don't fully trust, read this section before you rely on `capabilities`/`policy`/`createNetworkFetch` for anything.
287
+
288
+ **What andbox guarantees:**
289
+
290
+ - **No DOM access.** Worker-mode code executes in a real Worker global scope, which has no `document`, `window`, or other DOM references -- this is a platform property of Workers, not something andbox has to enforce itself.
291
+ - **No implicit host object references.** Only what you explicitly pass in (`capabilities`, `globals`, import map entries) is reachable from sandboxed code -- ordinary (non-adversarial) code cannot accidentally read or mutate host-side state it wasn't given a reference to.
292
+ - **Hard kill on timeout.** `evaluate()` calls that exceed `timeoutMs` `terminate()` the Worker outright and start a fresh one for the next call -- this is a real process-level kill, not a cooperative cancellation the running code could ignore. See "still yours" below for what a kill does *not* undo.
293
+ - **The capability gate cannot be walked around via the prototype chain.** `gateCapabilities()` builds the gated object with `Object.create(null)`, so `host.call('constructor', ...)` cannot resolve through `Object.prototype` to the real global `Object` constructor. Previously fixed; see [andbox#5](https://github.com/johnhenry/andbox/issues/5).
294
+ - **`createNetworkFetch()`'s allowlist is redirect-safe.** Requests are made with `redirect: 'manual'` and any redirect response is rejected outright, so an allowlisted host cannot silently redirect a caller to a non-allowlisted one. Previously fixed; see [andbox#6](https://github.com/johnhenry/andbox/issues/6).
295
+ - **`gateCapabilities()` enforces call/argument-size/concurrency caps per capability**, for cooperative callers that stay within the capabilities you actually granted.
296
+
297
+ **What is still yours:**
193
298
 
194
299
  - **Worker-global APIs are directly reachable, regardless of `capabilities`.** Sandboxed code executes in a real Worker global scope, so `fetch`, `WebSocket`, `Worker` (nested workers), `importScripts`, `indexedDB`, and `self.postMessage` are all callable directly -- omitting a `fetch` capability does not block network access. This is fundamental to how `Function`-based evaluation works and isn't fixable without a different execution strategy (e.g. a cross-origin iframe with a strict CSP, or a Realms/Compartments-based approach). See [andbox#10](https://github.com/johnhenry/andbox/issues/10).
195
300
  - **`sandboxImport()` will load and execute an arbitrary remote URL.** Any `http(s)://` specifier is passed straight to `import()` with no allowlist, independent of any network policy configured for capabilities. See [andbox#7](https://github.com/johnhenry/andbox/issues/7).
196
301
  - **A timeout stops message delivery, not in-flight host-side effects.** If a capability call with a real side effect (a write, an API call) is in flight when the timeout fires, that side effect still completes on the host even though the Worker is killed. Capabilities with real side effects should be designed to be idempotent and/or cancellable via `AbortSignal`. See [andbox#8](https://github.com/johnhenry/andbox/issues/8).
302
+ - **`mode: 'service-worker'` does not provide isolation by merely existing.** It's a hosting mechanism -- a real Service Worker, same-origin by default, serving your `files` map with real fetch/navigation interception. Content served through it can see and touch its own origin exactly like any other same-origin page can; nothing about registering a Service Worker sandboxes what runs inside the pages it serves. If you're hosting content you don't fully trust, point this mode at a genuinely separate origin from day one -- the same recommendation the `fetch`/`WebSocket`/`Worker` item above makes for `worker` mode (a cross-origin iframe with a strict CSP), not something bolted on after the fact. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
303
+ - **The Service Worker does not control the very first navigation into its scope.** A page/iframe navigation into `scope` that happens *before* the registration has finished activating is a normal, unintercepted network request -- Service Workers never retroactively intercept a request that already went out. `createSandbox({ mode: 'service-worker' })`'s returned promise only resolves once the registration is active (its generated script also calls `clients.claim()` on activate, which helps *already-open* clients but not fresh navigations); the documented, load-bearing contract is: don't navigate anything into `scope` until that promise resolves. Do that and every request is intercepted from the first byte, because the registration already matches `scope` before the navigation request is made. See [andbox#14](https://github.com/johnhenry/andbox/issues/14).
197
304
 
198
- Fixed as of this audit (kept here for history -- see the linked issues for details):
305
+ If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
199
306
 
200
- - ~~`gateCapabilities()`'s check could be bypassed via the prototype chain~~ (`host.call('constructor', ...)` resolved through `Object.prototype` to the real global `Object` constructor). Fixed by building the gated object with `Object.create(null)`. See [andbox#5](https://github.com/johnhenry/andbox/issues/5).
201
- - ~~`createNetworkFetch()`'s allowlist was not redirect-safe~~ (it checked the request hostname before the fetch, not the final response URL). Fixed by fetching with `redirect: 'manual'` and rejecting any redirect response outright. See [andbox#6](https://github.com/johnhenry/andbox/issues/6).
307
+ ## Family
202
308
 
203
- If you need to run untrusted/adversarial code safely, andbox alone is not sufficient -- pair it with OS-level isolation (a separate process/container with its own network and filesystem restrictions) or use a purpose-built sandboxing runtime. Capability gating and rate limits here are for organizing and throttling code you already trust, not for containing code you don't.
309
+ andbox isn't just a standalone sandbox runtime -- it's the sandboxing engine
310
+ that [aimatey](https://github.com/johnhenry/aimatey) middleware wires in for
311
+ code-based tool execution.
312
+
313
+ - **[`@johnhenry/aimatey-middleware-andbox`](https://github.com/johnhenry/aimatey-middleware-andbox)** --
314
+ wires andbox's `createSandbox()` factory (or a pre-built sandbox) into an
315
+ [aimatey](https://github.com/johnhenry/aimatey) bridge middleware, via
316
+ `toolsToCapabilities()` to convert the middleware's `tools`/`executeToolFn`
317
+ into andbox `capabilities`. This package's own [Security model](#security-model)
318
+ is the source of truth for what that capability gate does and does not
319
+ guarantee -- the middleware's Security model section points back here
320
+ rather than repeating it.
204
321
 
205
322
  ## License
206
323
 
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@johnhenry/andbox",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
6
6
  "main": "./src/index.mjs",
7
+ "types": "./src/index.d.ts",
7
8
  "exports": {
8
- ".": "./src/index.mjs"
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.mjs"
12
+ }
9
13
  },
10
14
  "files": [
11
15
  "src",
@@ -18,7 +22,8 @@
18
22
  "example:02": "node examples/02-capability-limits-cut-off-abuse.mjs",
19
23
  "example:03": "node examples/03-network-allowlist-blocks-unapproved-hosts.mjs",
20
24
  "example:04": "node examples/04-runaway-code-gets-timed-out.mjs",
21
- "examples": "npm run example:01 && npm run example:02 && npm run example:03 && npm run example:04"
25
+ "example:05": "node examples/05-virtual-module-registry-resolves-a-multi-file-tree.mjs",
26
+ "examples": "npm run example:01 && npm run example:02 && npm run example:03 && npm run example:04 && npm run example:05"
22
27
  },
23
28
  "keywords": [
24
29
  "sandbox",
@@ -35,6 +40,6 @@
35
40
  },
36
41
  "homepage": "https://opensource.johnhenry.me/andbox/",
37
42
  "engines": {
38
- "node": ">=24.0.0"
43
+ "node": ">=26.0.0"
39
44
  }
40
45
  }
package/src/index.d.ts CHANGED
@@ -133,6 +133,64 @@ export declare function resolveWithImportMap(
133
133
  parentURL?: string,
134
134
  ): string | null;
135
135
 
136
+ // ── virtual-module-registry ──
137
+
138
+ /** A registry of in-memory files, each backed by its own `blob:` URL. */
139
+ export interface VirtualModuleRegistry {
140
+ /**
141
+ * Get the `blob:` URL registered for a path.
142
+ *
143
+ * @param path
144
+ * @returns the blob URL, or null if the path is unknown
145
+ */
146
+ resolve(path: string): string | null;
147
+
148
+ /**
149
+ * Resolve a module specifier the way `import` would need to, from the
150
+ * point of view of a given importing file: import-map resolution first,
151
+ * then a relative-path (`./x.js`, `../x.js`) fallback against the known
152
+ * file table, then null if neither matched.
153
+ *
154
+ * @param specifier
155
+ * @param parentPath path of the file doing the importing
156
+ * @returns a resolved URL, or null if nothing matched
157
+ */
158
+ resolveSpecifier(specifier: string, parentPath?: string): string | null;
159
+
160
+ /**
161
+ * Register (or replace) a file at runtime. Replacing an existing path
162
+ * revokes its previous blob URL before minting a new one.
163
+ *
164
+ * @returns the new blob URL
165
+ */
166
+ define(path: string, source: string): string;
167
+
168
+ /** Whether a path is registered. */
169
+ has(path: string): boolean;
170
+
171
+ /** All registered paths. */
172
+ paths(): string[];
173
+
174
+ /** Revoke every blob URL this registry has ever minted. */
175
+ dispose(): void;
176
+
177
+ /** Whether dispose() has been called. */
178
+ isDisposed(): boolean;
179
+ }
180
+
181
+ /**
182
+ * Create a registry of in-memory files, each backed by its own `blob:`
183
+ * URL, with specifier resolution that combines import-map resolution
184
+ * with a relative-path fallback against the known file table.
185
+ *
186
+ * @param files path → source text
187
+ * @param options optional import map used for bare-specifier resolution
188
+ */
189
+ export declare function createVirtualModuleRegistry(
190
+ files?: Record<string, string>,
191
+ options?: { importMap?: ImportMap },
192
+ ): VirtualModuleRegistry;
193
+
136
194
  // ── network-policy ──
137
195
 
138
196
  /**
@@ -178,6 +236,49 @@ export declare function createStdio(): StdioStream;
178
236
  */
179
237
  export declare function makeWorkerSource(): string;
180
238
 
239
+ // ── service-worker-source ──
240
+
241
+ /**
242
+ * Generate the Service Worker source code as a string, for `mode:
243
+ * 'service-worker'`.
244
+ *
245
+ * Unlike makeWorkerSource() (whose output becomes a `blob:` URL passed to
246
+ * `new Worker(...)`), Service Worker registration requires a real
247
+ * same-origin http(s) scriptURL — a `blob:` URL is not accepted by the
248
+ * platform. The caller is responsible for serving this string's content at
249
+ * whatever path it passes as `scriptURL` to `createSandbox()`.
250
+ *
251
+ * @returns The complete Service Worker script source code as a string.
252
+ */
253
+ export declare function makeServiceWorkerSource(): string;
254
+
255
+ // ── service-worker-response ──
256
+
257
+ /** A file served by `mode: 'service-worker'`. */
258
+ export interface ServedFile {
259
+ body: string | ArrayBuffer;
260
+ contentType?: string;
261
+ status?: number;
262
+ headers?: Record<string, string>;
263
+ }
264
+
265
+ /**
266
+ * Given a request pathname and the known served-file map, produce the
267
+ * Response the Service Worker's `fetch` handler would return for an
268
+ * in-scope request, or null if it should fall through to the network.
269
+ *
270
+ * Pure logic factored out of the generated Service Worker script so it's
271
+ * testable under plain Node (which has no `ServiceWorkerGlobalScope` at
272
+ * all, but does have real `Response`/`Headers` globals).
273
+ *
274
+ * @param pathname
275
+ * @param files path → served-file map (Map or plain object)
276
+ */
277
+ export declare function resolveServiceWorkerResponse(
278
+ pathname: string,
279
+ files: Map<string, ServedFile> | Record<string, ServedFile>,
280
+ ): Response | null;
281
+
181
282
  // ── sandbox ──
182
283
 
183
284
  /** Options for evaluate(). */
@@ -251,10 +352,58 @@ export interface Sandbox {
251
352
  isDisposed(): boolean;
252
353
  }
253
354
 
355
+ /** Options for createSandbox({ mode: 'service-worker' }). */
356
+ export interface ServiceWorkerSandboxOptions {
357
+ /** Mode discriminant. */
358
+ mode: 'service-worker';
359
+ /**
360
+ * Real same-origin http(s) URL serving makeServiceWorkerSource()'s
361
+ * output. A blob: URL is not accepted by the platform for Service
362
+ * Worker registration.
363
+ */
364
+ scriptURL: string;
365
+ /** Scope to register the Service Worker under. Defaults to scriptURL's own directory. */
366
+ scope?: string;
367
+ /** Initial path → content map. */
368
+ files?: Record<string, string | ServedFile>;
369
+ /**
370
+ * A #13 virtual module registry to pull additional served content from
371
+ * (its known paths' real blob content), reusing its path/blob
372
+ * bookkeeping instead of a second one.
373
+ */
374
+ registry?: VirtualModuleRegistry;
375
+ }
376
+
377
+ /** A registered Service Worker instance backing a path → content map. */
378
+ export interface ServiceWorkerSandbox {
379
+ /** The scriptURL this Service Worker was registered from. */
380
+ scriptURL: string;
381
+ /** The registration's actual scope. */
382
+ scope: string;
383
+ /**
384
+ * Register (or replace) one served file.
385
+ *
386
+ * @param path
387
+ * @param source
388
+ * @param entryOpts contentType/status/headers overrides
389
+ */
390
+ define(
391
+ path: string,
392
+ source: string,
393
+ entryOpts?: { contentType?: string; status?: number; headers?: Record<string, string> },
394
+ ): Promise<void>;
395
+ /** Remove a served file; requests for it fall through to the network. */
396
+ remove(path: string): Promise<void>;
397
+ /** Unregister the Service Worker. */
398
+ dispose(): Promise<void>;
399
+ /** Returns true if dispose() has been called. */
400
+ isDisposed(): boolean;
401
+ }
402
+
254
403
  /**
255
404
  * Create a new sandboxed JavaScript runtime.
256
405
  *
257
- * The sandbox runs in an isolated Web Worker with:
406
+ * The default (Worker) mode runs in an isolated Web Worker with:
258
407
  * - RPC-based capability calls (host.call)
259
408
  * - Import map resolution
260
409
  * - Virtual module definitions
@@ -262,7 +411,15 @@ export interface Sandbox {
262
411
  * - Console forwarding
263
412
  * - Capability gating with rate limits
264
413
  *
414
+ * `mode: 'service-worker'` is a different shape entirely: it hosts a
415
+ * path → content map behind a real, same-origin, HTTP-shaped scope
416
+ * instead of evaluating code — see andbox#14 and README's Security model
417
+ * section before using it for untrusted content.
418
+ *
265
419
  * @param options Sandbox configuration options.
266
420
  * @returns A promise that resolves to the sandbox instance.
267
421
  */
268
422
  export declare function createSandbox(options?: SandboxOptions): Promise<Sandbox>;
423
+ export declare function createSandbox(
424
+ options: ServiceWorkerSandboxOptions,
425
+ ): Promise<ServiceWorkerSandbox>;
package/src/index.mjs CHANGED
@@ -6,9 +6,12 @@
6
6
 
7
7
  export { createSandbox } from './sandbox.mjs';
8
8
  export { resolveWithImportMap } from './import-map-resolver.mjs';
9
+ export { createVirtualModuleRegistry } from './virtual-module-registry.mjs';
9
10
  export { gateCapabilities } from './capability-gate.mjs';
10
11
  export { createStdio } from './stdio.mjs';
11
12
  export { createNetworkFetch } from './network-policy.mjs';
12
13
  export { makeDeferred, makeAbortError, makeTimeoutError } from './deferred.mjs';
13
14
  export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
14
15
  export { makeWorkerSource } from './worker-source.mjs';
16
+ export { makeServiceWorkerSource } from './service-worker-source.mjs';
17
+ export { resolveServiceWorkerResponse } from './service-worker-response.mjs';
package/src/sandbox.mjs CHANGED
@@ -101,6 +101,198 @@ ${preamble}${code}`;
101
101
  };
102
102
  }
103
103
 
104
+ // ── Service-Worker sandbox (path→content hosting, real URL/navigation semantics) ──
105
+
106
+ const CONTENT_TYPE_BY_EXTENSION = {
107
+ html: 'text/html;charset=utf-8',
108
+ css: 'text/css;charset=utf-8',
109
+ js: 'text/javascript;charset=utf-8',
110
+ mjs: 'text/javascript;charset=utf-8',
111
+ json: 'application/json;charset=utf-8',
112
+ svg: 'image/svg+xml',
113
+ txt: 'text/plain;charset=utf-8',
114
+ };
115
+
116
+ function guessContentType(path) {
117
+ const ext = path.split('.').pop();
118
+ return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
119
+ }
120
+
121
+ function normalizeServedEntry(path, value, defaults = {}) {
122
+ if (typeof value === 'string') {
123
+ return {
124
+ body: value,
125
+ contentType: defaults.contentType || guessContentType(path),
126
+ status: defaults.status || 200,
127
+ headers: defaults.headers || {},
128
+ };
129
+ }
130
+ return {
131
+ body: value.body,
132
+ contentType: value.contentType || defaults.contentType || guessContentType(path),
133
+ status: value.status || defaults.status || 200,
134
+ headers: value.headers || defaults.headers || {},
135
+ };
136
+ }
137
+
138
+ function normalizeServedFileMap(map) {
139
+ const out = {};
140
+ for (const [path, value] of Object.entries(map)) {
141
+ out[path] = normalizeServedEntry(path, value);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Resolve once the registration's worker has activated (or already has).
148
+ * Rejects if the worker instead reaches `'redundant'` (its install/activate
149
+ * threw, failed to parse, or was superseded before activating) -- without
150
+ * this check the returned promise would otherwise hang forever, since
151
+ * `'redundant'` is a valid terminal state `statechange` fires for that
152
+ * is neither `'activated'` nor another `statechange` event to wait on.
153
+ * Also bounded by `timeoutMs` as defense in depth for any lifecycle path
154
+ * that reaches neither terminal state.
155
+ */
156
+ export function waitForActive(registration, timeoutMs = DEFAULT_TIMEOUT_MS) {
157
+ if (registration.active) return Promise.resolve();
158
+ const worker = registration.installing || registration.waiting;
159
+ if (!worker) return Promise.resolve();
160
+ return new Promise((resolve, reject) => {
161
+ const timer = setTimeout(() => {
162
+ worker.removeEventListener('statechange', onChange);
163
+ reject(makeTimeoutError(timeoutMs));
164
+ }, timeoutMs);
165
+ function onChange() {
166
+ if (worker.state === 'activated') {
167
+ clearTimeout(timer);
168
+ worker.removeEventListener('statechange', onChange);
169
+ resolve();
170
+ } else if (worker.state === 'redundant') {
171
+ clearTimeout(timer);
172
+ worker.removeEventListener('statechange', onChange);
173
+ reject(new Error(
174
+ "Service Worker registration became 'redundant' before activating -- " +
175
+ 'its install/activate handler likely threw, or it failed to parse. ' +
176
+ 'Check the browser console for the actual Service Worker error.'
177
+ ));
178
+ }
179
+ }
180
+ worker.addEventListener('statechange', onChange);
181
+ });
182
+ }
183
+
184
+ /**
185
+ * @typedef {Object} ServiceWorkerSandboxOptions
186
+ * @property {string} scriptURL - Real same-origin http(s) URL serving makeServiceWorkerSource()'s output. A blob: URL is not accepted by the platform for Service Worker registration.
187
+ * @property {string} [scope] - Scope to register the Service Worker under. Defaults to scriptURL's own directory.
188
+ * @property {Record<string, string | { body: string, contentType?: string, status?: number, headers?: Record<string,string> }>} [files] - Initial path→content map.
189
+ * @property {import('./virtual-module-registry.mjs').VirtualModuleRegistry} [registry] - A #13 virtual module registry to pull additional served content from, reusing its path/blob bookkeeping instead of a second one.
190
+ * @property {number} [timeoutMs] - Max time to wait for the registration to activate before rejecting. Defaults to DEFAULT_TIMEOUT_MS.
191
+ */
192
+
193
+ /**
194
+ * Register a Service Worker that backs a path→content map with real
195
+ * HTTP-shaped fetch/navigation semantics -- see andbox#14.
196
+ *
197
+ * Requires `navigator.serviceWorker` (a real browser). The generated
198
+ * script (`makeServiceWorkerSource()`) cannot be registered from a
199
+ * `blob:` URL -- the caller must already be serving it at a real
200
+ * same-origin `scriptURL`.
201
+ *
202
+ * @param {ServiceWorkerSandboxOptions} [options]
203
+ */
204
+ async function createServiceWorkerSandbox(options = {}) {
205
+ const { scriptURL, scope, files = {}, registry, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
206
+
207
+ if (!scriptURL) {
208
+ throw new Error(
209
+ "createSandbox({ mode: 'service-worker' }) requires a scriptURL: the real " +
210
+ "same-origin http(s) URL where you are already serving makeServiceWorkerSource()'s " +
211
+ 'output. Service Worker registration requires a real http(s) scriptURL -- unlike ' +
212
+ "worker/data-uri mode, a blob: URL is not accepted by the platform. See andbox#14."
213
+ );
214
+ }
215
+ if (typeof navigator === 'undefined' || !navigator.serviceWorker) {
216
+ throw new Error(
217
+ "mode: 'service-worker' requires navigator.serviceWorker (a real browser); " +
218
+ 'it is not available under Node. See andbox#14.'
219
+ );
220
+ }
221
+
222
+ let disposed = false;
223
+
224
+ // Build the initial served-file map. Reuse a #13 virtual module registry's
225
+ // own path/blob bookkeeping when given one, rather than a second table:
226
+ // pull each of its known paths' real content straight from its blob URLs.
227
+ const initialFiles = { ...files };
228
+ if (registry) {
229
+ for (const path of registry.paths()) {
230
+ const url = registry.resolve(path);
231
+ const res = await fetch(url);
232
+ initialFiles[path] = { body: await res.text(), contentType: guessContentType(path) };
233
+ }
234
+ }
235
+
236
+ const registration = await navigator.serviceWorker.register(
237
+ scriptURL,
238
+ scope ? { scope } : undefined
239
+ );
240
+
241
+ // Only resolve once this registration is active. This -- combined with
242
+ // the documented contract "don't navigate anything into scope until this
243
+ // promise resolves" -- is what actually avoids the first-navigation race
244
+ // described in andbox#14 and in makeServiceWorkerSource()'s doc comment:
245
+ // clients.claim() (called in the generated script's activate handler)
246
+ // only takes over clients that are *already open*; it doesn't retroactively
247
+ // intercept a navigation into scope that started before activation.
248
+ await waitForActive(registration, timeoutMs);
249
+
250
+ async function send(message) {
251
+ if (disposed) throw new Error('Sandbox is disposed');
252
+ const target = registration.active;
253
+ if (!target) throw new Error('Service Worker is not active');
254
+ const { promise, resolve } = makeDeferred();
255
+ const channel = new MessageChannel();
256
+ channel.port1.onmessage = ({ data }) => resolve(data);
257
+ target.postMessage(message, [channel.port2]);
258
+ return promise;
259
+ }
260
+
261
+ await send({ type: 'configure', files: normalizeServedFileMap(initialFiles) });
262
+
263
+ /**
264
+ * Register (or replace) one served file.
265
+ * @param {string} path
266
+ * @param {string} source
267
+ * @param {{ contentType?: string, status?: number, headers?: Record<string,string> }} [entryOpts]
268
+ */
269
+ async function define(path, source, entryOpts = {}) {
270
+ const entry = normalizeServedEntry(path, source, entryOpts);
271
+ await send({ type: 'define', path, entry });
272
+ }
273
+
274
+ /** Remove a served file; requests for it fall through to the network. */
275
+ async function remove(path) {
276
+ await send({ type: 'remove', path });
277
+ }
278
+
279
+ /** Unregister the Service Worker. */
280
+ async function dispose() {
281
+ if (disposed) return;
282
+ disposed = true;
283
+ await registration.unregister();
284
+ }
285
+
286
+ return {
287
+ scriptURL,
288
+ scope: registration.scope,
289
+ define,
290
+ remove,
291
+ dispose,
292
+ isDisposed: () => disposed,
293
+ };
294
+ }
295
+
104
296
  /**
105
297
  * @typedef {Object} SandboxOptions
106
298
  * @property {{ imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> }} [importMap]
@@ -114,13 +306,14 @@ ${preamble}${code}`;
114
306
  /**
115
307
  * Create a new sandboxed runtime.
116
308
  *
117
- * @param {SandboxOptions} [options]
118
- * @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }>}
309
+ * @param {SandboxOptions | ServiceWorkerSandboxOptions} [options]
310
+ * @returns {{ execute: Function, terminate: Function } | Promise<{ evaluate: Function, defineModule: Function, dispose: Function, isDisposed: () => boolean }> | Promise<{ scriptURL: string, scope: string, define: Function, remove: Function, dispose: Function, isDisposed: () => boolean }>}
119
311
  */
120
312
  export function createSandbox(options = {}) {
121
313
  const mode = options.mode || 'worker';
122
314
  if (mode === 'inline') return createInlineSandbox(options);
123
315
  if (mode === 'data-uri') return createDataUriSandbox(options);
316
+ if (mode === 'service-worker') return createServiceWorkerSandbox(options);
124
317
  return createWorkerSandbox(options);
125
318
  }
126
319
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pure request-matching / response-synthesis logic for the `service-worker`
3
+ * sandbox mode.
4
+ *
5
+ * Factored out on its own so it's testable under plain Node without a real
6
+ * `ServiceWorkerGlobalScope` (Node has neither `self.clients`,
7
+ * `navigator.serviceWorker`, nor any part of the Service Worker API — see
8
+ * andbox#14). `Response`/`Headers` themselves *are* real Node globals
9
+ * (Node's built-in fetch implementation), so the "given a path and a file
10
+ * map, what Response would the SW's fetch handler return" question is pure
11
+ * logic and gets real coverage here.
12
+ *
13
+ * The generated Service Worker script (`makeServiceWorkerSource()`, in
14
+ * service-worker-source.mjs) inlines an equivalent copy of this matching
15
+ * logic as a self-contained string — the same way worker-source.mjs
16
+ * inlines a copy of resolveWithImportMap() rather than importing it, since
17
+ * the generated script must have zero file dependencies at runtime (it's
18
+ * registered directly as a Service Worker scriptURL, it can't `import`
19
+ * this module across origins/paths the way a bundler-built app could).
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} ServedFile
24
+ * @property {string | ArrayBuffer} body
25
+ * @property {string} [contentType]
26
+ * @property {number} [status]
27
+ * @property {Record<string,string>} [headers]
28
+ */
29
+
30
+ /** Normalize a path to a leading-slash pathname, the way a URL's pathname reads. */
31
+ export function normalizeScopePath(path) {
32
+ return path.startsWith('/') ? path : `/${path}`;
33
+ }
34
+
35
+ /**
36
+ * Find the served-file entry for a request pathname, with a directory-index
37
+ * convenience fallback (`/foo/` or `/foo` -> `/foo/index.html`) matching how
38
+ * a real static file server commonly behaves.
39
+ *
40
+ * @param {string} pathname
41
+ * @param {Map<string, ServedFile> | Record<string, ServedFile>} files
42
+ * @returns {ServedFile | null}
43
+ */
44
+ export function matchFile(pathname, files) {
45
+ const get = files instanceof Map
46
+ ? (p) => files.get(p)
47
+ : (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : undefined);
48
+
49
+ const direct = get(pathname);
50
+ if (direct !== undefined) return direct;
51
+
52
+ if (pathname.endsWith('/')) {
53
+ const indexed = get(pathname + 'index.html');
54
+ if (indexed !== undefined) return indexed;
55
+ } else {
56
+ const indexed = get(pathname + '/index.html');
57
+ if (indexed !== undefined) return indexed;
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Synthesize the Response a matched file entry should produce.
65
+ *
66
+ * @param {ServedFile | null} entry
67
+ * @returns {Response | null} null if entry is null (caller should fall through to network)
68
+ */
69
+ export function makeResponseForFile(entry) {
70
+ if (!entry) return null;
71
+ const headers = new Headers(entry.headers || {});
72
+ if (!headers.has('content-type')) {
73
+ headers.set('content-type', entry.contentType || 'application/octet-stream');
74
+ }
75
+ return new Response(entry.body, { status: entry.status || 200, headers });
76
+ }
77
+
78
+ /**
79
+ * End-to-end: given a request pathname and the known file map, produce the
80
+ * Response the Service Worker's `fetch` handler would return for an
81
+ * in-scope request, or null if it should fall through to the network.
82
+ *
83
+ * @param {string} pathname
84
+ * @param {Map<string, ServedFile> | Record<string, ServedFile>} files
85
+ * @returns {Response | null}
86
+ */
87
+ export function resolveServiceWorkerResponse(pathname, files) {
88
+ const entry = matchFile(normalizeScopePath(pathname), files);
89
+ return makeResponseForFile(entry);
90
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Service Worker source code template for andbox's `service-worker` mode.
3
+ *
4
+ * Returns a string containing the entire Service Worker script. Zero file
5
+ * dependencies at runtime, same rationale as worker-source.mjs's
6
+ * makeWorkerSource(): the script is registered directly as a Service
7
+ * Worker scriptURL, so it can't `import` sibling andbox modules and
8
+ * inlines its own copy of the matching logic in service-worker-response.mjs
9
+ * instead.
10
+ *
11
+ * Unlike makeWorkerSource() (whose output is turned into a `blob:` URL and
12
+ * handed to `new Worker(...)`), this script CANNOT be served from a
13
+ * `blob:` URL -- Service Worker registration requires a real http(s)
14
+ * scriptURL (see andbox#14). The host application is responsible for
15
+ * serving this string's content as a real same-origin file at whatever
16
+ * path it passes as `scriptURL` to `createSandbox({ mode: 'service-worker' })`.
17
+ */
18
+
19
+ /**
20
+ * Generate the Service Worker source code as a string.
21
+ *
22
+ * The Service Worker supports the following message types from the host
23
+ * (each sent with a `MessagePort` for an ack reply):
24
+ * - `configure`: Replace the entire served-file map
25
+ * - `define`: Add or replace one file
26
+ * - `remove`: Remove one file
27
+ *
28
+ * Lifecycle:
29
+ * - `install`: calls `skipWaiting()` so a new version activates promptly
30
+ * instead of waiting for all controlled clients to close.
31
+ * - `activate`: calls `clients.claim()` so already-open clients in scope
32
+ * are taken over without needing a reload. This does NOT by itself fix
33
+ * the "first navigation" gotcha (see andbox#14) -- a navigation into
34
+ * `scope` that happens *before* this Service Worker has finished
35
+ * activating is a normal, unintercepted network request, full stop.
36
+ * The host-side registration helper (`createSandbox({ mode:
37
+ * 'service-worker' })` in sandbox.mjs) handles this by resolving its
38
+ * returned promise only once `activate` has completed, and the documented
39
+ * contract is: don't navigate anything into `scope` until that promise
40
+ * resolves. Do that, and every request from the very first one is
41
+ * intercepted, because the registration is already active and matches
42
+ * `scope` before the navigation request is even made.
43
+ * - `fetch`: in-scope requests matching a known path get a synthesized
44
+ * Response; everything else (including cross-origin requests, which a
45
+ * Service Worker can technically see but should not opaquely rewrite)
46
+ * falls through to the network untouched.
47
+ *
48
+ * @returns {string} The Service Worker script source code.
49
+ */
50
+ export function makeServiceWorkerSource() {
51
+ return `
52
+ 'use strict';
53
+
54
+ // ── State ──
55
+ const files = new Map(); // pathname ('/foo/bar.js') -> { body, contentType, status, headers }
56
+
57
+ // ── Matching / response synthesis (inlined copy of service-worker-response.mjs) ──
58
+ function normalizeScopePath(path) {
59
+ return path.startsWith('/') ? path : '/' + path;
60
+ }
61
+
62
+ function matchFile(pathname) {
63
+ if (files.has(pathname)) return files.get(pathname);
64
+ if (pathname.endsWith('/') && files.has(pathname + 'index.html')) {
65
+ return files.get(pathname + 'index.html');
66
+ }
67
+ if (!pathname.endsWith('/') && files.has(pathname + '/index.html')) {
68
+ return files.get(pathname + '/index.html');
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function makeResponseForFile(entry) {
74
+ if (!entry) return null;
75
+ const headers = new Headers(entry.headers || {});
76
+ if (!headers.has('content-type')) {
77
+ headers.set('content-type', entry.contentType || 'application/octet-stream');
78
+ }
79
+ return new Response(entry.body, { status: entry.status || 200, headers });
80
+ }
81
+
82
+ // ── Lifecycle ──
83
+ self.addEventListener('install', (event) => {
84
+ event.waitUntil(self.skipWaiting());
85
+ });
86
+
87
+ self.addEventListener('activate', (event) => {
88
+ // Claims already-open clients so they're controlled without a reload.
89
+ // Does NOT retroactively intercept a navigation that already completed
90
+ // as a plain network request before this finished -- see the doc
91
+ // comment on makeServiceWorkerSource() in service-worker-source.mjs for
92
+ // the ordering that actually avoids that race.
93
+ event.waitUntil(self.clients.claim());
94
+ });
95
+
96
+ // ── Host <-> SW config protocol ──
97
+ self.addEventListener('message', (event) => {
98
+ const msg = event.data;
99
+ if (!msg || typeof msg.type !== 'string') return;
100
+ const reply = (payload) => {
101
+ if (event.ports && event.ports[0]) event.ports[0].postMessage(payload);
102
+ };
103
+
104
+ switch (msg.type) {
105
+ case 'configure': {
106
+ files.clear();
107
+ for (const [path, entry] of Object.entries(msg.files || {})) {
108
+ files.set(normalizeScopePath(path), entry);
109
+ }
110
+ reply({ type: 'configured' });
111
+ break;
112
+ }
113
+ case 'define': {
114
+ files.set(normalizeScopePath(msg.path), msg.entry);
115
+ reply({ type: 'defined', path: msg.path });
116
+ break;
117
+ }
118
+ case 'remove': {
119
+ files.delete(normalizeScopePath(msg.path));
120
+ reply({ type: 'removed', path: msg.path });
121
+ break;
122
+ }
123
+ default:
124
+ break;
125
+ }
126
+ });
127
+
128
+ // ── Request interception ──
129
+ self.addEventListener('fetch', (event) => {
130
+ const url = new URL(event.request.url);
131
+ if (url.origin !== self.location.origin) return; // never touch cross-origin requests
132
+
133
+ const response = makeResponseForFile(matchFile(url.pathname));
134
+ if (!response) return; // not a known path -- fall through to the network
135
+
136
+ event.respondWith(response);
137
+ });
138
+ `;
139
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Virtual module registry — path → blob URL bookkeeping for a set of
3
+ * in-memory files that reference each other by relative path.
4
+ *
5
+ * Generalizes the single-file `new Blob([...]) + URL.createObjectURL()`
6
+ * pattern `createDataUriSandbox()` (src/sandbox.mjs) already uses for its
7
+ * one-file case into a `path → content` table, and reuses
8
+ * `resolveWithImportMap()` (src/import-map-resolver.mjs) for import-map
9
+ * resolution rather than reimplementing it.
10
+ */
11
+
12
+ import { resolveWithImportMap } from './import-map-resolver.mjs';
13
+
14
+ /**
15
+ * @typedef {Object} VirtualModuleRegistry
16
+ * @property {(path: string) => string | null} resolve
17
+ * @property {(specifier: string, parentPath?: string) => string | null} resolveSpecifier
18
+ * @property {(path: string, source: string) => string} define
19
+ * @property {(path: string) => boolean} has
20
+ * @property {() => string[]} paths
21
+ * @property {() => void} dispose
22
+ * @property {() => boolean} isDisposed
23
+ */
24
+
25
+ /**
26
+ * Create a registry of in-memory files, each backed by its own `blob:` URL.
27
+ *
28
+ * @param {Record<string, string>} [files] - path → source text
29
+ * @param {{ importMap?: { imports?: Record<string,string>, scopes?: Record<string,Record<string,string>> } }} [options]
30
+ * @returns {VirtualModuleRegistry}
31
+ */
32
+ export function createVirtualModuleRegistry(files = {}, options = {}) {
33
+ const importMap = options.importMap || null;
34
+ const urls = new Map(); // path -> blob: URL
35
+ let disposed = false;
36
+
37
+ function blobify(path, source) {
38
+ const blob = new Blob([source], { type: 'text/javascript' });
39
+ const url = URL.createObjectURL(blob);
40
+ urls.set(path, url);
41
+ return url;
42
+ }
43
+
44
+ for (const [path, source] of Object.entries(files)) {
45
+ blobify(path, source);
46
+ }
47
+
48
+ function assertNotDisposed() {
49
+ if (disposed) throw new Error('Virtual module registry has been disposed');
50
+ }
51
+
52
+ /**
53
+ * Get the `blob:` URL registered for a path.
54
+ *
55
+ * @param {string} path
56
+ * @returns {string | null} the blob URL, or null if the path is unknown
57
+ */
58
+ function resolve(path) {
59
+ assertNotDisposed();
60
+ return urls.get(path) ?? null;
61
+ }
62
+
63
+ /**
64
+ * Register (or replace) a file at runtime. Replacing an existing path
65
+ * revokes its previous blob URL before minting a new one.
66
+ *
67
+ * @param {string} path
68
+ * @param {string} source
69
+ * @returns {string} the new blob URL
70
+ */
71
+ function define(path, source) {
72
+ assertNotDisposed();
73
+ if (urls.has(path)) URL.revokeObjectURL(urls.get(path));
74
+ return blobify(path, source);
75
+ }
76
+
77
+ /** @param {string} path */
78
+ function has(path) {
79
+ return urls.has(path);
80
+ }
81
+
82
+ /** @returns {string[]} */
83
+ function paths() {
84
+ return [...urls.keys()];
85
+ }
86
+
87
+ /**
88
+ * Resolve a module specifier the way `import` would need to, from the
89
+ * point of view of a given importing file. Checks three cases in order:
90
+ *
91
+ * 1. Import-map resolution (`imports`/`scopes`), delegated entirely to
92
+ * `resolveWithImportMap()` — the same algorithm the worker-mode
93
+ * sandbox's `sandboxImport()` uses. `parentPath`'s own blob URL (if
94
+ * known) is passed through as `parentURL` so scoped rules apply.
95
+ * 2. A plain relative-path fallback (`./x.js`, `../x.js`) resolved
96
+ * against `parentPath`'s directory and looked up in this registry's
97
+ * path table. `resolveWithImportMap()` has no notion of "known
98
+ * files" and returns null for these — this is the actual gap this
99
+ * registry closes.
100
+ * 3. Unresolved — null. A bare specifier that isn't in the import map
101
+ * and isn't a known relative path (a real npm/CDN specifier, say)
102
+ * is left for the caller to handle; this registry does not swallow
103
+ * it or guess.
104
+ *
105
+ * @param {string} specifier
106
+ * @param {string} [parentPath] - path of the file doing the importing
107
+ * @returns {string | null} a resolved URL, or null if nothing matched
108
+ */
109
+ function resolveSpecifier(specifier, parentPath) {
110
+ assertNotDisposed();
111
+
112
+ // 1. Import-map resolution (bare specifiers, prefix matches, scopes).
113
+ const parentURL = parentPath !== undefined ? urls.get(parentPath) : undefined;
114
+ const mapped = resolveWithImportMap(specifier, importMap, parentURL);
115
+ if (mapped !== null) return mapped;
116
+
117
+ // 2. Relative-path fallback against the known file table.
118
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
119
+ const targetPath = resolveRelativePath(parentPath || '', specifier);
120
+ const url = urls.get(targetPath);
121
+ if (url) return url;
122
+ }
123
+
124
+ // 3. Genuinely unresolved (e.g. a real external bare specifier).
125
+ return null;
126
+ }
127
+
128
+ /** Revoke every blob URL this registry has ever minted. */
129
+ function dispose() {
130
+ if (disposed) return;
131
+ disposed = true;
132
+ for (const url of urls.values()) URL.revokeObjectURL(url);
133
+ urls.clear();
134
+ }
135
+
136
+ return {
137
+ resolve,
138
+ resolveSpecifier,
139
+ define,
140
+ has,
141
+ paths,
142
+ dispose,
143
+ isDisposed: () => disposed,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Resolve a relative specifier against the path of the importing file.
149
+ *
150
+ * Virtual module paths aren't real URLs, so this borrows the URL parser's
151
+ * relative-reference algorithm rather than hand-rolling one: parse
152
+ * `parentPath` under a synthetic hierarchical scheme (`vfs:///...`, which,
153
+ * unlike `blob:`, has an explicit — if empty — authority component and so
154
+ * is *not* treated as opaque/"cannot be a base URL"), resolve `specifier`
155
+ * against that, and strip the scheme back off.
156
+ *
157
+ * `parentPath` is a registered map key, not a URL -- it may legitimately
158
+ * contain `#`/`?`/other URL-delimiter characters as literal path text, so
159
+ * each segment is percent-encoded before being embedded in the synthetic
160
+ * base URL (otherwise a `#`/`?` in `parentPath` would be parsed as the
161
+ * *base URL's own* fragment/query, silently truncating everything after it
162
+ * before relative resolution even runs). `specifier` is left as-is: it's
163
+ * resolved with real URL relative-reference semantics on purpose, so a
164
+ * literal `#`/`?` in a specifier is treated as a fragment/query exactly as
165
+ * it would be in a real module specifier or href.
166
+ *
167
+ * @param {string} parentPath
168
+ * @param {string} specifier
169
+ * @returns {string}
170
+ */
171
+ function resolveRelativePath(parentPath, specifier) {
172
+ const encodedParent = parentPath.split('/').map(encodeURIComponent).join('/');
173
+ const resolved = new URL(specifier, `vfs:///${encodedParent}`);
174
+ return decodeURIComponent(resolved.pathname.slice(1));
175
+ }