@seanhogg/builderforce-webcontainers 2026.10.2 → 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 +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,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSvelteCompiler,
|
|
3
|
+
createVueCompiler,
|
|
4
|
+
isRegistryRange,
|
|
5
|
+
type ComponentCompiler,
|
|
6
|
+
type ComponentCompilers,
|
|
7
|
+
type SvelteCompilerLike,
|
|
8
|
+
type VueCompilerSfcLike,
|
|
9
|
+
} from '@seanhogg/builderforce-webcontainers-core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Vue and Svelte compilers, fetched from a CDN the first time a project
|
|
13
|
+
* needs one — nothing is downloaded for a React app — and pinned to the
|
|
14
|
+
* project's own framework range, so compiler and runtime agree. (Svelte's
|
|
15
|
+
* compiled output calls internal runtime functions that change between minors;
|
|
16
|
+
* the runtime is served by esm.sh from the same range, so the compiler is too.)
|
|
17
|
+
*/
|
|
18
|
+
export interface CdnComponentCompilerOptions {
|
|
19
|
+
/** The ES module URL of `@vue/compiler-sfc`'s browser build for a `vue` range. */
|
|
20
|
+
vueCompilerUrl?: (range: string) => string;
|
|
21
|
+
/** The ES module URL of `svelte/compiler` for a `svelte` range. */
|
|
22
|
+
svelteCompilerUrl?: (range: string) => string;
|
|
23
|
+
/** Loads a module by URL. Default: native `import()`. */
|
|
24
|
+
importModule?: (url: string) => Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Self-contained ESM build the official Vue SFC playground loads. */
|
|
28
|
+
export const defaultVueCompilerUrl = (range: string) =>
|
|
29
|
+
`https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@${range}/dist/compiler-sfc.esm-browser.js`;
|
|
30
|
+
|
|
31
|
+
export const defaultSvelteCompilerUrl = (range: string) => `https://esm.sh/svelte@${range}/compiler`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `import()` of a runtime URL. The magic comments stop the host app's bundler
|
|
35
|
+
* (webpack, Vite) from trying to resolve the CDN URL at build time.
|
|
36
|
+
*/
|
|
37
|
+
const nativeImport = (url: string): Promise<unknown> => import(/* webpackIgnore: true */ /* @vite-ignore */ url);
|
|
38
|
+
|
|
39
|
+
function rangeOf(dependencies: Readonly<Record<string, string>>, name: string, fallback: string): string {
|
|
40
|
+
const range = dependencies[name];
|
|
41
|
+
return range && isRegistryRange(range) ? range : fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createCdnComponentCompilers(options: CdnComponentCompilerOptions = {}): ComponentCompilers {
|
|
45
|
+
const load = options.importModule ?? nativeImport;
|
|
46
|
+
const cache = new Map<string, Promise<ComponentCompiler>>();
|
|
47
|
+
|
|
48
|
+
const memo = (url: string, make: (module: unknown) => ComponentCompiler): Promise<ComponentCompiler> => {
|
|
49
|
+
let hit = cache.get(url);
|
|
50
|
+
if (!hit) {
|
|
51
|
+
hit = load(url).then(make);
|
|
52
|
+
hit.catch(() => cache.delete(url)); // a failed download may be retried
|
|
53
|
+
cache.set(url, hit);
|
|
54
|
+
}
|
|
55
|
+
return hit;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (extension, dependencies) => {
|
|
59
|
+
if (extension === '.vue') {
|
|
60
|
+
const url = (options.vueCompilerUrl ?? defaultVueCompilerUrl)(rangeOf(dependencies, 'vue', '3'));
|
|
61
|
+
return memo(url, (module) => createVueCompiler(module as VueCompilerSfcLike));
|
|
62
|
+
}
|
|
63
|
+
const url = (options.svelteCompilerUrl ?? defaultSvelteCompilerUrl)(rangeOf(dependencies, 'svelte', '5'));
|
|
64
|
+
return memo(url, (module) => createSvelteCompiler(module as SvelteCompilerLike));
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/esbuildWasm.ts
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
|
-
import { createEsbuildTransformer, type Transformer } from '@seanhogg/builderforce-webcontainers-core';
|
|
1
|
+
import { createEsbuildBundler, createEsbuildTransformer, type Bundler, type Transformer } from '@seanhogg/builderforce-webcontainers-core';
|
|
2
2
|
|
|
3
3
|
export interface EsbuildWasmOptions {
|
|
4
4
|
/** Where to fetch esbuild.wasm. Defaults to jsDelivr at the installed version. */
|
|
5
5
|
wasmURL?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
type EsbuildWasm = typeof import('esbuild-wasm');
|
|
9
|
+
|
|
10
|
+
let ready: Promise<EsbuildWasm> | undefined;
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* esbuild compiled to WebAssembly, run off the main thread. esbuild may be
|
|
14
|
+
* initialised once per page, so the dev-server transformer and the production
|
|
15
|
+
* bundler share one instance (the options of the first call win).
|
|
14
16
|
*/
|
|
15
|
-
export function
|
|
17
|
+
export function loadEsbuildWasm(options: EsbuildWasmOptions = {}): Promise<EsbuildWasm> {
|
|
16
18
|
ready ??= (async () => {
|
|
17
19
|
const esbuild = await import('esbuild-wasm');
|
|
18
20
|
const wasmURL = options.wasmURL ?? `https://cdn.jsdelivr.net/npm/esbuild-wasm@${esbuild.version}/esbuild.wasm`;
|
|
19
21
|
await esbuild.initialize({ wasmURL, worker: true });
|
|
20
|
-
return
|
|
22
|
+
return esbuild;
|
|
21
23
|
})().catch((error: unknown) => {
|
|
22
24
|
ready = undefined; // let a later call retry (a transient network failure fetching the wasm)
|
|
23
25
|
throw error;
|
|
24
26
|
});
|
|
25
27
|
return ready;
|
|
26
28
|
}
|
|
29
|
+
|
|
30
|
+
/** The default compiler for the dev server. */
|
|
31
|
+
export async function createEsbuildWasmTransformer(options: EsbuildWasmOptions = {}): Promise<Transformer> {
|
|
32
|
+
return createEsbuildTransformer(await loadEsbuildWasm(options));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The default bundler for `runtime.build()`. */
|
|
36
|
+
export async function createEsbuildWasmBundler(options: EsbuildWasmOptions = {}): Promise<Bundler> {
|
|
37
|
+
return createEsbuildBundler(await loadEsbuildWasm(options));
|
|
38
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -2,15 +2,23 @@ import {
|
|
|
2
2
|
DevServer,
|
|
3
3
|
ERROR_MESSAGE_TYPE,
|
|
4
4
|
VirtualFileSystem,
|
|
5
|
-
|
|
5
|
+
buildProject,
|
|
6
|
+
type BuildResult,
|
|
7
|
+
type Bundler,
|
|
8
|
+
type ComponentCompilers,
|
|
6
9
|
type FileSystemTree,
|
|
7
10
|
type FlatFiles,
|
|
11
|
+
type PackageCdnFactory,
|
|
8
12
|
type ProjectProfile,
|
|
9
13
|
type Transformer,
|
|
14
|
+
type SpawnOptions,
|
|
15
|
+
type WebContainerProcess,
|
|
10
16
|
} from '@seanhogg/builderforce-webcontainers-core';
|
|
11
|
-
import { PREVIEW_ID, isWireRequest, previewBase, type WireResponse } from './protocol.js';
|
|
17
|
+
import { PREVIEW_ID, isPortPath, isWireRequest, previewBase, type WireRequest, type WireResponse } from './protocol.js';
|
|
18
|
+
import { createProcessHost, servePortRequest, type NodeRuntimeOptions } from './node/index.js';
|
|
12
19
|
import { relayTransport, sameOriginTransport } from './transport.js';
|
|
13
|
-
import { createEsbuildWasmTransformer } from './esbuildWasm.js';
|
|
20
|
+
import { createEsbuildWasmBundler, createEsbuildWasmTransformer } from './esbuildWasm.js';
|
|
21
|
+
import { createCdnComponentCompilers } from './componentCompilers.js';
|
|
14
22
|
|
|
15
23
|
/** Serve previews on THIS page's origin. Only for code the page's own user trusts. */
|
|
16
24
|
export interface SameOriginOptions {
|
|
@@ -38,14 +46,20 @@ export type BootOptions = (SameOriginOptions | RelayOptions) & CommonBootOptions
|
|
|
38
46
|
export interface CommonBootOptions {
|
|
39
47
|
/** Compiler. Defaults to esbuild-wasm. */
|
|
40
48
|
transformer?: Transformer;
|
|
41
|
-
/** Package CDN factory. Defaults to esm.sh. */
|
|
42
|
-
cdn?:
|
|
49
|
+
/** Package CDN factory, for previews (`dev: true`) and builds (`dev: false`). Defaults to esm.sh. */
|
|
50
|
+
cdn?: PackageCdnFactory;
|
|
51
|
+
/** `.vue` / `.svelte` compilers. Default: the official ones, fetched from a CDN on first use. */
|
|
52
|
+
components?: ComponentCompilers;
|
|
53
|
+
/** Bundler for `build()`. Default: esbuild-wasm (the same instance as the compiler). */
|
|
54
|
+
bundler?: Bundler;
|
|
43
55
|
/** Stable id (e.g. the project id; `[A-Za-z0-9_-]`) — keeps the preview URL stable across reloads. */
|
|
44
56
|
id?: string;
|
|
45
57
|
/** The "Built with Builderforce.ai" badge in the preview. Default true — please keep it. */
|
|
46
58
|
attribution?: boolean;
|
|
47
59
|
/** Reload open previews when files change. Default true. */
|
|
48
60
|
liveReload?: boolean;
|
|
61
|
+
/** The Node runtime behind `spawn` (process workers, npm registry). */
|
|
62
|
+
node?: NodeRuntimeOptions;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
/** Edits arrive in bursts (a save, an agent writing several files): reload once per burst. */
|
|
@@ -57,6 +71,13 @@ export interface PreviewError {
|
|
|
57
71
|
href: string;
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
export interface RuntimeBuildOptions {
|
|
75
|
+
/** Where the site will be served from. Default `./` (works from any directory). */
|
|
76
|
+
base?: string;
|
|
77
|
+
/** Default true. */
|
|
78
|
+
minify?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
export interface PreviewRuntime {
|
|
61
82
|
readonly id: string;
|
|
62
83
|
/** Absolute URL to load in the preview iframe. */
|
|
@@ -66,8 +87,26 @@ export interface PreviewRuntime {
|
|
|
66
87
|
mount(files: FileSystemTree | FlatFiles): void;
|
|
67
88
|
/** Whether this runtime can serve the mounted project, and why not. */
|
|
68
89
|
profile(): ProjectProfile;
|
|
90
|
+
/**
|
|
91
|
+
* `npm run build` for the mounted files: a deployable static site (index.html,
|
|
92
|
+
* hashed `assets/`, `public/` copied). Rejects with the reason when the project
|
|
93
|
+
* is not one this runtime supports, or with the bundler's errors.
|
|
94
|
+
*/
|
|
95
|
+
build(options?: RuntimeBuildOptions): Promise<BuildResult>;
|
|
69
96
|
/** Uncaught errors and rejections raised inside the preview. */
|
|
70
97
|
onError(listener: (error: PreviewError) => void): () => void;
|
|
98
|
+
/**
|
|
99
|
+
* Run a command — `node server.js`, `npm install`, `npm run dev`, `jsh` for a
|
|
100
|
+
* terminal — in its own Web Worker. Same shape as `@webcontainer/api`'s
|
|
101
|
+
* `spawn`: `output`/`input` streams, `exit`, `kill()`, `resize()`. Pass
|
|
102
|
+
* `{ terminal: { cols, rows } }` when wiring it to xterm.js.
|
|
103
|
+
*/
|
|
104
|
+
spawn(command: string, args?: string[], options?: SpawnOptions): WebContainerProcess;
|
|
105
|
+
spawn(command: string, options?: SpawnOptions): WebContainerProcess;
|
|
106
|
+
/** `server-ready`: a process started listening (or a dev script handed off to the preview); `url` loads it in an iframe. */
|
|
107
|
+
on(event: 'server-ready', listener: (port: number, url: string) => void): () => void;
|
|
108
|
+
/** `port`: a virtual server opened or closed. */
|
|
109
|
+
on(event: 'port', listener: (port: number, type: 'open' | 'close', url: string) => void): () => void;
|
|
71
110
|
dispose(): void;
|
|
72
111
|
}
|
|
73
112
|
|
|
@@ -75,7 +114,7 @@ function randomId(): string {
|
|
|
75
114
|
return globalThis.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 16) ?? Math.random().toString(36).slice(2, 18);
|
|
76
115
|
}
|
|
77
116
|
|
|
78
|
-
function toWire(reqId: number, served: Awaited<ReturnType<DevServer['handle']>>): { message: WireResponse; transfer: Transferable[] } {
|
|
117
|
+
export function toWire(reqId: number, served: Awaited<ReturnType<DevServer['handle']>>): { message: WireResponse; transfer: Transferable[] } {
|
|
79
118
|
if (typeof served.body === 'string') {
|
|
80
119
|
return { message: { type: 'response', reqId, status: served.status, headers: served.headers, body: served.body }, transfer: [] };
|
|
81
120
|
}
|
|
@@ -101,20 +140,29 @@ export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewR
|
|
|
101
140
|
|
|
102
141
|
const base = previewBase(transport.scope, id);
|
|
103
142
|
const fs = new VirtualFileSystem();
|
|
143
|
+
const components = options.components ?? createCdnComponentCompilers();
|
|
104
144
|
let server: DevServer;
|
|
105
145
|
try {
|
|
106
146
|
const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
|
|
107
|
-
server = new DevServer({ fs, transformer, base, cdn: options.cdn, attribution: options.attribution });
|
|
147
|
+
server = new DevServer({ fs, transformer, base, cdn: options.cdn, components, attribution: options.attribution });
|
|
108
148
|
} catch (error) {
|
|
109
149
|
transport.dispose();
|
|
110
150
|
throw error;
|
|
111
151
|
}
|
|
112
152
|
|
|
153
|
+
const previewUrl = new URL(base, transport.origin).href;
|
|
154
|
+
// In relay mode processes run on the preview origin (the relay starts them), never the host's.
|
|
155
|
+
const node = { ...options.node, createWorker: options.node?.createWorker ?? transport.createWorker?.bind(transport) };
|
|
156
|
+
const processes = createProcessHost({ fs, base, origin: transport.origin, previewUrl, options: node });
|
|
157
|
+
|
|
158
|
+
const answer = async (request: WireRequest) =>
|
|
159
|
+
isPortPath(request.path) ? servePortRequest(processes, request) : toWire(request.reqId, await server.handle(request.path, request.search));
|
|
160
|
+
|
|
113
161
|
const attach = () => {
|
|
114
162
|
const channel = new MessageChannel();
|
|
115
163
|
channel.port1.onmessage = async (event) => {
|
|
116
164
|
if (!isWireRequest(event.data)) return;
|
|
117
|
-
const { message, transfer } =
|
|
165
|
+
const { message, transfer } = await answer(event.data);
|
|
118
166
|
channel.port1.postMessage(message, transfer);
|
|
119
167
|
};
|
|
120
168
|
transport.attach(id, channel.port2);
|
|
@@ -145,16 +193,23 @@ export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewR
|
|
|
145
193
|
|
|
146
194
|
return {
|
|
147
195
|
id,
|
|
148
|
-
url:
|
|
196
|
+
url: previewUrl,
|
|
149
197
|
fs,
|
|
150
198
|
server,
|
|
151
199
|
mount: (files) => fs.mount(files),
|
|
152
200
|
profile: () => server.profile(),
|
|
201
|
+
async build(buildOptions = {}) {
|
|
202
|
+
const bundler = options.bundler ?? (await createEsbuildWasmBundler());
|
|
203
|
+
return buildProject({ files: fs, bundler, cdn: options.cdn, components, base: buildOptions.base, minify: buildOptions.minify });
|
|
204
|
+
},
|
|
153
205
|
onError(listener) {
|
|
154
206
|
errorListeners.add(listener);
|
|
155
207
|
return () => errorListeners.delete(listener);
|
|
156
208
|
},
|
|
209
|
+
spawn: (command: string, args?: string[] | SpawnOptions, spawnOptions?: SpawnOptions) => processes.spawn(command, args as string[], spawnOptions),
|
|
210
|
+
on: ((event: 'server-ready' | 'port', listener: (...args: any[]) => void) => processes.on(event as 'port', listener)) as PreviewRuntime['on'],
|
|
157
211
|
dispose() {
|
|
212
|
+
processes.dispose();
|
|
158
213
|
window.removeEventListener('message', onWindowMessage);
|
|
159
214
|
errorListeners.clear();
|
|
160
215
|
clearTimeout(reloadTimer);
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
export { bootPreviewRuntime } from './host.js';
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
BootOptions,
|
|
4
|
+
SameOriginOptions,
|
|
5
|
+
RelayOptions,
|
|
6
|
+
CommonBootOptions,
|
|
7
|
+
PreviewRuntime,
|
|
8
|
+
PreviewError,
|
|
9
|
+
RuntimeBuildOptions,
|
|
10
|
+
} from './host.js';
|
|
3
11
|
export type { PreviewTransport } from './transport.js';
|
|
4
|
-
export { createEsbuildWasmTransformer } from './esbuildWasm.js';
|
|
12
|
+
export { createEsbuildWasmTransformer, createEsbuildWasmBundler, loadEsbuildWasm } from './esbuildWasm.js';
|
|
5
13
|
export type { EsbuildWasmOptions } from './esbuildWasm.js';
|
|
14
|
+
export { createCdnComponentCompilers, defaultVueCompilerUrl, defaultSvelteCompilerUrl } from './componentCompilers.js';
|
|
15
|
+
export type { CdnComponentCompilerOptions } from './componentCompilers.js';
|
|
16
|
+
export { createCacheStoragePackageCache } from './packageCache.js';
|
|
17
|
+
export { ProcessHost, createWorkerProcess } from './node/index.js';
|
|
18
|
+
export type { NodeRuntimeOptions, ProcessHostOptions, WorkerLike } from './node/index.js';
|
|
6
19
|
export { createSwRouter } from './swRouter.js';
|
|
7
|
-
export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse } from './swRouter.js';
|
|
8
|
-
export { previewBase, previewPrefix, PREVIEW_SEGMENT, PREVIEW_ID } from './protocol.js';
|
|
20
|
+
export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse, RequestDetails } from './swRouter.js';
|
|
21
|
+
export { previewBase, previewPrefix, PREVIEW_SEGMENT, PREVIEW_ID, PORT_SEGMENT, isPortPath } from './protocol.js';
|
|
9
22
|
export * from '@seanhogg/builderforce-webcontainers-core';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Glue between the preview runtime and the Node process host: options, the
|
|
3
|
+
* default Worker, and answering `/__port/<n>/…` preview requests from the
|
|
4
|
+
* virtual server listening on that port.
|
|
5
|
+
*/
|
|
6
|
+
import { parsePortPath, type VirtualFileSystem } from '@seanhogg/builderforce-webcontainers-core';
|
|
7
|
+
import type { WireRequest, WireResponse } from '../protocol.js';
|
|
8
|
+
import { ProcessHost, type WorkerLike } from './processHost.js';
|
|
9
|
+
|
|
10
|
+
export { ProcessHost } from './processHost.js';
|
|
11
|
+
export type { ProcessHostOptions, WorkerLike } from './processHost.js';
|
|
12
|
+
export { createWorkerProcess } from './workerProcess.js';
|
|
13
|
+
|
|
14
|
+
export interface NodeRuntimeOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Where this package's `dist/node/worker.js` is served. Default: resolved
|
|
17
|
+
* beside this module (`new URL('./worker.js', import.meta.url)`), which
|
|
18
|
+
* bundlers that understand `new Worker(new URL(…))` handle automatically.
|
|
19
|
+
*/
|
|
20
|
+
workerUrl?: string | URL;
|
|
21
|
+
/** Build each process Worker yourself (CSP, a test double). Wins over `workerUrl`. */
|
|
22
|
+
createWorker?: () => WorkerLike;
|
|
23
|
+
/** npm registry. Default https://registry.npmjs.org (it sends CORS headers). */
|
|
24
|
+
registry?: string;
|
|
25
|
+
/** Cache npm tarballs in Cache Storage. Default true. */
|
|
26
|
+
packageCache?: boolean;
|
|
27
|
+
/** Extra environment for every process. */
|
|
28
|
+
env?: Record<string, string>;
|
|
29
|
+
/** Grace period before a killed process's worker is terminated. Default 2000ms. */
|
|
30
|
+
killGraceMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createProcessHost(args: { fs: VirtualFileSystem; base: string; origin: string; previewUrl: string; options?: NodeRuntimeOptions }): ProcessHost {
|
|
34
|
+
const { options = {} } = args;
|
|
35
|
+
const createWorker =
|
|
36
|
+
options.createWorker ??
|
|
37
|
+
(() => new Worker(options.workerUrl ?? new URL('./worker.js', import.meta.url), { name: 'bfwc-process' }) as unknown as WorkerLike);
|
|
38
|
+
return new ProcessHost({
|
|
39
|
+
fs: args.fs,
|
|
40
|
+
createWorker,
|
|
41
|
+
previewOrigin: args.origin,
|
|
42
|
+
previewBase: args.base,
|
|
43
|
+
previewUrl: args.previewUrl,
|
|
44
|
+
registry: options.registry,
|
|
45
|
+
packageCache: options.packageCache,
|
|
46
|
+
env: options.env,
|
|
47
|
+
killGraceMs: options.killGraceMs,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TEXT = { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' };
|
|
52
|
+
|
|
53
|
+
/** Answer a preview request addressed to a virtual server. */
|
|
54
|
+
export async function servePortRequest(host: ProcessHost, request: WireRequest): Promise<{ message: WireResponse; transfer: Transferable[] }> {
|
|
55
|
+
const target = parsePortPath(request.path)!;
|
|
56
|
+
const response = await host.request(target.port, {
|
|
57
|
+
method: request.method ?? 'GET',
|
|
58
|
+
url: target.path + request.search,
|
|
59
|
+
headers: request.headers ?? {},
|
|
60
|
+
body: request.body ? new Uint8Array(request.body) : null,
|
|
61
|
+
});
|
|
62
|
+
if (!response) {
|
|
63
|
+
return { message: { type: 'response', reqId: request.reqId, status: 502, headers: TEXT, body: `Nothing is listening on port ${target.port}. Start a server (node server.js) first.` }, transfer: [] };
|
|
64
|
+
}
|
|
65
|
+
const headers: Record<string, string> = {};
|
|
66
|
+
for (const [key, value] of Object.entries(response.headers)) headers[key] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
67
|
+
const body = response.body.slice().buffer as ArrayBuffer;
|
|
68
|
+
return { message: { type: 'response', reqId: request.reqId, status: response.status, headers, body }, transfer: [body] };
|
|
69
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page side of the Node runtime: one Web Worker per process. The page owns
|
|
3
|
+
* the master file system and the port table; each worker gets a snapshot of
|
|
4
|
+
* the files at start, and writes flow both ways as batched ops (a worker's
|
|
5
|
+
* write reaches the page, then every other worker). Children a worker spawns
|
|
6
|
+
* (the shell running `node`, `child_process`) are started here too, so every
|
|
7
|
+
* process — however deep — can be killed by terminating its worker.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ENV,
|
|
11
|
+
PortRegistry,
|
|
12
|
+
allocatePid,
|
|
13
|
+
toWebContainerProcess,
|
|
14
|
+
type ChildHandle,
|
|
15
|
+
type FsChange,
|
|
16
|
+
type HttpRequestData,
|
|
17
|
+
type HttpResponseData,
|
|
18
|
+
type SpawnOptions,
|
|
19
|
+
type VirtualFileSystem,
|
|
20
|
+
type WebContainerProcess,
|
|
21
|
+
} from '@seanhogg/builderforce-webcontainers-core';
|
|
22
|
+
import type { FsOp, HostToWorker, WorkerToHost } from './protocol.js';
|
|
23
|
+
import { portPath } from './protocol.js';
|
|
24
|
+
|
|
25
|
+
/** The part of `Worker` the host uses — a real Worker, or an in-thread fake in tests. */
|
|
26
|
+
export interface WorkerLike {
|
|
27
|
+
postMessage(message: unknown): void;
|
|
28
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
29
|
+
onerror?: ((event: { message?: string }) => void) | null;
|
|
30
|
+
terminate(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProcessHostOptions {
|
|
34
|
+
fs: VirtualFileSystem;
|
|
35
|
+
createWorker(): WorkerLike;
|
|
36
|
+
/** Origin and base path previews are served under (`/__bfwc/<id>/`). */
|
|
37
|
+
previewOrigin: string;
|
|
38
|
+
previewBase: string;
|
|
39
|
+
/** The dev server's preview URL, for the `vite`/`react-scripts start` handoff. */
|
|
40
|
+
previewUrl?: string;
|
|
41
|
+
registry?: string;
|
|
42
|
+
/** Cache npm tarballs in Cache Storage. Default true. */
|
|
43
|
+
packageCache?: boolean;
|
|
44
|
+
env?: Record<string, string>;
|
|
45
|
+
/** How long a killed process may take to exit before its worker is terminated. Default 2000ms. */
|
|
46
|
+
killGraceMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SIGNAL_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143, SIGKILL: 137, SIGHUP: 129 };
|
|
50
|
+
|
|
51
|
+
interface Proc {
|
|
52
|
+
pid: number;
|
|
53
|
+
worker: WorkerLike;
|
|
54
|
+
stdout: Set<(chunk: string) => void>;
|
|
55
|
+
stderr: Set<(chunk: string) => void>;
|
|
56
|
+
exited: boolean;
|
|
57
|
+
resolveExit(code: number): void;
|
|
58
|
+
registrations: Map<number, { close(): void }>;
|
|
59
|
+
children: Map<number, ChildHandle>;
|
|
60
|
+
pendingHttp: Map<number, (response: HttpResponseData) => void>;
|
|
61
|
+
outbox: FsOp[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ProcessHost {
|
|
65
|
+
readonly ports = new PortRegistry();
|
|
66
|
+
private readonly procs = new Set<Proc>();
|
|
67
|
+
private readonly serverReady = new Set<(port: number, url: string) => void>();
|
|
68
|
+
private readonly portListeners = new Set<(port: number, type: 'open' | 'close', url: string) => void>();
|
|
69
|
+
private applyingFrom?: Proc;
|
|
70
|
+
private flushScheduled = false;
|
|
71
|
+
private nextReq = 1;
|
|
72
|
+
private readonly unwatch: () => void;
|
|
73
|
+
private readonly stopPorts: () => void;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly options: ProcessHostOptions) {
|
|
76
|
+
this.unwatch = options.fs.watch((change) => this.onFsChange(change));
|
|
77
|
+
this.stopPorts = this.ports.watch(({ type, port }) => {
|
|
78
|
+
const url = this.serverUrl(port);
|
|
79
|
+
for (const proc of this.procs) this.post(proc, { type: 'ports', open: type === 'open' ? [port] : [], closed: type === 'close' ? [port] : [] });
|
|
80
|
+
for (const l of this.portListeners) l(port, type, url);
|
|
81
|
+
if (type === 'open') for (const l of this.serverReady) l(port, url);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
serverUrl(port: number): string {
|
|
86
|
+
return new URL(portPath(this.options.previewBase, port), this.options.previewOrigin).href;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Same shape as `WebContainer#spawn`. */
|
|
90
|
+
spawn(command: string, args: string[] | SpawnOptions = [], options: SpawnOptions = {}): WebContainerProcess {
|
|
91
|
+
if (!Array.isArray(args)) [options, args] = [args, []];
|
|
92
|
+
return toWebContainerProcess(this.spawnChild(command, args, options), !!options.terminal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
spawnChild(command: string, args: string[] = [], options: SpawnOptions = {}): ChildHandle {
|
|
96
|
+
const pid = allocatePid();
|
|
97
|
+
const worker = this.options.createWorker();
|
|
98
|
+
let resolveExit!: (code: number) => void;
|
|
99
|
+
const exit = new Promise<number>((resolve) => (resolveExit = resolve));
|
|
100
|
+
const proc: Proc = {
|
|
101
|
+
pid,
|
|
102
|
+
worker,
|
|
103
|
+
stdout: new Set(),
|
|
104
|
+
stderr: new Set(),
|
|
105
|
+
exited: false,
|
|
106
|
+
resolveExit: (code) => {
|
|
107
|
+
if (proc.exited) return;
|
|
108
|
+
proc.exited = true;
|
|
109
|
+
this.cleanup(proc);
|
|
110
|
+
resolveExit(code);
|
|
111
|
+
},
|
|
112
|
+
registrations: new Map(),
|
|
113
|
+
children: new Map(),
|
|
114
|
+
pendingHttp: new Map(),
|
|
115
|
+
outbox: [],
|
|
116
|
+
};
|
|
117
|
+
this.procs.add(proc);
|
|
118
|
+
worker.onmessage = (event) => this.onWorkerMessage(proc, event.data as WorkerToHost);
|
|
119
|
+
worker.onerror = (event) => {
|
|
120
|
+
for (const l of proc.stderr) l(`Process worker failed: ${event?.message ?? 'unknown error'}\n`);
|
|
121
|
+
proc.resolveExit(1);
|
|
122
|
+
};
|
|
123
|
+
const { fs } = this.options;
|
|
124
|
+
this.post(proc, {
|
|
125
|
+
type: 'start',
|
|
126
|
+
pid,
|
|
127
|
+
command,
|
|
128
|
+
args,
|
|
129
|
+
cwd: options.cwd ?? '/',
|
|
130
|
+
env: { ...DEFAULT_ENV, ...this.options.env, ...options.env },
|
|
131
|
+
terminal: options.terminal,
|
|
132
|
+
files: fs.list().map((path) => [path, fs.readFile(path)!]),
|
|
133
|
+
dirs: fs.explicitDirectories(),
|
|
134
|
+
ports: this.ports.ports(),
|
|
135
|
+
previewOrigin: this.options.previewOrigin,
|
|
136
|
+
previewBase: this.options.previewBase,
|
|
137
|
+
previewUrl: this.options.previewUrl,
|
|
138
|
+
registry: this.options.registry,
|
|
139
|
+
packageCache: this.options.packageCache !== false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
pid,
|
|
144
|
+
onStdout: (l) => (proc.stdout.add(l), () => proc.stdout.delete(l)),
|
|
145
|
+
onStderr: (l) => (proc.stderr.add(l), () => proc.stderr.delete(l)),
|
|
146
|
+
write: (data) => this.post(proc, { type: 'stdin', data }),
|
|
147
|
+
closeStdin: () => this.post(proc, { type: 'stdin', data: null }),
|
|
148
|
+
kill: (signal = 'SIGTERM') => this.kill(proc, signal),
|
|
149
|
+
resize: (cols, rows) => this.post(proc, { type: 'resize', cols, rows }),
|
|
150
|
+
exit,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Dispatch an HTTP request to the server on `port` (the preview service worker's `/__port/<port>/` path). */
|
|
155
|
+
request(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
|
|
156
|
+
return this.ports.request(port, request);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
on(event: 'server-ready', listener: (port: number, url: string) => void): () => void;
|
|
160
|
+
on(event: 'port', listener: (port: number, type: 'open' | 'close', url: string) => void): () => void;
|
|
161
|
+
on(event: 'server-ready' | 'port', listener: (...args: any[]) => void): () => void {
|
|
162
|
+
const set = (event === 'server-ready' ? this.serverReady : this.portListeners) as Set<(...args: any[]) => void>;
|
|
163
|
+
set.add(listener);
|
|
164
|
+
return () => set.delete(listener);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
dispose(): void {
|
|
168
|
+
for (const proc of [...this.procs]) {
|
|
169
|
+
proc.worker.terminate();
|
|
170
|
+
proc.resolveExit(SIGNAL_CODES.SIGKILL!);
|
|
171
|
+
}
|
|
172
|
+
this.unwatch();
|
|
173
|
+
this.stopPorts();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private kill(proc: Proc, signal: string): void {
|
|
177
|
+
if (proc.exited) return;
|
|
178
|
+
if (signal === 'SIGKILL') {
|
|
179
|
+
proc.worker.terminate();
|
|
180
|
+
proc.resolveExit(SIGNAL_CODES.SIGKILL!);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.post(proc, { type: 'kill', signal });
|
|
184
|
+
// A process stuck in a loop never sees the signal: terminate its worker.
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
if (proc.exited) return;
|
|
187
|
+
proc.worker.terminate();
|
|
188
|
+
proc.resolveExit(SIGNAL_CODES[signal] ?? 1);
|
|
189
|
+
}, this.options.killGraceMs ?? 2000);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private cleanup(proc: Proc): void {
|
|
193
|
+
for (const registration of proc.registrations.values()) registration.close();
|
|
194
|
+
proc.registrations.clear();
|
|
195
|
+
for (const respond of proc.pendingHttp.values()) respond({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode('The server process exited.') });
|
|
196
|
+
proc.pendingHttp.clear();
|
|
197
|
+
this.procs.delete(proc);
|
|
198
|
+
queueMicrotask(() => proc.worker.terminate());
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private post(proc: Proc, message: HostToWorker): void {
|
|
202
|
+
if (!proc.exited || message.type === 'start') proc.worker.postMessage(message);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private onFsChange(change: FsChange): void {
|
|
206
|
+
const op: FsOp = change.type === 'write' ? { op: 'write', path: change.path, content: this.options.fs.readFile(change.path)! } : { op: change.type, path: change.path };
|
|
207
|
+
for (const proc of this.procs) if (proc !== this.applyingFrom) proc.outbox.push(op);
|
|
208
|
+
if (this.flushScheduled) return;
|
|
209
|
+
this.flushScheduled = true;
|
|
210
|
+
queueMicrotask(() => this.flushOutboxes());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private flushOutboxes(): void {
|
|
214
|
+
this.flushScheduled = false;
|
|
215
|
+
for (const proc of this.procs) {
|
|
216
|
+
if (!proc.outbox.length) continue;
|
|
217
|
+
const ops = proc.outbox;
|
|
218
|
+
proc.outbox = [];
|
|
219
|
+
this.post(proc, { type: 'fs', ops });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private onWorkerMessage(proc: Proc, message: WorkerToHost): void {
|
|
224
|
+
switch (message.type) {
|
|
225
|
+
case 'stdout':
|
|
226
|
+
for (const l of proc.stdout) l(message.data);
|
|
227
|
+
return;
|
|
228
|
+
case 'stderr':
|
|
229
|
+
for (const l of proc.stderr) l(message.data);
|
|
230
|
+
return;
|
|
231
|
+
case 'exit':
|
|
232
|
+
this.flushOutboxes();
|
|
233
|
+
return proc.resolveExit(message.code);
|
|
234
|
+
case 'fs': {
|
|
235
|
+
this.applyingFrom = proc;
|
|
236
|
+
try {
|
|
237
|
+
for (const op of message.ops) {
|
|
238
|
+
if (op.op === 'write') this.options.fs.writeFile(op.path, op.content);
|
|
239
|
+
else if (op.op === 'mkdir') this.options.fs.mkdir(op.path);
|
|
240
|
+
else this.options.fs.rm(op.path);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
this.applyingFrom = undefined;
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'spawn': {
|
|
248
|
+
this.flushOutboxes();
|
|
249
|
+
let child: ChildHandle;
|
|
250
|
+
try {
|
|
251
|
+
child = this.spawnChild(message.command, message.args, { cwd: message.cwd, env: message.env, terminal: message.terminal });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return this.post(proc, { type: 'child-error', childId: message.childId, code: (error as { code?: string }).code ?? 'EFAIL', message: (error as Error).message });
|
|
254
|
+
}
|
|
255
|
+
proc.children.set(message.childId, child);
|
|
256
|
+
child.onStdout((data) => this.post(proc, { type: 'child-out', childId: message.childId, stream: 'stdout', data }));
|
|
257
|
+
child.onStderr((data) => this.post(proc, { type: 'child-out', childId: message.childId, stream: 'stderr', data }));
|
|
258
|
+
void child.exit.then((code) => {
|
|
259
|
+
proc.children.delete(message.childId);
|
|
260
|
+
this.post(proc, { type: 'child-exit', childId: message.childId, code });
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
case 'child-stdin': {
|
|
265
|
+
const child = proc.children.get(message.childId);
|
|
266
|
+
if (message.data === null) child?.closeStdin();
|
|
267
|
+
else child?.write(message.data);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case 'child-kill':
|
|
271
|
+
return proc.children.get(message.childId)?.kill(message.signal);
|
|
272
|
+
case 'child-resize':
|
|
273
|
+
return proc.children.get(message.childId)?.resize?.(message.cols, message.rows);
|
|
274
|
+
case 'listen': {
|
|
275
|
+
try {
|
|
276
|
+
const registration = this.ports.listen(message.port, (request) => this.forward(proc, message.port, request));
|
|
277
|
+
proc.registrations.set(message.port, registration);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
for (const l of proc.stderr) l(`${(error as Error).message}\n`);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
case 'unlisten':
|
|
284
|
+
proc.registrations.get(message.port)?.close();
|
|
285
|
+
proc.registrations.delete(message.port);
|
|
286
|
+
return;
|
|
287
|
+
case 'http-response':
|
|
288
|
+
proc.pendingHttp.get(message.reqId)?.(message.response);
|
|
289
|
+
proc.pendingHttp.delete(message.reqId);
|
|
290
|
+
return;
|
|
291
|
+
case 'loopback':
|
|
292
|
+
void this.ports.request(message.port, message.request).then((response) => this.post(proc, { type: 'loopback-response', reqId: message.reqId, response: response ?? null }));
|
|
293
|
+
return;
|
|
294
|
+
case 'server-ready':
|
|
295
|
+
for (const l of this.serverReady) l(message.port, message.url);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private forward(proc: Proc, port: number, request: HttpRequestData): Promise<HttpResponseData> {
|
|
301
|
+
const reqId = this.nextReq++;
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
proc.pendingHttp.set(reqId, resolve);
|
|
304
|
+
this.post(proc, { type: 'http-request', reqId, port, request });
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|