@seanhogg/builderforce-webcontainers 2026.10.6 → 2026.10.8

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.
Files changed (72) hide show
  1. package/dist/assets.js +3 -3
  2. package/dist/check/worker.js +8 -8
  3. package/dist/host.d.ts +25 -9
  4. package/dist/host.d.ts.map +1 -1
  5. package/dist/host.js +39 -25
  6. package/dist/host.js.map +1 -1
  7. package/dist/index.d.ts +9 -3
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/node/index.d.ts +5 -9
  12. package/dist/node/index.d.ts.map +1 -1
  13. package/dist/node/index.js +1 -25
  14. package/dist/node/index.js.map +1 -1
  15. package/dist/node/portResponder.d.ts +45 -0
  16. package/dist/node/portResponder.d.ts.map +1 -0
  17. package/dist/node/portResponder.js +136 -0
  18. package/dist/node/portResponder.js.map +1 -0
  19. package/dist/node/processHost.d.ts +26 -2
  20. package/dist/node/processHost.d.ts.map +1 -1
  21. package/dist/node/processHost.js +67 -16
  22. package/dist/node/processHost.js.map +1 -1
  23. package/dist/node/protocol.d.ts +42 -9
  24. package/dist/node/protocol.d.ts.map +1 -1
  25. package/dist/node/protocol.js.map +1 -1
  26. package/dist/node/responseWire.d.ts +27 -0
  27. package/dist/node/responseWire.d.ts.map +1 -0
  28. package/dist/node/responseWire.js +99 -0
  29. package/dist/node/responseWire.js.map +1 -0
  30. package/dist/node/worker.js +143 -127
  31. package/dist/node/workerProcess.d.ts +3 -1
  32. package/dist/node/workerProcess.d.ts.map +1 -1
  33. package/dist/node/workerProcess.js +49 -19
  34. package/dist/node/workerProcess.js.map +1 -1
  35. package/dist/previewBridge.d.ts +37 -0
  36. package/dist/previewBridge.d.ts.map +1 -0
  37. package/dist/previewBridge.js +116 -0
  38. package/dist/previewBridge.js.map +1 -0
  39. package/dist/previewMessages.d.ts +24 -0
  40. package/dist/previewMessages.d.ts.map +1 -0
  41. package/dist/previewMessages.js +59 -0
  42. package/dist/previewMessages.js.map +1 -0
  43. package/dist/process-worker.js +143 -127
  44. package/dist/protocol.d.ts +23 -1
  45. package/dist/protocol.d.ts.map +1 -1
  46. package/dist/protocol.js +8 -0
  47. package/dist/protocol.js.map +1 -1
  48. package/dist/relay.html +1 -1
  49. package/dist/snapshotStore.d.ts +30 -0
  50. package/dist/snapshotStore.d.ts.map +1 -0
  51. package/dist/snapshotStore.js +115 -0
  52. package/dist/snapshotStore.js.map +1 -0
  53. package/dist/sw.js +1 -1
  54. package/dist/swRouter.d.ts +8 -1
  55. package/dist/swRouter.d.ts.map +1 -1
  56. package/dist/swRouter.js +40 -5
  57. package/dist/swRouter.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/host.ts +62 -31
  60. package/src/index.ts +8 -3
  61. package/src/node/index.ts +5 -24
  62. package/src/node/portResponder.ts +175 -0
  63. package/src/node/processHost.ts +92 -15
  64. package/src/node/protocol.ts +25 -4
  65. package/src/node/responseWire.ts +97 -0
  66. package/src/node/workerProcess.ts +52 -21
  67. package/src/previewBridge.ts +148 -0
  68. package/src/previewMessages.ts +79 -0
  69. package/src/protocol.ts +35 -1
  70. package/src/snapshotStore.ts +135 -0
  71. package/src/sw.ts +2 -0
  72. package/src/swRouter.ts +35 -6
@@ -1,25 +1,31 @@
1
1
  /**
2
2
  * The worker side of one process: mirror the file system, build a `System`
3
3
  * whose spawn/listen/loopback are relayed to the page, run the program, and
4
- * stream its output back. No worker globals in here — `post` and the message
4
+ * stream its output back. Its servers' responses stream to the page as they
5
+ * are written, and connections the page upgrades reach the server's `upgrade`
6
+ * handler as a byte pipe. No worker globals in here — `post` and the message
5
7
  * feed are injected — so the same code runs in a real Worker (worker.ts) and
6
8
  * in-thread in tests.
7
9
  */
