@seanhogg/builderforce-webcontainers 2026.10.2 → 2026.10.4
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 +6 -1
- package/dist/assets.d.ts +2 -0
- package/dist/assets.js +4 -2
- 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 +39 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +22 -7
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +9 -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 +9 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +7 -0
- package/dist/protocol.js.map +1 -1
- package/dist/relay.html +1 -1
- 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 +26 -4
- package/dist/swRouter.js.map +1 -1
- package/dist/transport.d.ts +7 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +7 -1
- package/dist/transport.js.map +1 -1
- package/package.json +12 -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 +64 -9
- package/src/index.ts +17 -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 +13 -0
- package/src/relay.ts +5 -1
- package/src/relayWorker.ts +53 -0
- package/src/sw.ts +25 -6
- package/src/swRouter.ts +33 -4
- package/src/transport.ts +14 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages between the page (the process host, which owns the master file
|
|
3
|
+
* system and the port table) and one process Worker. Everything is plain
|
|
4
|
+
* structured-clone data; file contents travel as strings or bytes.
|
|
5
|
+
*/
|
|
6
|
+
import type { FileContent, HttpRequestData, HttpResponseData } from '@seanhogg/builderforce-webcontainers-core';
|
|
7
|
+
|
|
8
|
+
export type FsOp = { op: 'write'; path: string; content: FileContent } | { op: 'remove'; path: string } | { op: 'mkdir'; path: string };
|
|
9
|
+
|
|
10
|
+
export interface StartMessage {
|
|
11
|
+
type: 'start';
|
|
12
|
+
pid: number;
|
|
13
|
+
command: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
cwd: string;
|
|
16
|
+
env: Record<string, string>;
|
|
17
|
+
terminal?: { cols: number; rows: number };
|
|
18
|
+
files: Array<[string, FileContent]>;
|
|
19
|
+
dirs: string[];
|
|
20
|
+
/** Ports already listening anywhere in the runtime (so `listen` can fail fast with EADDRINUSE). */
|
|
21
|
+
ports: number[];
|
|
22
|
+
/** `/__bfwc/<id>/` on the preview origin, for `serverUrl`. */
|
|
23
|
+
previewOrigin: string;
|
|
24
|
+
previewBase: string;
|
|
25
|
+
previewUrl?: string;
|
|
26
|
+
registry?: string;
|
|
27
|
+
/** Use Cache Storage for npm tarballs. */
|
|
28
|
+
packageCache: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** page → worker */
|
|
32
|
+
export type HostToWorker =
|
|
33
|
+
| StartMessage
|
|
34
|
+
| { type: 'stdin'; data: string | null }
|
|
35
|
+
| { type: 'kill'; signal: string }
|
|
36
|
+
| { type: 'resize'; cols: number; rows: number }
|
|
37
|
+
| { type: 'fs'; ops: FsOp[] }
|
|
38
|
+
| { type: 'ports'; open: number[]; closed: number[] }
|
|
39
|
+
| { type: 'child-out'; childId: number; stream: 'stdout' | 'stderr'; data: string }
|
|
40
|
+
| { type: 'child-exit'; childId: number; code: number }
|
|
41
|
+
| { type: 'child-error'; childId: number; code: string; message: string }
|
|
42
|
+
| { type: 'http-request'; reqId: number; port: number; request: HttpRequestData }
|
|
43
|
+
| { type: 'loopback-response'; reqId: number; response: HttpResponseData | null };
|
|
44
|
+
|
|
45
|
+
/** worker → page */
|
|
46
|
+
export type WorkerToHost =
|
|
47
|
+
| { type: 'stdout' | 'stderr'; data: string }
|
|
48
|
+
| { type: 'exit'; code: number }
|
|
49
|
+
| { type: 'fs'; ops: FsOp[] }
|
|
50
|
+
| { type: 'spawn'; childId: number; command: string; args: string[]; cwd?: string; env?: Record<string, string>; terminal?: { cols: number; rows: number } }
|
|
51
|
+
| { type: 'child-stdin'; childId: number; data: string | null }
|
|
52
|
+
| { type: 'child-kill'; childId: number; signal: string }
|
|
53
|
+
| { type: 'child-resize'; childId: number; cols: number; rows: number }
|
|
54
|
+
| { type: 'listen'; port: number }
|
|
55
|
+
| { type: 'unlisten'; port: number }
|
|
56
|
+
| { type: 'http-response'; reqId: number; response: HttpResponseData }
|
|
57
|
+
| { type: 'loopback'; reqId: number; port: number; request: HttpRequestData }
|
|
58
|
+
| { type: 'server-ready'; port: number; url: string };
|
|
59
|
+
|
|
60
|
+
/** The preview-relative URL a virtual server on `port` is reachable at. */
|
|
61
|
+
export function portPath(previewBase: string, port: number): string {
|
|
62
|
+
return `${previewBase}__port/${port}/`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process Worker (bundled to dist/node/worker.js): one program per worker,
|
|
3
|
+
* so a busy loop blocks only itself and `kill` can always terminate it.
|
|
4
|
+
*
|
|
5
|
+
* Worker globals are typed locally rather than through `lib.webworker`, which
|
|
6
|
+
* cannot share a compilation with the DOM lib the rest of the package needs.
|
|
7
|
+
*/
|
|
8
|
+
import { createWorkerProcess } from './workerProcess.js';
|
|
9
|
+
import { createCacheStoragePackageCache } from '../packageCache.js';
|
|
10
|
+
import type { HostToWorker } from './protocol.js';
|
|
11
|
+
|
|
12
|
+
interface WorkerScope {
|
|
13
|
+
postMessage(message: unknown, transfer?: Transferable[]): void;
|
|
14
|
+
addEventListener(type: 'message', listener: (event: { data: HostToWorker }) => void): void;
|
|
15
|
+
addEventListener(type: 'error', listener: (event: { error: unknown; message: string; preventDefault(): void }) => void): void;
|
|
16
|
+
addEventListener(type: 'unhandledrejection', listener: (event: { reason: unknown; preventDefault(): void }) => void): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const scope = globalThis as unknown as WorkerScope;
|
|
20
|
+
const nativeFetch = globalThis.fetch.bind(globalThis);
|
|
21
|
+
let cache: ReturnType<typeof createCacheStoragePackageCache> | undefined;
|
|
22
|
+
const uncaught = new Set<(error: unknown) => void>();
|
|
23
|
+
|
|
24
|
+
scope.addEventListener('error', (event) => {
|
|
25
|
+
if (!uncaught.size) return;
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
for (const handler of uncaught) handler(event.error ?? new Error(event.message));
|
|
28
|
+
});
|
|
29
|
+
scope.addEventListener('unhandledrejection', (event) => {
|
|
30
|
+
if (!uncaught.size) return;
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
for (const handler of uncaught) handler(event.reason);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const process = createWorkerProcess({
|
|
36
|
+
post: (message) => scope.postMessage(message),
|
|
37
|
+
fetch: (input, init) => nativeFetch(input, init),
|
|
38
|
+
packageCache: () => (cache ??= createCacheStoragePackageCache()),
|
|
39
|
+
ownsRealm: true,
|
|
40
|
+
trapUncaught(handler) {
|
|
41
|
+
uncaught.add(handler);
|
|
42
|
+
return () => uncaught.delete(handler);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
scope.addEventListener('message', (event) => process.receive(event.data));
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker side of one process: mirror the file system, build a `System`
|
|
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
|
|
5
|
+
* feed are injected — so the same code runs in a real Worker (worker.ts) and
|
|
6
|
+
* in-thread in tests.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
VirtualFileSystem,
|
|
10
|
+
commandNotFound,
|
|
11
|
+
defaultPrograms,
|
|
12
|
+
resolveCommand,
|
|
13
|
+
runProgram,
|
|
14
|
+
type ChildHandle,
|
|
15
|
+
type FsChange,
|
|
16
|
+
type HttpHandler,
|
|
17
|
+
type HttpResponseData,
|
|
18
|
+
type PackageCache,
|
|
19
|
+
type System,
|
|
20
|
+
} from '@seanhogg/builderforce-webcontainers-core';
|
|
21
|
+
import type { FsOp, HostToWorker, StartMessage, WorkerToHost } from './protocol.js';
|
|
22
|
+
import { portPath } from './protocol.js';
|
|
23
|
+
|
|
24
|
+
export interface WorkerProcessDeps {
|
|
25
|
+
post(message: WorkerToHost): void;
|
|
26
|
+
fetch: System['fetch'];
|
|
27
|
+
packageCache?: () => PackageCache | undefined;
|
|
28
|
+
/** Register for the realm's uncaught errors (a real worker's `error`/`unhandledrejection`). */
|
|
29
|
+
trapUncaught?(handler: (error: unknown) => void): () => void;
|
|
30
|
+
/** This process has the realm to itself (true in a real Worker). */
|
|
31
|
+
ownsRealm?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EPHEMERAL = 49152;
|
|
35
|
+
|
|
36
|
+
export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message: HostToWorker): void } {
|
|
37
|
+
const fs = new VirtualFileSystem();
|
|
38
|
+
const handlers = new Map<number, HttpHandler>();
|
|
39
|
+
const knownPorts = new Set<number>();
|
|
40
|
+
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>();
|
|
42
|
+
let nextChild = 1;
|
|
43
|
+
let nextReq = 1;
|
|
44
|
+
let applying = false;
|
|
45
|
+
let pending: FsOp[] = [];
|
|
46
|
+
let flushScheduled = false;
|
|
47
|
+
let process: ChildHandle | undefined;
|
|
48
|
+
const programs = defaultPrograms();
|
|
49
|
+
|
|
50
|
+
const flush = () => {
|
|
51
|
+
flushScheduled = false;
|
|
52
|
+
if (!pending.length) return;
|
|
53
|
+
const ops = pending;
|
|
54
|
+
pending = [];
|
|
55
|
+
deps.post({ type: 'fs', ops });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
fs.watch((change: FsChange) => {
|
|
59
|
+
if (applying) return;
|
|
60
|
+
if (change.type === 'write') pending.push({ op: 'write', path: change.path, content: fs.readFile(change.path)! });
|
|
61
|
+
else pending.push({ op: change.type, path: change.path });
|
|
62
|
+
if (!flushScheduled) {
|
|
63
|
+
flushScheduled = true;
|
|
64
|
+
queueMicrotask(flush);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const apply = (ops: FsOp[]) => {
|
|
69
|
+
applying = true;
|
|
70
|
+
try {
|
|
71
|
+
for (const op of ops) {
|
|
72
|
+
if (op.op === 'write') fs.writeFile(op.path, op.content);
|
|
73
|
+
else if (op.op === 'mkdir') fs.mkdir(op.path);
|
|
74
|
+
else fs.rm(op.path);
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
applying = false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function remoteChild(command: string, args: string[], options: { cwd?: string; env?: Record<string, string>; terminal?: { cols: number; rows: number } } = {}): ChildHandle {
|
|
82
|
+
flush(); // the child must see every write made so far
|
|
83
|
+
const childId = nextChild++;
|
|
84
|
+
const stdout = new Set<(s: string) => void>();
|
|
85
|
+
const stderr = new Set<(s: string) => void>();
|
|
86
|
+
let resolveExit!: (code: number) => void;
|
|
87
|
+
const exit = new Promise<number>((resolve) => (resolveExit = resolve));
|
|
88
|
+
children.set(childId, { stdout, stderr, exit: resolveExit });
|
|
89
|
+
deps.post({ type: 'spawn', childId, command, args, cwd: options.cwd, env: options.env, terminal: options.terminal });
|
|
90
|
+
return {
|
|
91
|
+
pid: childId,
|
|
92
|
+
onStdout: (l) => (stdout.add(l), () => stdout.delete(l)),
|
|
93
|
+
onStderr: (l) => (stderr.add(l), () => stderr.delete(l)),
|
|
94
|
+
write: (data) => deps.post({ type: 'child-stdin', childId, data }),
|
|
95
|
+
closeStdin: () => deps.post({ type: 'child-stdin', childId, data: null }),
|
|
96
|
+
kill: (signal = 'SIGTERM') => deps.post({ type: 'child-kill', childId, signal }),
|
|
97
|
+
resize: (cols, rows) => deps.post({ type: 'child-resize', childId, cols, rows }),
|
|
98
|
+
exit,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function start(message: StartMessage): void {
|
|
103
|
+
applying = true;
|
|
104
|
+
for (const dir of message.dirs) fs.mkdir(dir);
|
|
105
|
+
for (const [path, content] of message.files) fs.writeFile(path, content);
|
|
106
|
+
applying = false;
|
|
107
|
+
for (const port of message.ports) knownPorts.add(port);
|
|
108
|
+
|
|
109
|
+
const system: System = {
|
|
110
|
+
fs,
|
|
111
|
+
fetch: deps.fetch,
|
|
112
|
+
spawn(command, args, options = {}) {
|
|
113
|
+
// Resolve here, against the mirror, so a missing command fails synchronously
|
|
114
|
+
// with ENOENT exactly as it does in-process.
|
|
115
|
+
const cwd = options.cwd ?? message.cwd;
|
|
116
|
+
const env = { ...message.env, ...options.env };
|
|
117
|
+
if (!resolveCommand(fs, programs, command, args, cwd, env)) throw commandNotFound(command);
|
|
118
|
+
return remoteChild(command, args, { ...options, cwd, env });
|
|
119
|
+
},
|
|
120
|
+
listen(port, handler) {
|
|
121
|
+
let actual = port;
|
|
122
|
+
if (!actual) for (actual = EPHEMERAL + (message.pid % 1000) * 8; knownPorts.has(actual); actual++);
|
|
123
|
+
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
|
+
knownPorts.add(actual);
|
|
125
|
+
handlers.set(actual, handler);
|
|
126
|
+
deps.post({ type: 'listen', port: actual });
|
|
127
|
+
return {
|
|
128
|
+
port: actual,
|
|
129
|
+
close() {
|
|
130
|
+
if (handlers.get(actual) !== handler) return;
|
|
131
|
+
handlers.delete(actual);
|
|
132
|
+
knownPorts.delete(actual);
|
|
133
|
+
deps.post({ type: 'unlisten', port: actual });
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
loopback(port, request) {
|
|
138
|
+
const local = handlers.get(port);
|
|
139
|
+
if (local) return local(request);
|
|
140
|
+
const reqId = nextReq++;
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
loopbacks.set(reqId, resolve);
|
|
143
|
+
deps.post({ type: 'loopback', reqId, port, request });
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
serverUrl: (port) => new URL(portPath(message.previewBase, port), message.previewOrigin).href,
|
|
147
|
+
announceServer: (port, url) => deps.post({ type: 'server-ready', port, url }),
|
|
148
|
+
previewUrl: message.previewUrl,
|
|
149
|
+
registry: message.registry,
|
|
150
|
+
packageCache: message.packageCache ? deps.packageCache?.() : undefined,
|
|
151
|
+
ownsRealm: deps.ownsRealm,
|
|
152
|
+
trapUncaught: deps.trapUncaught,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const resolved = resolveCommand(fs, programs, message.command, message.args, message.cwd, message.env);
|
|
156
|
+
if (!resolved) {
|
|
157
|
+
deps.post({ type: 'stderr', data: `jsh: command not found: ${message.command}\n` });
|
|
158
|
+
deps.post({ type: 'exit', code: 127 });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
process = runProgram({ program: resolved.program, args: resolved.args, system, cwd: message.cwd, env: message.env, terminal: message.terminal, pid: message.pid });
|
|
162
|
+
process.onStdout((data) => deps.post({ type: 'stdout', data }));
|
|
163
|
+
process.onStderr((data) => deps.post({ type: 'stderr', data }));
|
|
164
|
+
void process.exit.then((code) => {
|
|
165
|
+
flush();
|
|
166
|
+
deps.post({ type: 'exit', code });
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
receive(message) {
|
|
172
|
+
switch (message.type) {
|
|
173
|
+
case 'start':
|
|
174
|
+
return start(message);
|
|
175
|
+
case 'stdin':
|
|
176
|
+
if (message.data === null) process?.closeStdin();
|
|
177
|
+
else process?.write(message.data);
|
|
178
|
+
return;
|
|
179
|
+
case 'kill':
|
|
180
|
+
return process?.kill(message.signal);
|
|
181
|
+
case 'resize':
|
|
182
|
+
return process?.resize?.(message.cols, message.rows);
|
|
183
|
+
case 'fs':
|
|
184
|
+
return apply(message.ops);
|
|
185
|
+
case 'ports':
|
|
186
|
+
for (const port of message.open) knownPorts.add(port);
|
|
187
|
+
for (const port of message.closed) if (!handlers.has(port)) knownPorts.delete(port);
|
|
188
|
+
return;
|
|
189
|
+
case 'child-out':
|
|
190
|
+
for (const l of children.get(message.childId)?.[message.stream] ?? []) l(message.data);
|
|
191
|
+
return;
|
|
192
|
+
case 'child-exit':
|
|
193
|
+
children.get(message.childId)?.exit(message.code);
|
|
194
|
+
children.delete(message.childId);
|
|
195
|
+
return;
|
|
196
|
+
case 'child-error': {
|
|
197
|
+
const child = children.get(message.childId);
|
|
198
|
+
for (const l of child?.stderr ?? []) l(`${message.message}\n`);
|
|
199
|
+
child?.exit(message.code === 'ENOENT' ? 127 : 1);
|
|
200
|
+
children.delete(message.childId);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
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
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case 'loopback-response':
|
|
213
|
+
loopbacks.get(message.reqId)?.(message.response ?? undefined);
|
|
214
|
+
loopbacks.delete(message.reqId);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PackageCache } from '@seanhogg/builderforce-webcontainers-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The installer's cache backed by Cache Storage, so a second `npm install` (or a
|
|
5
|
+
* reload) reuses tarballs instead of downloading them again. Cache Storage keys
|
|
6
|
+
* must be http(s) requests, so each key is folded into a URL on a reserved
|
|
7
|
+
* `.invalid` host that can never be fetched for real.
|
|
8
|
+
*
|
|
9
|
+
* Falls back to a no-op cache where Cache Storage is unavailable (an insecure
|
|
10
|
+
* context, or a browser that blocks it in private mode).
|
|
11
|
+
*/
|
|
12
|
+
export function createCacheStoragePackageCache(name = 'bfwc-packages'): PackageCache {
|
|
13
|
+
const storage = (globalThis as { caches?: CacheStorage }).caches;
|
|
14
|
+
if (!storage) return { get: async () => undefined, put: async () => undefined };
|
|
15
|
+
let opened: Promise<Cache | undefined> | undefined;
|
|
16
|
+
const open = () => (opened ??= storage.open(name).catch(() => undefined));
|
|
17
|
+
const keyUrl = (key: string) => `https://bfwc-cache.invalid/${encodeURIComponent(key)}`;
|
|
18
|
+
return {
|
|
19
|
+
async get(key) {
|
|
20
|
+
const response = await (await open())?.match(keyUrl(key)).catch(() => undefined);
|
|
21
|
+
return response ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
|
22
|
+
},
|
|
23
|
+
async put(key, data) {
|
|
24
|
+
const body = data.slice().buffer as ArrayBuffer;
|
|
25
|
+
await (await open())?.put(keyUrl(key), new Response(body)).catch(() => undefined); // quota errors just skip caching
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -30,6 +30,8 @@ export const RELAY_ATTACH = 'bfwc:relay-attach';
|
|
|
30
30
|
export const RELAY_REATTACH = 'bfwc:relay-reattach';
|
|
31
31
|
/** host → relay: reload every frame showing the preview at `base`. */
|
|
32
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';
|
|
33
35
|
|
|
34
36
|
export interface AttachMessage {
|
|
35
37
|
type: typeof ATTACH;
|
|
@@ -46,6 +48,17 @@ export interface WireRequest {
|
|
|
46
48
|
reqId: number;
|
|
47
49
|
path: string;
|
|
48
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}/`);
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export interface WireResponse {
|
package/src/relay.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Whoever may frame this page may drive it, so the server that hosts relay.html
|
|
8
8
|
* must restrict that with `Content-Security-Policy: frame-ancestors <host origins>`.
|
|
9
9
|
*/
|
|
10
|
-
import { ATTACH, REATTACH, RELAY_ATTACH, RELAY_READY, RELAY_REATTACH, RELAY_RELOAD } from './protocol.js';
|
|
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';
|
|
11
12
|
import { broadcastReload, waitForActive } from './worker.js';
|
|
12
13
|
|
|
13
14
|
const host = window.parent;
|
|
@@ -29,6 +30,9 @@ async function start(): Promise<void> {
|
|
|
29
30
|
registration.active?.postMessage({ type: ATTACH, id: data.id }, [event.ports[0]]);
|
|
30
31
|
} else if (data?.type === RELAY_RELOAD && typeof data.base === 'string') {
|
|
31
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' }));
|
|
32
36
|
}
|
|
33
37
|
});
|
|
34
38
|
|
|
@@ -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 { PREVIEW_ID, 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>;
|
|
@@ -84,7 +91,29 @@ export function createSwRouter(options: SwRouterOptions) {
|
|
|
84
91
|
return arrived;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
|
-
|
|
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> {
|
|
88
117
|
const port = await portFor(target.id);
|
|
89
118
|
if (!port) return { status: 503, headers: TEXT, body: 'This preview is not running in any open tab.' };
|
|
90
119
|
const reqId = ++nextId;
|
|
@@ -98,11 +127,11 @@ export function createSwRouter(options: SwRouterOptions) {
|
|
|
98
127
|
clearTimeout(timer);
|
|
99
128
|
resolve({ status: response.status, headers: response.headers, body: response.body });
|
|
100
129
|
});
|
|
101
|
-
port.postMessage({ type: 'request', reqId, path: target.path, search: target.search });
|
|
130
|
+
port.postMessage({ type: 'request', reqId, path: target.path, search: target.search, ...details });
|
|
102
131
|
});
|
|
103
132
|
}
|
|
104
133
|
|
|
105
|
-
return { attach, match, respond, detach: (id: string) => ports.delete(id) };
|
|
134
|
+
return { attach, match, matchForClient, rememberClient, respond, detach: (id: string) => ports.delete(id) };
|
|
106
135
|
}
|
|
107
136
|
|
|
108
137
|
export type SwRouter = ReturnType<typeof createSwRouter>;
|
package/src/transport.ts
CHANGED
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
RELAY_READY,
|
|
18
18
|
RELAY_REATTACH,
|
|
19
19
|
RELAY_RELOAD,
|
|
20
|
+
RELAY_SPAWN,
|
|
20
21
|
} from './protocol.js';
|
|
22
|
+
import type { WorkerLike } from './node/processHost.js';
|
|
23
|
+
import { workerOverPort } from './relayWorker.js';
|
|
21
24
|
import { broadcastReload, waitForActive } from './worker.js';
|
|
22
25
|
|
|
23
26
|
export interface PreviewTransport {
|
|
@@ -31,6 +34,12 @@ export interface PreviewTransport {
|
|
|
31
34
|
onReattach(listener: (id: string) => void): () => void;
|
|
32
35
|
/** Reload every frame showing the preview at `base`. */
|
|
33
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;
|
|
34
43
|
dispose(): void;
|
|
35
44
|
}
|
|
36
45
|
|
|
@@ -118,6 +127,11 @@ export async function relayTransport(relayUrl: string): Promise<PreviewTransport
|
|
|
118
127
|
return () => listeners.delete(listener);
|
|
119
128
|
},
|
|
120
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
|
+
},
|
|
121
135
|
dispose() {
|
|
122
136
|
window.removeEventListener('message', onRelayMessage);
|
|
123
137
|
listeners.clear();
|