@jxsuite/compiler 1.5.0 → 2.0.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.
@@ -13,8 +13,10 @@
13
13
 
14
14
  import { rmSync, writeFileSync } from "node:fs";
15
15
  import { createRequire } from "node:module";
16
- import { join, resolve } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { basename, join, resolve } from "node:path";
17
18
  import { isNpmSpecifier, NPM_SPECIFIER_PREFIX } from "@jxsuite/schema/asset-paths";
19
+ import type { BunPlugin } from "bun";
18
20
 
19
21
  /** Import specifiers left external in client bundles — provided by the page importmap. */
20
22
  export const CLIENT_EXTERNALS = ["@vue/reactivity", "lit-html"];
@@ -31,7 +33,7 @@ export function isBundleableSrc(specifier: string): boolean {
31
33
  * True when the Node code path (createRequire resolution, esbuild backend) should be used: always
32
34
  * under plain Node, or when `JX_BUNDLER=esbuild` forces it for parity checks.
33
35
  */
34
- function useNodeFallback(): boolean {
36
+ export function useNodeFallback(): boolean {
35
37
  return typeof Bun === "undefined" || process.env.JX_BUNDLER === "esbuild";
36
38
  }
37
39
 
@@ -65,12 +67,68 @@ export interface BundleRequest {
65
67
  /** Resolution/runtime targets: client sidecars use "browser"; the site worker uses the rest. */
66
68
  export type BundleTargetName = "browser" | "node" | "bun" | "workerd";
67
69
 
70
+ /**
71
+ * A resolution hook, in the one shape both backends accept.
72
+ *
73
+ * `external: true` leaves the import in the output **as the source wrote it** — Bun ignores a
74
+ * rewritten `path` for an external, so a caller that needs the two backends to agree returns the
75
+ * specifier it was given and plans around the original text.
76
+ */
77
+ export interface BundleResolver {
78
+ /** Which specifiers the hook is consulted for. */
79
+ filter: RegExp;
80
+ /** `undefined` falls through to the bundler's own resolution. */
81
+ resolve: (args: {
82
+ path: string;
83
+ importer: string;
84
+ }) => { path: string; external: true } | undefined;
85
+ }
86
+
68
87
  export interface BundleOptions {
69
88
  target: BundleTargetName;
70
89
  /** Bare specifiers left unresolved in the output. */
71
90
  external?: string[];
72
91
  /** Package.json export conditions for resolution (e.g. ["workerd", "worker"]). */
73
92
  conditions?: string[];
93
+ /** Resolution hooks, applied before the bundler's own resolution. */
94
+ resolver?: BundleResolver;
95
+ /**
96
+ * Called with the absolute path of every file the bundler loads.
97
+ *
98
+ * The only way to ask a backend where a bare specifier _actually_ lands: resolution depends on
99
+ * the target, the export conditions and the `NODE_ENV` define, so a caller that resolves the
100
+ * specifier itself gets a different file than the bundle did — which is how the client runtime
101
+ * used to hand pages the `node` build of a browser package.
102
+ */
103
+ onFileLoaded?: (path: string) => void;
104
+ }
105
+
106
+ interface PluginBuild {
107
+ onResolve: (
108
+ options: { filter: RegExp },
109
+ callback: (args: { path: string; importer: string }) => unknown,
110
+ ) => void;
111
+ onLoad: (options: { filter: RegExp }, callback: (args: { path: string }) => unknown) => void;
112
+ }
113
+
114
+ /** Wrap the hooks as the one-plugin array both backends take. */
115
+ function hookPlugins(opts: BundleOptions): NonNullable<Parameters<typeof Bun.build>[0]["plugins"]> {
116
+ const { onFileLoaded, resolver } = opts;
117
+ if (resolver === undefined && onFileLoaded === undefined) {
118
+ return [];
119
+ }
120
+ const setup = (build: PluginBuild): void => {
121
+ if (resolver !== undefined) {
122
+ build.onResolve({ filter: resolver.filter }, (args) => resolver.resolve(args));
123
+ }
124
+ if (onFileLoaded !== undefined) {
125
+ // Returning nothing hands the file back to the backend's own loader — this only observes.
126
+ build.onLoad({ filter: /.*/ }, (args) => {
127
+ onFileLoaded(args.path);
128
+ });
129
+ }
130
+ };
131
+ return [{ name: "jx-bundle-hooks", setup } as unknown as BunPlugin];
74
132
  }
75
133
 
76
134
  /**
@@ -90,6 +148,19 @@ export function workerBundleOptions(adapter: string): BundleOptions {
90
148
  return { target: adapter === "bun" ? "bun" : "node" };
91
149
  }
92
150
 
151
+ /**
152
+ * Substitutions applied to browser bundles.
153
+ *
154
+ * `process` does not exist in a browser, so `process.env.NODE_ENV` has to be replaced by something
155
+ * — but the value also decides which `exports` condition a package resolves to, and that is the
156
+ * part that mattered. Bun reads it from the build's own `define` (it ignores a `NODE_ENV` set after
157
+ * the process started), and with no answer it assumes development: `lit-html` was resolving to its
158
+ * 31 kB dev build, which logs "Lit is in dev mode" into every visitor's console, instead of the 10
159
+ * kB production one. esbuild already chose production, so the two backends were shipping different
160
+ * code — exactly what this module exists to prevent.
161
+ */
162
+ const BROWSER_DEFINE = { "process.env.NODE_ENV": '"production"' } as const;
163
+
93
164
  /** Map a bundle target to esbuild's platform (conditions carry the workerd specifics). */
94
165
  function esbuildPlatform(target: BundleTargetName): "browser" | "node" {
95
166
  return target === "node" || target === "bun" ? "node" : "browser";
@@ -109,6 +180,7 @@ function bunTarget(target: BundleTargetName): "browser" | "node" | "bun" {
109
180
  */
110
181
  export async function bundleEntry(request: BundleRequest, opts: BundleOptions): Promise<void> {
111
182
  const external = opts.external ?? [];
183
+ const plugins = hookPlugins(opts);
112
184
 
113
185
  if (useNodeFallback()) {
114
186
  // Dynamic import keeps esbuild (a native-binary dependency) out of Bun-run builds entirely.
@@ -116,6 +188,7 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
116
188
  await esbuild.build({
117
189
  bundle: true,
118
190
  ...(opts.conditions ? { conditions: opts.conditions } : {}),
191
+ ...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
119
192
  entryPoints: [request.entryPath],
120
193
  external,
121
194
  format: "esm",
@@ -123,16 +196,19 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
123
196
  minify: false,
124
197
  outfile: request.outfile,
125
198
  platform: esbuildPlatform(opts.target),
199
+ plugins: plugins as unknown as NonNullable<Parameters<typeof esbuild.build>[0]["plugins"]>,
126
200
  });
127
201
  return;
128
202
  }
129
203
 
130
204
  const result = await Bun.build({
131
205
  ...(opts.conditions ? { conditions: opts.conditions } : {}),
206
+ ...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
132
207
  entrypoints: [request.entryPath],
133
208
  external,
134
209
  format: "esm",
135
210
  minify: false,
211
+ plugins,
136
212
  target: bunTarget(opts.target),
137
213
  throw: false,
138
214
  });
@@ -143,41 +219,84 @@ export async function bundleEntry(request: BundleRequest, opts: BundleOptions):
143
219
  writeFileSync(request.outfile, await result.outputs[0]!.text(), "utf8");
144
220
  }
145
221
 
222
+ /** Basename prefix of the throwaway entry {@link bundleSource} writes for the Bun backend. */
223
+ const TRANSIENT_ENTRY_PREFIX = ".jx-entry-";
224
+
146
225
  /**
147
- * Bundle generated worker source (a string, not a file) into a self-contained module for the
148
- * adapter's runtime. Bare and relative specifiers resolve from the project root — exactly how the
149
- * unbundled source would have resolved when served from within the project.
226
+ * True for the throwaway entry a {@link bundleSource} call is bundling, under either backend.
227
+ *
228
+ * A resolver hook needs it to tell the entry's OWN import apart from the imports of whatever that
229
+ * import pulled in — the two want opposite answers, and only the importer distinguishes them.
230
+ * esbuild bundles the string from stdin and names it `<stdin>`; Bun has no stdin input and writes a
231
+ * real file.
150
232
  */
151
- export async function bundleWorkerSource(
233
+ export function isTransientEntry(importer: string): boolean {
234
+ return importer === "<stdin>" || basename(importer).startsWith(TRANSIENT_ENTRY_PREFIX);
235
+ }
236
+
237
+ /**
238
+ * The file a bare specifier resolves to **through the bundler** — under the same target, export
239
+ * conditions and `NODE_ENV` define the real bundle uses.
240
+ *
241
+ * `Bun.resolveSync` and `createRequire` answer a different question. Neither applies the export
242
+ * conditions a browser bundle does, so both hand back the `node` or `development` build of a
243
+ * package a page asked for the browser build of — the bug {@link BROWSER_DEFINE} exists to prevent,
244
+ * reintroduced one layer up. Returns `undefined` when the specifier does not resolve at all.
245
+ */
246
+ export async function resolveThroughBundler(
247
+ specifier: string,
248
+ resolveDir: string,
249
+ opts: BundleOptions,
250
+ ): Promise<string | undefined> {
251
+ const loaded: string[] = [];
252
+ const outfile = join(tmpdir(), `jx-resolve-${process.pid}.js`);
253
+ try {
254
+ await bundleSource(
255
+ `export * from ${JSON.stringify(specifier)};\n`,
256
+ { outfile, resolveDir },
257
+ { ...opts, onFileLoaded: (path) => loaded.push(path) },
258
+ );
259
+ } catch {
260
+ return undefined;
261
+ } finally {
262
+ rmSync(outfile, { force: true });
263
+ }
264
+ return loaded.find((path) => !isTransientEntry(path));
265
+ }
266
+
267
+ /**
268
+ * Bundle a source string rather than a file, resolving its imports from `resolveDir`.
269
+ *
270
+ * The indirection buys resolution control: what a bare specifier means depends on where you stand,
271
+ * and the two callers stand in different places. Worker source resolves from the project root,
272
+ * exactly as the unbundled source would have; the client runtime resolves from the compiler's own
273
+ * directory, because the compiler is what depends on it.
274
+ */
275
+ export async function bundleSource(
152
276
  source: string,
153
- { projectRoot, outfile, adapter }: { projectRoot: string; outfile: string; adapter: string },
277
+ { resolveDir, outfile }: { resolveDir: string; outfile: string },
278
+ opts: BundleOptions,
154
279
  ): Promise<void> {
155
- const opts = workerBundleOptions(adapter);
156
-
157
280
  if (useNodeFallback()) {
158
281
  const esbuild = await import("esbuild");
159
282
  await esbuild.build({
160
283
  bundle: true,
161
284
  ...(opts.conditions ? { conditions: opts.conditions } : {}),
285
+ ...(opts.target === "browser" ? { define: { ...BROWSER_DEFINE } } : {}),
162
286
  external: opts.external ?? [],
163
287
  format: "esm",
164
288
  logLevel: "silent",
165
289
  minify: false,
166
290
  outfile,
167
291
  platform: esbuildPlatform(opts.target),
168
- stdin: {
169
- contents: source,
170
- loader: "js",
171
- resolveDir: projectRoot,
172
- sourcefile: "jx-worker-entry.js",
173
- },
292
+ stdin: { contents: source, loader: "js", resolveDir, sourcefile: "jx-entry.js" },
174
293
  });
175
294
  return;
176
295
  }
177
296
 
178
- // Bun.build has no stdin input — write a transient entry at the project root so resolution
179
- // Matches the stdin/resolveDir behavior, and always remove it.
180
- const entryPath = join(projectRoot, ".jx-worker-entry.mjs");
297
+ // Bun.build has no stdin input — write a transient entry in `resolveDir` so resolution matches
298
+ // The stdin/resolveDir behavior, and always remove it.
299
+ const entryPath = join(resolveDir, `${TRANSIENT_ENTRY_PREFIX}${process.pid}.mjs`);
181
300
  writeFileSync(entryPath, source, "utf8");
182
301
  try {
183
302
  await bundleEntry({ entryPath, outfile }, opts);
@@ -185,3 +304,15 @@ export async function bundleWorkerSource(
185
304
  rmSync(entryPath, { force: true });
186
305
  }
187
306
  }
307
+
308
+ /**
309
+ * Bundle generated worker source into a self-contained module for the adapter's runtime. Bare and
310
+ * relative specifiers resolve from the project root — exactly how the unbundled source would have
311
+ * resolved when served from within the project.
312
+ */
313
+ export async function bundleWorkerSource(
314
+ source: string,
315
+ { projectRoot, outfile, adapter }: { projectRoot: string; outfile: string; adapter: string },
316
+ ): Promise<void> {
317
+ await bundleSource(source, { outfile, resolveDir: projectRoot }, workerBundleOptions(adapter));
318
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * The two modules every interactive page imports, served from the site rather than from a CDN.
3
+ *
4
+ * They used to resolve to `https://esm.sh/…` in the emitted import map. That put a third party in
5
+ * the load path of every interactive Jx site — no integrity metadata, no availability guarantee —
6
+ * and it made a strict Content-Security-Policy impossible, because `default-src 'self'` would have
7
+ * broken every one of those pages. The old argument for a shared CDN was a shared browser cache,
8
+ * and that argument expired when browsers partitioned the HTTP cache by top-level site: a visitor
9
+ * arriving from another esm.sh site downloads it again regardless.
10
+ *
11
+ * Bundled, not copied, and resolved from **this package's** directory rather than the project's:
12
+ * `@jxsuite/compiler` is what depends on these versions, so a project that never installed them
13
+ * still gets the runtime its compiled output was built against.
14
+ *
15
+ * @docs framework/build
16
+ */
17
+
18
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
19
+ import { createRequire } from "node:module";
20
+ import { dirname, join, relative, resolve } from "node:path";
21
+ import type { BundleResolver } from "./bundler.ts";
22
+ import {
23
+ bundleSource,
24
+ isTransientEntry,
25
+ resolveThroughBundler,
26
+ useNodeFallback,
27
+ } from "./bundler.ts";
28
+ import { DEFAULT_LIT_HTML_SRC, DEFAULT_REACTIVITY_SRC } from "../shared.ts";
29
+
30
+ /** One import-map entry the build can satisfy itself. */
31
+ interface RuntimeModule {
32
+ specifier: string;
33
+ assetPath: string;
34
+ /** Where the entry points when this package cannot be resolved at all. */
35
+ fallback: string;
36
+ /**
37
+ * Directory this package's SUBPATHS are served from, and the value of its import-map prefix key.
38
+ *
39
+ * A third-party component does not import only `lit-html`; it imports
40
+ * `lit-html/directives/class-map.js` too. Those stay external in its bundle — a package-name
41
+ * external covers the package's subpaths as well — so without a prefix key the page dies on
42
+ * `Failed to resolve module specifier`. Trailing slashes on both sides are required: that is what
43
+ * makes it a prefix mapping rather than an exact one.
44
+ */
45
+ assetDir: string;
46
+ }
47
+
48
+ export const CLIENT_RUNTIME_MODULES: readonly RuntimeModule[] = [
49
+ {
50
+ assetDir: "/assets/@vue/reactivity/",
51
+ assetPath: "/assets/vue-reactivity.js",
52
+ fallback: DEFAULT_REACTIVITY_SRC,
53
+ specifier: "@vue/reactivity",
54
+ },
55
+ {
56
+ assetDir: "/assets/lit-html/",
57
+ assetPath: "/assets/lit-html.js",
58
+ fallback: DEFAULT_LIT_HTML_SRC,
59
+ specifier: "lit-html",
60
+ },
61
+ ];
62
+
63
+ /** An import-map prefix key — `"lit-html/"` — as opposed to an exact specifier. */
64
+ export function isPrefixSpecifier(specifier: string): boolean {
65
+ return specifier.endsWith("/");
66
+ }
67
+
68
+ export interface ClientRuntime {
69
+ /** The import map's `imports` object. */
70
+ imports: Record<string, string>;
71
+ /** Warnings for any module that fell back to the CDN. */
72
+ warnings: string[];
73
+ /** Site-relative asset paths this build must write. Empty when everything fell back. */
74
+ assetPaths: string[];
75
+ }
76
+
77
+ /**
78
+ * Resolve where each runtime module will be served from, without writing anything yet.
79
+ *
80
+ * Split from the write so the import map can be built before the first page is compiled while the
81
+ * bundles are only produced if some page actually emitted one — the same shape the sidecar bundler
82
+ * uses.
83
+ *
84
+ * @param {string} [resolveDir] - Directory bare specifiers resolve from; defaults to this package
85
+ * @returns {ClientRuntime}
86
+ */
87
+ export function resolveClientRuntime(resolveDir: string = import.meta.dir): ClientRuntime {
88
+ const imports: Record<string, string> = {};
89
+ const warnings: string[] = [];
90
+ const assetPaths: string[] = [];
91
+
92
+ for (const mod of CLIENT_RUNTIME_MODULES) {
93
+ if (canResolve(mod.specifier, resolveDir)) {
94
+ imports[mod.specifier] = mod.assetPath;
95
+ /*
96
+ * The prefix key is emitted whether or not any subpath turns out to be used. It costs two
97
+ * lines of JSON, and the alternative is knowing the subpath set before the first page is
98
+ * compiled — which is impossible, since the set is discovered from the bundles themselves.
99
+ */
100
+ imports[`${mod.specifier}/`] = mod.assetDir;
101
+ assetPaths.push(mod.assetPath);
102
+ } else {
103
+ imports[mod.specifier] = mod.fallback;
104
+ warnings.push(
105
+ `Could not resolve "${mod.specifier}" — the import map falls back to ${mod.fallback}. ` +
106
+ "Interactive pages will load it from a third party, and a `default-src 'self'` policy " +
107
+ "will block them.",
108
+ );
109
+ }
110
+ }
111
+ return { assetPaths, imports, warnings };
112
+ }
113
+
114
+ /**
115
+ * Write the runtime bundles this build resolved locally.
116
+ *
117
+ * @param {ClientRuntime} runtime
118
+ * @param {string} outDir
119
+ * @param {string} [resolveDir]
120
+ * @returns {Promise<{ written: number; errors: string[] }>}
121
+ */
122
+ export async function writeClientRuntime(
123
+ runtime: ClientRuntime,
124
+ outDir: string,
125
+ resolveDir: string = import.meta.dir,
126
+ ): Promise<{ written: number; errors: string[] }> {
127
+ const errors: string[] = [];
128
+ let written = 0;
129
+
130
+ for (const mod of CLIENT_RUNTIME_MODULES) {
131
+ if (!runtime.assetPaths.includes(mod.assetPath)) {
132
+ continue;
133
+ }
134
+ const outfile = resolve(outDir, mod.assetPath.replace(/^\//, ""));
135
+ mkdirSync(dirname(outfile), { recursive: true });
136
+ try {
137
+ /*
138
+ * Re-export rather than copy: the published file may be the `node` condition of a package
139
+ * with several, and only a bundler standing in the right place with the right conditions
140
+ * picks the browser one.
141
+ */
142
+ await bundleSource(
143
+ `export * from ${JSON.stringify(mod.specifier)};\n`,
144
+ { outfile, resolveDir },
145
+ { target: "browser" },
146
+ );
147
+ written += 1;
148
+ } catch (error) {
149
+ errors.push(`Bundling client runtime "${mod.specifier}": ${(error as Error).message}`);
150
+ }
151
+ }
152
+ return { errors, written };
153
+ }
154
+
155
+ /** Render the `<script type="importmap">` block for a resolved runtime. */
156
+ export function renderImportMap(imports: Record<string, string>): string {
157
+ const entries = Object.entries(imports)
158
+ .map(([name, url]) => ` ${JSON.stringify(name)}: ${JSON.stringify(url)}`)
159
+ .join(",\n");
160
+ return `<script type="importmap">\n {\n "imports": {\n${entries}\n }\n }\n </script>`;
161
+ }
162
+
163
+ /**
164
+ * True when a bare specifier resolves from `resolveDir` under either backend.
165
+ *
166
+ * The backend choice is `bundler.ts`'s to make, not a second inline `typeof Bun` check: resolution
167
+ * and bundling must agree about which one they are on, or the import map promises a module the
168
+ * bundle then fails to produce.
169
+ */
170
+ function canResolve(specifier: string, resolveDir: string): boolean {
171
+ try {
172
+ if (useNodeFallback()) {
173
+ return typeof createRequire(join(resolveDir, "x.js")).resolve(specifier) === "string";
174
+ }
175
+ return typeof Bun.resolveSync(specifier, resolveDir) === "string";
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ /** How many discovery passes may run before the scan is declared cyclic rather than deep. */
182
+ const MAX_SUBPATH_PASSES = 10;
183
+
184
+ /**
185
+ * Emit the package subpaths the built bundles actually import, so the prefix keys resolve.
186
+ *
187
+ * The subpath set cannot be known before the build: it is a property of whichever third-party
188
+ * components a page happens to register. So it is discovered _from the output_ — every emitted
189
+ * `.js` asset is scanned for an import of `<runtime package>/<subpath>`, each is bundled to the
190
+ * directory its prefix key names, and the scan repeats over what that produced. Bundling can
191
+ * introduce new subpaths (a directive importing another directive), so the pass runs to closure
192
+ * rather than once.
193
+ *
194
+ * **The subpath itself must be bundled, and only the core kept external.** A package-name external
195
+ * covers the package's subpaths too, so listing `lit-html` in `external` externalises the entry —
196
+ * and the emitted asset is then a re-export of the very specifier the import map points back at it.
197
+ * That file imports itself: `export * from "lit-html/directives/class-map.js"` served AS
198
+ * `/assets/lit-html/directives/class-map.js`. A self-referential module has an empty namespace, so
199
+ * every page using a directive died on an undefined import while the build reported success. The
200
+ * externals are therefore decided by a resolver hook that can see the importer, not by a name
201
+ * list.
202
+ *
203
+ * The core stays shared, which is the part that must not regress: two copies of lit on a page is a
204
+ * documented breakage, not a waste. A package imports its own core by RELATIVE path from inside
205
+ * itself (`../lit-html.js`), and a bundler keeps an external's specifier exactly as the source
206
+ * wrote it — so the shared copy is reached by emitting a stub at the place that relative path lands
207
+ * in the OUTPUT tree, re-exporting the bare specifier the import map already resolves.
208
+ *
209
+ * @param {string} outDir - Build output root
210
+ * @param {string} [resolveDir] - Directory bare specifiers resolve from
211
+ * @returns {Promise<{ written: number; errors: string[] }>}
212
+ */
213
+ export async function writeRuntimeSubpaths(
214
+ outDir: string,
215
+ resolveDir: string = import.meta.dir,
216
+ ): Promise<{ written: number; errors: string[] }> {
217
+ const errors: string[] = [];
218
+ const done = new Set<string>();
219
+ /** Output path of a core re-export stub → the bare specifier it must re-export. */
220
+ const mirrors = new Map<string, string>();
221
+ /** Runtime package specifier → the core file the bundler resolves it to. */
222
+ const cores = new Map<string, string | undefined>();
223
+ const assetsDir = resolve(outDir, "assets");
224
+ let written = 0;
225
+
226
+ const coreOf = async (mod: RuntimeModule): Promise<string | undefined> => {
227
+ if (!cores.has(mod.specifier)) {
228
+ cores.set(
229
+ mod.specifier,
230
+ await resolveThroughBundler(mod.specifier, resolveDir, { target: "browser" }),
231
+ );
232
+ }
233
+ return cores.get(mod.specifier);
234
+ };
235
+
236
+ /** `from "lit-html/directives/class-map.js"` — the import forms a bundler emits. */
237
+ const importedSpecifiers = async (file: string): Promise<string[]> => {
238
+ const text = await Bun.file(file).text();
239
+ return [...text.matchAll(/(?:from|import)\s*["']([^"']+)["']/g)].map((m) => m[1] as string);
240
+ };
241
+
242
+ for (let pass = 0; pass < MAX_SUBPATH_PASSES; pass++) {
243
+ let discovered = 0;
244
+ let files: string[] = [];
245
+ try {
246
+ files = await Array.fromAsync(
247
+ new Bun.Glob("**/*.js").scan({ absolute: true, cwd: assetsDir }),
248
+ );
249
+ } catch {
250
+ return finish(outDir, mirrors, errors, written);
251
+ }
252
+
253
+ for (const file of files) {
254
+ let specifiers: string[] = [];
255
+ try {
256
+ specifiers = await importedSpecifiers(file);
257
+ } catch {
258
+ continue;
259
+ }
260
+ for (const specifier of specifiers) {
261
+ const mod = CLIENT_RUNTIME_MODULES.find((m) => specifier.startsWith(`${m.specifier}/`));
262
+ if (!mod || done.has(specifier)) {
263
+ continue;
264
+ }
265
+ done.add(specifier);
266
+ const subpath = specifier.slice(mod.specifier.length + 1);
267
+ const outfile = resolve(outDir, `${mod.assetDir.replace(/^\//, "")}${subpath}`);
268
+ mkdirSync(dirname(outfile), { recursive: true });
269
+ try {
270
+ await bundleSource(
271
+ `export * from ${JSON.stringify(specifier)};\n`,
272
+ { outfile, resolveDir },
273
+ {
274
+ resolver: shareRuntimeCore(mod, outfile, await coreOf(mod), mirrors),
275
+ target: "browser",
276
+ },
277
+ );
278
+ written += 1;
279
+ discovered += 1;
280
+ } catch (error) {
281
+ errors.push(`Bundling runtime subpath "${specifier}": ${(error as Error).message}`);
282
+ }
283
+ }
284
+ }
285
+ if (discovered === 0) {
286
+ return finish(outDir, mirrors, errors, written);
287
+ }
288
+ }
289
+ errors.push(
290
+ `Runtime subpath resolution did not settle in ${MAX_SUBPATH_PASSES} passes — a package subpath ` +
291
+ "graph this deep is more likely a cycle in the scan than a real dependency chain.",
292
+ );
293
+ return finish(outDir, mirrors, errors, written);
294
+ }
295
+
296
+ /**
297
+ * Keep a runtime package's core out of the subpath bundle, and note where the shared copy has to
298
+ * appear for the specifier the bundler will leave behind to reach it.
299
+ *
300
+ * @param {RuntimeModule} owner - The runtime package this subpath belongs to
301
+ * @param {string} outfile - Where this subpath's bundle is being written
302
+ * @param {string | undefined} core - The package's core file, or undefined if it did not resolve
303
+ * @param {Map<string, string>} mirrors - Collects the core re-export stubs the build must write
304
+ * @returns {BundleResolver}
305
+ */
306
+ function shareRuntimeCore(
307
+ owner: RuntimeModule,
308
+ outfile: string,
309
+ core: string | undefined,
310
+ mirrors: Map<string, string>,
311
+ ): BundleResolver {
312
+ return {
313
+ filter: /.*/,
314
+ resolve: ({ importer, path }) => {
315
+ /*
316
+ * The entry's own import is the subpath being bundled. It must resolve — externalising it
317
+ * is what made the emitted asset import itself.
318
+ */
319
+ if (isTransientEntry(importer)) {
320
+ return;
321
+ }
322
+ const runtime = CLIENT_RUNTIME_MODULES.find(
323
+ (mod) => path === mod.specifier || path.startsWith(`${mod.specifier}/`),
324
+ );
325
+ if (runtime !== undefined) {
326
+ // Another runtime package, or another of this one's subpaths: the next pass emits it.
327
+ return { external: true, path };
328
+ }
329
+ if (core === undefined || !path.startsWith(".")) {
330
+ return;
331
+ }
332
+ /*
333
+ * Package-internal relative imports are resolved by path rather than by asking a backend:
334
+ * the runtime packages are ESM and write their extensions, and the hook has to give the same
335
+ * answer under Bun and under esbuild.
336
+ */
337
+ if (resolve(dirname(importer), path) !== core) {
338
+ return;
339
+ }
340
+ mirrors.set(resolve(dirname(outfile), path), owner.specifier);
341
+ return { external: true, path };
342
+ },
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Write the core re-export stubs the emitted subpaths point at, and report the totals.
348
+ *
349
+ * A stub is skipped when a real bundle already occupies its path — that file is a subpath the build
350
+ * was asked for, and the request outranks the shim.
351
+ */
352
+ function finish(
353
+ outDir: string,
354
+ mirrors: Map<string, string>,
355
+ errors: string[],
356
+ written: number,
357
+ ): { written: number; errors: string[] } {
358
+ let total = written;
359
+ for (const [path, specifier] of mirrors) {
360
+ if (existsSync(path)) {
361
+ continue;
362
+ }
363
+ try {
364
+ mkdirSync(dirname(path), { recursive: true });
365
+ writeFileSync(path, `export * from ${JSON.stringify(specifier)};\n`, "utf8");
366
+ total += 1;
367
+ } catch (error) {
368
+ errors.push(
369
+ `Writing shared core stub ${relative(outDir, path)}: ${(error as Error).message}`,
370
+ );
371
+ }
372
+ }
373
+ return { errors, written: total };
374
+ }