8
10
  import {
9
11
  VirtualFileSystem,
10
12
  commandNotFound,
13
+ createChannelPair,
11
14
  defaultPrograms,
12
15
  resolveCommand,
13
16
  runProgram,
17
+ textResponse,
18
+ type ByteChannel,
14
19
  type ChildHandle,
15
20
  type FsChange,
16
21
  type HttpHandler,
17
- type HttpResponseData,
18
22
  type PackageCache,
19
23
  type System,
24
+ type UpgradeHandler,
20
25
  } from '@seanhogg/builderforce-webcontainers-core';
21
26
  import type { FsOp, HostToWorker, StartMessage, WorkerToHost } from './protocol.js';
22
27
  import { portPath } from './protocol.js';
28
+ import { ResponseReceiver, sendResponse } from './responseWire.js';
23
29
 
24
30
  export interface WorkerProcessDeps {
25
31
  post(message: WorkerToHost): void;
@@ -35,10 +41,15 @@ const EPHEMERAL = 49152;
35
41
 
36
42
  export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message: HostToWorker): void } {
37
43
  const fs = new VirtualFileSystem();
38
- const handlers = new Map<number, HttpHandler>();
44
+ const handlers = new Map<number, { handler: HttpHandler; upgrade?: UpgradeHandler }>();
39
45
  const knownPorts = new Set<number>();
40
46
  const children = new Map<number, { stdout: Set<(s: string) => void>; stderr: Set<(s: string) => void>; exit: (code: number) => void }>();
41
- const loopbacks = new Map<number, (response: HttpResponseData | undefined) => void>();
47
+ /** Responses to this process's loopback requests, streaming in from the page. */
48
+ const loopbacks = new ResponseReceiver();
49
+ /** Responses this process's servers are streaming to the page, by the page's request id. */
50
+ const serving = new Map<number, () => void>();
51
+ /** Upgraded connections the page opened, by its connection id (our end of the pipe to the server). */
52
+ const sockets = new Map<number, ByteChannel>();
42
53
  let nextChild = 1;
43
54
  let nextReq = 1;
44
55
  let applying = false;
@@ -117,17 +128,18 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
117
128
  if (!resolveCommand(fs, programs, command, args, cwd, env)) throw commandNotFound(command);
118
129
  return remoteChild(command, args, { ...options, cwd, env });
119
130
  },
