@seanhogg/builderforce-webcontainers-core 2026.10.0

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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +20 -0
  3. package/dist/attribution.d.ts +22 -0
  4. package/dist/attribution.d.ts.map +1 -0
  5. package/dist/attribution.js +50 -0
  6. package/dist/attribution.js.map +1 -0
  7. package/dist/compileScript.d.ts +31 -0
  8. package/dist/compileScript.d.ts.map +1 -0
  9. package/dist/compileScript.js +75 -0
  10. package/dist/compileScript.js.map +1 -0
  11. package/dist/detectProject.d.ts +16 -0
  12. package/dist/detectProject.d.ts.map +1 -0
  13. package/dist/detectProject.js +49 -0
  14. package/dist/detectProject.js.map +1 -0
  15. package/dist/devServer.d.ts +46 -0
  16. package/dist/devServer.d.ts.map +1 -0
  17. package/dist/devServer.js +136 -0
  18. package/dist/devServer.js.map +1 -0
  19. package/dist/html.d.ts +17 -0
  20. package/dist/html.d.ts.map +1 -0
  21. package/dist/html.js +46 -0
  22. package/dist/html.js.map +1 -0
  23. package/dist/index.d.ts +21 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +14 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/mime.d.ts +3 -0
  28. package/dist/mime.d.ts.map +1 -0
  29. package/dist/mime.js +35 -0
  30. package/dist/mime.js.map +1 -0
  31. package/dist/packageCdn.d.ts +19 -0
  32. package/dist/packageCdn.d.ts.map +1 -0
  33. package/dist/packageCdn.js +29 -0
  34. package/dist/packageCdn.js.map +1 -0
  35. package/dist/paths.d.ts +12 -0
  36. package/dist/paths.d.ts.map +1 -0
  37. package/dist/paths.js +36 -0
  38. package/dist/paths.js.map +1 -0
  39. package/dist/projectConfig.d.ts +36 -0
  40. package/dist/projectConfig.d.ts.map +1 -0
  41. package/dist/projectConfig.js +155 -0
  42. package/dist/projectConfig.js.map +1 -0
  43. package/dist/resolve.d.ts +19 -0
  44. package/dist/resolve.d.ts.map +1 -0
  45. package/dist/resolve.js +62 -0
  46. package/dist/resolve.js.map +1 -0
  47. package/dist/rewriteImports.d.ts +11 -0
  48. package/dist/rewriteImports.d.ts.map +1 -0
  49. package/dist/rewriteImports.js +28 -0
  50. package/dist/rewriteImports.js.map +1 -0
  51. package/dist/styleModules.d.ts +12 -0
  52. package/dist/styleModules.d.ts.map +1 -0
  53. package/dist/styleModules.js +53 -0
  54. package/dist/styleModules.js.map +1 -0
  55. package/dist/transformer.d.ts +42 -0
  56. package/dist/transformer.d.ts.map +1 -0
  57. package/dist/transformer.js +39 -0
  58. package/dist/transformer.js.map +1 -0
  59. package/dist/vfs.d.ts +57 -0
  60. package/dist/vfs.d.ts.map +1 -0
  61. package/dist/vfs.js +114 -0
  62. package/dist/vfs.js.map +1 -0
  63. package/package.json +67 -0
  64. package/src/attribution.ts +57 -0
  65. package/src/compileScript.ts +87 -0
  66. package/src/detectProject.ts +66 -0
  67. package/src/devServer.ts +156 -0
  68. package/src/html.ts +58 -0
  69. package/src/index.ts +20 -0
  70. package/src/mime.ts +36 -0
  71. package/src/packageCdn.ts +48 -0
  72. package/src/paths.ts +37 -0
  73. package/src/projectConfig.ts +157 -0
  74. package/src/resolve.ts +60 -0
  75. package/src/rewriteImports.ts +26 -0
  76. package/src/styleModules.ts +57 -0
  77. package/src/transformer.ts +76 -0
  78. package/src/vfs.ts +135 -0
