@statewalker/webrun-bundler 0.0.0-stage → 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-bundler",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Transpiler, resolver and bundler on esbuild-wasm that reads everything through a FilesApi (MemFilesApi, OPFS, a FilesApi over a port; never node:fs) and fetches npm packages on demand. Every transformation keeps a source map: each line of a bundle traces back to the user's file or to the npm package's own source.",
7
+ "bugs": {
8
+ "url": "https://github.com/statewalker/webrun-sites/issues"
9
+ },
10
+ "homepage": "https://github.com/statewalker/webrun-sites/tree/main/packages/webrun-bundler#readme",
11
+ "author": {
12
+ "name": "Mikhail Kotelnikov",
13
+ "email": "mikhail.kotelnikov@gmail.com"
14
+ },
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/statewalker/webrun-sites.git",
19
+ "directory": "packages/webrun-bundler"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "source": "./src/index.ts",
24
+ "types": "./dist/index.d.mts",
25
+ "import": "./dist/index.mjs"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src"
31
+ ],
32
+ "dependencies": {
33
+ "@statewalker/webrun-files": "^0.10.0",
34
+ "resolve.exports": "^2.0.3",
35
+ "@statewalker/webrun-modules": "^0.3.6"
36
+ },
37
+ "peerDependencies": {
38
+ "esbuild-wasm": "^0.28.0"
39
+ },
40
+ "devDependencies": {
41
+ "@jridgewell/trace-mapping": "^0.3.31",
42
+ "@statewalker/webrun-files-browser": "^0.10.2",
43
+ "@statewalker/webrun-files-mem": "^0.10.0",
44
+ "esbuild-wasm": "^0.28.2",
45
+ "playwright": "^1.63.0",
46
+ "rimraf": "^6.1.3",
47
+ "tsdown": "^0.23.0",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^5.0.3"
50
+ },
51
+ "sideEffects": false,
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "scripts": {
56
+ "build": "tsdown",
57
+ "dev": "tsdown --watch",
58
+ "test": "vitest run",
59
+ "test:watch": "vitest",
60
+ "typecheck": "tsc --noEmit",
61
+ "clean": "rimraf dist",
62
+ "lint": "biome check --write .",
63
+ "format": "biome format --write .",
64
+ "test:browser": "node e2e/browser.mjs"
65
+ }
6
66
  }