120
- listen(port, handler) {
131
+ listen(port, handler, upgrade) {
121
132
  let actual = port;
122
133
  if (!actual) for (actual = EPHEMERAL + (message.pid % 1000) * 8; knownPorts.has(actual); actual++);
123
134
  if (knownPorts.has(actual)) throw Object.assign(new Error(`listen EADDRINUSE: address already in use :::${actual}`), { code: 'EADDRINUSE', errno: -98, syscall: 'listen', port: actual });
124
135
  knownPorts.add(actual);
125
- handlers.set(actual, handler);
136
+ const entry = { handler, upgrade };
137
+ handlers.set(actual, entry);
126
138
  deps.post({ type: 'listen', port: actual });
127
139
  return {
128
140
  port: actual,
129
141
  close() {
130
- if (handlers.get(actual) !== handler) return;
142
+ if (handlers.get(actual) !== entry) return;
131
143
  handlers.delete(actual);
132
144
  knownPorts.delete(actual);
133
145
  deps.post({ type: 'unlisten', port: actual });
@@ -136,12 +148,11 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
136
148
  },
137
149
  loopback(port, request) {
138
150
  const local = handlers.get(port);
139
- if (local) return local(request);
151
+ if (local) return local.handler(request);
140
152
  const reqId = nextReq++;
141
- return new Promise((resolve) => {
142
- loopbacks.set(reqId, resolve);
143
- deps.post({ type: 'loopback', reqId, port, request });
144
- });
153
+ const response = loopbacks.expect(reqId, () => deps.post({ type: 'request-cancel', reqId }));
154
+ deps.post({ type: 'loopback', reqId, port, request });
155
+ return response;
145
156
  },
146
157
  serverUrl: (port) => new URL(portPath(message.previewBase, port), message.previewOrigin).href,
147
158
  announceServer: (port, url) => deps.post({ type: 'listening', port, url }),
@@ -201,17 +212,37 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
201
212
  return;
202
213
  }
203
214
  case 'http-request': {
204
- const handler = handlers.get(message.port);
205
- const respond = (response: HttpResponseData) => deps.post({ type: 'http-response', reqId: message.reqId, response });
206
- if (!handler) return respond({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode(`Nothing is listening on port ${message.port}.`) });
207
- void handler(message.request).then(respond, (error: unknown) =>
208
- respond({ status: 500, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode(String(error instanceof Error ? error.stack : error)) }),
209
- );
215
+ const { reqId } = message;
216
+ const entry = handlers.get(message.port);
217
+ const response = entry ? entry.handler(message.request) : Promise.resolve(textResponse(502, `Nothing is listening on port ${message.port}.`));
218
+ serving.set(reqId, sendResponse(deps.post, reqId, response, () => serving.delete(reqId)));
210
219
  return;
211
220
  }
212
- case 'loopback-response':
213
- loopbacks.get(message.reqId)?.(message.response ?? undefined);
214
- loopbacks.delete(message.reqId);
221
+ case 'request-cancel':
222
+ return serving.get(message.reqId)?.();
223
+ case 'response-head':
224
+ case 'response-chunk':
225
+ case 'response-end':
226
+ return loopbacks.receive(message);
227
+ case 'socket-open': {
228
+ const { connId } = message;
229
+ const upgrade = handlers.get(message.port)?.upgrade;
230
+ if (!upgrade) return deps.post({ type: 'socket-close', connId });
231
+ const [ours, server] = createChannelPair();
232
+ sockets.set(connId, ours);
233
+ ours.onData((data) => deps.post({ type: 'socket-data', connId, data }));
234
+ ours.onClose(() => {
235
+ sockets.delete(connId);
236
+ deps.post({ type: 'socket-close', connId });
237
+ });
238
+ upgrade(message.request, server);
239
+ return;
240
+ }
241
+ case 'socket-data':
242
+ return sockets.get(message.connId)?.send(message.data);
243
+ case 'socket-close':
244
+ sockets.get(message.connId)?.close();
245
+ sockets.delete(message.connId);
215
246
  return;
216
247
  }
217
248
  },
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The host end of the preview bridge (see core's previewBridge.ts for the page
3
+ * end). A service worker cannot carry a WebSocket or set a cookie, so preview
4
+ * pages ask this page instead, each over a MessagePort they transfer:
5
+ *
6
+ * • `SOCKET_MESSAGE_TYPE` — connect a WebSocket to the virtual server on a
7
+ * port. The handshake and frames run over an upgraded connection to the
8
+ * server (`connectWebSocket` over `ProcessHost.upgrade`), carrying the
9
+ * preview's cookies for that port; messages relay both ways on the port.
10
+ * • `COOKIE_MESSAGE_TYPE` — keep a virtual-server page's `document.cookie`
11
+ * in step with the jar, and store what the page assigns.
12
+ *
13
+ * One window listener per runtime, accepting only messages from the preview
14
+ * origin whose page sits under this runtime's base (as `listenToPreview` does).
15
+ */
16
+ import {
17
+ COOKIE_MESSAGE_TYPE,
18
+ SOCKET_MESSAGE_TYPE,
19
+ connectWebSocket,
20
+ parsePortPath,
21
+ type ByteChannel,
22
+ type HttpRequestData,
23
+ type PortCookieJars,
24
+ type WebSocketConnection,
25
+ } from '@seanhogg/builderforce-webcontainers-core';
26
+ import { previewPageOf } from './previewMessages.js';
27
+
28
+ export interface PreviewBridgeOptions {
29
+ /** Open an upgraded connection to the server on `port` (`ProcessHost.upgrade`). */
30
+ upgrade(port: number, request: HttpRequestData): ByteChannel | undefined;
31
+ /** The runtime's cookie jars: sent with WebSocket handshakes and mirrored into `document.cookie`. */
32
+ cookies?: PortCookieJars;
33
+ }
34
+
35
+ /** The slice of MessagePort the bridge uses. */
36
+ export interface BridgePort {
37
+ postMessage(message: unknown, transfer?: Transferable[]): void;
38
+ onmessage: ((event: { data: unknown }) => void) | null;
39
+ close(): void;
40
+ }
41
+
42
+ export interface PreviewBridge {
43
+ /** WebSockets currently open through the bridge. */
44
+ readonly openSockets: number;
45
+ dispose(): void;
46
+ }
47
+
48
+ /** Pages kept in sync per server port; older ones (navigated away, never said so) are dropped past this. */
49
+ const COOKIE_PAGES_PER_PORT = 50;
50
+
51
+ interface BridgeMessage {
52
+ type?: unknown;
53
+ href?: unknown;
54
+ port?: unknown;
55
+ path?: unknown;
56
+ protocols?: unknown;
57
+ }
58
+
59
+ export function listenToPreviewBridge(
60
+ target: Pick<Window, 'addEventListener' | 'removeEventListener'>,
61
+ origin: string,
62
+ base: string,
63
+ options: PreviewBridgeOptions,
64
+ ): PreviewBridge {
65
+ const sockets = new Set<WebSocketConnection>();
66
+ const cookiePages = new Map<number, Array<{ port: BridgePort; path: string }>>();
67
+
68
+ function openSocket(channel: BridgePort, data: BridgeMessage, page: URL): void {
69
+ const port = Number(data.port);
70
+ const path = typeof data.path === 'string' && data.path.startsWith('/') ? data.path : undefined;
71
+ const protocols = Array.isArray(data.protocols) ? data.protocols.filter((p): p is string => typeof p === 'string') : [];
72
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || !path) {
73
+ channel.postMessage({ kind: 'close', code: 1006, reason: '', wasClean: false });
74
+ return channel.close();
75
+ }
76
+ const headers: Record<string, string> = { host: `localhost:${port}`, origin: page.origin };
77
+ const cookie = options.cookies?.jar(port).header(path);
78
+ if (cookie) headers.cookie = cookie;
79
+ if (typeof navigator !== 'undefined' && navigator.userAgent) headers['user-agent'] = navigator.userAgent;
80
+ const connection = connectWebSocket((request) => options.upgrade(port, request), { path, protocols, headers });
81
+ sockets.add(connection);
82
+ connection.onOpen(({ protocol }) => channel.postMessage({ kind: 'open', protocol }));
83
+ connection.onMessage((message) => {
84
+ if (typeof message === 'string') return channel.postMessage({ kind: 'message', data: message });
85
+ const buffer = new Uint8Array(message).buffer as ArrayBuffer;
86
+ channel.postMessage({ kind: 'message', data: buffer }, [buffer]);
87
+ });
88
+ connection.onClose((info) => {
89
+ sockets.delete(connection);
90
+ channel.postMessage({ kind: 'close', ...info });
91
+ channel.close();
92
+ });
93
+ channel.onmessage = (event) => {
94
+ const message = event.data as { kind?: unknown; data?: unknown; code?: unknown; reason?: unknown } | null;
95
+ if (message?.kind === 'send') {
96
+ if (typeof message.data === 'string') connection.send(message.data);
97
+ else if (message.data instanceof ArrayBuffer) connection.send(new Uint8Array(message.data));
98
+ } else if (message?.kind === 'close') {
99
+ connection.close(typeof message.code === 'number' ? message.code : undefined, typeof message.reason === 'string' ? message.reason : undefined);
100
+ }
101
+ };
102
+ }
103
+
104
+ function syncCookies(channel: BridgePort, page: URL): void {
105
+ const cookies = options.cookies;
106
+ const server = parsePortPath(page.pathname.slice(base.length - 1));
107
+ if (!cookies || !server) return channel.close();
108
+ const pages = cookiePages.get(server.port) ?? [];
109
+ pages.push({ port: channel, path: server.path });
110
+ if (pages.length > COOKIE_PAGES_PER_PORT) pages.shift()?.port.close();
111
+ cookiePages.set(server.port, pages);
112
+ channel.onmessage = (event) => {
113
+ const message = event.data as { kind?: unknown; value?: unknown } | null;
114
+ if (message?.kind === 'set-cookie' && typeof message.value === 'string') cookies.jar(server.port).setFromDocument(message.value, server.path);
115
+ };
116
+ // The page started from the jar as it was when served; catch up on anything since.
117
+ channel.postMessage({ kind: 'cookies', value: cookies.jar(server.port).documentCookie(server.path) });
118
+ }
119
+
120
+ const stopCookies = options.cookies?.onChange((port) => {
121
+ const jar = options.cookies!.jar(port);
122
+ for (const page of cookiePages.get(port) ?? []) page.port.postMessage({ kind: 'cookies', value: jar.documentCookie(page.path) });
123
+ });
124
+
125
+ const onMessage = (event: MessageEvent) => {
126
+ const data = event.data as BridgeMessage | null;
127
+ const channel = event.ports?.[0] as BridgePort | undefined;
128
+ const page = channel && data ? previewPageOf(event, origin, base) : undefined;
129
+ if (!page) return;
130
+ if (data!.type === SOCKET_MESSAGE_TYPE) openSocket(channel!, data!, page);
131
+ else if (data!.type === COOKIE_MESSAGE_TYPE) syncCookies(channel!, page);
132
+ };
133
+ target.addEventListener('message', onMessage as EventListener);
134
+
135
+ return {
136
+ get openSockets() {
137
+ return sockets.size;
138
+ },
139
+ dispose() {
140
+ target.removeEventListener('message', onMessage as EventListener);
141
+ stopCookies?.();
142
+ for (const connection of [...sockets]) connection.close(1001, 'The runtime stopped.');
143
+ sockets.clear();
144
+ for (const pages of cookiePages.values()) for (const page of pages) page.port.close();
145
+ cookiePages.clear();
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * What a preview reports to the host page: uncaught errors and console output,
3
+ * posted by the bridge script every served page starts with. One window
4
+ * listener per runtime, accepting only messages from the preview origin whose
5
+ * page sits under this runtime's base, so two runtimes on one page never hear
6
+ * each other's previews.
7
+ */
8
+ import { CONSOLE_MESSAGE_TYPE, ERROR_MESSAGE_TYPE } from '@seanhogg/builderforce-webcontainers-core';
9
+
10
+ export interface PreviewError {
11
+ message: string;
12
+ stack?: string;
13
+ href: string;
14
+ }
15
+
16
+ export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
17
+
18
+ export interface PreviewConsoleMessage {
19
+ level: ConsoleLevel;
20
+ /** The arguments flattened to text, capped at 4,000 characters. */
21
+ text: string;
22
+ href: string;
23
+ }
24
+
25
+ export interface PreviewMessages {
26
+ onError(listener: (error: PreviewError) => void): () => void;
27
+ onConsole(listener: (message: PreviewConsoleMessage) => void): () => void;
28
+ dispose(): void;
29
+ }
30
+
31
+ const LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug']);
32
+
33
+ /**
34
+ * The preview page a window message came from, when it is one of THIS runtime's:
35
+ * sent from the preview origin, by a page under `base`. Undefined otherwise.
36
+ */
37
+ export function previewPageOf(event: MessageEvent, origin: string, base: string): URL | undefined {
38
+ const href = (event.data as { href?: unknown } | null)?.href;
39
+ if (typeof href !== 'string' || event.origin !== origin) return undefined;
40
+ try {
41
+ const page = new URL(href);
42
+ return page.pathname.startsWith(base) ? page : undefined; // else: another runtime's preview
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ function subscribe<T>(set: Set<(value: T) => void>, listener: (value: T) => void): () => void {
49
+ set.add(listener);
50
+ return () => set.delete(listener);
51
+ }
52
+
53
+ export function listenToPreview(target: Pick<Window, 'addEventListener' | 'removeEventListener'>, origin: string, base: string): PreviewMessages {
54
+ const errors = new Set<(error: PreviewError) => void>();
55
+ const consoles = new Set<(message: PreviewConsoleMessage) => void>();
56
+
57
+ const onMessage = (event: MessageEvent) => {
58
+ if (!previewPageOf(event, origin, base)) return;
59
+ const data = event.data as { type?: unknown; href: string; message?: unknown; stack?: unknown; level?: unknown; text?: unknown };
60
+ if (data.type === ERROR_MESSAGE_TYPE) {
61
+ const error: PreviewError = { message: String(data.message ?? ''), stack: typeof data.stack === 'string' ? data.stack : undefined, href: data.href };
62
+ for (const listener of errors) listener(error);
63
+ } else if (data.type === CONSOLE_MESSAGE_TYPE && typeof data.level === 'string' && LEVELS.has(data.level)) {
64
+ const message: PreviewConsoleMessage = { level: data.level as ConsoleLevel, text: String(data.text ?? ''), href: data.href };
65
+ for (const listener of consoles) listener(message);
66
+ }
67
+ };
68
+ target.addEventListener('message', onMessage as EventListener);
69
+
70
+ return {
71
+ onError: (listener) => subscribe(errors, listener),
72
+ onConsole: (listener) => subscribe(consoles, listener),
73
+ dispose() {
74
+ target.removeEventListener('message', onMessage as EventListener);
75
+ errors.clear();
76
+ consoles.clear();
77
+ },
78
+ };
79
+ }
package/src/protocol.ts CHANGED
@@ -66,9 +66,33 @@ export interface WireResponse {
66
66
  reqId: number;
67
67
  status: number;
68
68
  headers: Record<string, string>;
69
- body: string | ArrayBuffer;
69
+ /** The whole body — or, with `streaming`, null: the body follows as `chunk` messages until `end`. */
70
+ body: string | ArrayBuffer | null;
71
+ streaming?: boolean;
70
72
  }
71
73
 
74
+ /** page → worker: the next piece of a streaming response's body. */
75
+ export interface WireChunk {
76
+ type: 'chunk';
77
+ reqId: number;
78
+ data: ArrayBuffer;
79
+ }
80
+
81
+ /** page → worker: a streaming response is complete (`error`: it broke off, e.g. the server process exited). */
82
+ export interface WireEnd {
83
+ type: 'end';
84
+ reqId: number;
85
+ error?: string;
86
+ }
87
+
88
+ /** worker → page: the preview stopped reading a streaming response (closed an EventSource, navigated away). */
89
+ export interface WireCancel {
90
+ type: 'cancel';
91
+ reqId: number;
92
+ }
93
+
94
+ export type WireReply = WireResponse | WireChunk | WireEnd;
95
+
72
96
  /**
73
97
  * The URL prefix previews live under, for a worker scope. Serving the worker from
74
98
  * `/__bfwc/sw.js` (scope `/__bfwc/`) keeps it off every other page of the host app;
@@ -92,3 +116,13 @@ export function isWireResponse(value: unknown): value is WireResponse {
92
116
  const v = value as WireResponse | null;
93
117
  return !!v && v.type === 'response' && typeof v.reqId === 'number';
94
118
  }
119
+
120
+ export function isWireReply(value: unknown): value is WireReply {
121
+ const v = value as WireReply | null;
122
+ return !!v && (v.type === 'response' || v.type === 'chunk' || v.type === 'end') && typeof v.reqId === 'number';
123
+ }
124
+
125
+ export function isWireCancel(value: unknown): value is WireCancel {
126
+ const v = value as WireCancel | null;
127
+ return !!v && v.type === 'cancel' && typeof v.reqId === 'number';
128
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Snapshots that outlive the page: `exportZip` bytes kept in IndexedDB under a
3
+ * key the host chooses (say, the project id plus a hash of package.json), so a
4
+ * reload can `importZip` an installed `node_modules` instead of reinstalling.
5
+ *
6
+ * Bounded: snapshots over `maxBytes` are not stored, and only the
7
+ * `maxEntries` most recently used are kept. Every operation fails soft —
8
+ * storage blocked (private windows) or full means a miss, never a throw.
9
+ */
10
+
11
+ export interface SnapshotStoreOptions {
12
+ /** IndexedDB database name. Default `bfwc-snapshots`. */
13
+ name?: string;
14
+ /** Snapshots kept, least recently used dropped first. Default 4. */
15
+ maxEntries?: number;
16
+ /** Largest snapshot stored, in bytes. Default 256 MB. */
17
+ maxBytes?: number;
18
+ /** Injected for tests; defaults to `globalThis.indexedDB`. */
19
+ indexedDB?: IDBFactory;
20
+ }
21
+
22
+ export interface SnapshotStore {
23
+ /** The snapshot saved under `key`, or undefined. */
24
+ load(key: string): Promise<Uint8Array | undefined>;
25
+ /** Save (replacing any snapshot under `key`); false when it was not stored (too large, storage unavailable). */
26
+ save(key: string, zip: Uint8Array): Promise<boolean>;
27
+ remove(key: string): Promise<void>;
28
+ /** Keys held, most recently used first. */
29
+ keys(): Promise<string[]>;
30
+ }
31
+
32
+ interface Row {
33
+ key: string;
34
+ zip: Uint8Array;
35
+ usedAt: number;
36
+ }
37
+
38
+ const STORE = 'snapshots';
39
+
40
+ function request<T>(req: IDBRequest<T>): Promise<T> {
41
+ return new Promise((resolve, reject) => {
42
+ req.onsuccess = () => resolve(req.result);
43
+ req.onerror = () => reject(req.error);
44
+ });
45
+ }
46
+
47
+ function done(tx: IDBTransaction): Promise<void> {
48
+ return new Promise((resolve, reject) => {
49
+ tx.oncomplete = () => resolve();
50
+ tx.onerror = () => reject(tx.error);
51
+ tx.onabort = () => reject(tx.error);
52
+ });
53
+ }
54
+
55
+ export function createSnapshotStore(options: SnapshotStoreOptions = {}): SnapshotStore {
56
+ const maxEntries = options.maxEntries ?? 4;
57
+ const maxBytes = options.maxBytes ?? 256 * 1024 * 1024;
58
+ let opening: Promise<IDBDatabase | undefined> | undefined;
59
+
60
+ const open = () =>
61
+ (opening ??= (async () => {
62
+ const factory = options.indexedDB ?? globalThis.indexedDB;
63
+ if (!factory) return undefined;
64
+ try {
65
+ const req = factory.open(options.name ?? 'bfwc-snapshots', 1);
66
+ req.onupgradeneeded = () => {
67
+ if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE, { keyPath: 'key' });
68
+ };
69
+ return await request(req);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ })());
74
+
75
+ const rows = async (db: IDBDatabase): Promise<Row[]> => {
76
+ const all = (await request(db.transaction(STORE, 'readonly').objectStore(STORE).getAll())) as Row[];
77
+ return all.sort((a, b) => b.usedAt - a.usedAt);
78
+ };
79
+
80
+ return {
81
+ async load(key) {
82
+ const db = await open();
83
+ if (!db) return undefined;
84
+ try {
85
+ const tx = db.transaction(STORE, 'readwrite');
86
+ const store = tx.objectStore(STORE);
87
+ const row = (await request(store.get(key))) as Row | undefined;
88
+ if (row) store.put({ ...row, usedAt: Date.now() });
89
+ await done(tx);
90
+ return row?.zip;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ },
95
+ async save(key, zip) {
96
+ if (zip.byteLength > maxBytes) return false;
97
+ const db = await open();
98
+ if (!db) return false;
99
+ try {
100
+ const tx = db.transaction(STORE, 'readwrite');
101
+ tx.objectStore(STORE).put({ key, zip, usedAt: Date.now() } satisfies Row);
102
+ await done(tx);
103
+ const stale = (await rows(db)).slice(maxEntries);
104
+ if (stale.length) {
105
+ const prune = db.transaction(STORE, 'readwrite');
106
+ for (const row of stale) prune.objectStore(STORE).delete(row.key);
107
+ await done(prune);
108
+ }
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ },
114
+ async remove(key) {
115
+ const db = await open();
116
+ if (!db) return;
117
+ try {
118
+ const tx = db.transaction(STORE, 'readwrite');
119
+ tx.objectStore(STORE).delete(key);
120
+ await done(tx);
121
+ } catch {
122
+ // storage unavailable: nothing to remove
123
+ }
124
+ },
125
+ async keys() {
126
+ const db = await open();
127
+ if (!db) return [];
128
+ try {
129
+ return (await rows(db)).map((row) => row.key);
130
+ } catch {
131
+ return [];
132
+ }
133
+ },
134
+ };
135
+ }
package/src/sw.ts CHANGED
@@ -74,6 +74,8 @@ worker.addEventListener('fetch', (event) => {
74
74
  details = { method: request.method, headers, body };
75
75
  }
76
76
  const r = await router.respond(target, details);
77
+ // A virtual server's body is a stream fed as the server writes (SSE, chunked progress).
78
+ if (NULL_BODY.has(r.status) && r.body instanceof ReadableStream) void r.body.cancel();
77
79
  return new Response(NULL_BODY.has(r.status) ? null : r.body, { status: r.status, headers: r.headers });
78
80
  })(),
79
81
  );
package/src/swRouter.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PREVIEW_ID, isPortPath, isWireResponse, previewPrefix, type WireResponse } from './protocol.js';
1
+ import { PREVIEW_ID, isPortPath, isWireReply, previewPrefix, type WireCancel, 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 {
@@ -22,7 +22,8 @@ export interface RequestDetails {
22
22
  export interface RoutedResponse {
23
23
  status: number;
24
24
  headers: Record<string, string>;
25
- body: string | ArrayBuffer;
25
+ /** A stream when the page streams the body (a virtual server's response): chunks arrive as the server writes them. */
26
+ body: string | ArrayBuffer | ReadableStream<Uint8Array> | null;
26
27
  }
27
28
 
28
29
  export interface SwRouterOptions {
@@ -46,6 +47,12 @@ const TEXT = { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no
46
47
  * router asks open pages to re-attach and waits briefly. A port that stops
47
48
  * answering (its tab closed) is dropped on timeout, so the NEXT request goes
48
49
  * through the re-attach path instead of timing out forever.
50
+ *
51
+ * A virtual server's response streams: the page sends the head (`streaming`),
52
+ * then `chunk` messages and an `end`, and the router answers the fetch with a
53
+ * ReadableStream fed by them. The timeout covers only the wait for the head (an
54
+ * event stream may stay open indefinitely); cancelling the stream (the preview
55
+ * closed its EventSource) tells the page to hang up on the server.
49
56
  */
50
57
  export function createSwRouter(options: SwRouterOptions) {
51
58
  const prefix = previewPrefix(options.scopePath);
@@ -54,14 +61,29 @@ export function createSwRouter(options: SwRouterOptions) {
54
61
  const ports = new Map<string, PortLike>();
55
62
  const waiters = new Map<string, Array<(port: PortLike) => void>>();
56
63
  const pending = new Map<number, (response: WireResponse) => void>();
64
+ const streams = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
57
65
  let nextId = 0;
58
66
 
59
67
  function attach(id: string, port: PortLike): void {
60
68
  ports.set(id, port);
61
69
  port.onmessage = (event) => {
62
- if (!isWireResponse(event.data)) return;
63
- pending.get(event.data.reqId)?.(event.data);
64
- pending.delete(event.data.reqId);
70
+ const reply = event.data;
71
+ if (!isWireReply(reply)) return;
72
+ if (reply.type === 'response') {
73
+ pending.get(reply.reqId)?.(reply);
74
+ pending.delete(reply.reqId);
75
+ return;
76
+ }
77
+ const controller = streams.get(reply.reqId);
78
+ if (!controller) return;
79
+ try {
80
+ if (reply.type === 'chunk') return controller.enqueue(new Uint8Array(reply.data));
81
+ streams.delete(reply.reqId);
82
+ if (reply.error) controller.error(new Error(reply.error));
83
+ else controller.close();
84
+ } catch {
85
+ streams.delete(reply.reqId); // the reader already went away
86
+ }
65
87
  };
66
88
  for (const wake of waiters.get(id) ?? []) wake(port);
67
89
  waiters.delete(id);
@@ -125,7 +147,14 @@ export function createSwRouter(options: SwRouterOptions) {
125
147
  }, timeoutMs);
126
148
  pending.set(reqId, (response) => {
127
149
  clearTimeout(timer);
128
- resolve({ status: response.status, headers: response.headers, body: response.body });
150
+ if (!response.streaming) return resolve({ status: response.status, headers: response.headers, body: response.body });
151
+ const body = new ReadableStream<Uint8Array>({
152
+ start: (controller) => void streams.set(reqId, controller),
153
+ cancel: () => {
154
+ if (streams.delete(reqId)) port.postMessage({ type: 'cancel', reqId } satisfies WireCancel);
155
+ },
156
+ });
157
+ resolve({ status: response.status, headers: response.headers, body });
129
158
  });
130
159
  port.postMessage({ type: 'request', reqId, path: target.path, search: target.search, ...details });
131
160
  });