@seanhogg/builderforce-webcontainers 2026.10.1 → 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 (98) hide show
  1. package/README.md +11 -0
  2. package/dist/assets.d.ts +6 -0
  3. package/dist/assets.js +6 -0
  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 +61 -6
  26. package/dist/host.d.ts.map +1 -1
  27. package/dist/host.js +61 -42
  28. package/dist/host.js.map +1 -1
  29. package/dist/index.d.ts +10 -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 +27 -0
  56. package/dist/protocol.d.ts.map +1 -1
  57. package/dist/protocol.js +25 -0
  58. package/dist/protocol.js.map +1 -1
  59. package/dist/relay.html +2 -0
  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 +29 -5
  68. package/dist/swRouter.js.map +1 -1
  69. package/dist/transport.d.ts +23 -0
  70. package/dist/transport.d.ts.map +1 -0
  71. package/dist/transport.js +112 -0
  72. package/dist/transport.js.map +1 -0
  73. package/dist/worker.d.ts +5 -0
  74. package/dist/worker.d.ts.map +1 -0
  75. package/dist/worker.js +29 -0
  76. package/dist/worker.js.map +1 -0
  77. package/package.json +17 -3
  78. package/src/check/cachedFetch.ts +47 -0
  79. package/src/check/index.ts +98 -0
  80. package/src/check/protocol.ts +34 -0
  81. package/src/check/worker.ts +49 -0
  82. package/src/componentCompilers.ts +66 -0
  83. package/src/esbuildWasm.ts +19 -7
  84. package/src/host.ts +125 -42
  85. package/src/index.ts +18 -4
  86. package/src/node/index.ts +69 -0
  87. package/src/node/processHost.ts +307 -0
  88. package/src/node/protocol.ts +63 -0
  89. package/src/node/worker.ts +46 -0
  90. package/src/node/workerProcess.ts +219 -0
  91. package/src/packageCache.ts +28 -0
  92. package/src/protocol.ts +33 -0
  93. package/src/relay.ts +44 -0
  94. package/src/relayWorker.ts +53 -0
  95. package/src/sw.ts +25 -6
  96. package/src/swRouter.ts +36 -5
  97. package/src/transport.ts +141 -0
  98. package/src/worker.ts +25 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `@seanhogg/builderforce-webcontainers/check` — type-check a project in a Web
