@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.
- package/README.md +11 -0
- package/dist/assets.d.ts +6 -0
- package/dist/assets.js +6 -0
- package/dist/check/cachedFetch.d.ts +3 -0
- package/dist/check/cachedFetch.d.ts.map +1 -0
- package/dist/check/cachedFetch.js +39 -0
- package/dist/check/cachedFetch.js.map +1 -0
- package/dist/check/index.d.ts +41 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +74 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/protocol.d.ts +29 -0
- package/dist/check/protocol.d.ts.map +1 -0
- package/dist/check/protocol.js +13 -0
- package/dist/check/protocol.js.map +1 -0
- package/dist/check/worker.js +20 -0
- package/dist/componentCompilers.d.ts +21 -0
- package/dist/componentCompilers.d.ts.map +1 -0
- package/dist/componentCompilers.js +35 -0
- package/dist/componentCompilers.js.map +1 -0
- package/dist/esbuildWasm.d.ts +10 -4
- package/dist/esbuildWasm.d.ts.map +1 -1
- package/dist/esbuildWasm.js +14 -6
- package/dist/esbuildWasm.js.map +1 -1
- package/dist/host.d.ts +61 -6
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +61 -42
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +42 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +45 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/processHost.d.ts +65 -0
- package/dist/node/processHost.d.ts.map +1 -0
- package/dist/node/processHost.js +269 -0
- package/dist/node/processHost.js.map +1 -0
- package/dist/node/protocol.d.ts +139 -0
- package/dist/node/protocol.d.ts.map +1 -0
- package/dist/node/protocol.js +5 -0
- package/dist/node/protocol.js.map +1 -0
- package/dist/node/worker.js +197 -0
- package/dist/node/workerProcess.d.ts +22 -0
- package/dist/node/workerProcess.d.ts.map +1 -0
- package/dist/node/workerProcess.js +210 -0
- package/dist/node/workerProcess.js.map +1 -0
- package/dist/packageCache.d.ts +12 -0
- package/dist/packageCache.d.ts.map +1 -0
- package/dist/packageCache.js +28 -0
- package/dist/packageCache.js.map +1 -0
- package/dist/process-worker.js +197 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +25 -0
- package/dist/protocol.js.map +1 -1
- package/dist/relay.html +2 -0
- package/dist/relayWorker.d.ts +19 -0
- package/dist/relayWorker.d.ts.map +1 -0
- package/dist/relayWorker.js +39 -0
- package/dist/relayWorker.js.map +1 -0
- package/dist/sw.js +1 -1
- package/dist/swRouter.d.ts +9 -1
- package/dist/swRouter.d.ts.map +1 -1
- package/dist/swRouter.js +29 -5
- package/dist/swRouter.js.map +1 -1
- package/dist/transport.d.ts +23 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +112 -0
- package/dist/transport.js.map +1 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +29 -0
- package/dist/worker.js.map +1 -0
- package/package.json +17 -3
- package/src/check/cachedFetch.ts +47 -0
- package/src/check/index.ts +98 -0
- package/src/check/protocol.ts +34 -0
- package/src/check/worker.ts +49 -0
- package/src/componentCompilers.ts +66 -0
- package/src/esbuildWasm.ts +19 -7
- package/src/host.ts +125 -42
- package/src/index.ts +18 -4
- package/src/node/index.ts +69 -0
- package/src/node/processHost.ts +307 -0
- package/src/node/protocol.ts +63 -0
- package/src/node/worker.ts +46 -0
- package/src/node/workerProcess.ts +219 -0
- package/src/packageCache.ts +28 -0
- package/src/protocol.ts +33 -0
- package/src/relay.ts +44 -0
- package/src/relayWorker.ts +53 -0
- package/src/sw.ts +25 -6
- package/src/swRouter.ts +36 -5
- package/src/transport.ts +141 -0
- 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
|
+
}
|
package/src/esbuildWasm.ts
CHANGED
|
@@ -1,26 +1,38 @@
|
|
|
1
|
-
import { createEsbuildTransformer, type Transformer } from '@seanhogg/builderforce-webcontainers-core';
|
|
1
|
+
import { createEsbuildBundler, createEsbuildTransformer, type Bundler, type Transformer } from '@seanhogg/builderforce-webcontainers-core';
|
|
2
2
|
|
|
3
3
|
export interface EsbuildWasmOptions {
|
|
4
4
|
/** Where to fetch esbuild.wasm. Defaults to jsDelivr at the installed version. */
|
|
5
5
|
wasmURL?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
type EsbuildWasm = typeof import('esbuild-wasm');
|
|
9
|
+
|
|
10
|
+
let ready: Promise<EsbuildWasm> | undefined;
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* esbuild compiled to WebAssembly, run off the main thread. esbuild may be
|
|
14
|
+
* initialised once per page, so the dev-server transformer and the production
|
|
15
|
+
* bundler share one instance (the options of the first call win).
|
|
14
16
|
*/
|
|
15
|
-
export function
|
|
17
|
+
export function loadEsbuildWasm(options: EsbuildWasmOptions = {}): Promise<EsbuildWasm> {
|
|
16
18
|
ready ??= (async () => {
|
|
17
19
|
const esbuild = await import('esbuild-wasm');
|
|
18
20
|
const wasmURL = options.wasmURL ?? `https://cdn.jsdelivr.net/npm/esbuild-wasm@${esbuild.version}/esbuild.wasm`;
|
|
19
21
|
await esbuild.initialize({ wasmURL, worker: true });
|
|
20
|
-
return
|
|
22
|
+
return esbuild;
|
|
21
23
|
})().catch((error: unknown) => {
|
|
22
24
|
ready = undefined; // let a later call retry (a transient network failure fetching the wasm)
|
|
23
25
|
throw error;
|
|
24
26
|
});
|
|
25
27
|
return ready;
|
|
26
28
|
}
|
|
29
|
+
|
|
30
|
+
/** The default compiler for the dev server. */
|
|
31
|
+
export async function createEsbuildWasmTransformer(options: EsbuildWasmOptions = {}): Promise<Transformer> {
|
|
32
|
+
return createEsbuildTransformer(await loadEsbuildWasm(options));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The default bundler for `runtime.build()`. */
|
|
36
|
+
export async function createEsbuildWasmBundler(options: EsbuildWasmOptions = {}): Promise<Bundler> {
|
|
37
|
+
return createEsbuildBundler(await loadEsbuildWasm(options));
|
|
38
|
+
}
|
package/src/host.ts
CHANGED
|
@@ -2,36 +2,82 @@ import {
|
|
|
2
2
|
DevServer,
|
|
3
3
|
ERROR_MESSAGE_TYPE,
|
|
4
4
|
VirtualFileSystem,
|
|
5
|
-
|
|
5
|
+
buildProject,
|
|
6
|
+
type BuildResult,
|
|
7
|
+
type Bundler,
|
|
8
|
+
type ComponentCompilers,
|
|
6
9
|
type FileSystemTree,
|
|
7
10
|
type FlatFiles,
|
|
11
|
+
type PackageCdnFactory,
|
|
8
12
|
type ProjectProfile,
|
|
9
13
|
type Transformer,
|
|
14
|
+
type SpawnOptions,
|
|
15
|
+
type WebContainerProcess,
|
|
10
16
|
} from '@seanhogg/builderforce-webcontainers-core';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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
|
-
|
|
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?:
|
|
23
|
-
/**
|
|
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
|
|
82
|
-
if (!
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
const base = previewBase(scope, id);
|
|
141
|
+
const base = previewBase(transport.scope, id);
|
|
91
142
|
const fs = new VirtualFileSystem();
|
|
92
|
-
const
|
|
93
|
-
|
|
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
|
|
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 } =
|
|
165
|
+
const { message, transfer } = await answer(event.data);
|
|
100
166
|
channel.port1.postMessage(message, transfer);
|
|
101
167
|
};
|
|
102
|
-
|
|
168
|
+
transport.attach(id, channel.port2);
|
|
103
169
|
};
|
|
104
|
-
attach(
|
|
170
|
+
attach();
|
|
171
|
+
const stopReattach = transport.onReattach((wanted) => {
|
|
172
|
+
if (wanted === id) attach();
|
|
173
|
+
});
|
|
105
174
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
3
|
-
|
|
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
|
+
}
|