@seanhogg/builderforce-webcontainers 2026.10.5 → 2026.10.7

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/dist/relay.html CHANGED
@@ -1,2 +1,2 @@
1
- <!doctype html><html><head><meta charset="utf-8"><title>Preview relay</title></head><body><script>"use strict";(()=>{var a="bfwc:attach",c="bfwc:reattach",n="bfwc:relay-ready",p="bfwc:relay-attach",d="bfwc:relay-reattach",f="bfwc:relay-reload",m="bfwc:relay-spawn";function l(e,t){t.onmessage=r=>e.postMessage({kind:"message",data:r.data}),t.onerror=r=>e.postMessage({kind:"error",message:r.message||"The process worker failed."}),e.onmessage=r=>{let o=r.data;o?.kind==="message"?t.postMessage(o.data):o?.kind==="terminate"&&(t.terminate(),e.close())}}var g="process-worker.js";function i(e){return`bfwc:reload:${e}`}function u(e){if(e.active)return Promise.resolve(e.active);let t=e.installing??e.waiting;return t?new Promise((r,o)=>{t.addEventListener("statechange",()=>{t.state==="activated"&&e.active&&r(e.active),t.state==="redundant"&&o(new Error("The preview service worker failed to install."))})}):Promise.reject(new Error("The preview service worker did not install."))}function E(e){if(typeof BroadcastChannel>"u")return;let t=new BroadcastChannel(i(e));t.postMessage("reload"),t.close()}var s=window.parent;async function R(){let e=navigator.serviceWorker;if(!e)throw new Error("Service workers are unavailable on the preview origin.");let t=await e.register(new URL("./sw.js",location.href).href,{scope:"./"});await u(t),e.addEventListener("message",r=>{r.data?.type===c&&typeof r.data.id=="string"&&s.postMessage({type:d,id:r.data.id},"*")}),addEventListener("message",r=>{if(r.source!==s)return;let o=r.data;o?.type===p&&typeof o.id=="string"&&r.ports[0]?t.active?.postMessage({type:a,id:o.id},[r.ports[0]]):o?.type===f&&typeof o.base=="string"?E(o.base):o?.type===m&&r.ports[0]&&l(r.ports[0],new Worker(new URL(`./${g}`,location.href),{name:"bfwc-process"}))}),s.postMessage({type:n},"*")}R().catch(e=>{s.postMessage({type:n,error:e instanceof Error?e.message:String(e)},"*")});})();
1
+ <!doctype html><html><head><meta charset="utf-8"><title>Preview relay</title></head><body><script>"use strict";(()=>{var a="bfwc:attach",c="bfwc:reattach",n="bfwc:relay-ready",p="bfwc:relay-attach",f="bfwc:relay-reattach",l="bfwc:relay-reload",d="bfwc:relay-spawn";function m(e,t){t.onmessage=r=>e.postMessage({kind:"message",data:r.data}),t.onerror=r=>e.postMessage({kind:"error",message:r.message||"The process worker failed."}),e.onmessage=r=>{let o=r.data;o?.kind==="message"?t.postMessage(o.data):o?.kind==="terminate"&&(t.terminate(),e.close())}}var u="process-worker.js";function i(e){return`bfwc:reload:${e}`}function g(e){if(e.active)return Promise.resolve(e.active);let t=e.installing??e.waiting;return t?new Promise((r,o)=>{t.addEventListener("statechange",()=>{t.state==="activated"&&e.active&&r(e.active),t.state==="redundant"&&o(new Error("The preview service worker failed to install."))})}):Promise.reject(new Error("The preview service worker did not install."))}function E(e){if(typeof BroadcastChannel>"u")return;let t=new BroadcastChannel(i(e));t.postMessage("reload"),t.close()}var s=window.parent;async function x(){let e=navigator.serviceWorker;if(!e)throw new Error("Service workers are unavailable on the preview origin.");let t=await e.register(new URL("./sw.js",location.href).href,{scope:"./"});await g(t),e.addEventListener("message",r=>{r.data?.type===c&&typeof r.data.id=="string"&&s.postMessage({type:f,id:r.data.id},"*")}),addEventListener("message",r=>{if(r.source!==s)return;let o=r.data;o?.type===p&&typeof o.id=="string"&&r.ports[0]?t.active?.postMessage({type:a,id:o.id},[r.ports[0]]):o?.type===l&&typeof o.base=="string"?E(o.base):o?.type===d&&r.ports[0]&&m(r.ports[0],new Worker(new URL(`./${u}`,location.href),{name:"bfwc-process"}))}),s.postMessage({type:n},"*")}x().catch(e=>{s.postMessage({type:n,error:e instanceof Error?e.message:String(e)},"*")});})();
2
2
  </script></body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanhogg/builderforce-webcontainers",