@@ -0,0 +1,156 @@
1
+ import type { FsChange, VirtualFileSystem } from './vfs.js';
2
+ import type { Transformer } from './transformer.js';
3
+ import type { PackageCdn } from './packageCdn.js';
4
+ import { createEsmShCdn } from './packageCdn.js';
5
+ import { isConfigFile, readProjectConfig, type ProjectConfig } from './projectConfig.js';
6
+ import { detectProject, type ProjectProfile } from './detectProject.js';
7
+ import { compileScript, type CompileContext, type ImportQuery } from './compileScript.js';
8
+ import { transformHtml } from './html.js';
9
+ import { cssToModule, jsonToModule, rawToModule, rewriteCssUrls, urlToModule } from './styleModules.js';
10
+ import { loaderFor } from './transformer.js';
11
+ import { extname, join, normalizePath } from './paths.js';
12
+ import { JS_MIME, mimeFor } from './mime.js';
13
+
14
+ export interface ServedFile {
15
+ status: number;
16
+ headers: Record<string, string>;
17
+ body: string | Uint8Array;
18
+ }
19
+
20
+ export interface DevServerOptions {
21
+ fs: VirtualFileSystem;
22
+ transformer: Transformer;
23
+ /** The URL the preview is served under, ending in `/` (`/__bfwc/<id>/`). */
24
+ base: string;
25
+ /** Package CDN factory, given the project's dependency ranges. Default: esm.sh. */
26
+ cdn?: (dependencies: Record<string, string>) => PackageCdn;
27
+ /** The "Built with Builderforce.ai" badge in served pages. Default true. */
28
+ attribution?: boolean;
29
+ }
30
+
31
+ const NO_STORE = { 'cache-control': 'no-store' };
32
+
33
+ function reply(status: number, contentType: string, body: string | Uint8Array): ServedFile {
34
+ return { status, headers: { 'content-type': contentType, ...NO_STORE }, body };
35
+ }
36
+
37
+ /** A module that throws — evaluated in the preview, it reaches the error bridge. */
38
+ function errorModule(message: string): ServedFile {
39
+ return reply(200, JS_MIME, `throw new Error(${JSON.stringify(message)});`);
40
+ }
41
+
42
+ function parseQuery(search: string): ImportQuery | undefined {
43
+ const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
44
+ if (params.has('raw')) return 'raw';
45
+ if (params.has('url')) return 'url';
46
+ if (params.has('import')) return 'import';
47
+ return undefined;
48
+ }
49
+
50
+ /**
51
+ * The dev server: request path in, response out. It does not listen on anything —
52
+ * the host decides how requests arrive (a service worker in the browser, a plain
53
+ * function call in tests), which is what keeps it runtime-agnostic.
54
+ *
55
+ * Responses are cached per (file, query) and validated against the file's VFS
56
+ * version. Edits to an existing file evict only that file; creating or deleting a
57
+ * file, or touching a config file, clears everything, since either can change how
58
+ * OTHER files' imports resolve.
59
+ */
60
+ export class DevServer {
61
+ private config?: ProjectConfig;
62
+ private cdn?: PackageCdn;
63
+ private profileCache?: ProjectProfile;
64
+ private readonly cache = new Map<string, { version: number; file: ServedFile }>();
65
+ private readonly unwatch: () => void;
66
+
67
+ constructor(private readonly options: DevServerOptions) {
68
+ if (!options.base.endsWith('/')) throw new Error('DevServer base must end with "/"');
69
+ this.unwatch = options.fs.watch((change) => this.onChange(change));
70
+ }
71
+
72
+ dispose(): void {
73
+ this.unwatch();
74
+ this.cache.clear();
75
+ }
76
+
77
+ profile(): ProjectProfile {
78
+ return (this.profileCache ??= detectProject(this.options.fs));
79
+ }
80
+
81
+ async handle(requestPath: string, search = ''): Promise<ServedFile> {
82
+ const { fs } = this.options;
83
+ const path = normalizePath(decodeURIComponent(requestPath));
84
+ const query = parseQuery(search);
85
+ const profile = this.profile();
86
+
87
+ if (path === '/' || path === '/index.html' || path === profile.htmlPath) return this.serveDocument(profile);
88
+
89
+ const publicPath = join(profile.publicDir, path);
90
+ const file = fs.isFile(path) ? path : fs.isFile(publicPath) ? publicPath : undefined;
91
+ if (!file) {
92
+ // An extension-less path is a client-side route: serve the app (SPA fallback).
93
+ if (!extname(path) && !query) return this.serveDocument(profile);
94
+ return query || loaderFor(extname(path)) ? errorModule(`Cannot find module ${path}`) : reply(404, 'text/plain; charset=utf-8', `Not found: ${path}`);
95
+ }
96
+
97
+ const key = `${file}?${query ?? ''}`;
98
+ const version = fs.version(file) ?? 0;
99
+ const hit = this.cache.get(key);
100
+ if (hit && hit.version === version) return hit.file;
101
+
102
+ const served = await this.produce(file, query);
103
+ this.cache.set(key, { version, file: served });
104
+ return served;
105
+ }
106
+
107
+ private async produce(file: string, query: ImportQuery | undefined): Promise<ServedFile> {
108
+ const { fs, base } = this.options;
109
+ const ext = extname(file);
110
+ try {
111
+ if (query === 'raw') return reply(200, JS_MIME, rawToModule(fs.readText(file) ?? ''));
112
+ if (query === 'url') return reply(200, JS_MIME, urlToModule(base + file.slice(1)));
113
+ if (ext === '.css') {
114
+ const css = rewriteCssUrls(fs, file, fs.readText(file) ?? '', base);
115
+ return query === 'import' ? reply(200, JS_MIME, cssToModule(file, css)) : reply(200, mimeFor(ext), css);
116
+ }
117
+ if (ext === '.json' && query === 'import') return reply(200, JS_MIME, jsonToModule(fs.readText(file) ?? ''));
118
+ if (loaderFor(ext)) return reply(200, JS_MIME, await compileScript(this.context(), file));
119
+ if (ext === '.html' || ext === '.htm') {
120
+ return reply(200, mimeFor(ext), transformHtml(fs.readText(file) ?? '', { base, attribution: this.options.attribution }));
121
+ }
122
+ return reply(200, mimeFor(ext), fs.readFile(file) ?? '');
123
+ } catch (error) {
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ return query || loaderFor(ext) ? errorModule(`${file}: ${message}`) : reply(500, 'text/plain; charset=utf-8', message);
126
+ }
127
+ }
128
+
129
+ private serveDocument(profile: ProjectProfile): ServedFile {
130
+ if (!profile.supported || !profile.htmlPath) {
131
+ return reply(501, 'text/html; charset=utf-8', `<!doctype html><meta charset="utf-8"><p>${profile.reason ?? 'Unsupported project.'}</p>`);
132
+ }
133
+ const html = this.options.fs.readText(profile.htmlPath) ?? '';
134
+ const { base, attribution } = this.options;
135
+ return reply(200, 'text/html; charset=utf-8', transformHtml(html, { base, entry: profile.entry, attribution }));
136
+ }
137
+
138
+ private context(): CompileContext {
139
+ const { fs, transformer, base } = this.options;
140
+ this.config ??= readProjectConfig(fs);
141
+ this.cdn ??= (this.options.cdn ?? ((dependencies) => createEsmShCdn({ dependencies })))(this.config.dependencies);
142
+ return { fs, config: this.config, cdn: this.cdn, transformer, base };
143
+ }
144
+
145
+ private onChange(change: FsChange): void {
146
+ const structural = change.type === 'remove' || change.created === true || isConfigFile(change.path) || extname(change.path) === '.html';
147
+ if (!structural) {
148
+ for (const key of [...this.cache.keys()]) if (key.startsWith(change.path + '?')) this.cache.delete(key);
149
+ return;
150
+ }
151
+ this.cache.clear();
152
+ this.config = undefined;
153
+ this.cdn = undefined;
154
+ this.profileCache = undefined;
155
+ }
156
+ }
package/src/html.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Prepare the project's HTML for the preview:
3
+ * • absolute `src`/`href` paths (`/src/main.tsx`) are re-rooted under the preview
4
+ * base, since the preview is served from a sub-path, not the origin root;
5
+ * • CRA's `%PUBLIC_URL%` placeholder is filled;
6
+ * • a page with no module script (CRA) gets its entry injected;
7
+ * • the error bridge is prepended, so runtime errors reach the host;
8
+ * • the attribution badge is appended unless the host turned it off.
9
+ */
10
+ import { injectAttribution } from './attribution.js';
11
+
12
+ export const ERROR_MESSAGE_TYPE = 'bfwc:error';
13
+
14
+ /**
15
+ * Runs first in the preview. Forwards uncaught errors and rejections to the host
16
+ * page (`window.parent`), which is how an editor — or an agent — learns the app
17
+ * crashed without scraping the iframe.
18
+ */
19
+ export function errorBridgeScript(): string {
20
+ return `<script>(function(){
21
+ var send = function (message, stack) {
22
+ try { parent.postMessage({ type: ${JSON.stringify(ERROR_MESSAGE_TYPE)}, message: String(message), stack: stack ? String(stack) : undefined, href: location.href }, '*'); } catch (_) {}
23
+ };
24
+ addEventListener('error', function (e) { send(e.message, e.error && e.error.stack); });
25
+ addEventListener('unhandledrejection', function (e) { var r = e.reason; send(r && r.message ? r.message : r, r && r.stack); });
26
+ window.process = window.process || { env: { NODE_ENV: 'development' } };
27
+ })();</script>`;
28
+ }
29
+
30
+ const ABSOLUTE_ATTR = /(\s(?:src|href)\s*=\s*)(["'])\/(?!\/)([^"']*)\2/gi;
31
+ const MODULE_SCRIPT = /<script\b[^>]*type\s*=\s*["']module["'][^>]*>/i;
32
+
33
+ export interface HtmlOptions {
34
+ /** Preview base URL, ending in `/`. */
35
+ base: string;
36
+ /** Entry injected when the page has no module script. */
37
+ entry?: string;
38
+ /** The "Built with Builderforce.ai" badge. On unless explicitly `false`. */
39
+ attribution?: boolean;
40
+ }
41
+
42
+ export function transformHtml(html: string, options: HtmlOptions): string {
43
+ const { base } = options;
44
+ // `%PUBLIC_URL%/logo.png` becomes root-absolute here and is re-rooted once below;
45
+ // filling in the base directly would let the re-rooting prefix it a second time.
46
+ let out = html.replace(/%PUBLIC_URL%/g, '');
47
+ out = out.replace(ABSOLUTE_ATTR, (_m, attr: string, quote: string, rest: string) => `${attr}${quote}${base}${rest}${quote}`);
48
+
49
+ if (options.entry && !MODULE_SCRIPT.test(out)) {
50
+ const tag = `<script type="module" src="${base}${options.entry.slice(1)}"></script>`;
51
+ out = /<\/body>/i.test(out) ? out.replace(/<\/body>/i, `${tag}\n</body>`) : out + tag;
52
+ }
53
+ if (options.attribution !== false) out = injectAttribution(out);
54
+
55
+ const bridge = errorBridgeScript();
56
+ if (/<head[^>]*>/i.test(out)) return out.replace(/<head[^>]*>/i, (head) => `${head}\n${bridge}`);
57
+ return bridge + out;
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { VirtualFileSystem } from './vfs.js';
2
+ export type { FileContent, FileSystemTree, FlatFiles, FsChange } from './vfs.js';
3
+ export { normalizePath, dirname, basename, join, extname } from './paths.js';
4
+ export { DevServer } from './devServer.js';
5
+ export type { DevServerOptions, ServedFile } from './devServer.js';
6
+ export { detectProject } from './detectProject.js';
7
+ export type { ProjectKind, ProjectProfile } from './detectProject.js';
8
+ export { readProjectConfig, parseLooseJson, parseDotenv } from './projectConfig.js';
9
+ export type { ProjectConfig, PathAlias } from './projectConfig.js';
10
+ export { createEsmShCdn } from './packageCdn.js';
11
+ export type { PackageCdn, EsmShOptions } from './packageCdn.js';
12
+ export { createEsbuildTransformer, loaderFor } from './transformer.js';
13
+ export type { Transformer, TransformRequest, Loader, EsbuildLike } from './transformer.js';
14
+ export { rewriteImports } from './rewriteImports.js';
15
+ export { resolveLocal, resolveAlias, isBareSpecifier, splitPackageSpecifier, RESOLVE_EXTENSIONS } from './resolve.js';
16
+ export { compileScript, mapSpecifier, moduleUrl, buildDefine } from './compileScript.js';
17
+ export { transformHtml, errorBridgeScript, ERROR_MESSAGE_TYPE } from './html.js';
18
+ export { mimeFor } from './mime.js';
19
+ export { attributionScript, injectAttribution, ATTRIBUTION_URL, ATTRIBUTION_LABEL } from './attribution.js';
20
+ export type { AttributionOptions } from './attribution.js';
package/src/mime.ts ADDED
@@ -0,0 +1,36 @@
1
+ const MIME: Record<string, string> = {
2
+ '.html': 'text/html; charset=utf-8',
3
+ '.htm': 'text/html; charset=utf-8',
4
+ '.css': 'text/css; charset=utf-8',
5
+ '.js': 'text/javascript; charset=utf-8',
6
+ '.mjs': 'text/javascript; charset=utf-8',
7
+ '.json': 'application/json; charset=utf-8',
8
+ '.map': 'application/json; charset=utf-8',
9
+ '.txt': 'text/plain; charset=utf-8',
10
+ '.md': 'text/markdown; charset=utf-8',
11
+ '.xml': 'application/xml; charset=utf-8',
12
+ '.svg': 'image/svg+xml',
13
+ '.png': 'image/png',
14
+ '.jpg': 'image/jpeg',
15
+ '.jpeg': 'image/jpeg',
16
+ '.gif': 'image/gif',
17
+ '.webp': 'image/webp',
18
+ '.avif': 'image/avif',
19
+ '.ico': 'image/x-icon',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ '.ttf': 'font/ttf',
23
+ '.otf': 'font/otf',
24
+ '.mp3': 'audio/mpeg',
25
+ '.wav': 'audio/wav',
26
+ '.mp4': 'video/mp4',
27
+ '.webm': 'video/webm',
28
+ '.wasm': 'application/wasm',
29
+ '.pdf': 'application/pdf',
30
+ };
31
+
32
+ export const JS_MIME = 'text/javascript; charset=utf-8';
33
+
34
+ export function mimeFor(ext: string): string {
35
+ return MIME[ext] ?? 'application/octet-stream';
36
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Where a bare import (`react`, `react-dom/client`) is fetched from. A PORT, so a
3
+ * fork can point at a self-hosted mirror or an offline cache without touching the
4
+ * rest of the runtime. The default speaks esm.sh's URL scheme.
5
+ */
6
+ export interface PackageCdn {
7
+ /** The ES-module URL for `name` + `subpath` (`'' | '/client'`). */
8
+ urlFor(name: string, subpath: string): string;
9
+ }
10
+
11
+ export interface EsmShOptions {
12
+ /** Defaults to https://esm.sh. Any esm.sh-compatible server works. */
13
+ origin?: string;
14
+ /** The project's dependency ranges — pins versions and dedupes shared packages. */
15
+ dependencies?: Record<string, string>;
16
+ /** Serve development builds (readable React errors). Default true. */
17
+ dev?: boolean;
18
+ }
19
+
20
+ /** A range the CDN can't fetch (`workspace:*`, `file:../x`, `link:`, git URLs). */
21
+ function isRegistryRange(range: string): boolean {
22
+ return !/^(workspace|file|link|portal|git\+|git:|github:|https?:)/.test(range) && !range.includes('/');
23
+ }
24
+
25
+ export function createEsmShCdn(options: EsmShOptions = {}): PackageCdn {
26
+ const origin = (options.origin ?? 'https://esm.sh').replace(/\/+$/, '');
27
+ const dependencies = options.dependencies ?? {};
28
+ const dev = options.dev ?? true;
29
+
30
+ // Pin every declared dependency into each package's own graph, so `react-dom`
31
+ // and the app share ONE `react` — two copies is the classic "invalid hook call".
32
+ const pinned = Object.entries(dependencies)
33
+ .filter(([, range]) => isRegistryRange(range))
34
+ .map(([name, range]) => `${name}@${range}`)
35
+ .sort();
36
+
37
+ return {
38
+ urlFor(name, subpath) {
39
+ const range = dependencies[name];
40
+ const versioned = range && isRegistryRange(range) ? `${name}@${range}` : name;
41
+ const params: string[] = [];
42
+ const deps = pinned.filter((entry) => !entry.startsWith(name + '@'));
43
+ if (deps.length) params.push('deps=' + deps.map(encodeURIComponent).join(','));
44
+ if (dev) params.push('dev');
45
+ return `${origin}/${versioned}${subpath}${params.length ? '?' + params.join('&') : ''}`;
46
+ },
47
+ };
48
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * POSIX path helpers for the virtual file system. Every path the runtime stores is
3
+ * absolute and normalised (`/src/App.tsx`), so comparisons are plain string equality.
4
+ */
5
+
6
+ /** Absolute, normalised: resolves `.`/`..`, collapses slashes, accepts backslashes. */
7
+ export function normalizePath(path: string): string {
8
+ const parts: string[] = [];
9
+ for (const segment of path.replace(/\\/g, '/').split('/')) {
10
+ if (!segment || segment === '.') continue;
11
+ if (segment === '..') parts.pop();
12
+ else parts.push(segment);
13
+ }
14
+ return '/' + parts.join('/');
15
+ }
16
+
17
+ export function dirname(path: string): string {
18
+ const normalized = normalizePath(path);
19
+ const cut = normalized.lastIndexOf('/');
20
+ return cut <= 0 ? '/' : normalized.slice(0, cut);
21
+ }
22
+
23
+ export function basename(path: string): string {
24
+ const normalized = normalizePath(path);
25
+ return normalized.slice(normalized.lastIndexOf('/') + 1);
26
+ }
27
+
28
+ export function join(...segments: string[]): string {
29
+ return normalizePath(segments.join('/'));
30
+ }
31
+
32
+ /** Lower-cased extension including the dot (`.tsx`), or `''`. Dotfiles have none. */
33
+ export function extname(path: string): string {
34
+ const base = basename(path);
35
+ const dot = base.lastIndexOf('.');
36
+ return dot <= 0 ? '' : base.slice(dot).toLowerCase();
37
+ }
@@ -0,0 +1,157 @@
1
+ import type { VirtualFileSystem } from './vfs.js';
2
+ import { join, normalizePath } from './paths.js';
3
+
4
+ /**
5
+ * What the dev server needs to know about a project, read from its own files:
6
+ * dependency versions (for the package CDN), tsconfig path aliases and JSX mode,
7
+ * and `VITE_*` variables from `.env` files.
8
+ *
9
+ * Everything here is derived, so it is recomputed whenever one of
10
+ * {@link CONFIG_FILES} changes and is otherwise read from the cache.
11
+ */
12
+ export const CONFIG_FILES = [
13
+ '/package.json',
14
+ '/tsconfig.json',
15
+ '/jsconfig.json',
16
+ '/.env',
17
+ '/.env.local',
18
+ '/.env.development',
19
+ '/.env.development.local',
20
+ ] as const;
21
+
22
+ export interface PathAlias {
23
+ /** The specifier prefix, without the trailing `*` (`@/`). Exact when `wildcard` is false. */
24
+ prefix: string;
25
+ wildcard: boolean;
26
+ /** Absolute VFS targets, without the trailing `*`, tried in order. */
27
+ targets: string[];
28
+ }
29
+
30
+ export interface ProjectConfig {
31
+ /** dependencies + devDependencies, name → version range. */
32
+ dependencies: Record<string, string>;
33
+ aliases: PathAlias[];
34
+ jsx: { runtime: 'automatic' | 'classic'; importSource: string };
35
+ /** `VITE_*` and `NODE_ENV` style variables from the `.env` files, in load order. */
36
+ env: Record<string, string>;
37
+ }
38
+
39
+ /** JSON that tolerates the comments and trailing commas tsconfig files carry. */
40
+ export function parseLooseJson(text: string): unknown {
41
+ let out = '';
42
+ let inString = false;
43
+ for (let i = 0; i < text.length; i++) {
44
+ const ch = text[i]!;
45
+ const next = text[i + 1];
46
+ if (inString) {
47
+ out += ch;
48
+ if (ch === '\\') { out += next ?? ''; i++; }
49
+ else if (ch === '"') inString = false;
50
+ continue;
51
+ }
52
+ if (ch === '"') { inString = true; out += ch; continue; }
53
+ if (ch === '/' && next === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; continue; }
54
+ if (ch === '/' && next === '*') { i += 2; while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; i++; continue; }
55
+ out += ch;
56
+ }
57
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
58
+ }
59
+
60
+ function readJson(fs: VirtualFileSystem, path: string): Record<string, unknown> | undefined {
61
+ const text = fs.readText(path);
62
+ if (text === undefined) return undefined;
63
+ try {
64
+ const value = parseLooseJson(text);
65
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ function asRecord(value: unknown): Record<string, unknown> {
72
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
73
+ }
74
+
75
+ function readDependencies(fs: VirtualFileSystem): Record<string, string> {
76
+ const pkg = readJson(fs, '/package.json') ?? {};
77
+ const out: Record<string, string> = {};
78
+ for (const field of ['devDependencies', 'dependencies']) {
79
+ for (const [name, version] of Object.entries(asRecord(pkg[field]))) {
80
+ if (typeof version === 'string') out[name] = version;
81
+ }
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function readCompilerOptions(fs: VirtualFileSystem): Record<string, unknown> {
87
+ const tsconfig = readJson(fs, '/tsconfig.json') ?? readJson(fs, '/jsconfig.json') ?? {};
88
+ return asRecord(tsconfig.compilerOptions);
89
+ }
90
+
91
+ function readAliases(options: Record<string, unknown>): PathAlias[] {
92
+ const baseUrl = typeof options.baseUrl === 'string' ? options.baseUrl : '.';
93
+ const aliases: PathAlias[] = [];
94
+ for (const [pattern, rawTargets] of Object.entries(asRecord(options.paths))) {
95
+ if (!Array.isArray(rawTargets)) continue;
96
+ const wildcard = pattern.endsWith('*');
97
+ const targets = rawTargets
98
+ .filter((t): t is string => typeof t === 'string')
99
+ .map((t) => {
100
+ const stripped = wildcard ? t.replace(/\*$/, '') : t;
101
+ const resolved = join('/', baseUrl, stripped);
102
+ return stripped.endsWith('/') && resolved !== '/' ? resolved + '/' : resolved;
103
+ });
104
+ aliases.push({ prefix: wildcard ? pattern.slice(0, -1) : pattern, wildcard, targets });
105
+ }
106
+ // Longest prefix first, so `@/components/*` wins over `@/*`.
107
+ return aliases.sort((a, b) => b.prefix.length - a.prefix.length);
108
+ }
109
+
110
+ function readJsx(options: Record<string, unknown>): ProjectConfig['jsx'] {
111
+ const importSource = typeof options.jsxImportSource === 'string' ? options.jsxImportSource : 'react';
112
+ const runtime = options.jsx === 'react' ? 'classic' : 'automatic';
113
+ return { runtime, importSource };
114
+ }
115
+
116
+ /** `KEY=value` lines; quotes stripped, `#` comments and blank lines ignored. */
117
+ export function parseDotenv(text: string): Record<string, string> {
118
+ const out: Record<string, string> = {};
119
+ for (const raw of text.split(/\r?\n/)) {
120
+ const line = raw.trim();
121
+ if (!line || line.startsWith('#')) continue;
122
+ const eq = line.indexOf('=');
123
+ if (eq <= 0) continue;
124
+ const key = line.slice(0, eq).replace(/^export\s+/, '').trim();
125
+ let value = line.slice(eq + 1).trim();
126
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
127
+ value = value.slice(1, -1);
128
+ }
129
+ out[key] = value;
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function readEnv(fs: VirtualFileSystem): Record<string, string> {
135
+ const env: Record<string, string> = {};
136
+ for (const file of CONFIG_FILES) {
137
+ if (!file.startsWith('/.env')) continue;
138
+ const text = fs.readText(file);
139
+ if (text !== undefined) Object.assign(env, parseDotenv(text));
140
+ }
141
+ return env;
142
+ }
143
+
144
+ export function readProjectConfig(fs: VirtualFileSystem): ProjectConfig {
145
+ const options = readCompilerOptions(fs);
146
+ return {
147
+ dependencies: readDependencies(fs),
148
+ aliases: readAliases(options),
149
+ jsx: readJsx(options),
150
+ env: readEnv(fs),
151
+ };
152
+ }
153
+
154
+ /** True when a change to `path` invalidates the derived {@link ProjectConfig}. */
155
+ export function isConfigFile(path: string): boolean {
156
+ return (CONFIG_FILES as readonly string[]).includes(normalizePath(path));
157
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { VirtualFileSystem } from './vfs.js';
2
+ import type { PathAlias } from './projectConfig.js';
3
+ import { extname, join, normalizePath } from './paths.js';
4
+
5
+ /** Extensions probed, in order, for an extension-less import — Vite's default order. */
6
+ export const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json', '.css'] as const;
7
+
8
+ /** `foo`, `@scope/foo`, `foo/sub` — not relative, not absolute, not a URL. */
9
+ export function isBareSpecifier(specifier: string): boolean {
10
+ if (specifier.startsWith('.') || specifier.startsWith('/')) return false;
11
+ return !/^[a-z][a-z0-9+.-]*:/i.test(specifier);
12
+ }
13
+
14
+ /** Split a bare specifier into its package name and subpath (`/client`, or `''`). */
15
+ export function splitPackageSpecifier(specifier: string): { name: string; subpath: string } {
16
+ const parts = specifier.split('/');
17
+ const take = specifier.startsWith('@') ? 2 : 1;
18
+ return { name: parts.slice(0, take).join('/'), subpath: parts.length > take ? '/' + parts.slice(take).join('/') : '' };
19
+ }
20
+
21
+ function probe(fs: VirtualFileSystem, base: string): string | undefined {
22
+ if (fs.isFile(base)) return base;
23
+ for (const ext of RESOLVE_EXTENSIONS) if (fs.isFile(base + ext)) return base + ext;
24
+ // TypeScript ESM convention: `./util.js` written in source names `util.ts`.
25
+ const ext = extname(base);
26
+ if (ext === '.js' || ext === '.jsx' || ext === '.mjs') {
27
+ const stem = base.slice(0, -ext.length);
28
+ for (const alt of ['.ts', '.tsx']) if (fs.isFile(stem + alt)) return stem + alt;
29
+ }
30
+ if (fs.isDirectory(base)) {
31
+ for (const ext2 of RESOLVE_EXTENSIONS) {
32
+ const index = join(base, 'index' + ext2);
33
+ if (fs.isFile(index)) return index;
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /** Resolve a relative or absolute specifier against the importing file's directory. */
40
+ export function resolveLocal(fs: VirtualFileSystem, fromDir: string, specifier: string): string | undefined {
41
+ const base = specifier.startsWith('/') ? normalizePath(specifier) : join(fromDir, specifier);
42
+ return probe(fs, base);
43
+ }
44
+
45
+ /**
46
+ * Resolve a specifier through tsconfig `paths` (`@/components/Button` →
47
+ * `/src/components/Button.tsx`). Undefined when no alias matches or no target exists.
48
+ */
49
+ export function resolveAlias(fs: VirtualFileSystem, aliases: readonly PathAlias[], specifier: string): string | undefined {
50
+ for (const alias of aliases) {
51
+ const matches = alias.wildcard ? specifier.startsWith(alias.prefix) : specifier === alias.prefix;
52
+ if (!matches) continue;
53
+ const rest = alias.wildcard ? specifier.slice(alias.prefix.length) : '';
54
+ for (const target of alias.targets) {
55
+ const hit = probe(fs, normalizePath(target + rest));
56
+ if (hit) return hit;
57
+ }
58
+ }
59
+ return undefined;
60
+ }
@@ -0,0 +1,26 @@
1
+ import { init, parse } from 'es-module-lexer';
2
+
3
+ /**
4
+ * Rewrite every import specifier in an ES module — static `import`/`export … from`,
5
+ * side-effect `import 'x'`, and string-literal dynamic `import('x')` — through
6
+ * `map`. A `map` result of `undefined` leaves that specifier untouched, as are
7
+ * non-literal dynamic imports and `import.meta`.
8
+ *
9
+ * Positions come from es-module-lexer rather than a regex, so specifiers inside
10
+ * strings, comments and template literals are never touched.
11
+ */
12
+ export async function rewriteImports(code: string, map: (specifier: string) => string | undefined): Promise<string> {
13
+ await init;
14
+ const [imports] = parse(code);
15
+ let out = '';
16
+ let cursor = 0;
17
+ for (const entry of imports) {
18
+ if (entry.d === -2 || entry.n === undefined) continue; // import.meta, or a non-literal dynamic import
19
+ const replacement = map(entry.n);
20
+ if (replacement === undefined || replacement === entry.n) continue;
21
+ const dynamic = entry.d > -1;
22
+ out += code.slice(cursor, entry.s) + (dynamic ? JSON.stringify(replacement) : replacement);
23
+ cursor = entry.e;
24
+ }
25
+ return out + code.slice(cursor);
26
+ }
@@ -0,0 +1,57 @@
1
+ import type { VirtualFileSystem } from './vfs.js';
2
+ import { dirname } from './paths.js';
3
+ import { resolveLocal } from './resolve.js';
4
+
5
+ /**
6
+ * CSS handling. A stylesheet imported from JS is served as a JS module that
7
+ * injects a <style> tag (keyed by path, so a re-import replaces rather than
8
+ * duplicates). Its relative `url(...)` references are rewritten to absolute
9
+ * preview URLs, because once the text lives in a <style> tag the browser resolves
10
+ * them against the DOCUMENT, not the stylesheet's own location.
11
+ */
12
+
13
+ const URL_REF = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
14
+
15
+ export function rewriteCssUrls(fs: VirtualFileSystem, cssPath: string, css: string, base: string): string {
16
+ return css.replace(URL_REF, (match, quote: string, ref: string) => {
17
+ if (/^(data:|https?:|\/\/|#)/i.test(ref)) return match;
18
+ const [pathPart] = ref.split(/[?#]/);
19
+ if (!pathPart) return match;
20
+ const resolved = resolveLocal(fs, dirname(cssPath), pathPart);
21
+ if (!resolved) return match;
22
+ return `url(${quote}${base}${resolved.slice(1)}${ref.slice(pathPart.length)}${quote})`;
23
+ });
24
+ }
25
+
26
+ /**
27
+ * The JS module for an imported stylesheet. `.module.css` exports an identity map
28
+ * (class name → itself): real scoping needs the build API, so for now CSS Modules
29
+ * render correctly as long as class names don't collide.
30
+ */
31
+ export function cssToModule(path: string, css: string): string {
32
+ const id = JSON.stringify(path);
33
+ const exportsCssModule = /\.module\.css$/i.test(path);
34
+ return [
35
+ `const css = ${JSON.stringify(css)};`,
36
+ `let el = document.querySelector('style[data-bfwc=' + JSON.stringify(${id}) + ']');`,
37
+ `if (!el) { el = document.createElement('style'); el.setAttribute('data-bfwc', ${id}); document.head.appendChild(el); }`,
38
+ `el.textContent = css;`,
39
+ exportsCssModule
40
+ ? `export default new Proxy({}, { get: (_, key) => typeof key === 'string' ? key : undefined });`
41
+ : `export default css;`,
42
+ ].join('\n');
43
+ }
44
+
45
+ export function jsonToModule(text: string): string {
46
+ // Round-trip so a malformed file fails here with a clear message, not as a
47
+ // syntax error inside generated code.
48
+ return `export default ${JSON.stringify(JSON.parse(text))};`;
49
+ }
50
+
51
+ export function rawToModule(text: string): string {
52
+ return `export default ${JSON.stringify(text)};`;
53
+ }
54
+
55
+ export function urlToModule(url: string): string {
56
+ return `export default ${JSON.stringify(url)};`;
57
+ }