@seanhogg/builderforce-webcontainers 2026.10.1 → 2026.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/assets.d.ts +6 -0
- package/dist/assets.js +6 -0
- package/dist/check/cachedFetch.d.ts +3 -0
- package/dist/check/cachedFetch.d.ts.map +1 -0
- package/dist/check/cachedFetch.js +39 -0
- package/dist/check/cachedFetch.js.map +1 -0
- package/dist/check/index.d.ts +41 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +74 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/protocol.d.ts +29 -0
- package/dist/check/protocol.d.ts.map +1 -0
- package/dist/check/protocol.js +13 -0
- package/dist/check/protocol.js.map +1 -0
- package/dist/check/worker.js +20 -0
- package/dist/componentCompilers.d.ts +21 -0
- package/dist/componentCompilers.d.ts.map +1 -0
- package/dist/componentCompilers.js +35 -0
- package/dist/componentCompilers.js.map +1 -0
- package/dist/esbuildWasm.d.ts +10 -4
- package/dist/esbuildWasm.d.ts.map +1 -1
- package/dist/esbuildWasm.js +14 -6
- package/dist/esbuildWasm.js.map +1 -1
- package/dist/host.d.ts +61 -6
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +61 -42
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +42 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +45 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/processHost.d.ts +65 -0
- package/dist/node/processHost.d.ts.map +1 -0
- package/dist/node/processHost.js +269 -0
- package/dist/node/processHost.js.map +1 -0
- package/dist/node/protocol.d.ts +139 -0
- package/dist/node/protocol.d.ts.map +1 -0
- package/dist/node/protocol.js +5 -0
- package/dist/node/protocol.js.map +1 -0
- package/dist/node/worker.js +197 -0
- package/dist/node/workerProcess.d.ts +22 -0
- package/dist/node/workerProcess.d.ts.map +1 -0
- package/dist/node/workerProcess.js +210 -0
- package/dist/node/workerProcess.js.map +1 -0
- package/dist/packageCache.d.ts +12 -0
- package/dist/packageCache.d.ts.map +1 -0
- package/dist/packageCache.js +28 -0
- package/dist/packageCache.js.map +1 -0
- package/dist/process-worker.js +197 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +25 -0
- package/dist/protocol.js.map +1 -1
- package/dist/relay.html +2 -0
- package/dist/relayWorker.d.ts +19 -0
- package/dist/relayWorker.d.ts.map +1 -0
- package/dist/relayWorker.js +39 -0
- package/dist/relayWorker.js.map +1 -0
- package/dist/sw.js +1 -1
- package/dist/swRouter.d.ts +9 -1
- package/dist/swRouter.d.ts.map +1 -1
- package/dist/swRouter.js +29 -5
- package/dist/swRouter.js.map +1 -1
- package/dist/transport.d.ts +23 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +112 -0
- package/dist/transport.js.map +1 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +29 -0
- package/dist/worker.js.map +1 -0
- package/package.json +17 -3
- package/src/check/cachedFetch.ts +47 -0
- package/src/check/index.ts +98 -0
- package/src/check/protocol.ts +34 -0
- package/src/check/worker.ts +49 -0
- package/src/componentCompilers.ts +66 -0
- package/src/esbuildWasm.ts +19 -7
- package/src/host.ts +125 -42
- package/src/index.ts +18 -4
- package/src/node/index.ts +69 -0
- package/src/node/processHost.ts +307 -0
- package/src/node/protocol.ts +63 -0
- package/src/node/worker.ts +46 -0
- package/src/node/workerProcess.ts +219 -0
- package/src/packageCache.ts +28 -0
- package/src/protocol.ts +33 -0
- package/src/relay.ts +44 -0
- package/src/relayWorker.ts +53 -0
- package/src/sw.ts +25 -6
- package/src/swRouter.ts +36 -5
- package/src/transport.ts +141 -0
- package/src/worker.ts +25 -0
package/src/protocol.ts
CHANGED
|
@@ -6,11 +6,33 @@
|
|
|
6
6
|
|
|
7
7
|
export const PREVIEW_SEGMENT = '__bfwc';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* What a preview id may contain. No dots, so a file served beside the worker
|
|
11
|
+
* (`sw.js`, `relay.html`) can never be mistaken for a preview.
|
|
12
|
+
*/
|
|
13
|
+
export const PREVIEW_ID = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
|
|
9
15
|
/** page → worker, with a MessagePort in `ports[0]`: "serve preview `id` over this port". */
|
|
10
16
|
export const ATTACH = 'bfwc:attach';
|
|
11
17
|
/** worker → pages: "I lost the port for `id` (I was restarted) — attach again". */
|
|
12
18
|
export const REATTACH = 'bfwc:reattach';
|
|
13
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The cross-origin relay (`relay.html`, served on the preview origin and framed,
|
|
22
|
+
* hidden, by the host). The host cannot talk to a worker on another origin, so the
|
|
23
|
+
* relay registers it and passes messages both ways.
|
|
24
|
+
*/
|
|
25
|
+
/** relay → host: the worker is active; send ports. */
|
|
26
|
+
export const RELAY_READY = 'bfwc:relay-ready';
|
|
27
|
+
/** host → relay, with a MessagePort: forward as ATTACH for `id`. */
|
|
28
|
+
export const RELAY_ATTACH = 'bfwc:relay-attach';
|
|
29
|
+
/** relay → host: the worker asked for `id` again. */
|
|
30
|
+
export const RELAY_REATTACH = 'bfwc:relay-reattach';
|
|
31
|
+
/** host → relay: reload every frame showing the preview at `base`. */
|
|
32
|
+
export const RELAY_RELOAD = 'bfwc:relay-reload';
|
|
33
|
+
/** host → relay, with a MessagePort: start a process worker on the preview origin, bridged over the port. */
|
|
34
|
+
export const RELAY_SPAWN = 'bfwc:relay-spawn';
|
|
35
|
+
|
|
14
36
|
export interface AttachMessage {
|
|
15
37
|
type: typeof ATTACH;
|
|
16
38
|
id: string;
|
|
@@ -26,6 +48,17 @@ export interface WireRequest {
|
|
|
26
48
|
reqId: number;
|
|
27
49
|
path: string;
|
|
28
50
|
search: string;
|
|
51
|
+
/** Present for requests to a virtual Node server (`/__port/<n>/…`), which see the full request. */
|
|
52
|
+
method?: string;
|
|
53
|
+
headers?: Record<string, string>;
|
|
54
|
+
body?: ArrayBuffer | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The path segment that routes a preview request to a virtual server: `/__port/3000/…`. */
|
|
58
|
+
export const PORT_SEGMENT = '__port';
|
|
59
|
+
|
|
60
|
+
export function isPortPath(path: string): boolean {
|
|
61
|
+
return path.startsWith(`/${PORT_SEGMENT}/`);
|
|
29
62
|
}
|
|
30
63
|
|
|
31
64
|
export interface WireResponse {
|
package/src/relay.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The relay page's script (bundled inline into dist/relay.html). Served on the
|
|
3
|
+
* preview origin and framed, hidden, by the host page, it registers the preview
|
|
4
|
+
* worker on ITS origin and passes ports, re-attach requests and reloads between
|
|
5
|
+
* the two. See `relayTransport`.
|
|
6
|
+
*
|
|
7
|
+
* Whoever may frame this page may drive it, so the server that hosts relay.html
|
|
8
|
+
* must restrict that with `Content-Security-Policy: frame-ancestors <host origins>`.
|
|
9
|
+
*/
|
|
10
|
+
import { ATTACH, REATTACH, RELAY_ATTACH, RELAY_READY, RELAY_REATTACH, RELAY_RELOAD, RELAY_SPAWN } from './protocol.js';
|
|
11
|
+
import { bridgeWorker, RELAY_PROCESS_WORKER } from './relayWorker.js';
|
|
12
|
+
import { broadcastReload, waitForActive } from './worker.js';
|
|
13
|
+
|
|
14
|
+
const host = window.parent;
|
|
15
|
+
|
|
16
|
+
async function start(): Promise<void> {
|
|
17
|
+
const container = navigator.serviceWorker;
|
|
18
|
+
if (!container) throw new Error('Service workers are unavailable on the preview origin.');
|
|
19
|
+
const registration = await container.register(new URL('./sw.js', location.href).href, { scope: './' });
|
|
20
|
+
await waitForActive(registration);
|
|
21
|
+
|
|
22
|
+
container.addEventListener('message', (event) => {
|
|
23
|
+
if (event.data?.type === REATTACH && typeof event.data.id === 'string') host.postMessage({ type: RELAY_REATTACH, id: event.data.id }, '*');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
addEventListener('message', (event) => {
|
|
27
|
+
if (event.source !== host) return;
|
|
28
|
+
const data = event.data as { type?: string; id?: unknown; base?: unknown } | null;
|
|
29
|
+
if (data?.type === RELAY_ATTACH && typeof data.id === 'string' && event.ports[0]) {
|
|
30
|
+
registration.active?.postMessage({ type: ATTACH, id: data.id }, [event.ports[0]]);
|
|
31
|
+
} else if (data?.type === RELAY_RELOAD && typeof data.base === 'string') {
|
|
32
|
+
broadcastReload(data.base);
|
|
33
|
+
} else if (data?.type === RELAY_SPAWN && event.ports[0]) {
|
|
34
|
+
// Process code runs HERE, on the preview origin, never on the host's.
|
|
35
|
+
bridgeWorker(event.ports[0], new Worker(new URL(`./${RELAY_PROCESS_WORKER}`, location.href), { name: 'bfwc-process' }));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
host.postMessage({ type: RELAY_READY }, '*');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
start().catch((error: unknown) => {
|
|
43
|
+
host.postMessage({ type: RELAY_READY, error: error instanceof Error ? error.message : String(error) }, '*');
|
|
44
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process workers on the PREVIEW origin, driven from the host page.
|
|
3
|
+
*
|
|
4
|
+
* A process worker runs whatever the project runs — npm packages, the user's
|
|
5
|
+
* server, an AI-written script. A worker started by the host page shares the
|
|
6
|
+
* host's origin: its IndexedDB, Cache Storage and same-origin requests. In relay
|
|
7
|
+
* mode that is exactly what the preview origin exists to keep apart, so the
|
|
8
|
+
* relay frame starts each worker on ITS origin and this bridges the two over a
|
|
9
|
+
* MessageChannel. The host side is a `WorkerLike`, so the process host cannot
|
|
10
|
+
* tell the difference.
|
|
11
|
+
*/
|
|
12
|
+
import type { WorkerLike } from './node/processHost.js';
|
|
13
|
+
|
|
14
|
+
type ToWorker = { kind: 'message'; data: unknown } | { kind: 'terminate' };
|
|
15
|
+
type FromWorker = { kind: 'message'; data: unknown } | { kind: 'error'; message: string };
|
|
16
|
+
|
|
17
|
+
/** Host side: a `WorkerLike` whose worker lives at the far end of `port`. */
|
|
18
|
+
export function workerOverPort(port: MessagePort): WorkerLike {
|
|
19
|
+
const worker: WorkerLike = {
|
|
20
|
+
onmessage: null,
|
|
21
|
+
onerror: null,
|
|
22
|
+
postMessage(data) {
|
|
23
|
+
port.postMessage({ kind: 'message', data } satisfies ToWorker);
|
|
24
|
+
},
|
|
25
|
+
terminate() {
|
|
26
|
+
port.postMessage({ kind: 'terminate' } satisfies ToWorker);
|
|
27
|
+
port.close();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
port.onmessage = (event: MessageEvent<FromWorker>) => {
|
|
31
|
+
const message = event.data;
|
|
32
|
+
if (message?.kind === 'message') worker.onmessage?.({ data: message.data });
|
|
33
|
+
else if (message?.kind === 'error') worker.onerror?.({ message: message.message });
|
|
34
|
+
};
|
|
35
|
+
return worker;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Relay side: run `worker`, relaying both ways over `port` until terminated. */
|
|
39
|
+
export function bridgeWorker(port: MessagePort, worker: Worker): void {
|
|
40
|
+
worker.onmessage = (event) => port.postMessage({ kind: 'message', data: event.data } satisfies FromWorker);
|
|
41
|
+
worker.onerror = (event) => port.postMessage({ kind: 'error', message: event.message || 'The process worker failed.' } satisfies FromWorker);
|
|
42
|
+
port.onmessage = (event: MessageEvent<ToWorker>) => {
|
|
43
|
+
const message = event.data;
|
|
44
|
+
if (message?.kind === 'message') worker.postMessage(message.data);
|
|
45
|
+
else if (message?.kind === 'terminate') {
|
|
46
|
+
worker.terminate();
|
|
47
|
+
port.close();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The process worker's file name beside `relay.html` on the preview origin. */
|
|
53
|
+
export const RELAY_PROCESS_WORKER = 'process-worker.js';
|
package/src/sw.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* Worker globals are typed locally rather than through `lib.webworker`, which
|
|
7
7
|
* cannot share a compilation with the DOM lib the rest of the package needs.
|
|
8
8
|
*/
|
|
9
|
-
import { createSwRouter, type PortLike } from './swRouter.js';
|
|
10
|
-
import { ATTACH, REATTACH, type AttachMessage } from './protocol.js';
|
|
9
|
+
import { createSwRouter, type PortLike, type RequestDetails } from './swRouter.js';
|
|
10
|
+
import { ATTACH, REATTACH, isPortPath, type AttachMessage } from './protocol.js';
|
|
11
11
|
|
|
12
12
|
interface WindowClientLike {
|
|
13
13
|
postMessage(message: unknown): void;
|
|
@@ -25,7 +25,7 @@ interface ServiceWorkerGlobalLike {
|
|
|
25
25
|
addEventListener(type: 'message', listener: (event: { data: unknown; ports: readonly unknown[] }) => void): void;
|
|
26
26
|
addEventListener(
|
|
27
27
|
type: 'fetch',
|
|
28
|
-
listener: (event: { request: Request; respondWith(response: Promise<Response>): void }) => void,
|
|
28
|
+
listener: (event: { request: Request; clientId?: string; resultingClientId?: string; respondWith(response: Promise<Response>): void }) => void,
|
|
29
29
|
): void;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -53,9 +53,28 @@ worker.addEventListener('message', (event) => {
|
|
|
53
53
|
if (message?.type === ATTACH && typeof message.id === 'string' && port) router.attach(message.id, port as PortLike);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
/** Statuses whose responses must not carry a body. */
|
|
57
|
+
const NULL_BODY = new Set([101, 204, 205, 304]);
|
|
58
|
+
|
|
56
59
|
worker.addEventListener('fetch', (event) => {
|
|
57
|
-
|
|
58
|
-
const target = router.
|
|
60
|
+
const { request } = event;
|
|
61
|
+
const target = router.matchForClient(new URL(request.url), event.clientId, new URL(worker.registration.scope).origin);
|
|
59
62
|
if (!target) return;
|
|
60
|
-
|
|
63
|
+
const toServer = isPortPath(target.path);
|
|
64
|
+
// Only virtual Node servers take non-GET requests; the dev server serves files.
|
|
65
|
+
if (request.method !== 'GET' && !toServer) return;
|
|
66
|
+
if (toServer) router.rememberClient(event.resultingClientId, target);
|
|
67
|
+
event.respondWith(
|
|
68
|
+
(async () => {
|
|
69
|
+
let details: RequestDetails | undefined;
|
|
70
|
+
if (toServer) {
|
|
71
|
+
const headers: Record<string, string> = {};
|
|
72
|
+
request.headers.forEach((value, key) => (headers[key] = value));
|
|
73
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? null : await request.arrayBuffer();
|
|
74
|
+
details = { method: request.method, headers, body };
|
|
75
|
+
}
|
|
76
|
+
const r = await router.respond(target, details);
|
|
77
|
+
return new Response(NULL_BODY.has(r.status) ? null : r.body, { status: r.status, headers: r.headers });
|
|
78
|
+
})(),
|
|
79
|
+
);
|
|
61
80
|
});
|
package/src/swRouter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isWireResponse, previewPrefix, type WireResponse } from './protocol.js';
|
|
1
|
+
import { PREVIEW_ID, isPortPath, isWireResponse, previewPrefix, type WireResponse } from './protocol.js';
|
|
2
2
|
|
|
3
3
|
/** The part of MessagePort the router uses — a fake in tests, a real port in the worker. */
|
|
4
4
|
export interface PortLike {
|
|
@@ -12,6 +12,13 @@ export interface PreviewTarget {
|
|
|
12
12
|
search: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** The parts of a request a virtual server needs (method, headers, body). */
|
|
16
|
+
export interface RequestDetails {
|
|
17
|
+
method: string;
|
|
18
|
+
headers: Record<string, string>;
|
|
19
|
+
body: ArrayBuffer | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export interface RoutedResponse {
|
|
16
23
|
status: number;
|
|
17
24
|
headers: Record<string, string>;
|
|
@@ -65,7 +72,9 @@ export function createSwRouter(options: SwRouterOptions) {
|
|
|
65
72
|
const rest = url.pathname.slice(prefix.length);
|
|
66
73
|
const slash = rest.indexOf('/');
|
|
67
74
|
const id = slash < 0 ? rest : rest.slice(0, slash);
|
|
68
|
-
|
|
75
|
+
// Ids are `[A-Za-z0-9_-]`; a dotted segment is a real file beside the worker
|
|
76
|
+
// (`sw.js`, `relay.html`), which must load from the network.
|
|
77
|
+
if (!id || !PREVIEW_ID.test(id)) return null;
|
|
69
78
|
return { id, path: slash < 0 ? '/' : rest.slice(slash), search: url.search };
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -82,7 +91,29 @@ export function createSwRouter(options: SwRouterOptions) {
|
|
|
82
91
|
return arrived;
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Pages served by a virtual server (`/__bfwc/<id>/__port/3000/`) write absolute
|
|
96
|
+
* URLs (`/style.css`, `fetch('/api')`). A service worker sees every request its
|
|
97
|
+
* clients make, so a same-origin request from such a page that falls outside
|
|
98
|
+
* the preview prefix is mapped back under that server's path.
|
|
99
|
+
*/
|
|
100
|
+
const clientServers = new Map<string, { id: string; prefix: string }>();
|
|
101
|
+
|
|
102
|
+
function rememberClient(clientId: string | undefined, target: PreviewTarget): void {
|
|
103
|
+
if (!clientId || !isPortPath(target.path)) return;
|
|
104
|
+
const prefix = /^\/__port\/\d+/.exec(target.path)![0];
|
|
105
|
+
clientServers.set(clientId, { id: target.id, prefix });
|
|
106
|
+
if (clientServers.size > 200) clientServers.delete(clientServers.keys().next().value!);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function matchForClient(url: URL, clientId: string | undefined, origin: string): PreviewTarget | null {
|
|
110
|
+
const direct = match(url);
|
|
111
|
+
if (direct || !clientId || url.origin !== origin) return direct;
|
|
112
|
+
const server = clientServers.get(clientId);
|
|
113
|
+
return server ? { id: server.id, path: server.prefix + url.pathname, search: url.search } : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function respond(target: PreviewTarget, details?: RequestDetails): Promise<RoutedResponse> {
|
|
86
117
|
const port = await portFor(target.id);
|
|
87
118
|
if (!port) return { status: 503, headers: TEXT, body: 'This preview is not running in any open tab.' };
|
|
88
119
|
const reqId = ++nextId;
|
|
@@ -96,11 +127,11 @@ export function createSwRouter(options: SwRouterOptions) {
|
|
|
96
127
|
clearTimeout(timer);
|
|
97
128
|
resolve({ status: response.status, headers: response.headers, body: response.body });
|
|
98
129
|
});
|
|
99
|
-
port.postMessage({ type: 'request', reqId, path: target.path, search: target.search });
|
|
130
|
+
port.postMessage({ type: 'request', reqId, path: target.path, search: target.search, ...details });
|
|
100
131
|
});
|
|
101
132
|
}
|
|
102
133
|
|
|
103
|
-
return { attach, match, respond, detach: (id: string) => ports.delete(id) };
|
|
134
|
+
return { attach, match, matchForClient, rememberClient, respond, detach: (id: string) => ports.delete(id) };
|
|
104
135
|
}
|
|
105
136
|
|
|
106
137
|
export type SwRouter = ReturnType<typeof createSwRouter>;
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the host page reaches the preview service worker.
|
|
3
|
+
*
|
|
4
|
+
* Same origin: the page registers the worker itself. Simple, but the preview then
|
|
5
|
+
* runs on the host's origin, with the host's storage and session — fine for a
|
|
6
|
+
* page that only previews code its own user wrote and trusts.
|
|
7
|
+
*
|
|
8
|
+
* Relay: the worker lives on a SEPARATE preview origin. A hidden `relay.html` on
|
|
9
|
+
* that origin registers it and passes ports and reloads across. The preview can
|
|
10
|
+
* then never read the host's cookies, storage or DOM, which is what an editor
|
|
11
|
+
* running AI-written code or arbitrary npm packages needs.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
ATTACH,
|
|
15
|
+
REATTACH,
|
|
16
|
+
RELAY_ATTACH,
|
|
17
|
+
RELAY_READY,
|
|
18
|
+
RELAY_REATTACH,
|
|
19
|
+
RELAY_RELOAD,
|
|
20
|
+
RELAY_SPAWN,
|
|
21
|
+
} from './protocol.js';
|
|
22
|
+
import type { WorkerLike } from './node/processHost.js';
|
|
23
|
+
import { workerOverPort } from './relayWorker.js';
|
|
24
|
+
import { broadcastReload, waitForActive } from './worker.js';
|
|
25
|
+
|
|
26
|
+
export interface PreviewTransport {
|
|
27
|
+
/** The origin previews are served from. */
|
|
28
|
+
readonly origin: string;
|
|
29
|
+
/** The worker's scope path on that origin. */
|
|
30
|
+
readonly scope: string;
|
|
31
|
+
/** Hand the worker the port that serves preview `id`. */
|
|
32
|
+
attach(id: string, port: MessagePort): void;
|
|
33
|
+
/** The worker restarted and wants `id` again. */
|
|
34
|
+
onReattach(listener: (id: string) => void): () => void;
|
|
35
|
+
/** Reload every frame showing the preview at `base`. */
|
|
36
|
+
reload(base: string): void;
|
|
37
|
+
/**
|
|
38
|
+
* Start a process worker on the preview origin. Only the relay has one to
|
|
39
|
+
* offer; same-origin mode runs processes on the host's origin, which is the
|
|
40
|
+
* trust it already chose.
|
|
41
|
+
*/
|
|
42
|
+
createWorker?(): WorkerLike;
|
|
43
|
+
dispose(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How long the relay has to register its worker before boot gives up. */
|
|
47
|
+
const RELAY_TIMEOUT_MS = 15_000;
|
|
48
|
+
|
|
49
|
+
export async function sameOriginTransport(serviceWorkerUrl: string, scopeOption?: string): Promise<PreviewTransport> {
|
|
50
|
+
const container = navigator.serviceWorker;
|
|
51
|
+
if (!container) throw new Error('Service workers are unavailable here (a secure context — https or localhost — is required).');
|
|
52
|
+
|
|
53
|
+
const scriptUrl = new URL(serviceWorkerUrl, location.href);
|
|
54
|
+
const scope = new URL(scopeOption ?? './', scriptUrl).pathname;
|
|
55
|
+
const registration = await container.register(scriptUrl.href, { scope });
|
|
56
|
+
await waitForActive(registration);
|
|
57
|
+
|
|
58
|
+
const listeners = new Set<(id: string) => void>();
|
|
59
|
+
const onWorkerMessage = (event: MessageEvent) => {
|
|
60
|
+
if (event.data?.type === REATTACH && typeof event.data.id === 'string') for (const listener of listeners) listener(event.data.id);
|
|
61
|
+
};
|
|
62
|
+
container.addEventListener('message', onWorkerMessage);
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
origin: location.origin,
|
|
66
|
+
scope,
|
|
67
|
+
attach: (id, port) => registration.active?.postMessage({ type: ATTACH, id }, [port]),
|
|
68
|
+
onReattach(listener) {
|
|
69
|
+
listeners.add(listener);
|
|
70
|
+
return () => listeners.delete(listener);
|
|
71
|
+
},
|
|
72
|
+
reload: broadcastReload,
|
|
73
|
+
dispose() {
|
|
74
|
+
container.removeEventListener('message', onWorkerMessage);
|
|
75
|
+
listeners.clear();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function relayTransport(relayUrl: string): Promise<PreviewTransport> {
|
|
81
|
+
const url = new URL(relayUrl, location.href);
|
|
82
|
+
const frame = document.createElement('iframe');
|
|
83
|
+
frame.src = url.href;
|
|
84
|
+
frame.title = 'Preview relay';
|
|
85
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
86
|
+
frame.tabIndex = -1;
|
|
87
|
+
frame.style.cssText = 'position:absolute;width:0;height:0;border:0;visibility:hidden';
|
|
88
|
+
|
|
89
|
+
const fromRelay = (event: MessageEvent) => event.origin === url.origin && event.source === frame.contentWindow;
|
|
90
|
+
|
|
91
|
+
const ready = new Promise<void>((resolve, reject) => {
|
|
92
|
+
const timer = setTimeout(() => {
|
|
93
|
+
window.removeEventListener('message', onReady);
|
|
94
|
+
reject(new Error(`The preview relay at ${url.origin} did not start.`));
|
|
95
|
+
}, RELAY_TIMEOUT_MS);
|
|
96
|
+
function onReady(event: MessageEvent) {
|
|
97
|
+
if (!fromRelay(event) || event.data?.type !== RELAY_READY) return;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
window.removeEventListener('message', onReady);
|
|
100
|
+
if (typeof event.data.error === 'string') reject(new Error(event.data.error));
|
|
101
|
+
else resolve();
|
|
102
|
+
}
|
|
103
|
+
window.addEventListener('message', onReady);
|
|
104
|
+
});
|
|
105
|
+
document.body.appendChild(frame);
|
|
106
|
+
try {
|
|
107
|
+
await ready;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
frame.remove();
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const listeners = new Set<(id: string) => void>();
|
|
114
|
+
const onRelayMessage = (event: MessageEvent) => {
|
|
115
|
+
if (!fromRelay(event) || event.data?.type !== RELAY_REATTACH || typeof event.data.id !== 'string') return;
|
|
116
|
+
for (const listener of listeners) listener(event.data.id);
|
|
117
|
+
};
|
|
118
|
+
window.addEventListener('message', onRelayMessage);
|
|
119
|
+
const post = (message: unknown, transfer: Transferable[] = []) => frame.contentWindow?.postMessage(message, url.origin, transfer);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
origin: url.origin,
|
|
123
|
+
scope: new URL('./', url).pathname,
|
|
124
|
+
attach: (id, port) => post({ type: RELAY_ATTACH, id }, [port]),
|
|
125
|
+
onReattach(listener) {
|
|
126
|
+
listeners.add(listener);
|
|
127
|
+
return () => listeners.delete(listener);
|
|
128
|
+
},
|
|
129
|
+
reload: (base) => post({ type: RELAY_RELOAD, base }),
|
|
130
|
+
createWorker() {
|
|
131
|
+
const channel = new MessageChannel();
|
|
132
|
+
post({ type: RELAY_SPAWN }, [channel.port2]);
|
|
133
|
+
return workerOverPort(channel.port1);
|
|
134
|
+
},
|
|
135
|
+
dispose() {
|
|
136
|
+
window.removeEventListener('message', onRelayMessage);
|
|
137
|
+
listeners.clear();
|
|
138
|
+
frame.remove();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small helpers shared by the page-side transports and the relay page.
|
|
3
|
+
*/
|
|
4
|
+
import { reloadChannelName } from '@seanhogg/builderforce-webcontainers-core';
|
|
5
|
+
|
|
6
|
+
/** Resolve once the registration has an active worker. */
|
|
7
|
+
export function waitForActive(registration: ServiceWorkerRegistration): Promise<ServiceWorker> {
|
|
8
|
+
if (registration.active) return Promise.resolve(registration.active);
|
|
9
|
+
const installing = registration.installing ?? registration.waiting;
|
|
10
|
+
if (!installing) return Promise.reject(new Error('The preview service worker did not install.'));
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
installing.addEventListener('statechange', () => {
|
|
13
|
+
if (installing.state === 'activated' && registration.active) resolve(registration.active);
|
|
14
|
+
if (installing.state === 'redundant') reject(new Error('The preview service worker failed to install.'));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reload every frame (on this origin) showing the preview served at `base`. */
|
|
20
|
+
export function broadcastReload(base: string): void {
|
|
21
|
+
if (typeof BroadcastChannel === 'undefined') return;
|
|
22
|
+
const channel = new BroadcastChannel(reloadChannelName(base));
|
|
23
|
+
channel.postMessage('reload');
|
|
24
|
+
channel.close();
|
|
25
|
+
}
|