@statewalker/webrun-bundler 0.0.0-stage → 0.1.1

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/src/bundle.ts ADDED
@@ -0,0 +1,291 @@
1
+ import { readText } from "@statewalker/webrun-files";
2
+ import type { Loader, Plugin } from "esbuild-wasm";
3
+ import { inlineInputMap, withoutInputMap } from "./input-maps.js";
4
+ import { nodeModulesPackages } from "./packages.js";
5
+ import { dirOf, idOf, isFile, joinWithin, pathOf, readManifest } from "./paths.js";
6
+ import { resolveSpecifier } from "./resolve.js";
7
+ import type {
8
+ BundleOptions,
9
+ BundleResult,
10
+ ExternalPattern,
11
+ Mount,
12
+ SourceMap,
13
+ TranspileOptions,
14
+ TranspileResult,
15
+ } from "./types.js";
16
+
17
+ /** esbuild's namespace for the files the bundler serves; its source-map names drop it. */
18
+ const NS = "webrun";
19
+ /** esbuild's namespace for the host's modules (`modules`): `virtual:<specifier>` in maps. */
20
+ const VNS = "webrun-virtual";
21
+ /** Marks an `inject` entry, so that it resolves like an import, not like the entry point. */
22
+ const INJECT = "webrun-inject:";
23
+
24
+ /** esbuild `supported` flags that lower `await` to generators. */
25
+ const LOWER_AWAIT = { "async-await": false, "async-generator": false, "for-await": false };
26
+
27
+ const LOADERS: Record<string, Loader> = {
28
+ ts: "ts",
29
+ mts: "ts",
30
+ cts: "ts",
31
+ tsx: "tsx",
32
+ jsx: "jsx",
33
+ js: "js",
34
+ mjs: "js",
35
+ cjs: "js",
36
+ json: "json",
37
+ css: "css",
38
+ txt: "text",
39
+ wasm: "binary",
40
+ };
41
+ export const loaderFor = (path: string): Loader =>
42
+ LOADERS[/\.([a-z0-9]+)$/i.exec(path)?.[1]?.toLowerCase() ?? ""] ?? "js";
43
+
44
+ const isExternal = (spec: string, patterns: ExternalPattern[]) =>
45
+ /^(https?:|data:)/.test(spec) ||
46
+ patterns.some((p) =>
47
+ typeof p === "string"
48
+ ? p.endsWith("*")
49
+ ? spec.startsWith(p.slice(0, -1))
50
+ : spec === p
51
+ : p.test(spec),
52
+ );
53
+
54
+ /** A source name of esbuild's map → the file id it stands for. */
55
+ const sourceId = (s: string) =>
56
+ s
57
+ .replace(/^(\.\.\/)*/, "")
58
+ .replace(new RegExp(`^${VNS}:`), "virtual:")
59
+ .replace(new RegExp(`^${NS}:`), "")
60
+ .replace("?type=module.mjs", ""); // ESM_SUFFIX: the file's id, as in the map's other sources
61
+
62
+ const toBase64 = (text: string) => {
63
+ const bytes = new TextEncoder().encode(text);
64
+ let bin = "";
65
+ for (let i = 0; i < bytes.length; i += 0x8000)
66
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
67
+ return btoa(bin);
68
+ };
69
+
70
+ /** `code` with its trailing map link replaced: by `map` inlined, or by a link to `name`. */
71
+ function linkMap(code: string, map: SourceMap, how: "external" | "inline", name: string) {
72
+ const body = code.replace(/\n?\/\/# sourceMappingURL=\S+\s*$/, "");
73
+ const url =
74
+ how === "inline"
75
+ ? `data:application/json;base64,${toBase64(JSON.stringify(map))}`
76
+ : `${name.split("/").pop()}.map`;
77
+ return `${body}\n//# sourceMappingURL=${url}\n`;
78
+ }
79
+
80
+ /**
81
+ * Bundles `entry` of a project into one module with esbuild (esbuild-wasm in browsers).
82
+ * Every file is read through FilesApi (the project's, or a package's from `packages`):
83
+ * esbuild resolves and loads nothing itself. esbuild transpiles TypeScript and JSX,
84
+ * converts CommonJS, lowers `await` when asked, and bundles, all in one pass, and its
85
+ * source map goes back through each step to the files it read; where a package ships
86
+ * its own map (TypeScript compiled to `.js`), to the package's original source. The
87
+ * map's sources are file ids: `~/src/app.ts`, `npm:pkg@1.2.3/src/index.ts`.
88
+ */
89
+ /** Marks the id of a `.js` file in a `"type": "module"` package scope (see `moduleTypeOf`). */
90
+ const ESM_SUFFIX = "?type=module.mjs";
91
+
92
+ export async function bundle(opts: BundleOptions): Promise<BundleResult> {
93
+ const root = opts.root ?? "/";
94
+ const project: Mount = {
95
+ prefix: "~",
96
+ files: opts.files,
97
+ root,
98
+ manifest: await readManifest(opts.files, root),
99
+ };
100
+ const packages = opts.packages ?? nodeModulesPackages();
101
+ const external = opts.external ?? [];
102
+ const browser = (opts.platform ?? "browser") === "browser";
103
+ const conditions = opts.conditions ?? ["browser"];
104
+ const outfile = opts.outfile ?? "bundle.js";
105
+ const mounts = new Map<string, Mount>([[project.prefix, project]]);
106
+ const supplied = opts.modules;
107
+ const virtualSource = (spec: string) =>
108
+ typeof supplied === "function" ? supplied(spec) : supplied?.[spec];
109
+
110
+ /** The mount and path of a file id; the longest prefix wins (`npm:@a/b@1.0.0/…`). */
111
+ /**
112
+ * The module type of a `.js` file, as Node.js decides it: the `type` of the nearest
113
+ * `package.json` (tslib ships `modules/package.json` with `"type": "module"`). esbuild reads
114
+ * that only for files on its own file system; for ours, a file in a `"type": "module"` scope
115
+ * gets an id ending in ESM_SUFFIX (`.mjs`), so that esbuild gives its imports of CommonJS
116
+ * modules Node's interop: the default import is `module.exports`, even with `__esModule`.
117
+ * Vite gets the same from esbuild by pre-bundling dependencies from disk.
118
+ */
119
+ const scopeTypes = new Map<string, string | undefined>();
120
+ const moduleTypeOf = async (mount: Mount, path: string): Promise<string | undefined> => {
121
+ const root = mount.root.replace(/\/$/, "");
122
+ for (let dir = dirOf(path); dir.length >= root.length; dir = dirOf(dir)) {
123
+ const key = `${mount.prefix}:${dir}`;
124
+ if (scopeTypes.has(key)) return scopeTypes.get(key);
125
+ const pj = `${dir === "/" ? "" : dir}/package.json`;
126
+ if (await isFile(mount, pj)) {
127
+ let type: string | undefined;
128
+ try {
129
+ type = JSON.parse(await readText(mount.files, pj)).type;
130
+ } catch {}
131
+ scopeTypes.set(key, type);
132
+ return type;
133
+ }
134
+ if (dir === root || dir === "/" || !dir) break;
135
+ }
136
+ return undefined;
137
+ };
138
+
139
+ const locate = (id: string) => {
140
+ if (id.endsWith(ESM_SUFFIX)) id = id.slice(0, -ESM_SUFFIX.length);
141
+ let best: Mount | undefined;
142
+ for (const m of mounts.values())
143
+ if (id.startsWith(`${m.prefix}/`) && (!best || m.prefix.length > best.prefix.length))
144
+ best = m;
145
+ const path = best && pathOf(best, id);
146
+ if (!best || !path) throw new Error(`webrun-bundler: no file ${id}`);
147
+ return { mount: best, path };
148
+ };
149
+
150
+ const plugin: Plugin = {
151
+ name: "webrun-files",
152
+ setup(b) {
153
+ b.onResolve({ filter: /.*/ }, async (args) => {
154
+ const injected = args.path.startsWith(INJECT);
155
+ const spec = injected ? args.path.slice(INJECT.length) : args.path;
156
+ if (args.kind === "entry-point" && !injected) {
157
+ const path = joinWithin(root, root, args.path.replace(/^\/+/, ""));
158
+ if (!path) return { errors: [{ text: `entry ${args.path} is outside the project` }] };
159
+ return { path: idOf(project, path), namespace: NS };
160
+ }
161
+ if (virtualSource(spec) !== undefined) return { path: spec, namespace: VNS };
162
+ if (isExternal(spec, external)) return { path: spec, external: true };
163
+ // The host's modules and `inject` entries import from the project's root.
164
+ const importer =
165
+ injected || args.namespace === VNS
166
+ ? { mount: project, path: `${root === "/" ? "" : root}/` }
167
+ : locate(args.importer);
168
+ const require = args.kind === "require-call" || args.kind === "require-resolve";
169
+ const failed = (why?: string) => ({
170
+ errors: [
171
+ {
172
+ text: `Could not resolve "${spec}" (imported by ${args.importer || "inject"})${why ? `: ${why}` : ""}`,
173
+ },
174
+ ],
175
+ });
176
+ let r: Awaited<ReturnType<typeof resolveSpecifier>>;
177
+ try {
178
+ r = await resolveSpecifier(spec, importer, packages, {
179
+ conditions,
180
+ require,
181
+ browser,
182
+ });
183
+ } catch (e) {
184
+ return failed((e as Error)?.message ?? String(e));
185
+ }
186
+ if (!r) return failed();
187
+ mounts.set(r.mount.prefix, r.mount);
188
+ const esm = /\.js$/.test(r.path) && (await moduleTypeOf(r.mount, r.path)) === "module";
189
+ return { path: idOf(r.mount, r.path) + (esm ? ESM_SUFFIX : ""), namespace: NS };
190
+ });
191
+ b.onLoad({ filter: /.*/, namespace: VNS }, (args) => ({
192
+ contents: virtualSource(args.path) ?? "",
193
+ loader: "js",
194
+ }));
195
+ b.onLoad({ filter: /.*/, namespace: NS }, async (args) => {
196
+ const { mount, path } = locate(args.path);
197
+ const loader = loaderFor(path);
198
+ if (loader === "binary") {
199
+ const chunks: Uint8Array[] = [];
200
+ for await (const c of mount.files.read(path)) chunks.push(c);
201
+ const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
202
+ let at = 0;
203
+ for (const c of chunks) {
204
+ bytes.set(c, at);
205
+ at += c.length;
206
+ }
207
+ return { contents: bytes, loader };
208
+ }
209
+ const text = await readText(mount.files, path);
210
+ const js = loader === "js" || loader === "ts" || loader === "jsx" || loader === "tsx";
211
+ // Without an output map, the inputs' own maps are not read: esbuild would only parse them.
212
+ if (!js) return { contents: text, loader };
213
+ if (opts.sourcemap === false) return { contents: withoutInputMap(text), loader };
214
+ return { contents: await inlineInputMap(mount, path, text), loader };
215
+ });
216
+ },
217
+ };
218
+
219
+ const { plugins: extraPlugins = [], ...rest } = opts.esbuildOptions ?? {};
220
+ const result = await opts.esbuild.build({
221
+ entryPoints: [opts.entry],
222
+ bundle: true,
223
+ write: false,
224
+ format: opts.format ?? "esm",
225
+ platform: opts.platform ?? "browser",
226
+ target: opts.target ?? "es2022",
227
+ outfile,
228
+ absWorkingDir: "/",
229
+ metafile: true,
230
+ logLevel: "silent",
231
+ minify: opts.minify,
232
+ define: opts.define,
233
+ ...(opts.lowerAwait ? { supported: LOWER_AWAIT } : {}),
234
+ ...rest,
235
+ ...(opts.inject?.length ? { inject: opts.inject.map((s) => INJECT + s) } : {}),
236
+ // `sourcemap: false`: no map at all (a large bundle's map, with every source's content,
237
+ // can take more memory than the bundle).
238
+ sourcemap: opts.sourcemap === false ? false : "external",
239
+ sourcesContent: opts.sourcemap !== false,
240
+ plugins: [...extraPlugins, plugin],
241
+ });
242
+ const out = result.outputFiles ?? [];
243
+ const jsFile = out.find((f) => !f.path.endsWith(".map"));
244
+ const mapFile = out.find((f) => f.path.endsWith(".map"));
245
+ if (!jsFile) throw new Error("webrun-bundler: esbuild produced no output");
246
+ if (!mapFile)
247
+ return {
248
+ code: jsFile.text,
249
+ map: { version: 3, sources: [], names: [], mappings: "" } as unknown as SourceMap,
250
+ metafile: result.metafile as BundleResult["metafile"],
251
+ warnings: result.warnings,
252
+ } as BundleResult;
253
+ const raw = JSON.parse(mapFile.text) as SourceMap;
254
+ const map: SourceMap = {
255
+ ...raw,
256
+ file: outfile.split("/").pop(),
257
+ sources: raw.sources.map(sourceId),
258
+ };
259
+ delete map.sourceRoot;
260
+ return {
261
+ code: linkMap(jsFile.text, map, opts.sourcemap || "external", outfile),
262
+ map,
263
+ metafile: result.metafile as BundleResult["metafile"],
264
+ warnings: result.warnings,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Transpiles one file (TypeScript, JSX, modern syntax down to `target`; `await` lowered
270
+ * when asked) with a source map back to it. A map the code carries (`sourceMappingURL`,
271
+ * inline) is composed in, so the result still traces to the original.
272
+ */
273
+ export async function transpile(code: string, opts: TranspileOptions): Promise<TranspileResult> {
274
+ const r = await opts.esbuild.transform(code, {
275
+ loader: loaderFor(opts.path),
276
+ sourcefile: opts.path,
277
+ sourcemap: "external",
278
+ sourcesContent: true,
279
+ format: opts.format,
280
+ target: opts.target ?? "es2022",
281
+ minify: opts.minify,
282
+ define: opts.define,
283
+ ...(opts.lowerAwait ? { supported: LOWER_AWAIT } : {}),
284
+ });
285
+ const map = JSON.parse(r.map) as SourceMap;
286
+ return {
287
+ code: linkMap(r.code, map, opts.sourcemap ?? "external", opts.path.replace(/\.[^./]+$/, ".js")),
288
+ map,
289
+ warnings: r.warnings,
290
+ };
291
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { bundle, loaderFor, transpile } from "./bundle.js";
2
+ export { inlineInputMap } from "./input-maps.js";
3
+ export { chainPackages, nodeModulesPackages, npmPackages } from "./packages.js";
4
+ export { resolveSpecifier } from "./resolve.js";
5
+ export type {
6
+ BundleOptions,
7
+ BundleResult,
8
+ Esbuild,
9
+ ExternalPattern,
10
+ Importer,
11
+ Mount,
12
+ PackageManifest,
13
+ PackageStore,
14
+ SourceMap,
15
+ TranspileOptions,
16
+ TranspileResult,
17
+ } from "./types.js";
@@ -0,0 +1,90 @@
1
+ import { tryReadText } from "@statewalker/webrun-files";
2
+ import { dirOf, idOf, joinWithin } from "./paths.js";
3
+ import type { Mount, SourceMap } from "./types.js";
4
+
5
+ // The last `//# sourceMappingURL=…` (or `//@`) comment of a JS file.
6
+ const MAP_COMMENT = /\/\/[#@]\s*sourceMappingURL=(\S+)\s*$/;
7
+
8
+ const toBase64 = (text: string) => {
9
+ const bytes = new TextEncoder().encode(text);
10
+ let bin = "";
11
+ for (let i = 0; i < bytes.length; i += 0x8000)
12
+ bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
13
+ return btoa(bin);
14
+ };
15
+ const fromBase64 = (b64: string) =>
16
+ new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
17
+
18
+ /**
19
+ * A file's own source map, made usable by esbuild, which reads only maps inlined in
20
+ * the file it loads (it cannot follow a linked `.map` through a FilesApi):
21
+ *
22
+ * - a linked map (`//# sourceMappingURL=index.js.map`) is read from the same tree and
23
+ * inlined; an inline `data:` map is kept;
24
+ * - its sources become ids of the same tree (`npm:pkg@1.0.0/src/index.ts`), so the
25
+ * bundle's map names the package's original files, not the published JavaScript;
26
+ * - missing `sourcesContent` is filled from the tree when the original file ships
27
+ * with the package.
28
+ *
29
+ * esbuild then composes it with its own: a bundled line traces to the original source.
30
+ * A map that is missing or unreadable is dropped (the file maps to itself).
31
+ */
32
+ /**
33
+ * `code` without its trailing `sourceMappingURL` comment (and without reading the map): for
34
+ * bundles built with no source map, where an inlined input map would only add to what esbuild
35
+ * parses and holds (a package's maps, with their sources' text, can outweigh its code).
36
+ */
37
+ export function withoutInputMap(code: string): string {
38
+ const end = code.trimEnd();
39
+ const nl = end.lastIndexOf("\n");
40
+ return MAP_COMMENT.test(end.slice(nl + 1)) ? end.slice(0, Math.max(0, nl)) : code;
41
+ }
42
+
43
+ export async function inlineInputMap(mount: Mount, path: string, code: string): Promise<string> {
44
+ const lines = code.trimEnd().split("\n");
45
+ const last = lines[lines.length - 1] ?? "";
46
+ const match = MAP_COMMENT.exec(last);
47
+ if (!match) return code;
48
+ const url = match[1] as string;
49
+ const body = lines.slice(0, -1).join("\n");
50
+ let text: string | undefined;
51
+ let mapDir = dirOf(path);
52
+ if (url.startsWith("data:")) {
53
+ const comma = url.indexOf(",");
54
+ const meta = url.slice(5, comma);
55
+ const payload = url.slice(comma + 1);
56
+ try {
57
+ text = meta.endsWith(";base64") ? fromBase64(payload) : decodeURIComponent(payload);
58
+ } catch {
59
+ text = undefined;
60
+ }
61
+ } else if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) {
62
+ const mapPath = joinWithin(mount.root, dirOf(path), decodeURIComponent(url));
63
+ if (mapPath) {
64
+ text = await tryReadText(mount.files, mapPath);
65
+ mapDir = dirOf(mapPath);
66
+ }
67
+ }
68
+ let map: SourceMap;
69
+ try {
70
+ map = JSON.parse(text ?? "") as SourceMap;
71
+ } catch {
72
+ return body; // no usable map: the file is its own source
73
+ }
74
+ const sourceDir = map.sourceRoot
75
+ ? (joinWithin("/", mapDir, map.sourceRoot.replace(/\/$/, "")) ?? mapDir)
76
+ : mapDir;
77
+ const contents: (string | null)[] = map.sourcesContent ? [...map.sourcesContent] : [];
78
+ const sources: string[] = [];
79
+ for (let i = 0; i < map.sources.length; i++) {
80
+ const src = map.sources[i] as string;
81
+ const local = /^[a-z][a-z0-9+.-]*:/i.test(src)
82
+ ? undefined
83
+ : joinWithin(mount.root, sourceDir, src);
84
+ sources.push(local ? idOf(mount, local) : src);
85
+ if (contents[i] == null && local) contents[i] = (await tryReadText(mount.files, local)) ?? null;
86
+ }
87
+ const fixed: SourceMap = { ...map, sources, sourcesContent: contents };
88
+ delete fixed.sourceRoot;
89
+ return `${body}\n//# sourceMappingURL=data:application/json;base64,${toBase64(JSON.stringify(fixed))}\n`;
90
+ }
@@ -0,0 +1,126 @@
1
+ import type { Source } from "@statewalker/webrun-modules";
2
+ import { dirOf, readManifest } from "./paths.js";
3
+ import type { Importer, Mount, PackageStore } from "./types.js";
4
+
5
+ const mountPrefix = (name: string, version: string | undefined) =>
6
+ `npm:${name}@${version ?? "0.0.0"}`;
7
+
8
+ /**
9
+ * Packages from `node_modules` folders, found as Node finds them: in the importing
10
+ * file's directory and each parent, inside the importer's own FilesApi. A package
11
+ * installed in the project (or in another package's `node_modules`) is read where it is.
12
+ *
13
+ * pnpm layouts: a package's own dependencies sit next to its real directory in
14
+ * `node_modules/.pnpm`, which Node reaches through the symlink's real path and a FilesApi
15
+ * cannot see. When the walk finds nothing, each ancestor's `node_modules/.pnpm/node_modules`
16
+ * (pnpm's hoisted links to every installed package) is tried too.
17
+ */
18
+ export function nodeModulesPackages(): PackageStore {
19
+ const mounts = new Map<string, Mount>();
20
+ const at = async (files: Mount["files"], root: string, name: string) => {
21
+ const known = mounts.get(root);
22
+ if (known && known.files === files) return known;
23
+ const manifest = await readManifest(files, root);
24
+ if (!manifest) return undefined;
25
+ const mount: Mount = { prefix: mountPrefix(name, manifest.version), files, root, manifest };
26
+ mounts.set(root, mount);
27
+ return mount;
28
+ };
29
+ const ancestors = (path: string) => {
30
+ const dirs: string[] = [];
31
+ for (let dir = dirOf(path); ; dir = dirOf(dir)) {
32
+ dirs.push(dir === "/" ? "" : dir);
33
+ if (dir === "/") return dirs;
34
+ }
35
+ };
36
+ return {
37
+ async get(name, importer) {
38
+ const files = importer.mount.files;
39
+ const dirs = ancestors(importer.path);
40
+ for (const dir of dirs) {
41
+ const m = await at(files, `${dir}/node_modules/${name}`, name);
42
+ if (m) return m;
43
+ }
44
+ for (const dir of dirs) {
45
+ const m = await at(files, `${dir}/node_modules/.pnpm/node_modules/${name}`, name);
46
+ if (m) return m;
47
+ }
48
+ return undefined;
49
+ },
50
+ };
51
+ }
52
+
53
+ const DEPENDENCY_FIELDS = [
54
+ "dependencies",
55
+ "peerDependencies",
56
+ "optionalDependencies",
57
+ "devDependencies",
58
+ ] as const;
59
+
60
+ /** The version range `importer`'s `package.json` gives `name`, if any. */
61
+ function rangeFor(name: string, importer: Importer): string | undefined {
62
+ const m = importer.mount.manifest;
63
+ for (const field of DEPENDENCY_FIELDS) {
64
+ const range = m?.[field]?.[name];
65
+ if (range) return range;
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ /**
71
+ * Packages fetched on demand (default: the npm registry, through webrun-modules'
72
+ * `npmRegistrySource`) and kept in memory: each one is its own FilesApi tree. The
73
+ * version is the range the importer's `package.json` declares (the project's for the
74
+ * project's files), else the latest; one range loads once.
75
+ */
76
+ export function npmPackages(opts: { source?: Source } = {}): PackageStore {
77
+ // Loaded only when used: a bundle of the bundler that brings its own source stays small.
78
+ let registry: Promise<Source> | undefined;
79
+ const source: Source = opts.source ?? {
80
+ matches: (ref) => "pkg" in ref,
81
+ load: async (ref) => {
82
+ registry ??= import("@statewalker/webrun-modules").then((m) => m.npmRegistrySource());
83
+ return (await registry).load(ref);
84
+ },
85
+ };
86
+ const byRange = new Map<string, Promise<Mount>>();
87
+ const byVersion = new Map<string, Mount>();
88
+ return {
89
+ get(name, importer) {
90
+ const range = rangeFor(name, importer);
91
+ const key = `${name}@${range ?? "latest"}`;
92
+ let mount = byRange.get(key);
93
+ if (!mount) {
94
+ mount = source.load({ pkg: name, version: range }).then((p) => {
95
+ const prefix = mountPrefix(p.name, p.version);
96
+ const seen = byVersion.get(prefix);
97
+ if (seen) return seen; // two ranges, one version: one copy of the package
98
+ const m: Mount = { prefix, files: p.files, root: "/", manifest: p.manifest };
99
+ byVersion.set(prefix, m);
100
+ return m;
101
+ });
102
+ byRange.set(key, mount);
103
+ }
104
+ return mount;
105
+ },
106
+ };
107
+ }
108
+
109
+ /** The first store that has the package wins (`node_modules` first, then the registry). */
110
+ export function chainPackages(...stores: PackageStore[]): PackageStore {
111
+ return {
112
+ async get(name, importer) {
113
+ let failure: unknown;
114
+ for (const s of stores) {
115
+ try {
116
+ const m = await s.get(name, importer);
117
+ if (m) return m;
118
+ } catch (e) {
119
+ failure ??= e; // the next store may still have it
120
+ }
121
+ }
122
+ if (failure) throw failure;
123
+ return undefined;
124
+ },
125
+ };
126
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { normalizePath, tryReadText } from "@statewalker/webrun-files";
2
+ import type { Mount, PackageManifest } from "./types.js";
3
+
4
+ /** `path` joined to `dir`, with `.` and `..` resolved; `undefined` when it leaves `root`. */
5
+ export function joinWithin(root: string, dir: string, path: string): string | undefined {
6
+ const parts = (path.startsWith("/") ? path : `${dir}/${path}`).split("/");
7
+ const out: string[] = [];
8
+ for (const p of parts) {
9
+ if (!p || p === ".") continue;
10
+ if (p === "..") {
11
+ if (!out.length) return undefined;
12
+ out.pop();
13
+ } else out.push(p);
14
+ }
15
+ const joined = `/${out.join("/")}`;
16
+ const r = normalizePath(root);
17
+ return r === "/" || joined === r || joined.startsWith(`${r}/`) ? joined : undefined;
18
+ }
19
+
20
+ export const dirOf = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/";
21
+
22
+ /** The id of a mount's file: `<prefix>/<path below root>`. */
23
+ export function idOf(mount: Mount, path: string): string {
24
+ const r = normalizePath(mount.root);
25
+ const rel = r === "/" ? path : path.slice(r.length);
26
+ return `${mount.prefix}${rel || "/"}`;
27
+ }
28
+
29
+ /** A mount's file from its id; `undefined` when the id is not the mount's. */
30
+ export function pathOf(mount: Mount, id: string): string | undefined {
31
+ if (!id.startsWith(`${mount.prefix}/`)) return undefined;
32
+ const r = normalizePath(mount.root);
33
+ const rel = id.slice(mount.prefix.length);
34
+ return r === "/" ? rel : `${r}${rel}`;
35
+ }
36
+
37
+ export async function isFile(mount: Mount, path: string): Promise<boolean> {
38
+ const s = await mount.files.stats(path);
39
+ return s?.kind === "file";
40
+ }
41
+
42
+ export async function readManifest(
43
+ files: Mount["files"],
44
+ dir: string,
45
+ ): Promise<PackageManifest | undefined> {
46
+ const text = await tryReadText(files, `${dir === "/" ? "" : dir}/package.json`);
47
+ if (text === undefined) return undefined;
48
+ try {
49
+ return JSON.parse(text) as PackageManifest;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /** `react` → `{ name: "react" }`; `@a/b/c.js` → `{ name: "@a/b", subpath: "c.js" }`. */
56
+ export function parsePackageSpecifier(spec: string): { name: string; subpath?: string } {
57
+ const parts = spec.split("/");
58
+ const n = spec.startsWith("@") ? 2 : 1;
59
+ const name = parts.slice(0, n).join("/");
60
+ const subpath = parts.slice(n).join("/");
61
+ return subpath ? { name, subpath } : { name };
62
+ }