@@ -0,0 +1,388 @@
1
+ import { originalPositionFor, TraceMap } from "@jridgewell/trace-mapping";
2
+ import type { FilesApi } from "@statewalker/webrun-files";
3
+ import { MemFilesApi } from "@statewalker/webrun-files-mem";
4
+ import type { Source } from "@statewalker/webrun-modules";
5
+ import * as esbuild from "esbuild-wasm";
6
+ import { describe, expect, it } from "vitest";
7
+ import { bundle, chainPackages, nodeModulesPackages, npmPackages, transpile } from "./index.js";
8
+ import type { SourceMap } from "./types.js";
9
+
10
+ // esbuild-wasm itself (its Node build runs the same WebAssembly as in browsers); every
11
+ // file comes from a MemFilesApi, nothing from a disk.
12
+
13
+ const enc = new TextEncoder();
14
+ async function filesOf(tree: Record<string, string>): Promise<FilesApi> {
15
+ const files = new MemFilesApi();
16
+ for (const [path, text] of Object.entries(tree)) await files.write(path, [enc.encode(text)]);
17
+ return files;
18
+ }
19
+
20
+ /** 1-based line of the first line of `text` containing `marker`. */
21
+ const lineOf = (text: string, marker: string) =>
22
+ text.split("\n").findIndex((l) => l.includes(marker)) + 1;
23
+
24
+ /** Where the bundled line holding `marker` comes from. */
25
+ function trace(code: string, map: SourceMap, marker: string) {
26
+ const lines = code.split("\n");
27
+ const line = lines.findIndex((l) => l.includes(marker));
28
+ expect(line, `${marker} in the bundle`).toBeGreaterThanOrEqual(0);
29
+ const tm = new TraceMap(map as never);
30
+ const text = lines[line] as string;
31
+ // the first mapped column of the line
32
+ for (let column = 0; column < text.length; column++) {
33
+ const pos = originalPositionFor(tm, { line: line + 1, column });
34
+ if (pos.source) return { source: pos.source, line: pos.line };
35
+ }
36
+ return { source: null, line: null };
37
+ }
38
+
39
+ // The original TypeScript of a published package: types and comments make its lines differ
40
+ // from the compiled JavaScript's, so a trace that ends at the .js would be off.
41
+ const ESM_PKG_SRC = `/** Greeting helpers. */
42
+ export interface Options {
43
+ punctuation?: string;
44
+ }
45
+
46
+ export type Name = string;
47
+
48
+ export async function greet(name: Name, opts: Options = {}): Promise<string> {
49
+ await Promise.resolve();
50
+ return "hello " + name + (opts.punctuation ?? "!") + "MARK_ESM_PKG".slice(99);
51
+ }
52
+ `;
53
+
54
+ /** `esm-pkg` as npm ships it: compiled dist/, a linked map without sourcesContent, src/. */
55
+ async function esmPackage(): Promise<Record<string, string>> {
56
+ const out = await esbuild.transform(ESM_PKG_SRC, {
57
+ loader: "ts",
58
+ sourcefile: "../src/index.ts",
59
+ sourcemap: "external",
60
+ format: "esm",
61
+ target: "es2022",
62
+ });
63
+ const map = JSON.parse(out.map);
64
+ delete map.sourcesContent; // many packages ship maps without it: the bundler reads src/
65
+ return {
66
+ "/package.json": JSON.stringify({
67
+ name: "esm-pkg",
68
+ version: "1.2.3",
69
+ type: "module",
70
+ exports: { ".": { browser: "./dist/browser.js", default: "./dist/index.js" } },
71
+ }),
72
+ "/dist/browser.js": `${out.code}//# sourceMappingURL=browser.js.map\n`,
73
+ "/dist/browser.js.map": JSON.stringify(map),
74
+ "/dist/index.js": 'export const greet = () => "not the browser build";\n',
75
+ "/src/index.ts": ESM_PKG_SRC,
76
+ };
77
+ }
78
+
79
+ const CJS_PKG = {
80
+ "/package.json": JSON.stringify({ name: "cjs-pkg", version: "2.0.0", main: "lib/index.js" }),
81
+ "/lib/index.js": `"use strict";
82
+ exports.shout = function (s) {
83
+ return s.toUpperCase() + "!" + "MARK_CJS".slice(99);
84
+ };
85
+ `,
86
+ };
87
+
88
+ const APP = `import cjs from "cjs-pkg";
89
+ import { greet } from "esm-pkg";
90
+ import data from "./data.json";
91
+
92
+ export async function main(name: string): Promise<string> {
93
+ const g = (await greet(name)) + "MARK_APP_AWAIT".slice(99);
94
+ return \`\${g} \${cjs.shout(name)} \${data.v}\${"MARK_APP_RETURN".slice(99)}\`;
95
+ }
96
+ `;
97
+
98
+ /** A registry `Source` over in-memory packages; counts loads. */
99
+ function fakeRegistry(packages: Record<string, Record<string, string>>) {
100
+ const loads: string[] = [];
101
+ const source: Source = {
102
+ matches: (ref) => "pkg" in ref,
103
+ async load(ref) {
104
+ if (!("pkg" in ref)) throw new Error("not a package");
105
+ loads.push(`${ref.pkg}@${ref.version ?? "latest"}`);
106
+ const tree = packages[ref.pkg];
107
+ if (!tree) throw new Error(`no package ${ref.pkg}`);
108
+ const files = await filesOf(tree);
109
+ const manifest = JSON.parse(tree["/package.json"] as string);
110
+ return { name: ref.pkg, version: manifest.version, files, manifest };
111
+ },
112
+ };
113
+ return { source, loads };
114
+ }
115
+
116
+ async function run(code: string) {
117
+ const bytes = enc.encode(code);
118
+ let bin = "";
119
+ for (const b of bytes) bin += String.fromCharCode(b);
120
+ const url = `data:text/javascript;base64,${btoa(bin)}`;
121
+ return import(/* @vite-ignore */ url);
122
+ }
123
+
124
+ describe("bundle", () => {
125
+ it("bundles from a FilesApi with packages fetched on demand; every line traces to its source", async () => {
126
+ const files = await filesOf({
127
+ "/package.json": JSON.stringify({
128
+ dependencies: { "esm-pkg": "^1.0.0", "cjs-pkg": "^2.0.0" },
129
+ }),
130
+ "/src/app.ts": APP,
131
+ "/src/data.json": '{ "v": 42 }',
132
+ });
133
+ const { source, loads } = fakeRegistry({ "esm-pkg": await esmPackage(), "cjs-pkg": CJS_PKG });
134
+ const r = await bundle({
135
+ esbuild,
136
+ files,
137
+ entry: "/src/app.ts",
138
+ packages: npmPackages({ source }),
139
+ });
140
+
141
+ expect(loads.sort()).toEqual(["cjs-pkg@^2.0.0", "esm-pkg@^1.0.0"]);
142
+ expect((await run(r.code)).main).toBeTypeOf("function");
143
+ expect(await (await run(r.code)).main("bob")).toBe("hello bob! BOB! 42");
144
+ expect(r.code).toMatch(/\/\/# sourceMappingURL=bundle\.js\.map\n$/);
145
+
146
+ // The user's TypeScript, line for line.
147
+ expect(trace(r.code, r.map, "MARK_APP_AWAIT")).toEqual({
148
+ source: "~/src/app.ts",
149
+ line: lineOf(APP, "MARK_APP_AWAIT"),
150
+ });
151
+ expect(trace(r.code, r.map, "MARK_APP_RETURN")).toEqual({
152
+ source: "~/src/app.ts",
153
+ line: lineOf(APP, "MARK_APP_RETURN"),
154
+ });
155
+ // Through the package's own map, to its original TypeScript (not dist/browser.js).
156
+ expect(trace(r.code, r.map, "MARK_ESM_PKG")).toEqual({
157
+ source: "npm:esm-pkg@1.2.3/src/index.ts",
158
+ line: lineOf(ESM_PKG_SRC, "MARK_ESM_PKG"),
159
+ });
160
+ // CommonJS, converted by esbuild, still maps to the package's file.
161
+ expect(trace(r.code, r.map, "MARK_CJS")).toEqual({
162
+ source: "npm:cjs-pkg@2.0.0/lib/index.js",
163
+ line: 3,
164
+ });
165
+ // The map carries every source's text, the package's TypeScript included.
166
+ const i = r.map.sources.indexOf("npm:esm-pkg@1.2.3/src/index.ts");
167
+ expect(r.map.sourcesContent?.[i]).toBe(ESM_PKG_SRC);
168
+ expect(r.map.sources).not.toContain("npm:esm-pkg@1.2.3/dist/browser.js");
169
+ });
170
+
171
+ it("maps every line that comes from an input; only esbuild's own helpers have no source", async () => {
172
+ const files = await filesOf({
173
+ "/package.json": JSON.stringify({
174
+ dependencies: { "esm-pkg": "^1.0.0", "cjs-pkg": "^2.0.0" },
175
+ }),
176
+ "/src/app.ts": APP,
177
+ "/src/data.json": '{ "v": 42 }',
178
+ });
179
+ const { source } = fakeRegistry({ "esm-pkg": await esmPackage(), "cjs-pkg": CJS_PKG });
180
+ for (const lowerAwait of [false, true]) {
181
+ const r = await bundle({
182
+ esbuild,
183
+ files,
184
+ entry: "/src/app.ts",
185
+ packages: npmPackages({ source }),
186
+ lowerAwait,
187
+ });
188
+ const tm = new TraceMap(r.map as never);
189
+ const lines = r.code.split("\n");
190
+ // esbuild's runtime (`__commonJS`, `__toESM`, with lowering `__async`) comes first, up
191
+ // to the first module's comment; it is the only code no input wrote.
192
+ const firstModule = lines.findIndex((l) => l.startsWith("// webrun:"));
193
+ expect(firstModule).toBeGreaterThan(0);
194
+ // …and its closing `export { … };` clause, which lists the entry's exports.
195
+ const exportClause = lines.lastIndexOf("export {");
196
+ const unmapped: string[] = [];
197
+ lines.slice(firstModule, exportClause < 0 ? undefined : exportClause).forEach((text, i) => {
198
+ const code = text.trim();
199
+ if (!code || code.startsWith("//") || /^[\]{}()[;,]+$/.test(code)) return;
200
+ let mapped = false;
201
+ for (let column = 0; column < text.length && !mapped; column++)
202
+ mapped = !!originalPositionFor(tm, { line: firstModule + i + 1, column }).source;
203
+ if (!mapped) unmapped.push(code);
204
+ });
205
+ // Besides them, only esbuild's CommonJS wrapper lines: `var require_x = __commonJS({`
206
+ // and the module's function head.
207
+ expect(
208
+ unmapped.filter(
209
+ (l) => !/^var require_\w+ = __commonJS\(\{$|^"webrun:[^"]+"\(.*\) \{$/.test(l),
210
+ ),
211
+ ).toEqual([]);
212
+ if (lowerAwait) {
213
+ expect(r.code).not.toMatch(/\bawait\b/);
214
+ expect(trace(r.code, r.map, "MARK_APP_AWAIT").source).toBe("~/src/app.ts");
215
+ expect(trace(r.code, r.map, "MARK_ESM_PKG").source).toBe("npm:esm-pkg@1.2.3/src/index.ts");
216
+ expect(await (await run(r.code)).main("amy")).toBe("hello amy! AMY! 42");
217
+ }
218
+ }
219
+ });
220
+
221
+ it("reads packages from node_modules folders of the FilesApi, nested ones first", async () => {
222
+ const files = await filesOf({
223
+ "/src/main.js":
224
+ 'import { a } from "a";\nimport { v } from "b";\nexport const out = a + "/" + v;\n',
225
+ "/node_modules/a/package.json": JSON.stringify({
226
+ name: "a",
227
+ version: "1.0.0",
228
+ main: "index.js",
229
+ }),
230
+ "/node_modules/a/index.js": 'export { v as a } from "b";\n',
231
+ "/node_modules/a/node_modules/b/package.json": JSON.stringify({
232
+ name: "b",
233
+ version: "2.0.0",
234
+ module: "m.js",
235
+ }),
236
+ "/node_modules/a/node_modules/b/m.js": 'export const v = "b2" + "MARK_B2".slice(99);\n',
237
+ "/node_modules/b/package.json": JSON.stringify({ name: "b", version: "1.0.0" }),
238
+ "/node_modules/b/index.js": 'export const v = "b1";\n',
239
+ });
240
+ const r = await bundle({ esbuild, files, entry: "/src/main.js" });
241
+ expect((await run(r.code)).out).toBe("b2/b1");
242
+ expect(trace(r.code, r.map, "MARK_B2").source).toBe("npm:b@2.0.0/m.js");
243
+ // a/index.js only re-exports: no code of its own, so esbuild maps nothing to it.
244
+ expect(r.map.sources).toEqual(
245
+ expect.arrayContaining(["~/src/main.js", "npm:b@2.0.0/m.js", "npm:b@1.0.0/index.js"]),
246
+ );
247
+ });
248
+
249
+ it("keeps externals as imports, picks exports conditions, resolves .js to .ts and #imports", async () => {
250
+ const files = await filesOf({
251
+ "/package.json": JSON.stringify({ imports: { "#util": "./src/util.ts" } }),
252
+ "/src/worker.ts":
253
+ 'import { DurableObject } from "cloudflare:workers";\nimport { readFileSync } from "node:fs";\nimport { which } from "dual";\nimport { util } from "#util";\nimport { helper } from "./helper.js";\nexport const all = [DurableObject, readFileSync, which, util, helper];\n',
254
+ "/src/util.ts": 'export const util: string = "util";\n',
255
+ "/src/helper.ts": 'export const helper: string = "helper";\n',
256
+ "/node_modules/dual/package.json": JSON.stringify({
257
+ name: "dual",
258
+ version: "1.0.0",
259
+ exports: { workerd: "./w.js", browser: "./b.js", default: "./d.js" },
260
+ }),
261
+ "/node_modules/dual/w.js": 'export const which = "workerd";\n',
262
+ "/node_modules/dual/b.js": 'export const which = "browser";\n',
263
+ "/node_modules/dual/d.js": 'export const which = "default";\n',
264
+ });
265
+ const r = await bundle({
266
+ esbuild,
267
+ files,
268
+ entry: "/src/worker.ts",
269
+ external: ["cloudflare:*", "node:*"],
270
+ conditions: ["workerd", "worker", "browser"],
271
+ platform: "neutral",
272
+ });
273
+ expect(r.code).toMatch(/from "cloudflare:workers"/);
274
+ expect(r.code).toMatch(/from "node:fs"/);
275
+ expect(r.code).toContain('"workerd"');
276
+ expect(r.code).not.toContain('"browser"');
277
+ expect(r.map.sources).toEqual(
278
+ expect.arrayContaining([
279
+ "~/src/worker.ts",
280
+ "~/src/util.ts",
281
+ "~/src/helper.ts",
282
+ "npm:dual@1.0.0/w.js",
283
+ ]),
284
+ );
285
+ });
286
+
287
+ it("node_modules first, then the registry; unresolvable imports are esbuild errors", async () => {
288
+ const files = await filesOf({
289
+ "/package.json": JSON.stringify({ dependencies: { "cjs-pkg": "2" } }),
290
+ "/src/main.js":
291
+ 'import local from "local";\nimport cjs from "cjs-pkg";\nexport const out = local + cjs.shout("a");\n',
292
+ "/node_modules/local/package.json": JSON.stringify({ name: "local", version: "0.1.0" }),
293
+ "/node_modules/local/index.js": 'export default "local:";\n',
294
+ });
295
+ const { source, loads } = fakeRegistry({ "cjs-pkg": CJS_PKG });
296
+ const packages = chainPackages(nodeModulesPackages(), npmPackages({ source }));
297
+ const r = await bundle({ esbuild, files, entry: "/src/main.js", packages });
298
+ expect((await run(r.code)).out).toBe("local:A!");
299
+ expect(loads).toEqual(["cjs-pkg@2"]);
300
+
301
+ await files.write("/src/bad.js", [enc.encode('import "nowhere";\n')]);
302
+ await expect(bundle({ esbuild, files, entry: "/src/bad.js", packages })).rejects.toThrow(
303
+ /Could not resolve "nowhere"/,
304
+ );
305
+ });
306
+
307
+ it("host modules (by name or function) and inject; their lines map to virtual:<specifier>", async () => {
308
+ const files = await filesOf({
309
+ "/src/main.ts":
310
+ 'import { env } from "cloudflare:workers";\nimport { join } from "node:path";\nimport { tag } from "./tag.ts";\nexport const out = [env.NAME, join("a", "b"), tag, new Response("r").kind];\n',
311
+ "/src/tag.ts": 'export const tag: string = "tag";\n',
312
+ "/src/shim.ts":
313
+ 'export class Response { kind = "shimmed"; constructor(public body: string) {} }\n',
314
+ });
315
+ const r = await bundle({
316
+ esbuild,
317
+ files,
318
+ entry: "/src/main.ts",
319
+ modules: (spec) =>
320
+ spec === "cloudflare:workers"
321
+ ? 'export const env = { NAME: "host-env" };\n'
322
+ : spec.startsWith("node:")
323
+ ? `export const join = (...p) => p.join("/") + ${JSON.stringify(`<${spec}>`)};\n`
324
+ : undefined,
325
+ inject: ["/src/shim.ts"],
326
+ });
327
+ expect((await run(r.code)).out).toEqual(["host-env", "a/b<node:path>", "tag", "shimmed"]);
328
+ expect(r.map.sources).toEqual(
329
+ expect.arrayContaining([
330
+ "virtual:cloudflare:workers",
331
+ "virtual:node:path",
332
+ "~/src/main.ts",
333
+ "~/src/shim.ts",
334
+ ]),
335
+ );
336
+ // a record works too, and an unknown specifier still resolves as usual
337
+ const r2 = await bundle({
338
+ esbuild,
339
+ files,
340
+ entry: "/src/main.ts",
341
+ modules: {
342
+ "cloudflare:workers": "export const env = { NAME: 1 };\n",
343
+ "node:path": "export const join = () => 2;\n",
344
+ },
345
+ });
346
+ expect((await run(r2.code)).out.slice(0, 3)).toEqual([1, 2, "tag"]);
347
+ });
348
+
349
+ it("pnpm layouts: a package's own dependencies come from node_modules/.pnpm/node_modules", async () => {
350
+ const files = await filesOf({
351
+ "/app/main.js": 'import { a } from "a";\nexport const out = a;\n',
352
+ // node_modules/a is a symlink into .pnpm in a real install; its dependency b is only
353
+ // next to a's real directory, reachable through pnpm's hoisted links.
354
+ "/app/node_modules/a/package.json": JSON.stringify({ name: "a", version: "1.0.0" }),
355
+ "/app/node_modules/a/index.js": 'import { b } from "b";\nexport const a = "a+" + b;\n',
356
+ "/app/node_modules/.pnpm/node_modules/b/package.json": JSON.stringify({
357
+ name: "b",
358
+ version: "4.0.0",
359
+ }),
360
+ "/app/node_modules/.pnpm/node_modules/b/index.js": 'export const b = "b";\n',
361
+ });
362
+ const r = await bundle({ esbuild, files, root: "/app", entry: "/main.js" });
363
+ expect((await run(r.code)).out).toBe("a+b");
364
+ expect(r.map.sources).toContain("npm:b@4.0.0/index.js");
365
+ });
366
+
367
+ it("inline maps: the bundle carries its own map", async () => {
368
+ const files = await filesOf({ "/a.ts": "export const a: number = 1; // MARK_A\n" });
369
+ const r = await bundle({ esbuild, files, entry: "/a.ts", sourcemap: "inline" });
370
+ const b64 = /sourceMappingURL=data:application\/json;base64,(\S+)/.exec(r.code)?.[1];
371
+ expect(
372
+ JSON.parse(
373
+ new TextDecoder().decode(Uint8Array.from(atob(b64 as string), (c) => c.charCodeAt(0))),
374
+ ),
375
+ ).toEqual(r.map);
376
+ });
377
+ });
378
+
379
+ describe("transpile", () => {
380
+ it("one file, its map back to it; await lowered when asked", async () => {
381
+ const src =
382
+ "type T = number;\n\nexport async function f(x: T) {\n return (await x) + 1 + 'MARK_F'.length * 0;\n}\n";
383
+ const r = await transpile(src, { esbuild, path: "~/src/f.ts", lowerAwait: true });
384
+ expect(r.code).not.toMatch(/\bawait\b/);
385
+ expect(trace(r.code, r.map, "MARK_F")).toEqual({ source: "~/src/f.ts", line: 4 });
386
+ expect(r.code).toMatch(/\/\/# sourceMappingURL=f\.js\.map\n$/);
387
+ });
388
+ });