@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022-2026 statewalker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,191 @@
1
- # Temporary Holding Version
1
+ # @statewalker/webrun-bundler
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ## What it is
4
+
5
+ A transpiler, resolver and bundler on [esbuild-wasm](https://esbuild.github.io/getting-started/#wasm)
6
+ that reads every file through a
7
+ [`FilesApi`](https://www.npmjs.com/package/@statewalker/webrun-files): a `MemFilesApi` in tests,
8
+ OPFS or a folder the user picked in a browser, a FilesApi served over a `MessagePort` to a worker.
9
+ It never touches `node:fs`, and esbuild never reads a file itself.
10
+
11
+ Every transformation keeps a source map. TypeScript, JSX, CommonJS-to-ESM, `await` lowering and
12
+ bundling all happen in one esbuild pass, and where a package ships its own map (TypeScript compiled
13
+ to `.js`), that map is composed in. Each line of a bundle traces back to the user's file or to the
14
+ npm package's original source, and the map carries the text of each.
15
+
16
+ ## Why it exists
17
+
18
+ Code that runs in Durable Objects, workers and browsers is built in places without a disk: in a
19
+ browser worker, in a ServiceWorker, inside a Worker runtime. esbuild-wasm runs there, but it can only
20
+ read what it is handed, and a bundle whose map stops at a package's compiled `dist/` (or at an
21
+ intermediate transform) says little about where an error comes from. This package hands esbuild
22
+ the files from a FilesApi, finds packages in `node_modules` folders or fetches them from the npm
23
+ registry (through `@statewalker/webrun-modules`' `npmRegistrySource`), and keeps the map chain
24
+ unbroken.
25
+
26
+ ## How to use
27
+
28
+ ### Install
29
+
30
+ ```sh
31
+ pnpm add @statewalker/webrun-bundler esbuild-wasm
32
+ ```
33
+
34
+ `esbuild-wasm` is a peer dependency: initialize it once (in a browser, `esbuild.initialize({
35
+ wasmURL })`; on Node it needs nothing) and pass it in. Native `esbuild` works too on Node.
36
+
37
+ ### Bundle a project
38
+
39
+ ```ts
40
+ import * as esbuild from "esbuild-wasm";
41
+ import { getOPFSFilesApi } from "@statewalker/webrun-files-browser";
42
+ import { bundle, chainPackages, nodeModulesPackages, npmPackages } from "@statewalker/webrun-bundler";
43
+
44
+ await esbuild.initialize({ wasmURL: "/esbuild.wasm" });
45
+ const files = await getOPFSFilesApi();
46
+
47
+ const { code, map } = await bundle({
48
+ esbuild,
49
+ files,
50
+ root: "/my-project",
51
+ entry: "/src/index.ts",
52
+ // node_modules in the FilesApi first, then the npm registry
53
+ packages: chainPackages(nodeModulesPackages(), npmPackages()),
54
+ external: ["cloudflare:*", "node:*"], // left to the runtime
55
+ conditions: ["workerd", "worker", "browser"],
56
+ lowerAwait: true,
57
+ });
58
+ // `code` ends with `//# sourceMappingURL=bundle.js.map`; `map` is that map.
59
+ ```
60
+
61
+ `map.sources` are file ids: `~/src/index.ts` for the project, `npm:<name>@<version>/<file>` for
62
+ packages (`npm:hono@4.6.3/src/hono.ts` when the package ships its TypeScript and a map).
63
+
64
+ ### Transpile one file
65
+
66
+ ```ts
67
+ import { transpile } from "@statewalker/webrun-bundler";
68
+
69
+ const { code, map } = await transpile(source, { esbuild, path: "~/src/f.ts", lowerAwait: true });
70
+ ```
71
+
72
+ ### `bundle` options
73
+
74
+ | Option | Default | Meaning |
75
+ |---|---|---|
76
+ | `esbuild` | required | `esbuild-wasm` (or native `esbuild`) |
77
+ | `files`, `root` | required, `/` | the project's FilesApi and its directory |
78
+ | `entry` | required | the entry module, a project path |
79
+ | `packages` | `nodeModulesPackages()` | where bare specifiers go |
80
+ | `external` | `[]` | specifiers kept as imports; `"x:*"` is a prefix. `http(s):`/`data:` always are |
81
+ | `modules` | | modules the host supplies by specifier (a record, or a function returning source or `undefined`); mapped as `virtual:<specifier>` |
82
+ | `inject` | | modules whose exports replace same-named globals in every file (esbuild `inject`), resolved like imports |
83
+ | `conditions` | `["browser"]` | `exports` conditions besides `import`/`require` and `default` |
84
+ | `format`, `platform`, `target` | `esm`, `browser`, `es2022` | as in esbuild |
85
+ | `lowerAwait` | `false` | lower `async`/`await`, async generators and `for await` to generators |
86
+ | `minify`, `define` | | as in esbuild |
87
+ | `outfile` | `bundle.js` | the name the map link uses (`<outfile>.map`) |
88
+ | `sourcemap` | `external` | `inline` also puts the map in `code`; `false` builds no map (`map` is empty, `code` has no link) and does not read the inputs' maps |
89
+ | `esbuildOptions` | | other esbuild options, applied last (the plugin and source maps stay) |
90
+
91
+ ## Examples
92
+
93
+ ### Packages from the registry, at the versions the project asks for
94
+
95
+ `npmPackages()` takes each package at the range the importer's `package.json` declares (the
96
+ project's for project files, the package's own for its dependencies), else the latest. One range
97
+ loads once, and two ranges that land on one version share it. Pass `source` to fetch from
98
+ elsewhere (any `@statewalker/webrun-modules` `Source`).
99
+
100
+ ### The host supplies its runtime's modules
101
+
102
+ A runtime embedding the bundle provides modules the bundle cannot read from files: `cloudflare:workers`,
103
+ `node:*` polyfills from its own realm, its own `Response`. `modules` gives their source by
104
+ specifier (a function can cover a whole family), and `inject` replaces globals with a module's
105
+ exports:
106
+
107
+ ```ts
108
+ await bundle({
109
+ esbuild, files, entry: "/src/index.ts",
110
+ modules: (spec) =>
111
+ spec.startsWith("node:")
112
+ ? `const m = globalThis.__host.get(${JSON.stringify(spec)}); export default m; export const { Buffer } = m;`
113
+ : undefined,
114
+ inject: ["/src/runtime-globals.ts"],
115
+ });
116
+ ```
117
+
118
+ ### `lowerAwait` for an AsyncLocalStorage polyfill
119
+
120
+ Browsers have no async context propagation yet (TC39 AsyncContext is at Stage 2). A polyfill that
121
+ patches `Promise.prototype.then` cannot see a native `await`; with `lowerAwait`, each `await`
122
+ resumes through `then`, and the source map follows the lowered code.
123
+
124
+ ### Tests
125
+
126
+ `pnpm test` runs the Node suite: esbuild-wasm on `MemFilesApi` projects, with packages from a fake
127
+ registry and from `node_modules`, checking that the bundles run and that their lines trace to the
128
+ right files and lines. `pnpm test:browser` runs `e2e/browser.mjs`: a Dedicated Worker in Chromium
129
+ bundles a project stored in OPFS with esbuild-wasm and checks the same.
130
+
131
+ ## Internals
132
+
133
+ ### One esbuild pass, one plugin
134
+
135
+ The bundler is an esbuild plugin. `onResolve` resolves every specifier (relative paths with
136
+ extension and `index` resolution, `./x.js` written for `./x.ts`, `#imports`, packages with their
137
+ `exports` conditions, `module` and `main`) and names the file by its id; `onLoad` reads it from its
138
+ FilesApi. esbuild does the rest. There is no intermediate file, so there is no intermediate map
139
+ to lose.
140
+
141
+ ### Input maps are inlined, and their sources renamed
142
+
143
+ esbuild composes a loaded file's map with its own only when the map is inline. A published file
144
+ usually links one (`//# sourceMappingURL=index.js.map`): the bundler reads it from the package's
145
+ tree, rewrites its sources to ids of that tree (`../src/index.ts` → `npm:pkg@1.0.0/src/index.ts`),
146
+ fills a missing `sourcesContent` from the package's files, and inlines it. A map that is missing
147
+ or broken is dropped: the file then maps to itself.
148
+
149
+ With `sourcemap: false` none of this happens: the `sourceMappingURL` comment is removed and the map
150
+ is not read. An inlined map carries its sources' text and can outweigh the code: for EmDash's
151
+ server bundle (cellb.dev's in-browser builder) esbuild's input goes from 58.9 MB to 27.0 MB.
152
+
153
+ ### Module types follow Node
154
+
155
+ esbuild reads a `package.json` `"type"` only for files on its own file system. For the files this
156
+ plugin loads, a `.js` file in a `"type": "module"` scope gets an id ending in `?type=module.mjs`,
157
+ so esbuild treats it as ESM the way Node does: a default import of a CommonJS module is its
158
+ `module.exports`, even when it sets `__esModule`. tslib 2.3's `modules/index.js` depends on it.
159
+ The suffix is dropped from the ids in the source map.
160
+
161
+ ### pnpm layouts
162
+
163
+ In a pnpm install, `node_modules/x` is a symlink into `node_modules/.pnpm`, and `x`'s own
164
+ dependencies sit next to its real directory there. Node follows the real path; a FilesApi sees
165
+ only the link. When the usual walk up the `node_modules` folders finds nothing,
166
+ `nodeModulesPackages` tries each ancestor's `node_modules/.pnpm/node_modules`, where pnpm links
167
+ every installed package (with one version per name: a package that two dependencies need in
168
+ different versions resolves to the hoisted one).
169
+
170
+ ### What has no source
171
+
172
+ esbuild's own runtime (`__commonJS`, `__toESM`, `__async` when lowering), the wrapper lines of a
173
+ CommonJS module, and the bundle's closing `export { … }` are written by esbuild, not by an input:
174
+ they have no mapping. Every other line maps to a file.
175
+
176
+ ### What does not work yet
177
+
178
+ - The `browser` field as a map of substitutions (only a string `browser` entry is used).
179
+ - CSS bundling beyond what esbuild's `css` loader does; `@import` of packages resolves like JS.
180
+ - Packages are matched by name and range, not deduplicated across ranges that resolve to
181
+ different versions (as npm would install both).
182
+
183
+ ### Dependencies
184
+
185
+ `@statewalker/webrun-files` (FilesApi), `@statewalker/webrun-modules` (the npm registry `Source`,
186
+ loaded only when `npmPackages()` falls back to it), `resolve.exports` (`exports`/`imports` maps);
187
+ `esbuild-wasm` as a peer.
188
+
189
+ ## License
190
+
191
+ MIT
@@ -0,0 +1,202 @@
1
+ import { FilesApi } from "@statewalker/webrun-files";
2
+ import { BuildOptions, Loader, Message, Metafile, build, transform } from "esbuild-wasm";
3
+ import { Source } from "@statewalker/webrun-modules";
4
+ //#region src/types.d.ts
5
+ /**
6
+ * The esbuild API the bundler calls: `esbuild-wasm` (initialized, in browsers and
7
+ * workers) or native `esbuild` on Node. Only `build` and `transform` are used, and
8
+ * every file reaches esbuild through the bundler's plugin: esbuild never touches a
9
+ * file system itself.
10
+ */
11
+ interface Esbuild {
12
+ build: typeof build;
13
+ transform: typeof transform;
14
+ }
15
+ /** The fields of a `package.json` the resolver reads. */
16
+ interface PackageManifest {
17
+ name?: string;
18
+ version?: string;
19
+ exports?: unknown;
20
+ imports?: unknown;
21
+ main?: string;
22
+ module?: string;
23
+ browser?: unknown;
24
+ dependencies?: Record<string, string>;
25
+ peerDependencies?: Record<string, string>;
26
+ optionalDependencies?: Record<string, string>;
27
+ devDependencies?: Record<string, string>;
28
+ [key: string]: unknown;
29
+ }
30
+ /**
31
+ * A tree of files the bundler reads: the project, or one npm package. Its files get
32
+ * ids `<prefix><path>` (`~/src/app.ts`, `npm:react@18.3.1/index.js`); the ids are what
33
+ * the source maps name, so every bundled line traces back to the user's file or to the
34
+ * package it came from.
35
+ */
36
+ interface Mount {
37
+ /** `~` for the project, `npm:<name>@<version>` for a package. */
38
+ prefix: string;
39
+ files: FilesApi;
40
+ /** The tree's directory in `files` (`/` for a package of its own, `/node_modules/x`). */
41
+ root: string;
42
+ /** Its `package.json`, when it has one. */
43
+ manifest?: PackageManifest;
44
+ }
45
+ /** Where an import comes from: a file of a mount. */
46
+ interface Importer {
47
+ mount: Mount;
48
+ /** The importing file's path in `mount.files` (root included). */
49
+ path: string;
50
+ }
51
+ /**
52
+ * Finds the npm packages bare specifiers name: in `node_modules` folders of a FilesApi
53
+ * (`nodeModulesPackages`), fetched from a registry on demand (`npmPackages`), or both
54
+ * (`chainPackages`).
55
+ */
56
+ interface PackageStore {
57
+ /** The package `name` as `importer` sees it; `undefined` when it has none. */
58
+ get(name: string, importer: Importer): Promise<Mount | undefined>;
59
+ }
60
+ /** A specifier left to the runtime (`cloudflare:workers`, `node:fs`): a string ending in `*` is a prefix. */
61
+ type ExternalPattern = string | RegExp;
62
+ interface BundleOptions {
63
+ /** `esbuild-wasm` (or native `esbuild` on Node). */
64
+ esbuild: Esbuild;
65
+ /** The project's files. */
66
+ files: FilesApi;
67
+ /** The project's directory in `files` (default `/`). */
68
+ root?: string;
69
+ /** The entry module, a path in the project (`/src/index.ts`). */
70
+ entry: string;
71
+ /** Where bare specifiers go (default: `node_modules` folders of `files`). */
72
+ packages?: PackageStore;
73
+ /** Specifiers kept as imports for the runtime. `http(s):` and `data:` URLs always are. */
74
+ external?: ExternalPattern[];
75
+ /**
76
+ * Modules the host supplies, by specifier (`cloudflare:workers`, `node:buffer`): their
77
+ * JavaScript source, or `undefined` to resolve the specifier as usual. They come before
78
+ * `external`. Their own imports resolve from the project's root; they name themselves
79
+ * `virtual:<specifier>` in the source map.
80
+ */
81
+ modules?: Record<string, string> | ((specifier: string) => string | undefined);
82
+ /**
83
+ * Modules whose exports replace the globals of the same name in every bundled file
84
+ * (esbuild `inject`): specifiers or project paths, resolved like imports (`modules` too).
85
+ */
86
+ inject?: string[];
87
+ /**
88
+ * `package.json` `exports` conditions besides `import`/`require` and `default`
89
+ * (default `["browser"]`; a workerd target takes `["workerd", "worker", "browser"]`).
90
+ */
91
+ conditions?: string[];
92
+ /** Output format (default `esm`). */
93
+ format?: "esm" | "iife" | "cjs";
94
+ /** esbuild platform (default `browser`): sets `process.env.NODE_ENV` handling and the like. */
95
+ platform?: "browser" | "node" | "neutral";
96
+ /** Language target (default `es2022`). */
97
+ target?: string | string[];
98
+ /**
99
+ * Lower `async`/`await`, async generators and `for await` to generators, so that each
100
+ * `await` resumes through `Promise.prototype.then` (an AsyncLocalStorage polyfill can then
101
+ * carry its context through it). Source maps follow the lowered code.
102
+ */
103
+ lowerAwait?: boolean;
104
+ minify?: boolean;
105
+ define?: Record<string, string>;
106
+ /** Name of the output file: the map is linked as `<outfile>.map` (default `bundle.js`). */
107
+ outfile?: string;
108
+ /** `external` (default): `code` links `<outfile>.map`, which `map` holds; `inline`: `code` carries it too. */
109
+ sourcemap?: "external" | "inline" | false;
110
+ /** Other esbuild options, applied last (the bundler's plugin and source maps stay). */
111
+ esbuildOptions?: Partial<BuildOptions>;
112
+ }
113
+ /** A source map (version 3). */
114
+ interface SourceMap {
115
+ version: 3;
116
+ file?: string;
117
+ sources: string[];
118
+ sourcesContent?: (string | null)[];
119
+ names: string[];
120
+ mappings: string;
121
+ sourceRoot?: string;
122
+ }
123
+ interface BundleResult {
124
+ code: string;
125
+ /** The bundle's source map: sources are file ids (`~/src/app.ts`, `npm:pkg@1.0.0/src/x.ts`). */
126
+ map: SourceMap;
127
+ metafile: Metafile;
128
+ warnings: Message[];
129
+ }
130
+ interface TranspileOptions {
131
+ esbuild: Esbuild;
132
+ /** The file's id or path: names it in the source map and picks the loader by extension. */
133
+ path: string;
134
+ format?: "esm" | "iife" | "cjs";
135
+ target?: string | string[];
136
+ lowerAwait?: boolean;
137
+ minify?: boolean;
138
+ define?: Record<string, string>;
139
+ sourcemap?: "external" | "inline";
140
+ }
141
+ interface TranspileResult {
142
+ code: string;
143
+ map: SourceMap;
144
+ warnings: Message[];
145
+ }
146
+ //#endregion
147
+ //#region src/bundle.d.ts
148
+ export declare const loaderFor: (path: string) => Loader;
149
+ export declare function bundle(opts: BundleOptions): Promise<BundleResult>;
150
+ /**
151
+ * Transpiles one file (TypeScript, JSX, modern syntax down to `target`; `await` lowered
152
+ * when asked) with a source map back to it. A map the code carries (`sourceMappingURL`,
153
+ * inline) is composed in, so the result still traces to the original.
154
+ */
155
+ export declare function transpile(code: string, opts: TranspileOptions): Promise<TranspileResult>;
156
+ //#endregion
157
+ //#region src/input-maps.d.ts
158
+ export declare function inlineInputMap(mount: Mount, path: string, code: string): Promise<string>;
159
+ //#endregion
160
+ //#region src/packages.d.ts
161
+ /**
162
+ * Packages from `node_modules` folders, found as Node finds them: in the importing
163
+ * file's directory and each parent, inside the importer's own FilesApi. A package
164
+ * installed in the project (or in another package's `node_modules`) is read where it is.
165
+ *
166
+ * pnpm layouts: a package's own dependencies sit next to its real directory in
167
+ * `node_modules/.pnpm`, which Node reaches through the symlink's real path and a FilesApi
168
+ * cannot see. When the walk finds nothing, each ancestor's `node_modules/.pnpm/node_modules`
169
+ * (pnpm's hoisted links to every installed package) is tried too.
170
+ */
171
+ export declare function nodeModulesPackages(): PackageStore;
172
+ /**
173
+ * Packages fetched on demand (default: the npm registry, through webrun-modules'
174
+ * `npmRegistrySource`) and kept in memory: each one is its own FilesApi tree. The
175
+ * version is the range the importer's `package.json` declares (the project's for the
176
+ * project's files), else the latest; one range loads once.
177
+ */
178
+ export declare function npmPackages(opts?: {
179
+ source?: Source;
180
+ }): PackageStore;
181
+ /** The first store that has the package wins (`node_modules` first, then the registry). */
182
+ export declare function chainPackages(...stores: PackageStore[]): PackageStore;
183
+ //#endregion
184
+ //#region src/resolve.d.ts
185
+ interface Resolved {
186
+ mount: Mount;
187
+ path: string;
188
+ }
189
+ type Conditions = {
190
+ conditions: string[];
191
+ require: boolean;
192
+ browser: boolean;
193
+ };
194
+ /**
195
+ * Resolves `spec` as written in `importer`: a path of the same tree (`./x`, `../y.js`,
196
+ * `/z`), a `#imports` alias of the importer's package, or a package found by `packages`
197
+ * (with its `exports`, `module` and `main`).
198
+ */
199
+ export declare function resolveSpecifier(spec: string, importer: Importer, packages: PackageStore, c: Conditions): Promise<Resolved | undefined>;
200
+ //#endregion
201
+ export type { BundleOptions, BundleResult, Esbuild, ExternalPattern, Importer, Mount, PackageManifest, PackageStore, SourceMap, TranspileOptions, TranspileResult };
202
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/bundle.ts","../src/input-maps.ts","../src/packages.ts","../src/resolve.ts"],"mappings":";;;;;;;;;;UASiB;EACf,cAAc;EACd,kBAAkB;;;UAIH;EACf;EACA;EACA;EACA;EACA;EACA;EACA;EACA,eAAe;EACf,mBAAmB;EACnB,uBAAuB;EACvB,kBAAkB;GACjB;;;;;;;;UASc;;EAEf;EACA,OAAO;;EAEP;;EAEA,WAAW;;;UAII;EACf,OAAO;;EAEP;;;;;;;UAQe;;EAEf,IAAI,cAAc,UAAU,WAAW,QAAQ;;;KAIrC,2BAA2B;UAEtB;;EAEf,SAAS;;EAET,OAAO;;EAEP;;EAEA;;EAEA,WAAW;;EAEX,WAAW;;;;;;;EAOX,UAAU,2BAA2B;;;;;EAKrC;;;;;EAKA;;EAEA;;EAEA;;EAEA;;;;;;EAMA;EACA;EACA,SAAS;;EAET;;EAEA;;EAEA,iBAAiB,QAAQ;;;UAIV;EACf;EACA;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf;;EAEA,KAAK;EACL,UAAU;EACV,UAAU;;UAGK;EACf,SAAS;;EAET;EACA;EACA;EACA;EACA;EACA,SAAS;EACT;;UAGe;EACf;EACA,KAAK;EACL,UAAU;;;;qBChHC,YAAS,iBAAmB;wBAmDnB,OAAO,MAAM,gBAAgB,QAAQ;;;;;;wBAqLrC,UAAU,cAAc,MAAM,mBAAmB,QAAQ;;;wBCtOzD,eAAe,OAAO,OAAO,cAAc,eAAe;;;;;;;;;;;;;wBCzBhE,uBAAuB;;;;;;;wBA0DvB,YAAY;EAAQ,SAAS;IAAgB;;wBAkC7C,iBAAiB,QAAQ,iBAAiB;;;UCtGzC;EACf,OAAO;EACP;;KAoBG;EAAe;EAAsB;EAAkB;;;;;;;wBAoDtC,iBACpB,cACA,UAAU,UACV,UAAU,cACV,GAAG,aACF,QAAQ"}