3
- "version": "2026.10.5",
3
+ "version": "2026.10.7",
4
4
  "description": "BuilderForce WebContainers — browser runtime. Boots an in-browser dev server for a project held in memory and serves it to an iframe through a service worker. Open source, no COOP/COEP, no session caps.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "esbuild-wasm": "^0.24.0",
68
- "@seanhogg/builderforce-webcontainers-core": "^2026.10.5"
68
+ "@seanhogg/builderforce-webcontainers-core": "^2026.10.7"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@vue/compiler-sfc": "^3.5.0",
@@ -48,7 +48,7 @@ export function checkInputs(files: VirtualFileSystem | FlatFiles): Record<string
48
48
  if (files instanceof VirtualFileSystem) fs = files;
49
49
  else {
50
50
  fs = new VirtualFileSystem();
51
- fs.mount(files);
51
+ fs.writeTree(files);
52
52
  }
53
53
  const out: Record<string, string> = {};
54
54
  for (const path of fs.list()) if (isCheckInput(path)) out[path.slice(1)] = fs.readText(path) ?? '';
package/src/host.ts CHANGED
@@ -1,21 +1,25 @@
1
1
  import {
2
2
  DevServer,
3
- ERROR_MESSAGE_TYPE,
4
3
  VirtualFileSystem,
5
4
  buildProject,
5
+ exportZip,
6
+ importZip,
6
7
  type BuildResult,
7
8
  type Bundler,
8
9
  type ComponentCompilers,
9
- type FileSystemTree,
10
- type FlatFiles,
10
+ type FileTree,
11
11
  type PackageCdnFactory,
12
+ type PortChange,
13
+ type ProcessOptions,
12
14
  type ProjectProfile,
15
+ type RuntimeProcess,
16
+ type ServerAddress,
13
17
  type Transformer,
14
- type SpawnOptions,
15
- type WebContainerProcess,
18
+ type ZipExportOptions,
16
19
  } from '@seanhogg/builderforce-webcontainers-core';
17
20
  import { PREVIEW_ID, isPortPath, isWireRequest, previewBase, type WireRequest, type WireResponse } from './protocol.js';
18
- import { createProcessHost, servePortRequest, type NodeRuntimeOptions } from './node/index.js';
21
+ import { createProcessHost, servePortRequest, type NodeRuntimeOptions, type ProcessInfo } from './node/index.js';
22
+ import { listenToPreview, type PreviewConsoleMessage, type PreviewError } from './previewMessages.js';
19
23
  import { relayTransport, sameOriginTransport } from './transport.js';
20
24
  import { createEsbuildWasmBundler, createEsbuildWasmTransformer } from './esbuildWasm.js';
21
25
  import { createCdnComponentCompilers } from './componentCompilers.js';
@@ -41,9 +45,9 @@ export interface RelayOptions {
41
45
  scope?: never;
42
46
  }
43
47
 
44
- export type BootOptions = (SameOriginOptions | RelayOptions) & CommonBootOptions;
48
+ export type PreviewRuntimeOptions = (SameOriginOptions | RelayOptions) & CommonRuntimeOptions;
45
49
 
