@seanhogg/builderforce-webcontainers 2026.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Hogg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @seanhogg/builderforce-webcontainers
2
+
3
+ The browser runtime of
4
+ [BuilderForce WebContainers](https://github.com/SeanHogg/builderforce-webcontainers):
5
+ boot an in-browser dev server and preview the project in an iframe. No COOP/COEP
6
+ headers, no session caps, MIT licensed.
7
+
8
+ 1. Serve `dist/sw.js` from your site, ideally at `/__bfwc/sw.js` so the worker's
9
+ scope stays off the rest of your app. You can resolve its path with
10
+ `import.meta.resolve('@seanhogg/builderforce-webcontainers/sw.js')`, or copy it
11
+ in your build.
12
+ 2. Boot and mount:
13
+
14
+ ```ts
15
+ import { bootPreviewRuntime } from '@seanhogg/builderforce-webcontainers';
16
+
17
+ const runtime = await bootPreviewRuntime({ serviceWorkerUrl: '/__bfwc/sw.js', id: projectId });
18
+ runtime.mount(files);
19
+ if (runtime.profile().supported) iframe.src = runtime.url;
20
+ else showFallback(runtime.profile().reason);
21
+ ```
22
+
23
+ The package re-exports everything from
24
+ [`@seanhogg/builderforce-webcontainers-core`](../core).
@@ -0,0 +1,12 @@
1
+ import { type Transformer } from '@seanhogg/builderforce-webcontainers-core';
2
+ export interface EsbuildWasmOptions {
3
+ /** Where to fetch esbuild.wasm. Defaults to jsDelivr at the installed version. */
4
+ wasmURL?: string;
5
+ }
6
+ /**
7
+ * The default compiler: esbuild compiled to WebAssembly, run off the main thread.
8
+ * esbuild may be initialised once per page, so the transformer is shared by every
9
+ * runtime booted on it (the options of the first call win).
10
+ */
11
+ export declare function createEsbuildWasmTransformer(options?: EsbuildWasmOptions): Promise<Transformer>;
12
+ //# sourceMappingURL=esbuildWasm.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"esbuildWasm.d.ts","sourceRoot":"","sources":["../src/esbuildWasm.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,KAAK,WAAW,EAAE,MAAM,2CAA2C,CAAC;AAEvG,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAID;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,WAAW,CAAC,CAWnG"}
@@ -0,0 +1,20 @@
1
+ import { createEsbuildTransformer } from '@seanhogg/builderforce-webcontainers-core';
2
+ let ready;
3
+ /**
4
+ * The default compiler: esbuild compiled to WebAssembly, run off the main thread.
5
+ * esbuild may be initialised once per page, so the transformer is shared by every
6
+ * runtime booted on it (the options of the first call win).
7
+ */
8
+ export function createEsbuildWasmTransformer(options = {}) {
9
+ ready ??= (async () => {
10
+ const esbuild = await import('esbuild-wasm');
11
+ const wasmURL = options.wasmURL ?? `https://cdn.jsdelivr.net/npm/esbuild-wasm@${esbuild.version}/esbuild.wasm`;
12
+ await esbuild.initialize({ wasmURL, worker: true });
13
+ return createEsbuildTransformer(esbuild);
14
+ })().catch((error) => {
15
+ ready = undefined; // let a later call retry (a transient network failure fetching the wasm)
16
+ throw error;
17
+ });
18
+ return ready;
19
+ }
20
+ //# sourceMappingURL=esbuildWasm.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"esbuildWasm.js","sourceRoot":"","sources":["../src/esbuildWasm.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAoB,MAAM,2CAA2C,CAAC;AAOvG,IAAI,KAAuC,CAAC;AAE5C;;;;GAIG;AACH,MAAM,UAAU,4BAA4B,CAAC,UAA8B,EAAE;IAC3E,KAAK,KAAK,CAAC,KAAK,IAAI,EAAE;QACpB,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC,CAAC;QAC7C,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,6CAA6C,OAAO,CAAC,OAAO,eAAe,CAAC;QAC/G,MAAM,OAAO,CAAC,UAAU,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;QACpD,OAAO,wBAAwB,CAAC,OAAO,CAAC,CAAC;IAC3C,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QAC5B,KAAK,GAAG,SAAS,CAAC,CAAC,yEAAyE;QAC5F,MAAM,KAAK,CAAC;IACd,CAAC,CAAC,CAAC;IACH,OAAO,KAAK,CAAC;AACf,CAAC"}
package/dist/host.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { DevServer, VirtualFileSystem, type DevServerOptions, type FileSystemTree, type FlatFiles, type ProjectProfile, type Transformer } from '@seanhogg/builderforce-webcontainers-core';
2
+ export interface BootOptions {
3
+ /** Where the host serves this package's `dist/sw.js` (e.g. `/__bfwc/sw.js`). */
4
+ serviceWorkerUrl: string;
5
+ /** Worker scope. Defaults to the worker script's directory. */
6
+ scope?: string;
7
+ /** Compiler. Defaults to esbuild-wasm. */
8
+ transformer?: Transformer;
9
+ /** Package CDN factory. Defaults to esm.sh. */
10
+ cdn?: DevServerOptions['cdn'];
11
+ /** Stable id (e.g. the project id) — keeps the preview URL stable across reloads. */
12
+ id?: string;
13
+ /** The "Built with Builderforce.ai" badge in the preview. Default true — please keep it. */
14
+ attribution?: boolean;
15
+ }
16
+ export interface PreviewError {
17
+ message: string;
18
+ stack?: string;
19
+ href: string;
20
+ }
21
+ export interface PreviewRuntime {
22
+ readonly id: string;
23
+ /** Absolute URL to load in the preview iframe. */
24
+ readonly url: string;
25
+ readonly fs: VirtualFileSystem;
26
+ readonly server: DevServer;
27
+ mount(files: FileSystemTree | FlatFiles): void;
28
+ /** Whether this runtime can serve the mounted project, and why not. */
29
+ profile(): ProjectProfile;
30
+ /** Uncaught errors and rejections raised inside the preview. */
31
+ onError(listener: (error: PreviewError) => void): () => void;
32
+ dispose(): void;
33
+ }
34
+ /**
35
+ * Boot a preview runtime in this page: a virtual file system, a dev server over
36
+ * it, and a service worker that routes the preview iframe's requests back here.
37
+ *
38
+ * Needs a secure context (https or localhost) for the service worker — and
39
+ * nothing else: no cross-origin isolation headers, no SharedArrayBuffer.
40
+ */
41
+ export declare function bootPreviewRuntime(options: BootOptions): Promise<PreviewRuntime>;
42
+ //# sourceMappingURL=host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EAET,iBAAiB,EACjB,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,WAAW,EACjB,MAAM,2CAA2C,CAAC;AAInD,MAAM,WAAW,WAAW;IAC1B,gFAAgF;IAChF,gBAAgB,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0CAA0C;IAC1C,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,+CAA+C;IAC/C,GAAG,CAAC,EAAE,gBAAgB,CAAC,KAAK,CAAC,CAAC;IAC9B,qFAAqF;IACrF,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4FAA4F;IAC5F,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,EAAE,iBAAiB,CAAC;IAC/B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,KAAK,CAAC,KAAK,EAAE,cAAc,GAAG,SAAS,GAAG,IAAI,CAAC;IAC/C,uEAAuE;IACvE,OAAO,IAAI,cAAc,CAAC;IAC1B,gEAAgE;IAChE,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAC7D,OAAO,IAAI,IAAI,CAAC;CACjB;AA0BD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,cAAc,CAAC,CA2DtF"}
package/dist/host.js ADDED
@@ -0,0 +1,96 @@
1
+ import { DevServer, ERROR_MESSAGE_TYPE, VirtualFileSystem, } from '@seanhogg/builderforce-webcontainers-core';
2
+ import { ATTACH, REATTACH, isWireRequest, previewBase } from './protocol.js';
3
+ import { createEsbuildWasmTransformer } from './esbuildWasm.js';
4
+ function waitForActive(registration) {
5
+ if (registration.active)
6
+ return Promise.resolve(registration.active);
7
+ const installing = registration.installing ?? registration.waiting;
8
+ if (!installing)
9
+ return Promise.reject(new Error('The preview service worker did not install.'));
10
+ return new Promise((resolve, reject) => {
11
+ installing.addEventListener('statechange', () => {
12
+ if (installing.state === 'activated' && registration.active)
13
+ resolve(registration.active);
14
+ if (installing.state === 'redundant')
15
+ reject(new Error('The preview service worker failed to install.'));
16
+ });
17
+ });
18
+ }
19
+ function randomId() {
20
+ return globalThis.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 16) ?? Math.random().toString(36).slice(2, 18);
21
+ }
22
+ function toWire(reqId, served) {
23
+ if (typeof served.body === 'string') {
24
+ return { message: { type: 'response', reqId, status: served.status, headers: served.headers, body: served.body }, transfer: [] };
25
+ }
26
+ const body = served.body.slice().buffer; // a copy: transferring must not detach the VFS's own bytes
27
+ return { message: { type: 'response', reqId, status: served.status, headers: served.headers, body }, transfer: [body] };
28
+ }
29
+ /**
30
+ * Boot a preview runtime in this page: a virtual file system, a dev server over
31
+ * it, and a service worker that routes the preview iframe's requests back here.
32
+ *
33
+ * Needs a secure context (https or localhost) for the service worker — and
34
+ * nothing else: no cross-origin isolation headers, no SharedArrayBuffer.
35
+ */
36
+ export async function bootPreviewRuntime(options) {
37
+ const container = navigator.serviceWorker;
38
+ if (!container)
39
+ throw new Error('Service workers are unavailable here (a secure context — https or localhost — is required).');
40
+ const scriptUrl = new URL(options.serviceWorkerUrl, location.href);
41
+ const scope = new URL(options.scope ?? './', scriptUrl).pathname;
42
+ const registration = await container.register(scriptUrl.href, { scope });
43
+ await waitForActive(registration);
44
+ const id = options.id ?? randomId();
45
+ const base = previewBase(scope, id);
46
+ const fs = new VirtualFileSystem();
47
+ const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
48
+ const server = new DevServer({ fs, transformer, base, cdn: options.cdn, attribution: options.attribution });
49
+ const attach = (worker) => {
50
+ const channel = new MessageChannel();
51
+ channel.port1.onmessage = async (event) => {
52
+ if (!isWireRequest(event.data))
53
+ return;
54
+ const { message, transfer } = toWire(event.data.reqId, await server.handle(event.data.path, event.data.search));
55
+ channel.port1.postMessage(message, transfer);
56
+ };
57
+ worker.postMessage({ type: ATTACH, id }, [channel.port2]);
58
+ };
59
+ attach(registration.active);
60
+ const onWorkerMessage = (event) => {
61
+ if (event.data?.type === REATTACH && event.data.id === id && registration.active)
62
+ attach(registration.active);
63
+ };
64
+ container.addEventListener('message', onWorkerMessage);
65
+ const errorListeners = new Set();
66
+ const onWindowMessage = (event) => {
67
+ const data = event.data;
68
+ if (data?.type !== ERROR_MESSAGE_TYPE || typeof data.href !== 'string')
69
+ return;
70
+ if (!new URL(data.href).pathname.startsWith(base))
71
+ return; // another runtime's preview
72
+ const error = { message: String(data.message ?? ''), stack: data.stack, href: data.href };
73
+ for (const listener of errorListeners)
74
+ listener(error);
75
+ };
76
+ window.addEventListener('message', onWindowMessage);
77
+ return {
78
+ id,
79
+ url: new URL(base, location.origin).href,
80
+ fs,
81
+ server,
82
+ mount: (files) => fs.mount(files),
83
+ profile: () => server.profile(),
84
+ onError(listener) {
85
+ errorListeners.add(listener);
86
+ return () => errorListeners.delete(listener);
87
+ },
88
+ dispose() {
89
+ container.removeEventListener('message', onWorkerMessage);
90
+ window.removeEventListener('message', onWindowMessage);
91
+ errorListeners.clear();
92
+ server.dispose();
93
+ },
94
+ };
95
+ }
96
+ //# sourceMappingURL=host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"host.js","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ export { bootPreviewRuntime } from './host.js';
2
+ export type { BootOptions, PreviewRuntime, PreviewError } from './host.js';
3
+ export { createEsbuildWasmTransformer } from './esbuildWasm.js';
4
+ export type { EsbuildWasmOptions } from './esbuildWasm.js';
5
+ export { createSwRouter } from './swRouter.js';
6
+ export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse } from './swRouter.js';
7
+ export { previewBase, previewPrefix, PREVIEW_SEGMENT } from './protocol.js';
8
+ export * from '@seanhogg/builderforce-webcontainers-core';
9
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAC/C,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAChE,YAAY,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,YAAY,EAAE,QAAQ,EAAE,eAAe,EAAE,QAAQ,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACxG,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC5E,cAAc,2CAA2C,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { bootPreviewRuntime } from './host.js';
2
+ export { createEsbuildWasmTransformer } from './esbuildWasm.js';
3
+ export { createSwRouter } from './swRouter.js';
4
+ export { previewBase, previewPrefix, PREVIEW_SEGMENT } from './protocol.js';
5
+ export * from '@seanhogg/builderforce-webcontainers-core';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAEhE,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAE/C,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAC5E,cAAc,2CAA2C,CAAC"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The wire between the host page (which owns the file system and the dev server)
3
+ * and the service worker (which owns the network). The worker holds no project
4
+ * state: every request it intercepts is forwarded over a MessagePort to the page.
5
+ */
6
+ export declare const PREVIEW_SEGMENT = "__bfwc";
7
+ /** page → worker, with a MessagePort in `ports[0]`: "serve preview `id` over this port". */
8
+ export declare const ATTACH = "bfwc:attach";
9
+ /** worker → pages: "I lost the port for `id` (I was restarted) — attach again". */
10
+ export declare const REATTACH = "bfwc:reattach";
11
+ export interface AttachMessage {
12
+ type: typeof ATTACH;
13
+ id: string;
14
+ }
15
+ export interface ReattachMessage {
16
+ type: typeof REATTACH;
17
+ id: string;
18
+ }
19
+ export interface WireRequest {
20
+ type: 'request';
21
+ reqId: number;
22
+ path: string;
23
+ search: string;
24
+ }
25
+ export interface WireResponse {
26
+ type: 'response';
27
+ reqId: number;
28
+ status: number;
29
+ headers: Record<string, string>;
30
+ body: string | ArrayBuffer;
31
+ }
32
+ /**
33
+ * The URL prefix previews live under, for a worker scope. Serving the worker from
34
+ * `/__bfwc/sw.js` (scope `/__bfwc/`) keeps it off every other page of the host app;
35
+ * a root-scoped worker still only answers under `/__bfwc/`.
36
+ */
37
+ export declare function previewPrefix(scopePath: string): string;
38
+ export declare function previewBase(scopePath: string, id: string): string;
39
+ export declare function isWireRequest(value: unknown): value is WireRequest;
40
+ export declare function isWireResponse(value: unknown): value is WireResponse;
41
+ //# sourceMappingURL=protocol.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,eAAO,MAAM,eAAe,WAAW,CAAC;AAExC,4FAA4F;AAC5F,eAAO,MAAM,MAAM,gBAAgB,CAAC;AACpC,mFAAmF;AACnF,eAAO,MAAM,QAAQ,kBAAkB,CAAC;AAExC,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,OAAO,MAAM,CAAC;IACpB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,OAAO,QAAQ,CAAC;IACtB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;CAC5B;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAGvD;AAED,wBAAgB,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,WAAW,CAGlE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAGpE"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The wire between the host page (which owns the file system and the dev server)
3
+ * and the service worker (which owns the network). The worker holds no project
4
+ * state: every request it intercepts is forwarded over a MessagePort to the page.
5
+ */
6
+ export const PREVIEW_SEGMENT = '__bfwc';
7
+ /** page → worker, with a MessagePort in `ports[0]`: "serve preview `id` over this port". */
8
+ export const ATTACH = 'bfwc:attach';
9
+ /** worker → pages: "I lost the port for `id` (I was restarted) — attach again". */
10
+ export const REATTACH = 'bfwc:reattach';
11
+ /**
12
+ * The URL prefix previews live under, for a worker scope. Serving the worker from
13
+ * `/__bfwc/sw.js` (scope `/__bfwc/`) keeps it off every other page of the host app;
14
+ * a root-scoped worker still only answers under `/__bfwc/`.
15
+ */
16
+ export function previewPrefix(scopePath) {
17
+ const scope = scopePath.endsWith('/') ? scopePath : scopePath + '/';
18
+ return scope.endsWith(`/${PREVIEW_SEGMENT}/`) ? scope : `${scope}${PREVIEW_SEGMENT}/`;
19
+ }
20
+ export function previewBase(scopePath, id) {
21
+ return `${previewPrefix(scopePath)}${id}/`;
22
+ }
23
+ export function isWireRequest(value) {
24
+ const v = value;
25
+ return !!v && v.type === 'request' && typeof v.reqId === 'number' && typeof v.path === 'string';
26
+ }
27
+ export function isWireResponse(value) {
28
+ const v = value;
29
+ return !!v && v.type === 'response' && typeof v.reqId === 'number';
30
+ }
31
+ //# sourceMappingURL=protocol.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,CAAC,MAAM,eAAe,GAAG,QAAQ,CAAC;AAExC,4FAA4F;AAC5F,MAAM,CAAC,MAAM,MAAM,GAAG,aAAa,CAAC;AACpC,mFAAmF;AACnF,MAAM,CAAC,MAAM,QAAQ,GAAG,eAAe,CAAC;AA2BxC;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB;IAC7C,MAAM,KAAK,GAAG,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC;IACpE,OAAO,KAAK,CAAC,QAAQ,CAAC,IAAI,eAAe,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,eAAe,GAAG,CAAC;AACxF,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,SAAiB,EAAE,EAAU;IACvD,OAAO,GAAG,aAAa,CAAC,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC;AAC7C,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,MAAM,CAAC,GAAG,KAA2B,CAAC;IACtC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC;AAClG,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,MAAM,CAAC,GAAG,KAA4B,CAAC;IACvC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,UAAU,IAAI,OAAO,CAAC,CAAC,KAAK,KAAK,QAAQ,CAAC;AACrE,CAAC"}
package/dist/sw.js ADDED
@@ -0,0 +1 @@
1
+ "use strict";(()=>{var f="__bfwc",g="bfwc:attach",m="bfwc:reattach";function w(e){let n=e.endsWith("/")?e:e+"/";return n.endsWith(`/${f}/`)?n:`${n}${f}/`}function y(e){let n=e;return!!n&&n.type==="response"&&typeof n.reqId=="number"}var v={"content-type":"text/plain; charset=utf-8","cache-control":"no-store"};function R(e){let n=w(e.scopePath),i=e.timeoutMs??3e4,k=e.reattachWaitMs??3e3,c=new Map,d=new Map,p=new Map,T=0;function W(t,r){c.set(t,r),r.onmessage=s=>{y(s.data)&&(p.get(s.data.reqId)?.(s.data),p.delete(s.data.reqId))};for(let s of d.get(t)??[])s(r);d.delete(t)}function P(t){if(!t.pathname.startsWith(n))return null;let r=t.pathname.slice(n.length),s=r.indexOf("/"),o=s<0?r:r.slice(0,s);return o?{id:o,path:s<0?"/":r.slice(s),search:t.search}:null}async function b(t){let r=c.get(t);if(r)return r;let s=new Promise(o=>{let u=d.get(t)??[];u.push(o),d.set(t,u),setTimeout(()=>o(void 0),k)});return await e.requestReattach(t),s}async function x(t){let r=await b(t.id);if(!r)return{status:503,headers:v,body:"This preview is not running in any open tab."};let s=++T;return new Promise(o=>{let u=setTimeout(()=>{p.delete(s),c.get(t.id)===r&&c.delete(t.id),o({status:504,headers:v,body:"The preview did not answer in time."})},i);p.set(s,l=>{clearTimeout(u),o({status:l.status,headers:l.headers,body:l.body})}),r.postMessage({type:"request",reqId:s,path:t.path,search:t.search})})}return{attach:W,match:P,respond:x,detach:t=>c.delete(t)}}var a=globalThis,h=R({scopePath:new URL(a.registration.scope).pathname,async requestReattach(e){let n=await a.clients.matchAll({type:"window",includeUncontrolled:!0});for(let i of n)i.postMessage({type:m,id:e})}});a.addEventListener("install",()=>{a.skipWaiting()});a.addEventListener("activate",e=>{e.waitUntil(a.clients.claim())});a.addEventListener("message",e=>{let n=e.data,i=e.ports[0];n?.type===g&&typeof n.id=="string"&&i&&h.attach(n.id,i)});a.addEventListener("fetch",e=>{if(e.request.method!=="GET")return;let n=h.match(new URL(e.request.url));n&&e.respondWith(h.respond(n).then(i=>new Response(i.body,{status:i.status,headers:i.headers})))});})();
@@ -0,0 +1,44 @@
1
+ /** The part of MessagePort the router uses — a fake in tests, a real port in the worker. */
2
+ export interface PortLike {
3
+ postMessage(message: unknown): void;
4
+ onmessage: ((event: {
5
+ data: unknown;
6
+ }) => void) | null;
7
+ }
8
+ export interface PreviewTarget {
9
+ id: string;
10
+ path: string;
11
+ search: string;
12
+ }
13
+ export interface RoutedResponse {
14
+ status: number;
15
+ headers: Record<string, string>;
16
+ body: string | ArrayBuffer;
17
+ }
18
+ export interface SwRouterOptions {
19
+ /** The worker registration's scope path (`/__bfwc/` or `/`). */
20
+ scopePath: string;
21
+ /** Ask open pages to re-attach `id` (the worker restarted and lost its ports). */
22
+ requestReattach(id: string): Promise<void> | void;
23
+ /** How long a page has to answer one request. */
24
+ timeoutMs?: number;
25
+ /** How long to wait for a page to re-attach before giving up. */
26
+ reattachWaitMs?: number;
27
+ }
28
+ /**
29
+ * Request routing for the preview service worker, with no worker globals in it.
30
+ *
31
+ * Browsers stop idle service workers at will, and a stopped worker forgets every
32
+ * MessagePort it held. So a request for an unknown preview id is not a 404: the
33
+ * router asks open pages to re-attach and waits briefly. A port that stops
34
+ * answering (its tab closed) is dropped on timeout, so the NEXT request goes
35
+ * through the re-attach path instead of timing out forever.
36
+ */
37
+ export declare function createSwRouter(options: SwRouterOptions): {
38
+ attach: (id: string, port: PortLike) => void;
39
+ match: (url: URL) => PreviewTarget | null;
40
+ respond: (target: PreviewTarget) => Promise<RoutedResponse>;
41
+ detach: (id: string) => boolean;
42
+ };
43
+ export type SwRouter = ReturnType<typeof createSwRouter>;
44
+ //# sourceMappingURL=swRouter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"swRouter.d.ts","sourceRoot":"","sources":["../src/swRouter.ts"],"names":[],"mappings":"AAEA,4FAA4F;AAC5F,MAAM,WAAW,QAAQ;IACvB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACpC,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAC;CAC5B;AAED,MAAM,WAAW,eAAe;IAC9B,gEAAgE;IAChE,SAAS,EAAE,MAAM,CAAC;IAClB,kFAAkF;IAClF,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAClD,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,eAAe;iBASjC,MAAM,QAAQ,QAAQ,KAAG,IAAI;iBAW7B,GAAG,KAAG,aAAa,GAAG,IAAI;sBAsBf,aAAa,KAAG,OAAO,CAAC,cAAc,CAAC;iBAkBxB,MAAM;EACrD;AAED,MAAM,MAAM,QAAQ,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC"}
@@ -0,0 +1,76 @@
1
+ import { isWireResponse, previewPrefix } from './protocol.js';
2
+ const TEXT = { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' };
3
+ /**
4
+ * Request routing for the preview service worker, with no worker globals in it.
5
+ *
6
+ * Browsers stop idle service workers at will, and a stopped worker forgets every
7
+ * MessagePort it held. So a request for an unknown preview id is not a 404: the
8
+ * router asks open pages to re-attach and waits briefly. A port that stops
9
+ * answering (its tab closed) is dropped on timeout, so the NEXT request goes
10
+ * through the re-attach path instead of timing out forever.
11
+ */
12
+ export function createSwRouter(options) {
13
+ const prefix = previewPrefix(options.scopePath);
14
+ const timeoutMs = options.timeoutMs ?? 30_000;
15
+ const reattachWaitMs = options.reattachWaitMs ?? 3_000;
16
+ const ports = new Map();
17
+ const waiters = new Map();
18
+ const pending = new Map();
19
+ let nextId = 0;
20
+ function attach(id, port) {
21
+ ports.set(id, port);
22
+ port.onmessage = (event) => {
23
+ if (!isWireResponse(event.data))
24
+ return;
25
+ pending.get(event.data.reqId)?.(event.data);
26
+ pending.delete(event.data.reqId);
27
+ };
28
+ for (const wake of waiters.get(id) ?? [])
29
+ wake(port);
30
+ waiters.delete(id);
31
+ }
32
+ function match(url) {
33
+ if (!url.pathname.startsWith(prefix))
34
+ return null;
35
+ const rest = url.pathname.slice(prefix.length);
36
+ const slash = rest.indexOf('/');
37
+ const id = slash < 0 ? rest : rest.slice(0, slash);
38
+ if (!id)
39
+ return null;
40
+ return { id, path: slash < 0 ? '/' : rest.slice(slash), search: url.search };
41
+ }
42
+ async function portFor(id) {
43
+ const known = ports.get(id);
44
+ if (known)
45
+ return known;
46
+ const arrived = new Promise((resolve) => {
47
+ const list = waiters.get(id) ?? [];
48
+ list.push(resolve);
49
+ waiters.set(id, list);
50
+ setTimeout(() => resolve(undefined), reattachWaitMs);
51
+ });
52
+ await options.requestReattach(id);
53
+ return arrived;
54
+ }
55
+ async function respond(target) {
56
+ const port = await portFor(target.id);
57
+ if (!port)
58
+ return { status: 503, headers: TEXT, body: 'This preview is not running in any open tab.' };
59
+ const reqId = ++nextId;
60
+ return new Promise((resolve) => {
61
+ const timer = setTimeout(() => {
62
+ pending.delete(reqId);
63
+ if (ports.get(target.id) === port)
64
+ ports.delete(target.id);
65
+ resolve({ status: 504, headers: TEXT, body: 'The preview did not answer in time.' });
66
+ }, timeoutMs);
67
+ pending.set(reqId, (response) => {
68
+ clearTimeout(timer);
69
+ resolve({ status: response.status, headers: response.headers, body: response.body });
70
+ });
71
+ port.postMessage({ type: 'request', reqId, path: target.path, search: target.search });
72
+ });
73
+ }
74
+ return { attach, match, respond, detach: (id) => ports.delete(id) };
75
+ }
76
+ //# sourceMappingURL=swRouter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"swRouter.js","sourceRoot":"","sources":["../src/swRouter.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@seanhogg/builderforce-webcontainers",
3
+ "version": "2026.10.0",
4
+ "description": "BuilderForce WebContainers — browser runtime. Boots an in-browser dev server for a project held in memory and serves it to an iframe through a service worker. Open source, no COOP/COEP, no session caps.",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./sw.js": "./dist/sw.js"
14
+ },
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "keywords": [
24
+ "webcontainer",
25
+ "service-worker",
26
+ "in-browser",
27
+ "dev-server",
28
+ "preview",
29
+ "esbuild-wasm",
30
+ "builderforce"
31
+ ],
32
+ "author": {
33
+ "name": "Sean Hogg",
34
+ "email": "seanhogg@gmail.com",
35
+ "url": "https://builderforce.ai"
36
+ },
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/SeanHogg/builderforce-webcontainers.git",
41
+ "directory": "packages/browser"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/SeanHogg/builderforce-webcontainers/issues"
45
+ },
46
+ "homepage": "https://github.com/SeanHogg/builderforce-webcontainers/tree/main/packages/browser#readme",
47
+ "engines": {
48
+ "node": ">=18.0.0"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "registry": "https://registry.npmjs.org"
53
+ },
54
+ "dependencies": {
55
+ "esbuild-wasm": "^0.24.0",
56
+ "@seanhogg/builderforce-webcontainers-core": "^2026.10.0"
57
+ },
58
+ "devDependencies": {
59
+ "esbuild": "^0.24.0",
60
+ "typescript": "^5.5.0",
61
+ "vitest": "^2.1.0"
62
+ },
63
+ "scripts": {
64
+ "build": "tsc -p tsconfig.build.json && node scripts/build-sw.mjs",
65
+ "typecheck": "tsc --noEmit",
66
+ "test": "vitest run"
67
+ }
68
+ }
@@ -0,0 +1,26 @@
1
+ import { createEsbuildTransformer, type Transformer } from '@seanhogg/builderforce-webcontainers-core';
2
+
3
+ export interface EsbuildWasmOptions {
4
+ /** Where to fetch esbuild.wasm. Defaults to jsDelivr at the installed version. */
5
+ wasmURL?: string;
6
+ }
7
+
8
+ let ready: Promise<Transformer> | undefined;
9
+
10
+ /**
11
+ * The default compiler: esbuild compiled to WebAssembly, run off the main thread.
12
+ * esbuild may be initialised once per page, so the transformer is shared by every
13
+ * runtime booted on it (the options of the first call win).
14
+ */
15
+ export function createEsbuildWasmTransformer(options: EsbuildWasmOptions = {}): Promise<Transformer> {
16
+ ready ??= (async () => {
17
+ const esbuild = await import('esbuild-wasm');
18
+ const wasmURL = options.wasmURL ?? `https://cdn.jsdelivr.net/npm/esbuild-wasm@${esbuild.version}/esbuild.wasm`;
19
+ await esbuild.initialize({ wasmURL, worker: true });
20
+ return createEsbuildTransformer(esbuild);
21
+ })().catch((error: unknown) => {
22
+ ready = undefined; // let a later call retry (a transient network failure fetching the wasm)
23
+ throw error;
24
+ });
25
+ return ready;
26
+ }
package/src/host.ts ADDED
@@ -0,0 +1,139 @@
1
+ import {
2
+ DevServer,
3
+ ERROR_MESSAGE_TYPE,
4
+ VirtualFileSystem,
5
+ type DevServerOptions,
6
+ type FileSystemTree,
7
+ type FlatFiles,
8
+ type ProjectProfile,
9
+ type Transformer,
10
+ } from '@seanhogg/builderforce-webcontainers-core';
11
+ import { ATTACH, REATTACH, isWireRequest, previewBase, type WireResponse } from './protocol.js';
12
+ import { createEsbuildWasmTransformer } from './esbuildWasm.js';
13
+
14
+ export interface BootOptions {
15
+ /** Where the host serves this package's `dist/sw.js` (e.g. `/__bfwc/sw.js`). */
16
+ serviceWorkerUrl: string;
17
+ /** Worker scope. Defaults to the worker script's directory. */
18
+ scope?: string;
19
+ /** Compiler. Defaults to esbuild-wasm. */
20
+ transformer?: Transformer;
21
+ /** Package CDN factory. Defaults to esm.sh. */
22
+ cdn?: DevServerOptions['cdn'];
23
+ /** Stable id (e.g. the project id) — keeps the preview URL stable across reloads. */
24
+ id?: string;
25
+ /** The "Built with Builderforce.ai" badge in the preview. Default true — please keep it. */
26
+ attribution?: boolean;
27
+ }
28
+
29
+ export interface PreviewError {
30
+ message: string;
31
+ stack?: string;
32
+ href: string;
33
+ }
34
+
35
+ export interface PreviewRuntime {
36
+ readonly id: string;
37
+ /** Absolute URL to load in the preview iframe. */
38
+ readonly url: string;
39
+ readonly fs: VirtualFileSystem;
40
+ readonly server: DevServer;
41
+ mount(files: FileSystemTree | FlatFiles): void;
42
+ /** Whether this runtime can serve the mounted project, and why not. */
43
+ profile(): ProjectProfile;
44
+ /** Uncaught errors and rejections raised inside the preview. */
45
+ onError(listener: (error: PreviewError) => void): () => void;
46
+ dispose(): void;
47
+ }
48
+
49
+ function waitForActive(registration: ServiceWorkerRegistration): Promise<ServiceWorker> {
50
+ if (registration.active) return Promise.resolve(registration.active);
51
+ const installing = registration.installing ?? registration.waiting;
52
+ if (!installing) return Promise.reject(new Error('The preview service worker did not install.'));
53
+ return new Promise((resolve, reject) => {
54
+ installing.addEventListener('statechange', () => {
55
+ if (installing.state === 'activated' && registration.active) resolve(registration.active);
56
+ if (installing.state === 'redundant') reject(new Error('The preview service worker failed to install.'));
57
+ });
58
+ });
59
+ }
60
+
61
+ function randomId(): string {
62
+ return globalThis.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 16) ?? Math.random().toString(36).slice(2, 18);
63
+ }
64
+
65
+ function toWire(reqId: number, served: Awaited<ReturnType<DevServer['handle']>>): { message: WireResponse; transfer: Transferable[] } {
66
+ if (typeof served.body === 'string') {
67
+ return { message: { type: 'response', reqId, status: served.status, headers: served.headers, body: served.body }, transfer: [] };
68
+ }
69
+ const body = served.body.slice().buffer as ArrayBuffer; // a copy: transferring must not detach the VFS's own bytes
70
+ return { message: { type: 'response', reqId, status: served.status, headers: served.headers, body }, transfer: [body] };
71
+ }
72
+
73
+ /**
74
+ * Boot a preview runtime in this page: a virtual file system, a dev server over
75
+ * it, and a service worker that routes the preview iframe's requests back here.
76
+ *
77
+ * Needs a secure context (https or localhost) for the service worker — and
78
+ * nothing else: no cross-origin isolation headers, no SharedArrayBuffer.
79
+ */
80
+ export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewRuntime> {
81
+ const container = navigator.serviceWorker;
82
+ if (!container) throw new Error('Service workers are unavailable here (a secure context — https or localhost — is required).');
83
+
84
+ const scriptUrl = new URL(options.serviceWorkerUrl, location.href);
85
+ const scope = new URL(options.scope ?? './', scriptUrl).pathname;
86
+ const registration = await container.register(scriptUrl.href, { scope });
87
+ await waitForActive(registration);
88
+
89
+ const id = options.id ?? randomId();
90
+ const base = previewBase(scope, id);
91
+ const fs = new VirtualFileSystem();
92
+ const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
93
+ const server = new DevServer({ fs, transformer, base, cdn: options.cdn, attribution: options.attribution });
94
+
95
+ const attach = (worker: ServiceWorker) => {
96
+ const channel = new MessageChannel();
97
+ channel.port1.onmessage = async (event) => {
98
+ if (!isWireRequest(event.data)) return;
99
+ const { message, transfer } = toWire(event.data.reqId, await server.handle(event.data.path, event.data.search));
100
+ channel.port1.postMessage(message, transfer);
101
+ };
102
+ worker.postMessage({ type: ATTACH, id }, [channel.port2]);
103
+ };
104
+ attach(registration.active!);
105
+
106
+ const onWorkerMessage = (event: MessageEvent) => {
107
+ if (event.data?.type === REATTACH && event.data.id === id && registration.active) attach(registration.active);
108
+ };
109
+ container.addEventListener('message', onWorkerMessage);
110
+
111
+ const errorListeners = new Set<(error: PreviewError) => void>();
112
+ const onWindowMessage = (event: MessageEvent) => {
113
+ const data = event.data as Partial<PreviewError> & { type?: string };
114
+ if (data?.type !== ERROR_MESSAGE_TYPE || typeof data.href !== 'string') return;
115
+ if (!new URL(data.href).pathname.startsWith(base)) return; // another runtime's preview
116
+ const error: PreviewError = { message: String(data.message ?? ''), stack: data.stack, href: data.href };
117
+ for (const listener of errorListeners) listener(error);
118
+ };
119
+ window.addEventListener('message', onWindowMessage);
120
+
121
+ return {
122
+ id,
123
+ url: new URL(base, location.origin).href,
124
+ fs,
125
+ server,
126
+ mount: (files) => fs.mount(files),
127
+ profile: () => server.profile(),
128
+ onError(listener) {
129
+ errorListeners.add(listener);
130
+ return () => errorListeners.delete(listener);
131
+ },
132
+ dispose() {
133
+ container.removeEventListener('message', onWorkerMessage);
134
+ window.removeEventListener('message', onWindowMessage);
135
+ errorListeners.clear();
136
+ server.dispose();
137
+ },
138
+ };
139
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export { bootPreviewRuntime } from './host.js';
2
+ export type { BootOptions, PreviewRuntime, PreviewError } from './host.js';
3
+ export { createEsbuildWasmTransformer } from './esbuildWasm.js';
4
+ export type { EsbuildWasmOptions } from './esbuildWasm.js';
5
+ export { createSwRouter } from './swRouter.js';
6
+ export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse } from './swRouter.js';
7
+ export { previewBase, previewPrefix, PREVIEW_SEGMENT } from './protocol.js';
8
+ export * from '@seanhogg/builderforce-webcontainers-core';
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The wire between the host page (which owns the file system and the dev server)
3
+ * and the service worker (which owns the network). The worker holds no project
4
+ * state: every request it intercepts is forwarded over a MessagePort to the page.
5
+ */
6
+
7
+ export const PREVIEW_SEGMENT = '__bfwc';
8
+
9
+ /** page → worker, with a MessagePort in `ports[0]`: "serve preview `id` over this port". */
10
+ export const ATTACH = 'bfwc:attach';
11
+ /** worker → pages: "I lost the port for `id` (I was restarted) — attach again". */
12
+ export const REATTACH = 'bfwc:reattach';
13
+
14
+ export interface AttachMessage {
15
+ type: typeof ATTACH;
16
+ id: string;
17
+ }
18
+
19
+ export interface ReattachMessage {
20
+ type: typeof REATTACH;
21
+ id: string;
22
+ }
23
+
24
+ export interface WireRequest {
25
+ type: 'request';
26
+ reqId: number;
27
+ path: string;
28
+ search: string;
29
+ }
30
+
31
+ export interface WireResponse {
32
+ type: 'response';
33
+ reqId: number;
34
+ status: number;
35
+ headers: Record<string, string>;
36
+ body: string | ArrayBuffer;
37
+ }
38
+
39
+ /**
40
+ * The URL prefix previews live under, for a worker scope. Serving the worker from
41
+ * `/__bfwc/sw.js` (scope `/__bfwc/`) keeps it off every other page of the host app;
42
+ * a root-scoped worker still only answers under `/__bfwc/`.
43
+ */
44
+ export function previewPrefix(scopePath: string): string {
45
+ const scope = scopePath.endsWith('/') ? scopePath : scopePath + '/';
46
+ return scope.endsWith(`/${PREVIEW_SEGMENT}/`) ? scope : `${scope}${PREVIEW_SEGMENT}/`;
47
+ }
48
+
49
+ export function previewBase(scopePath: string, id: string): string {
50
+ return `${previewPrefix(scopePath)}${id}/`;
51
+ }
52
+
53
+ export function isWireRequest(value: unknown): value is WireRequest {
54
+ const v = value as WireRequest | null;
55
+ return !!v && v.type === 'request' && typeof v.reqId === 'number' && typeof v.path === 'string';
56
+ }
57
+
58
+ export function isWireResponse(value: unknown): value is WireResponse {
59
+ const v = value as WireResponse | null;
60
+ return !!v && v.type === 'response' && typeof v.reqId === 'number';
61
+ }
package/src/sw.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The preview service worker (bundled to dist/sw.js). It answers only requests
3
+ * under the preview prefix; everything else falls through to the network
4
+ * untouched, so registering it never changes how the host app itself loads.
5
+ *
6
+ * Worker globals are typed locally rather than through `lib.webworker`, which
7
+ * cannot share a compilation with the DOM lib the rest of the package needs.
8
+ */
9
+ import { createSwRouter, type PortLike } from './swRouter.js';
10
+ import { ATTACH, REATTACH, type AttachMessage } from './protocol.js';
11
+
12
+ interface WindowClientLike {
13
+ postMessage(message: unknown): void;
14
+ }
15
+
16
+ interface ServiceWorkerGlobalLike {
17
+ registration: { scope: string };
18
+ clients: {
19
+ matchAll(options: { type: 'window'; includeUncontrolled: boolean }): Promise<WindowClientLike[]>;
20
+ claim(): Promise<void>;
21
+ };
22
+ skipWaiting(): Promise<void>;
23
+ addEventListener(type: 'install', listener: () => void): void;
24
+ addEventListener(type: 'activate', listener: (event: { waitUntil(promise: Promise<unknown>): void }) => void): void;
25
+ addEventListener(type: 'message', listener: (event: { data: unknown; ports: readonly unknown[] }) => void): void;
26
+ addEventListener(
27
+ type: 'fetch',
28
+ listener: (event: { request: Request; respondWith(response: Promise<Response>): void }) => void,
29
+ ): void;
30
+ }
31
+
32
+ const worker = globalThis as unknown as ServiceWorkerGlobalLike;
33
+
34
+ const router = createSwRouter({
35
+ scopePath: new URL(worker.registration.scope).pathname,
36
+ async requestReattach(id) {
37
+ const pages = await worker.clients.matchAll({ type: 'window', includeUncontrolled: true });
38
+ for (const page of pages) page.postMessage({ type: REATTACH, id });
39
+ },
40
+ });
41
+
42
+ worker.addEventListener('install', () => {
43
+ void worker.skipWaiting();
44
+ });
45
+
46
+ worker.addEventListener('activate', (event) => {
47
+ event.waitUntil(worker.clients.claim());
48
+ });
49
+
50
+ worker.addEventListener('message', (event) => {
51
+ const message = event.data as AttachMessage | null;
52
+ const port = event.ports[0];
53
+ if (message?.type === ATTACH && typeof message.id === 'string' && port) router.attach(message.id, port as PortLike);
54
+ });
55
+
56
+ worker.addEventListener('fetch', (event) => {
57
+ if (event.request.method !== 'GET') return;
58
+ const target = router.match(new URL(event.request.url));
59
+ if (!target) return;
60
+ event.respondWith(router.respond(target).then((r) => new Response(r.body, { status: r.status, headers: r.headers })));
61
+ });
@@ -0,0 +1,106 @@
1
+ import { isWireResponse, previewPrefix, type WireResponse } from './protocol.js';
2
+
3
+ /** The part of MessagePort the router uses — a fake in tests, a real port in the worker. */
4
+ export interface PortLike {
5
+ postMessage(message: unknown): void;
6
+ onmessage: ((event: { data: unknown }) => void) | null;
7
+ }
8
+
9
+ export interface PreviewTarget {
10
+ id: string;
11
+ path: string;
12
+ search: string;
13
+ }
14
+
15
+ export interface RoutedResponse {
16
+ status: number;
17
+ headers: Record<string, string>;
18
+ body: string | ArrayBuffer;
19
+ }
20
+
21
+ export interface SwRouterOptions {
22
+ /** The worker registration's scope path (`/__bfwc/` or `/`). */
23
+ scopePath: string;
24
+ /** Ask open pages to re-attach `id` (the worker restarted and lost its ports). */
25
+ requestReattach(id: string): Promise<void> | void;
26
+ /** How long a page has to answer one request. */
27
+ timeoutMs?: number;
28
+ /** How long to wait for a page to re-attach before giving up. */
29
+ reattachWaitMs?: number;
30
+ }
31
+
32
+ const TEXT = { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' };
33
+
34
+ /**
35
+ * Request routing for the preview service worker, with no worker globals in it.
36
+ *
37
+ * Browsers stop idle service workers at will, and a stopped worker forgets every
38
+ * MessagePort it held. So a request for an unknown preview id is not a 404: the
39
+ * router asks open pages to re-attach and waits briefly. A port that stops
40
+ * answering (its tab closed) is dropped on timeout, so the NEXT request goes
41
+ * through the re-attach path instead of timing out forever.
42
+ */
43
+ export function createSwRouter(options: SwRouterOptions) {
44
+ const prefix = previewPrefix(options.scopePath);
45
+ const timeoutMs = options.timeoutMs ?? 30_000;
46
+ const reattachWaitMs = options.reattachWaitMs ?? 3_000;
47
+ const ports = new Map<string, PortLike>();
48
+ const waiters = new Map<string, Array<(port: PortLike) => void>>();
49
+ const pending = new Map<number, (response: WireResponse) => void>();
50
+ let nextId = 0;
51
+
52
+ function attach(id: string, port: PortLike): void {
53
+ ports.set(id, port);
54
+ port.onmessage = (event) => {
55
+ if (!isWireResponse(event.data)) return;
56
+ pending.get(event.data.reqId)?.(event.data);
57
+ pending.delete(event.data.reqId);
58
+ };
59
+ for (const wake of waiters.get(id) ?? []) wake(port);
60
+ waiters.delete(id);
61
+ }
62
+
63
+ function match(url: URL): PreviewTarget | null {
64
+ if (!url.pathname.startsWith(prefix)) return null;
65
+ const rest = url.pathname.slice(prefix.length);
66
+ const slash = rest.indexOf('/');
67
+ const id = slash < 0 ? rest : rest.slice(0, slash);
68
+ if (!id) return null;
69
+ return { id, path: slash < 0 ? '/' : rest.slice(slash), search: url.search };
70
+ }
71
+
72
+ async function portFor(id: string): Promise<PortLike | undefined> {
73
+ const known = ports.get(id);
74
+ if (known) return known;
75
+ const arrived = new Promise<PortLike | undefined>((resolve) => {
76
+ const list = waiters.get(id) ?? [];
77
+ list.push(resolve);
78
+ waiters.set(id, list);
79
+ setTimeout(() => resolve(undefined), reattachWaitMs);
80
+ });
81
+ await options.requestReattach(id);
82
+ return arrived;
83
+ }
84
+
85
+ async function respond(target: PreviewTarget): Promise<RoutedResponse> {
86
+ const port = await portFor(target.id);
87
+ if (!port) return { status: 503, headers: TEXT, body: 'This preview is not running in any open tab.' };
88
+ const reqId = ++nextId;
89
+ return new Promise((resolve) => {
90
+ const timer = setTimeout(() => {
91
+ pending.delete(reqId);
92
+ if (ports.get(target.id) === port) ports.delete(target.id);
93
+ resolve({ status: 504, headers: TEXT, body: 'The preview did not answer in time.' });
94
+ }, timeoutMs);
95
+ pending.set(reqId, (response) => {
96
+ clearTimeout(timer);
97
+ resolve({ status: response.status, headers: response.headers, body: response.body });
98
+ });
99
+ port.postMessage({ type: 'request', reqId, path: target.path, search: target.search });
100
+ });
101
+ }
102
+
103
+ return { attach, match, respond, detach: (id: string) => ports.delete(id) };
104
+ }
105
+
106
+ export type SwRouter = ReturnType<typeof createSwRouter>;