@johnhenry/andbox 0.0.1 → 0.0.2
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 +80 -1
- package/package.json +3 -2
- package/src/index.d.ts +158 -1
- package/src/index.mjs +3 -0
- package/src/sandbox.mjs +195 -2
- package/src/service-worker-response.mjs +90 -0
- package/src/service-worker-source.mjs +139 -0
- package/src/virtual-module-registry.mjs +175 -0
package/README.md
CHANGED
|
@@ -68,11 +68,12 @@ await sandbox.dispose();
|
|
|
68
68
|
|
|
69
69
|
## Sandbox Modes
|
|
70
70
|
|
|
71
|
-
andbox supports
|
|
71
|
+
andbox supports four execution modes:
|
|
72
72
|
|
|
73
73
|
- **`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
74
|
- **`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
75
|
- **`data-uri`** -- Dynamic `import()` via Blob URL. Module-level separation without a Worker. Supports globals injection.
|
|
76
|
+
- **`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
77
|
|
|
77
78
|
```js
|
|
78
79
|
// Inline mode (no Worker)
|
|
@@ -82,6 +83,15 @@ const result = await inline.execute('return math.sqrt(16)');
|
|
|
82
83
|
// Data-URI mode
|
|
83
84
|
const dataUri = createSandbox({ mode: 'data-uri', globals: { x: 42 } });
|
|
84
85
|
const result = await dataUri.execute('print(x)');
|
|
86
|
+
|
|
87
|
+
// Service-Worker mode (browser only -- see examples/06-service-worker-mode/)
|
|
88
|
+
const site = await createSandbox({
|
|
89
|
+
mode: 'service-worker',
|
|
90
|
+
scriptURL: '/andbox-sw.js', // you serve makeServiceWorkerSource()'s output here
|
|
91
|
+
scope: '/virtual/',
|
|
92
|
+
files: { '/virtual/hello.html': '<h1>Hello</h1>' },
|
|
93
|
+
});
|
|
94
|
+
// Only navigate into scope *after* this resolves -- see Security model.
|
|
85
95
|
```
|
|
86
96
|
|
|
87
97
|
## API
|
|
@@ -153,6 +163,28 @@ const { gated, stats } = gateCapabilities(
|
|
|
153
163
|
|
|
154
164
|
Resolves a module specifier against an import map, following the browser import map algorithm.
|
|
155
165
|
|
|
166
|
+
### `createVirtualModuleRegistry(files?, options?)`
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
import { createVirtualModuleRegistry } from '@johnhenry/andbox';
|
|
172
|
+
|
|
173
|
+
const registry = createVirtualModuleRegistry({
|
|
174
|
+
'index.js': 'import { add } from "./util.js"; export const result = add(2, 3);',
|
|
175
|
+
'util.js': 'export function add(a, b) { return a + b; }',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
registry.resolve('index.js'); // blob: URL, or null if unknown
|
|
179
|
+
registry.resolveSpecifier('./util.js', 'index.js'); // resolves relative to the importing file
|
|
180
|
+
registry.define('extra.js', 'export const x = 1;'); // register (or replace) a file at runtime
|
|
181
|
+
registry.dispose(); // revokes every blob URL it ever minted
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`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.
|
|
185
|
+
|
|
186
|
+
**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.
|
|
187
|
+
|
|
156
188
|
### `createNetworkFetch(allowedHosts?, fetchFn?)`
|
|
157
189
|
|
|
158
190
|
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 +209,51 @@ Promise and error utilities used internally, also available for consumers.
|
|
|
177
209
|
|
|
178
210
|
Returns the Worker script source code as a string (useful for custom Worker setups).
|
|
179
211
|
|
|
212
|
+
### `createSandbox({ mode: 'service-worker', ... })`
|
|
213
|
+
|
|
214
|
+
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.
|
|
215
|
+
|
|
216
|
+
**Options:**
|
|
217
|
+
|
|
218
|
+
| Option | Type | Description |
|
|
219
|
+
|--------|------|-------------|
|
|
220
|
+
| `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. |
|
|
221
|
+
| `scope` | `string` | Path prefix to register the Service Worker under. Defaults to `scriptURL`'s own directory. |
|
|
222
|
+
| `files` | `Record<string, string \| { body, contentType?, status?, headers? }>` | Initial served-file map. |
|
|
223
|
+
| `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. |
|
|
224
|
+
| `timeoutMs` | `number` | Max time to wait for the registration to become active before rejecting. Defaults to `DEFAULT_TIMEOUT_MS`. |
|
|
225
|
+
|
|
226
|
+
**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`.
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
import { createSandbox } from '@johnhenry/andbox';
|
|
230
|
+
|
|
231
|
+
const site = await createSandbox({
|
|
232
|
+
mode: 'service-worker',
|
|
233
|
+
scriptURL: '/andbox-sw.js', // you serve makeServiceWorkerSource()'s output here
|
|
234
|
+
scope: '/virtual/',
|
|
235
|
+
files: {
|
|
236
|
+
'/virtual/hello.html': { body: '<h1>Hello</h1>', contentType: 'text/html' },
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Only now -- with the registration active -- point something at the scope:
|
|
241
|
+
iframe.src = '/virtual/hello.html';
|
|
242
|
+
|
|
243
|
+
await site.define('/virtual/about.html', '<h1>About</h1>');
|
|
244
|
+
await site.dispose(); // unregisters the Service Worker
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
See `examples/06-service-worker-mode/` for a complete, runnable (browser-only) demo, including the dev server that serves the generated script.
|
|
248
|
+
|
|
249
|
+
### `makeServiceWorkerSource()`
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
### `resolveServiceWorkerResponse(pathname, files)`
|
|
254
|
+
|
|
255
|
+
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.
|
|
256
|
+
|
|
180
257
|
## Execution model
|
|
181
258
|
|
|
182
259
|
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:
|
|
@@ -194,6 +271,8 @@ andbox is **not** a boundary against code that is actively trying to escape it.
|
|
|
194
271
|
- **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
272
|
- **`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
273
|
- **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).
|
|
274
|
+
- **`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).
|
|
275
|
+
- **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
276
|
|
|
198
277
|
Fixed as of this audit (kept here for history -- see the linked issues for details):
|
|
199
278
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/andbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sandboxed JavaScript runtime with Worker isolation, RPC, import maps, and timeouts",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"example:02": "node examples/02-capability-limits-cut-off-abuse.mjs",
|
|
19
19
|
"example:03": "node examples/03-network-allowlist-blocks-unapproved-hosts.mjs",
|
|
20
20
|
"example:04": "node examples/04-runaway-code-gets-timed-out.mjs",
|
|
21
|
-
"
|
|
21
|
+
"example:05": "node examples/05-virtual-module-registry-resolves-a-multi-file-tree.mjs",
|
|
22
|
+
"examples": "npm run example:01 && npm run example:02 && npm run example:03 && npm run example:04 && npm run example:05"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
|
24
25
|
"sandbox",
|
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
|
|
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
|
+
}
|