46
- export interface CommonBootOptions {
50
+ export interface CommonRuntimeOptions {
47
51
  /** Compiler. Defaults to esbuild-wasm. */
48
52
  transformer?: Transformer;
49
53
  /** Package CDN factory, for previews (`dev: true`) and builds (`dev: false`). Defaults to esm.sh. */
@@ -58,19 +62,15 @@ export interface CommonBootOptions {
58
62
  attribution?: boolean;
59
63
  /** Reload open previews when files change. Default true. */
60
64
  liveReload?: boolean;
61
- /** The Node runtime behind `spawn` (process workers, npm registry). */
65
+ /** Your scripts (JavaScript source), run in every preview page before the app — analytics, test hooks, a mock `fetch`. */
66
+ previewScripts?: string[];
67
+ /** The Node runtime behind `run` (process workers, npm registry). */
62
68
  node?: NodeRuntimeOptions;
63
69
  }
64
70
 
65
71
  /** Edits arrive in bursts (a save, an agent writing several files): reload once per burst. */
66
72
  const RELOAD_DEBOUNCE_MS = 120;
67
73
 
68
- export interface PreviewError {
69
- message: string;
70
- stack?: string;
71
- href: string;
72
- }
73
-
74
74
  export interface RuntimeBuildOptions {
75
75
  /** Where the site will be served from. Default `./` (works from any directory). */
76
76
  base?: string;
@@ -84,7 +84,14 @@ export interface PreviewRuntime {
84
84
  readonly url: string;
85
85
  readonly fs: VirtualFileSystem;
86
86
  readonly server: DevServer;
87
- mount(files: FileSystemTree | FlatFiles): void;
87
+ /** Write files into the runtime (nested or flat; see `FileTree`), under `at` (default `/`). Open previews reload. */
88
+ writeTree(files: FileTree, at?: string): void;
89
+ /** The project as a zip — "Download project" with `exclude: ['node_modules']`, or a full snapshot to restore later. */
90
+ exportZip(options?: ZipExportOptions): Uint8Array;
91
+ /** Write a zip's contents (an `exportZip` snapshot, a downloaded template) into the runtime, under `at`. */
92
+ importZip(zip: Uint8Array, at?: string): void;
93
+ /** Reload every frame showing this preview now (live reload does this on edits by itself). */
94
+ reload(): void;
88
95
  /** Whether this runtime can serve the mounted project, and why not. */
89
96
  profile(): ProjectProfile;
90
97
  /**
@@ -95,18 +102,23 @@ export interface PreviewRuntime {
95
102
  build(options?: RuntimeBuildOptions): Promise<BuildResult>;
96
103
  /** Uncaught errors and rejections raised inside the preview. */
97
104
  onError(listener: (error: PreviewError) => void): () => void;
105
+ /** The preview's `console.log/info/warn/error/debug` output (first 1,000 messages per page load). */
106
+ onPreviewConsole(listener: (message: PreviewConsoleMessage) => void): () => void;
107
+ /** Replace the scripts run in preview pages; open previews reload to pick them up. */
108
+ setPreviewScripts(scripts: string[]): void;
98
109
  /**
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.
110
+ * Run a command — `node server.js`, `npm install`, `npm run dev`, `bfsh` for a
111
+ * terminal — in its own Web Worker. Pass `{ terminal: { cols, rows } }` when
112
+ * wiring it to xterm.js.
103
113
  */
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;
114
+ run(command: string, args?: string[], options?: ProcessOptions): RuntimeProcess;
115
+ run(command: string, options?: ProcessOptions): RuntimeProcess;
116
+ /** A process started listening (or a dev script handed off to the preview); `url` loads it in an iframe. */
117
+ onServerListening(listener: (server: ServerAddress) => void): () => void;
118
+ /** A virtual server opened or closed its port. */
119
+ onPortChange(listener: (change: PortChange) => void): () => void;
120
+ /** Processes still running, with the ports their servers listen on. */
121
+ listProcesses(): ProcessInfo[];
110
122
  dispose(): void;
111
123
  }
112
124
 
@@ -130,7 +142,7 @@ export function toWire(reqId: number, served: Awaited<ReturnType<DevServer['hand
130
142
  * nothing else: no cross-origin isolation headers, no SharedArrayBuffer. Pass
131
143
  * `relayUrl` to serve the preview from its own origin (see `RelayOptions`).
132
144
  */
133
- export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewRuntime> {
145
+ export async function startPreviewRuntime(options: PreviewRuntimeOptions): Promise<PreviewRuntime> {
134
146
  const id = options.id ?? randomId();
135
147
  if (!PREVIEW_ID.test(id)) throw new Error(`Preview id "${id}" may only contain letters, digits, "_" and "-".`);
136
148
 
@@ -144,7 +156,7 @@ export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewR
144
156
  let server: DevServer;
145
157
  try {
146
158
  const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
147
- server = new DevServer({ fs, transformer, base, cdn: options.cdn, components, attribution: options.attribution });
159
+ server = new DevServer({ fs, transformer, base, cdn: options.cdn, components, attribution: options.attribution, previewScripts: options.previewScripts });
148
160
  } catch (error) {
149
161
  transport.dispose();
150
162
  throw error;
@@ -181,37 +193,35 @@ export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewR
181
193
  reloadTimer = setTimeout(() => transport.reload(base), RELOAD_DEBOUNCE_MS);
182
194
  });
183
195
 
184
- const errorListeners = new Set<(error: PreviewError) => void>();
185
- const onWindowMessage = (event: MessageEvent) => {
186
- const data = event.data as Partial<PreviewError> & { type?: string };
187
- if (data?.type !== ERROR_MESSAGE_TYPE || typeof data.href !== 'string') return;
188
- if (event.origin !== transport.origin || !new URL(data.href).pathname.startsWith(base)) return; // another runtime's preview
189
- const error: PreviewError = { message: String(data.message ?? ''), stack: data.stack, href: data.href };
190
- for (const listener of errorListeners) listener(error);
191
- };
192
- window.addEventListener('message', onWindowMessage);
196
+ const previewMessages = listenToPreview(window, transport.origin, base);
193
197
 
194
198
  return {
195
199
  id,
196
200
  url: previewUrl,
197
201
  fs,
198
202
  server,
199
- mount: (files) => fs.mount(files),
203
+ writeTree: (files, at) => fs.writeTree(files, at),
204
+ exportZip: (zipOptions) => exportZip(fs, zipOptions),
205
+ importZip: (zip, at) => importZip(fs, zip, at),
206
+ reload: () => transport.reload(base),
200
207
  profile: () => server.profile(),
201
208
  async build(buildOptions = {}) {
202
209
  const bundler = options.bundler ?? (await createEsbuildWasmBundler());
203
210
  return buildProject({ files: fs, bundler, cdn: options.cdn, components, base: buildOptions.base, minify: buildOptions.minify });
204
211
  },
205
- onError(listener) {
206
- errorListeners.add(listener);
207
- return () => errorListeners.delete(listener);
212
+ onError: (listener) => previewMessages.onError(listener),
213
+ onPreviewConsole: (listener) => previewMessages.onConsole(listener),
214
+ setPreviewScripts(scripts) {
215
+ server.setPreviewScripts(scripts);
216
+ transport.reload(base);
208
217
  },
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'],
218
+ run: (command: string, args?: string[] | ProcessOptions, runOptions?: ProcessOptions) => processes.run(command, args as string[], runOptions),
219
+ onServerListening: (listener) => processes.servers.onListening(listener),
220
+ onPortChange: (listener) => processes.servers.onPortChange(listener),
221
+ listProcesses: () => processes.list(),
211
222
  dispose() {
212
223
  processes.dispose();
213
- window.removeEventListener('message', onWindowMessage);
214
- errorListeners.clear();
224
+ previewMessages.dispose();
215
225
  clearTimeout(reloadTimer);
216
226
  stopWatching?.();
217
227
  stopReattach();
package/src/index.ts CHANGED
@@ -1,13 +1,14 @@
1
- export { bootPreviewRuntime } from './host.js';
1
+ export { startPreviewRuntime } from './host.js';
2
2
  export type {
3
- BootOptions,
3
+ PreviewRuntimeOptions,
4
4
  SameOriginOptions,
5
5
  RelayOptions,
6
- CommonBootOptions,
6
+ CommonRuntimeOptions,
7
7
  PreviewRuntime,
8
- PreviewError,
9
8
  RuntimeBuildOptions,
10
9
  } from './host.js';
10
+ export { listenToPreview } from './previewMessages.js';
11
+ export type { PreviewError, PreviewConsoleMessage, ConsoleLevel, PreviewMessages } from './previewMessages.js';
11
12
  export type { PreviewTransport } from './transport.js';
12
13
  export { createEsbuildWasmTransformer, createEsbuildWasmBundler, loadEsbuildWasm } from './esbuildWasm.js';
13
14
  export type { EsbuildWasmOptions } from './esbuildWasm.js';
@@ -15,7 +16,7 @@ export { createCdnComponentCompilers, defaultVueCompilerUrl, defaultSvelteCompil
15
16
  export type { CdnComponentCompilerOptions } from './componentCompilers.js';
16
17
  export { createCacheStoragePackageCache } from './packageCache.js';
17
18
  export { ProcessHost, createWorkerProcess } from './node/index.js';
18
- export type { NodeRuntimeOptions, ProcessHostOptions, WorkerLike } from './node/index.js';
19
+ export type { NodeRuntimeOptions, ProcessHostOptions, ProcessInfo, WorkerLike } from './node/index.js';
19
20
  export { createSwRouter } from './swRouter.js';
20
21
  export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse, RequestDetails } from './swRouter.js';
21
22
  export { previewBase, previewPrefix, PREVIEW_SEGMENT, PREVIEW_ID, PORT_SEGMENT, isPortPath } from './protocol.js';
package/src/node/index.ts CHANGED
@@ -8,7 +8,7 @@ import type { WireRequest, WireResponse } from '../protocol.js';
8
8
  import { ProcessHost, type WorkerLike } from './processHost.js';
9
9
 
10
10
  export { ProcessHost } from './processHost.js';
11
- export type { ProcessHostOptions, WorkerLike } from './processHost.js';
11
+ export type { ProcessHostOptions, ProcessInfo, WorkerLike } from './processHost.js';
12
12
  export { createWorkerProcess } from './workerProcess.js';
13
13
 
14
14
  export interface NodeRuntimeOptions {
@@ -9,15 +9,17 @@
9
9
  import {
10
10
  DEFAULT_ENV,
11
11
  PortRegistry,
12
+ ServerEvents,
12
13
  allocatePid,
13
- toWebContainerProcess,
14
+ runArguments,
15
+ toRuntimeProcess,
14
16
  type ChildHandle,
15
17
  type FsChange,
16
18
  type HttpRequestData,
17
19
  type HttpResponseData,
18
- type SpawnOptions,
20
+ type ProcessOptions,
21
+ type RuntimeProcess,
19
22
  type VirtualFileSystem,
20
- type WebContainerProcess,
21
23
  } from '@seanhogg/builderforce-webcontainers-core';
22
24
  import type { FsOp, HostToWorker, WorkerToHost } from './protocol.js';
23
25
  import { portPath } from './protocol.js';
@@ -48,8 +50,21 @@ export interface ProcessHostOptions {
48
50
 
49
51
  const SIGNAL_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143, SIGKILL: 137, SIGHUP: 129 };
50
52
 
53
+ /** A running process, as `listProcesses()` reports it. */
54
+ export interface ProcessInfo {
55
+ pid: number;
56
+ command: string;
57
+ args: string[];
58
+ cwd: string;
59
+ /** Epoch ms. */
60
+ startedAt: number;
61
+ /** Ports its servers listen on. */
62
+ ports: number[];
63
+ }
64
+
51
65
  interface Proc {
52
66
  pid: number;
67
+ info: Omit<ProcessInfo, 'ports'>;
53
68
  worker: WorkerLike;
54
69
  stdout: Set<(chunk: string) => void>;
55
70
  stderr: Set<(chunk: string) => void>;
@@ -63,9 +78,9 @@ interface Proc {
63
78
 
64
79
  export class ProcessHost {
65
80
  readonly ports = new PortRegistry();
81
+ /** Servers listening, ports opening and closing. */
82
+ readonly servers = new ServerEvents();
66
83
  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
84
  private applyingFrom?: Proc;
70
85
  private flushScheduled = false;
71
86
  private nextReq = 1;
@@ -77,8 +92,7 @@ export class ProcessHost {
77
92
  this.stopPorts = this.ports.watch(({ type, port }) => {
78
93
  const url = this.serverUrl(port);
79
94
  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);
95
+ this.servers.portChanged({ type, port, url });
82
96
  });
83
97
  }
84
98
 
@@ -86,19 +100,22 @@ export class ProcessHost {
86
100
  return new URL(portPath(this.options.previewBase, port), this.options.previewOrigin).href;
87
101
  }
88
102
 
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);
103
+ /** Start a command in its own Web Worker, as a stream-based {@link RuntimeProcess}. */
104
+ run(command: string, args?: string[], options?: ProcessOptions): RuntimeProcess;
105
+ run(command: string, options?: ProcessOptions): RuntimeProcess;
106
+ run(command: string, argsOrOptions?: string[] | ProcessOptions, maybeOptions?: ProcessOptions): RuntimeProcess {
107
+ const [args, options] = runArguments(argsOrOptions, maybeOptions);
108
+ return toRuntimeProcess(this.spawnChild(command, args, options), !!options.terminal);
93
109
  }
94
110
 
95
- spawnChild(command: string, args: string[] = [], options: SpawnOptions = {}): ChildHandle {
111
+ spawnChild(command: string, args: string[] = [], options: ProcessOptions = {}): ChildHandle {
96
112
  const pid = allocatePid();
97
113
  const worker = this.options.createWorker();
98
114
  let resolveExit!: (code: number) => void;
99
115
  const exit = new Promise<number>((resolve) => (resolveExit = resolve));
100
116
  const proc: Proc = {
101
117
  pid,
118
+ info: { pid, command, args: [...args], cwd: options.cwd ?? '/', startedAt: Date.now() },
102
119
  worker,
103
120
  stdout: new Set(),
104
121
  stderr: new Set(),
@@ -151,19 +168,16 @@ export class ProcessHost {
151
168
  };
152
169
  }
153
170
 
171
+ /** Every process still running, oldest first — children started by a shell included. */
172
+ list(): ProcessInfo[] {
173
+ return [...this.procs].map((proc) => ({ ...proc.info, args: [...proc.info.args], ports: [...proc.registrations.keys()] }));
174
+ }
175
+
154
176
  /** Dispatch an HTTP request to the server on `port` (the preview service worker's `/__port/<port>/` path). */
155
177
  request(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
156
178
  return this.ports.request(port, request);
157
179
  }
158
180
 
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
181
  dispose(): void {
168
182
  for (const proc of [...this.procs]) {
169
183
  proc.worker.terminate();
@@ -291,8 +305,8 @@ export class ProcessHost {
291
305
  case 'loopback':
292
306
  void this.ports.request(message.port, message.request).then((response) => this.post(proc, { type: 'loopback-response', reqId: message.reqId, response: response ?? null }));
293
307
  return;
294
- case 'server-ready':
295
- for (const l of this.serverReady) l(message.port, message.url);
308
+ case 'listening':
309
+ this.servers.announce({ port: message.port, url: message.url });
296
310
  return;
297
311
  }
298
312
  }
@@ -55,7 +55,7 @@ export type WorkerToHost =
55
55
  | { type: 'unlisten'; port: number }
56
56
  | { type: 'http-response'; reqId: number; response: HttpResponseData }
57
57
  | { type: 'loopback'; reqId: number; port: number; request: HttpRequestData }
58
- | { type: 'server-ready'; port: number; url: string };
58
+ | { type: 'listening'; port: number; url: string };
59
59
 
60
60
  /** The preview-relative URL a virtual server on `port` is reachable at. */
61
61
  export function portPath(previewBase: string, port: number): string {
@@ -144,7 +144,7 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
144
144
  });
145
145
  },
146
146
  serverUrl: (port) => new URL(portPath(message.previewBase, port), message.previewOrigin).href,
147
- announceServer: (port, url) => deps.post({ type: 'server-ready', port, url }),
147
+ announceServer: (port, url) => deps.post({ type: 'listening', port, url }),
148
148
  previewUrl: message.previewUrl,
149
149
  registry: message.registry,
150
150
  packageCache: message.packageCache ? deps.packageCache?.() : undefined,
@@ -154,7 +154,7 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
154
154
 
155
155
  const resolved = resolveCommand(fs, programs, message.command, message.args, message.cwd, message.env);
156
156
  if (!resolved) {
157
- deps.post({ type: 'stderr', data: `jsh: command not found: ${message.command}\n` });
157
+ deps.post({ type: 'stderr', data: `bfsh: command not found: ${message.command}\n` });
158
158
  deps.post({ type: 'exit', code: 127 });
159
159
  return;
160
160
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What a preview reports to the host page: uncaught errors and console output,
3
+ * posted by the bridge script every served page starts with. One window
4
+ * listener per runtime, accepting only messages from the preview origin whose
5
+ * page sits under this runtime's base, so two runtimes on one page never hear
6
+ * each other's previews.
7
+ */
8
+ import { CONSOLE_MESSAGE_TYPE, ERROR_MESSAGE_TYPE } from '@seanhogg/builderforce-webcontainers-core';
9
+
10
+ export interface PreviewError {
11
+ message: string;
12
+ stack?: string;
13
+ href: string;
14
+ }
15
+
16
+ export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
17
+
18
+ export interface PreviewConsoleMessage {
19
+ level: ConsoleLevel;
20
+ /** The arguments flattened to text, capped at 4,000 characters. */
21
+ text: string;
22
+ href: string;
23
+ }
24
+
25
+ export interface PreviewMessages {
26
+ onError(listener: (error: PreviewError) => void): () => void;
27
+ onConsole(listener: (message: PreviewConsoleMessage) => void): () => void;
28
+ dispose(): void;
29
+ }
30
+
31
+ const LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug']);
32
+
33
+ function subscribe<T>(set: Set<(value: T) => void>, listener: (value: T) => void): () => void {
34
+ set.add(listener);
35
+ return () => set.delete(listener);
36
+ }
37
+
38
+ export function listenToPreview(target: Pick<Window, 'addEventListener' | 'removeEventListener'>, origin: string, base: string): PreviewMessages {
39
+ const errors = new Set<(error: PreviewError) => void>();
40
+ const consoles = new Set<(message: PreviewConsoleMessage) => void>();
41
+
42
+ const onMessage = (event: MessageEvent) => {
43
+ const data = event.data as { type?: unknown; href?: unknown; message?: unknown; stack?: unknown; level?: unknown; text?: unknown } | null;
44
+ if (!data || typeof data.href !== 'string' || event.origin !== origin) return;
45
+ let path: string;
46
+ try {
47
+ path = new URL(data.href).pathname;
48
+ } catch {
49
+ return;
50
+ }
51
+ if (!path.startsWith(base)) return; // another runtime's preview
52
+ if (data.type === ERROR_MESSAGE_TYPE) {
53
+ const error: PreviewError = { message: String(data.message ?? ''), stack: typeof data.stack === 'string' ? data.stack : undefined, href: data.href };
54
+ for (const listener of errors) listener(error);
55
+ } else if (data.type === CONSOLE_MESSAGE_TYPE && typeof data.level === 'string' && LEVELS.has(data.level)) {
56
+ const message: PreviewConsoleMessage = { level: data.level as ConsoleLevel, text: String(data.text ?? ''), href: data.href };
57
+ for (const listener of consoles) listener(message);
58
+ }
59
+ };
60
+ target.addEventListener('message', onMessage as EventListener);
61
+
62
+ return {
63
+ onError: (listener) => subscribe(errors, listener),
64
+ onConsole: (listener) => subscribe(consoles, listener),
65
+ dispose() {
66
+ target.removeEventListener('message', onMessage as EventListener);
67
+ errors.clear();
68
+ consoles.clear();
69
+ },
70
+ };
71
+ }