3
+ * Worker, with no install and no server.
4
+ *
5
+ * The worker loads the TypeScript compiler from a CDN (pinned below) and fetches
6
+ * dependency types from esm.sh, caching both, so the page's main thread never
7
+ * parses lib.dom.d.ts. Diagnostics come back as plain data (see CheckDiagnostic).
8
+ */
9
+ import { VirtualFileSystem, type FlatFiles } from '@seanhogg/builderforce-webcontainers-core';
10
+ import type { TypeSources, TypecheckResult } from '@seanhogg/builderforce-webcontainers-core/check';
11
+ import { CHECK, CHECK_RESULT, isCheckInput, isCheckReply, type CheckReply } from './protocol.js';
12
+
13
+ export type { CheckDiagnostic, DiagnosticCategory, TypecheckResult } from '@seanhogg/builderforce-webcontainers-core/check';
14
+ export { formatDiagnostic } from '@seanhogg/builderforce-webcontainers-core/check';
15
+
16
+ /** The TypeScript the checker runs. Pinned, so results do not change under a project. */
17
+ export const TYPESCRIPT_VERSION = '5.9.3';
18
+ export const DEFAULT_TYPESCRIPT_URL = `https://cdn.jsdelivr.net/npm/typescript@${TYPESCRIPT_VERSION}/lib/typescript.js`;
19
+
20
+ export interface CheckerOptions {
21
+ /**
22
+ * The worker script (this package's `dist/check/worker.js`). By default it is
23
+ * resolved beside this module, a pattern Vite, webpack 5 and Next.js all bundle.
24
+ */
25
+ workerUrl?: string | URL;
26
+ /** Build the Worker yourself (a CSP that needs a specific origin, a test double). */
27
+ createWorker?: () => Worker;
28
+ /** A classic-script build of TypeScript that sets `self.ts`. Default: jsDelivr, {@link TYPESCRIPT_VERSION}. */
29
+ typescriptUrl?: string;
30
+ /** Where types, libs and tsconfig packages come from. Libs default to the loaded TypeScript's version. */
31
+ sources?: Partial<TypeSources>;
32
+ }
33
+
34
+ export interface CheckResult extends TypecheckResult {
35
+ durationMs: number;
36
+ }
37
+
38
+ export interface Checker {
39
+ /** Type-check the project. Checks queue; each resolves with its own result. */
40
+ check(files: VirtualFileSystem | FlatFiles): Promise<CheckResult>;
41
+ /** Stop the worker; pending checks reject. */
42
+ dispose(): void;
43
+ }
44
+
45
+ /** Only the files that can affect checking, as text: assets never cross to the worker. */
46
+ export function checkInputs(files: VirtualFileSystem | FlatFiles): Record<string, string> {
47
+ let fs: VirtualFileSystem;
48
+ if (files instanceof VirtualFileSystem) fs = files;
49
+ else {
50
+ fs = new VirtualFileSystem();
51
+ fs.mount(files);
52
+ }
53
+ const out: Record<string, string> = {};
54
+ for (const path of fs.list()) if (isCheckInput(path)) out[path.slice(1)] = fs.readText(path) ?? '';
55
+ return out;
56
+ }
57
+
58
+ export function createChecker(options: CheckerOptions = {}): Checker {
59
+ const worker = options.createWorker?.() ?? new Worker(options.workerUrl ?? new URL('./worker.js', import.meta.url));
60
+ const pending = new Map<number, { resolve(result: CheckResult): void; reject(error: Error): void }>();
61
+ let nextId = 0;
62
+
63
+ worker.addEventListener('message', (event: MessageEvent) => {
64
+ if (!isCheckReply(event.data)) return;
65
+ const reply: CheckReply = event.data;
66
+ const waiter = pending.get(reply.id);
67
+ pending.delete(reply.id);
68
+ if (reply.type === CHECK_RESULT) waiter?.resolve({ ...reply.result, durationMs: reply.durationMs });
69
+ else waiter?.reject(new Error(reply.message));
70
+ });
71
+ worker.addEventListener('error', (event: ErrorEvent) => {
72
+ const error = new Error(event.message || 'The check worker failed to start.');
73
+ for (const waiter of pending.values()) waiter.reject(error);
74
+ pending.clear();
75
+ });
76
+
77
+ return {
78
+ check(files) {
79
+ const id = ++nextId;
80
+ return new Promise<CheckResult>((resolve, reject) => {
81
+ pending.set(id, { resolve, reject });
82
+ worker.postMessage({
83
+ type: CHECK,
84
+ id,
85
+ files: checkInputs(files),
86
+ typescriptUrl: options.typescriptUrl ?? DEFAULT_TYPESCRIPT_URL,
87
+ sources: options.sources,
88
+ });
89
+ });
90
+ },
91
+ dispose() {
92
+ worker.terminate();
93
+ const error = new Error('The checker was disposed.');
94
+ for (const waiter of pending.values()) waiter.reject(error);
95
+ pending.clear();
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Messages between `createChecker()` (page) and the check worker. Files travel as
3
+ * strings: only source and config files are sent, never assets.
4
+ */
5
+ import type { TypeSources, TypecheckResult } from '@seanhogg/builderforce-webcontainers-core/check';
6
+
7
+ export const CHECK = 'bfwc:check';
8
+ export const CHECK_RESULT = 'bfwc:check-result';
9
+ export const CHECK_ERROR = 'bfwc:check-error';
10
+
11
+ export interface CheckRequest {
12
+ type: typeof CHECK;
13
+ id: number;
14
+ files: Record<string, string>;
15
+ /** Classic script URL of the TypeScript compiler (sets `self.ts`). */
16
+ typescriptUrl: string;
17
+ sources?: Partial<TypeSources>;
18
+ }
19
+
20
+ export type CheckReply =
21
+ | { type: typeof CHECK_RESULT; id: number; result: TypecheckResult; durationMs: number }
22
+ | { type: typeof CHECK_ERROR; id: number; message: string };
23
+
24
+ /** Paths whose contents can affect type-checking. */
25
+ const CHECKED = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|json)$/i;
26
+
27
+ export function isCheckInput(path: string): boolean {
28
+ return CHECKED.test(path) && !/(^|\/)(node_modules|dist)\//.test(path);
29
+ }
30
+
31
+ export function isCheckReply(value: unknown): value is CheckReply {
32
+ const v = value as CheckReply | null;
33
+ return !!v && (v.type === CHECK_RESULT || v.type === CHECK_ERROR) && typeof v.id === 'number';
34
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The check worker (bundled to dist/check/worker.js as one classic script). It
3
+ * loads the TypeScript compiler with `importScripts` — TypeScript's own UMD
4
+ * build, which sets `self.ts` — and keeps one TypeStore for its lifetime, so
5
+ * every check after the first reuses fetched and parsed declarations.
6
+ *
7
+ * Worker globals are typed locally, as in sw.ts: `lib.webworker` cannot share a
8
+ * compilation with the DOM lib the rest of the package needs.
9
+ */
10
+ import type * as TS from 'typescript';
11
+ import { TypeStore, typecheckProject } from '@seanhogg/builderforce-webcontainers-core/check';
12
+ import { createCachedFetch } from './cachedFetch.js';
13
+ import { CHECK, CHECK_ERROR, CHECK_RESULT, type CheckReply, type CheckRequest } from './protocol.js';
14
+
15
+ interface WorkerGlobalLike {
16
+ ts?: typeof TS;
17
+ importScripts(url: string): void;
18
+ postMessage(message: CheckReply): void;
19
+ addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void;
20
+ }
21
+
22
+ const worker = globalThis as unknown as WorkerGlobalLike;
23
+ const store = new TypeStore();
24
+ const fetchTypes = createCachedFetch();
25
+ let loadedFrom: string | undefined;
26
+
27
+ function typescript(url: string): typeof TS {
28
+ if (!worker.ts || loadedFrom !== url) {
29
+ worker.importScripts(url);
30
+ loadedFrom = url;
31
+ }
32
+ if (!worker.ts) throw new Error(`${url} did not define the TypeScript compiler.`);
33
+ return worker.ts;
34
+ }
35
+
36
+ worker.addEventListener('message', (event) => {
37
+ const request = event.data as CheckRequest | null;
38
+ if (request?.type !== CHECK) return;
39
+ const started = Date.now();
40
+ void (async () => {
41
+ try {
42
+ const ts = typescript(request.typescriptUrl);
43
+ const result = await typecheckProject(ts, request.files, { fetch: fetchTypes, store, sources: request.sources });
44
+ worker.postMessage({ type: CHECK_RESULT, id: request.id, result, durationMs: Date.now() - started });
45
+ } catch (error) {
46
+ worker.postMessage({ type: CHECK_ERROR, id: request.id, message: error instanceof Error ? error.message : String(error) });
47
+ }
48
+ })();
49
+ });
@@ -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,36 +2,82 @@ 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 { ATTACH, REATTACH, isWireRequest, previewBase, type WireResponse } from './protocol.js';
12
- import { createEsbuildWasmTransformer } from './esbuildWasm.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';
19
+ import { relayTransport, sameOriginTransport } from './transport.js';
20
+ import { createEsbuildWasmBundler, createEsbuildWasmTransformer } from './esbuildWasm.js';
21
+ import { createCdnComponentCompilers } from './componentCompilers.js';
13
22
 
14
- export interface BootOptions {
23
+ /** Serve previews on THIS page's origin. Only for code the page's own user trusts. */
24
+ export interface SameOriginOptions {
15
25
  /** Where the host serves this package's `dist/sw.js` (e.g. `/__bfwc/sw.js`). */
16
26
  serviceWorkerUrl: string;
17
27
  /** Worker scope. Defaults to the worker script's directory. */
18
28
  scope?: string;
29
+ relayUrl?: never;
30
+ }
31
+
32
+ /**
33
+ * Serve previews on a SEPARATE origin, isolated from this page's cookies, storage
34
+ * and DOM. That origin serves `dist/relay.html` and `dist/sw.js` side by side, with
35
+ * `frame-ancestors` limited to the pages allowed to drive it.
36
+ */
37
+ export interface RelayOptions {
38
+ /** e.g. `https://preview.example.com/__bfwc/relay.html`. */
39
+ relayUrl: string;
40
+ serviceWorkerUrl?: never;
41
+ scope?: never;
42
+ }
43
+
44
+ export type BootOptions = (SameOriginOptions | RelayOptions) & CommonBootOptions;
45
+
46
+ export interface CommonBootOptions {
19
47
  /** Compiler. Defaults to esbuild-wasm. */
20
48
  transformer?: Transformer;
21
- /** Package CDN factory. Defaults to esm.sh. */
22
- cdn?: DevServerOptions['cdn'];
23
- /** Stable id (e.g. the project id) — keeps the preview URL stable across reloads. */
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;
55
+ /** Stable id (e.g. the project id; `[A-Za-z0-9_-]`) — keeps the preview URL stable across reloads. */
24
56
  id?: string;
25
57
  /** The "Built with Builderforce.ai" badge in the preview. Default true — please keep it. */
26
58
  attribution?: boolean;
59
+ /** Reload open previews when files change. Default true. */
60
+ liveReload?: boolean;
61
+ /** The Node runtime behind `spawn` (process workers, npm registry). */
62
+ node?: NodeRuntimeOptions;
27
63
  }
28
64
 
65
+ /** Edits arrive in bursts (a save, an agent writing several files): reload once per burst. */
66
+ const RELOAD_DEBOUNCE_MS = 120;
67
+
29
68
  export interface PreviewError {
30
69
  message: string;
31
70
  stack?: string;
32
71
  href: string;
33
72
  }
34
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
+
35
81
  export interface PreviewRuntime {
36
82
  readonly id: string;
37
83
  /** Absolute URL to load in the preview iframe. */
@@ -41,28 +87,34 @@ export interface PreviewRuntime {
41
87
  mount(files: FileSystemTree | FlatFiles): void;
42
88
  /** Whether this runtime can serve the mounted project, and why not. */
43
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>;
44
96
  /** Uncaught errors and rejections raised inside the preview. */
45
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;
46
110
  dispose(): void;
47
111
  }
48
112
 
49
- function waitForActive(registration: ServiceWorkerRegistration): Promise<ServiceWorker> {
50
- if (registration.active) return Promise.resolve(registration.active);
51
- const installing = registration.installing ?? registration.waiting;
52
- if (!installing) return Promise.reject(new Error('The preview service worker did not install.'));
53
- return new Promise((resolve, reject) => {
54
- installing.addEventListener('statechange', () => {
55
- if (installing.state === 'activated' && registration.active) resolve(registration.active);
56
- if (installing.state === 'redundant') reject(new Error('The preview service worker failed to install.'));
57
- });
58
- });
59
- }
60
-
61
113
  function randomId(): string {
62
114
  return globalThis.crypto?.randomUUID?.().replace(/-/g, '').slice(0, 16) ?? Math.random().toString(36).slice(2, 18);
63
115
  }
64
116
 
65
- 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[] } {
66
118
  if (typeof served.body === 'string') {
67
119
  return { message: { type: 'response', reqId, status: served.status, headers: served.headers, body: served.body }, transfer: [] };
68
120
  }
@@ -75,44 +127,65 @@ function toWire(reqId: number, served: Awaited<ReturnType<DevServer['handle']>>)
75
127
  * it, and a service worker that routes the preview iframe's requests back here.
76
128
  *
77
129
  * Needs a secure context (https or localhost) for the service worker — and
78
- * nothing else: no cross-origin isolation headers, no SharedArrayBuffer.
130
+ * nothing else: no cross-origin isolation headers, no SharedArrayBuffer. Pass
131
+ * `relayUrl` to serve the preview from its own origin (see `RelayOptions`).
79
132
  */
80
133
  export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewRuntime> {
81
- const container = navigator.serviceWorker;
82
- if (!container) throw new Error('Service workers are unavailable here (a secure context — https or localhost — is required).');
134
+ const id = options.id ?? randomId();
135
+ if (!PREVIEW_ID.test(id)) throw new Error(`Preview id "${id}" may only contain letters, digits, "_" and "-".`);
83
136
 
84
- const scriptUrl = new URL(options.serviceWorkerUrl, location.href);
85
- const scope = new URL(options.scope ?? './', scriptUrl).pathname;
86
- const registration = await container.register(scriptUrl.href, { scope });
87
- await waitForActive(registration);
137
+ const transport = options.relayUrl
138
+ ? await relayTransport(options.relayUrl)
139
+ : await sameOriginTransport((options as SameOriginOptions).serviceWorkerUrl, options.scope);
88
140
 
89
- const id = options.id ?? randomId();
90
- const base = previewBase(scope, id);
141
+ const base = previewBase(transport.scope, id);
91
142
  const fs = new VirtualFileSystem();
92
- const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
93
- const server = new DevServer({ fs, transformer, base, cdn: options.cdn, attribution: options.attribution });
143
+ const components = options.components ?? createCdnComponentCompilers();
144
+ let server: DevServer;
145
+ try {
146
+ const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
147
+ server = new DevServer({ fs, transformer, base, cdn: options.cdn, components, attribution: options.attribution });
148
+ } catch (error) {
149
+ transport.dispose();
150
+ throw error;
151
+ }
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 });
94
157
 
95
- const attach = (worker: ServiceWorker) => {
158
+ const answer = async (request: WireRequest) =>
159
+ isPortPath(request.path) ? servePortRequest(processes, request) : toWire(request.reqId, await server.handle(request.path, request.search));
160
+
161
+ const attach = () => {
96
162
  const channel = new MessageChannel();
97
163
  channel.port1.onmessage = async (event) => {
98
164
  if (!isWireRequest(event.data)) return;
99
- const { message, transfer } = toWire(event.data.reqId, await server.handle(event.data.path, event.data.search));
165
+ const { message, transfer } = await answer(event.data);
100
166
  channel.port1.postMessage(message, transfer);
101
167
  };
102
- worker.postMessage({ type: ATTACH, id }, [channel.port2]);
168
+ transport.attach(id, channel.port2);
103
169
  };
104
- attach(registration.active!);
170
+ attach();
171
+ const stopReattach = transport.onReattach((wanted) => {
172
+ if (wanted === id) attach();
173
+ });
105
174
 
106
- const onWorkerMessage = (event: MessageEvent) => {
107
- if (event.data?.type === REATTACH && event.data.id === id && registration.active) attach(registration.active);
108
- };
109
- container.addEventListener('message', onWorkerMessage);
175
+ // Live reload: any change to the files reloads every frame showing this preview.
176
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
177
+ const stopWatching = options.liveReload === false
178
+ ? undefined
179
+ : fs.watch(() => {
180
+ clearTimeout(reloadTimer);
181
+ reloadTimer = setTimeout(() => transport.reload(base), RELOAD_DEBOUNCE_MS);
182
+ });
110
183
 
111
184
  const errorListeners = new Set<(error: PreviewError) => void>();
112
185
  const onWindowMessage = (event: MessageEvent) => {
113
186
  const data = event.data as Partial<PreviewError> & { type?: string };
114
187
  if (data?.type !== ERROR_MESSAGE_TYPE || typeof data.href !== 'string') return;
115
- if (!new URL(data.href).pathname.startsWith(base)) return; // another runtime's preview
188
+ if (event.origin !== transport.origin || !new URL(data.href).pathname.startsWith(base)) return; // another runtime's preview
116
189
  const error: PreviewError = { message: String(data.message ?? ''), stack: data.stack, href: data.href };
117
190
  for (const listener of errorListeners) listener(error);
118
191
  };
@@ -120,19 +193,29 @@ export async function bootPreviewRuntime(options: BootOptions): Promise<PreviewR
120
193
 
121
194
  return {
122
195
  id,
123
- url: new URL(base, location.origin).href,
196
+ url: previewUrl,
124
197
  fs,
125
198
  server,
126
199
  mount: (files) => fs.mount(files),
127
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
+ },
128
205
  onError(listener) {
129
206
  errorListeners.add(listener);
130
207
  return () => errorListeners.delete(listener);
131
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'],
132
211
  dispose() {
133
- container.removeEventListener('message', onWorkerMessage);
212
+ processes.dispose();
134
213
  window.removeEventListener('message', onWindowMessage);
135
214
  errorListeners.clear();
215
+ clearTimeout(reloadTimer);
216
+ stopWatching?.();
217
+ stopReattach();
218
+ transport.dispose();
136
219
  server.dispose();
137
220
  },
138
221
  };
package/src/index.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  export { bootPreviewRuntime } from './host.js';
2
- export type { BootOptions, PreviewRuntime, PreviewError } from './host.js';
3
- export { createEsbuildWasmTransformer } from './esbuildWasm.js';
2
+ export type {
3
+ BootOptions,
4
+ SameOriginOptions,
5
+ RelayOptions,
6
+ CommonBootOptions,
7
+ PreviewRuntime,
8
+ PreviewError,
9
+ RuntimeBuildOptions,
10
+ } from './host.js';
11
+ export type { PreviewTransport } from './transport.js';
12
+ export { createEsbuildWasmTransformer, createEsbuildWasmBundler, loadEsbuildWasm } from './esbuildWasm.js';
4
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';
5
19
  export { createSwRouter } from './swRouter.js';
6
- export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse } from './swRouter.js';
7
- export { previewBase, previewPrefix, PREVIEW_SEGMENT } 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';
8
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
+ }