@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.
Files changed (93) hide show
  1. package/README.md +6 -1
  2. package/dist/assets.d.ts +2 -0
  3. package/dist/assets.js +4 -2
  4. package/dist/check/cachedFetch.d.ts +3 -0
  5. package/dist/check/cachedFetch.d.ts.map +1 -0
  6. package/dist/check/cachedFetch.js +39 -0
  7. package/dist/check/cachedFetch.js.map +1 -0
  8. package/dist/check/index.d.ts +41 -0
  9. package/dist/check/index.d.ts.map +1 -0
  10. package/dist/check/index.js +74 -0
  11. package/dist/check/index.js.map +1 -0
  12. package/dist/check/protocol.d.ts +29 -0
  13. package/dist/check/protocol.d.ts.map +1 -0
  14. package/dist/check/protocol.js +13 -0
  15. package/dist/check/protocol.js.map +1 -0
  16. package/dist/check/worker.js +20 -0
  17. package/dist/componentCompilers.d.ts +21 -0
  18. package/dist/componentCompilers.d.ts.map +1 -0
  19. package/dist/componentCompilers.js +35 -0
  20. package/dist/componentCompilers.js.map +1 -0
  21. package/dist/esbuildWasm.d.ts +10 -4
  22. package/dist/esbuildWasm.d.ts.map +1 -1
  23. package/dist/esbuildWasm.js +14 -6
  24. package/dist/esbuildWasm.js.map +1 -1
  25. package/dist/host.d.ts +39 -3
  26. package/dist/host.d.ts.map +1 -1
  27. package/dist/host.js +22 -7
  28. package/dist/host.js.map +1 -1
  29. package/dist/index.d.ts +9 -4
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +5 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/node/index.d.ts +42 -0
  34. package/dist/node/index.d.ts.map +1 -0
  35. package/dist/node/index.js +45 -0
  36. package/dist/node/index.js.map +1 -0
  37. package/dist/node/processHost.d.ts +65 -0
  38. package/dist/node/processHost.d.ts.map +1 -0
  39. package/dist/node/processHost.js +269 -0
  40. package/dist/node/processHost.js.map +1 -0
  41. package/dist/node/protocol.d.ts +139 -0
  42. package/dist/node/protocol.d.ts.map +1 -0
  43. package/dist/node/protocol.js +5 -0
  44. package/dist/node/protocol.js.map +1 -0
  45. package/dist/node/worker.js +197 -0
  46. package/dist/node/workerProcess.d.ts +22 -0
  47. package/dist/node/workerProcess.d.ts.map +1 -0
  48. package/dist/node/workerProcess.js +210 -0
  49. package/dist/node/workerProcess.js.map +1 -0
  50. package/dist/packageCache.d.ts +12 -0
  51. package/dist/packageCache.d.ts.map +1 -0
  52. package/dist/packageCache.js +28 -0
  53. package/dist/packageCache.js.map +1 -0
  54. package/dist/process-worker.js +197 -0
  55. package/dist/protocol.d.ts +9 -0
  56. package/dist/protocol.d.ts.map +1 -1
  57. package/dist/protocol.js +7 -0
  58. package/dist/protocol.js.map +1 -1
  59. package/dist/relay.html +1 -1
  60. package/dist/relayWorker.d.ts +19 -0
  61. package/dist/relayWorker.d.ts.map +1 -0
  62. package/dist/relayWorker.js +39 -0
  63. package/dist/relayWorker.js.map +1 -0
  64. package/dist/sw.js +1 -1
  65. package/dist/swRouter.d.ts +9 -1
  66. package/dist/swRouter.d.ts.map +1 -1
  67. package/dist/swRouter.js +26 -4
  68. package/dist/swRouter.js.map +1 -1
  69. package/dist/transport.d.ts +7 -0
  70. package/dist/transport.d.ts.map +1 -1
  71. package/dist/transport.js +7 -1
  72. package/dist/transport.js.map +1 -1
  73. package/package.json +12 -3
  74. package/src/check/cachedFetch.ts +47 -0
  75. package/src/check/index.ts +98 -0
  76. package/src/check/protocol.ts +34 -0
  77. package/src/check/worker.ts +49 -0
  78. package/src/componentCompilers.ts +66 -0
  79. package/src/esbuildWasm.ts +19 -7
  80. package/src/host.ts +64 -9
  81. package/src/index.ts +17 -4
  82. package/src/node/index.ts +69 -0
  83. package/src/node/processHost.ts +307 -0
  84. package/src/node/protocol.ts +63 -0
  85. package/src/node/worker.ts +46 -0
  86. package/src/node/workerProcess.ts +219 -0
  87. package/src/packageCache.ts +28 -0
  88. package/src/protocol.ts +13 -0
  89. package/src/relay.ts +5 -1
  90. package/src/relayWorker.ts +53 -0
  91. package/src/sw.ts +25 -6
  92. package/src/swRouter.ts +33 -4
  93. 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
+ }
@@ -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
- let ready: Promise<Transformer> | undefined;
8
+ type EsbuildWasm = typeof import('esbuild-wasm');
9
+
10
+ let ready: Promise<EsbuildWasm> | undefined;
9
11
 
10
12
  /**
11
- * The default compiler: esbuild compiled to WebAssembly, run off the main thread.
12
- * esbuild may be initialised once per page, so the transformer is shared by every
13
- * runtime booted on it (the options of the first call win).
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 createEsbuildWasmTransformer(options: EsbuildWasmOptions = {}): Promise<Transformer> {
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 createEsbuildTransformer(esbuild);
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
- type DevServerOptions,
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?: DevServerOptions['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 } = toWire(event.data.reqId, await server.handle(event.data.path, event.data.search));
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: new URL(base, transport.origin).href,
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 { BootOptions, SameOriginOptions, RelayOptions, CommonBootOptions, PreviewRuntime, PreviewError } from './host.js';
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
+ }