@akanjs/devkit 3.0.0-beta.25 → 3.0.0-beta.26

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.
@@ -35,6 +35,9 @@ export class CsrDevPatcher {
35
35
  readonly #bundler: DevRegistryBundler;
36
36
  readonly #resident: boolean;
37
37
  #state: CsrDevPatcherState | null = null;
38
+ //? A pending root handed to a worker, by the hash it had: one that fails there too waits for an edit of its own or of
39
+ //? a file the worker failed in, instead of sending every save to a worker.
40
+ readonly #delegated = new Map<string, { hash: string; failed?: Record<string, string>; before: string }>();
38
41
 
39
42
  constructor(bundler: DevRegistryBundler, { resident = false }: { resident?: boolean } = {}) {
40
43
  this.#bundler = bundler;
@@ -46,6 +49,11 @@ export class CsrDevPatcher {
46
49
  this.#state = null;
47
50
  }
48
51
 
52
+ /** Roots handed to a worker that died before it wrote their failure: the next round hands them over again. */
53
+ releaseHandedOver(): void {
54
+ for (const [id, handedOver] of this.#delegated) if (!handedOver.failed) this.#delegated.delete(id);
55
+ }
56
+
49
57
  async update(
50
58
  changedFiles: string[] = [],
51
59
  { announce, roots = [], onlyRoots = false, allowWholeAppBuilds = false }: CsrDevPatchOptions = {},
@@ -104,7 +112,10 @@ export class CsrDevPatcher {
104
112
  for (const id of this.#withoutFactory(state, present)) changed.add(id);
105
113
  for (const file of entries.changed) changed.add(paths.idOf(file));
106
114
  const rootFiles = [...Object.values(entries.files), ...roots.filter((file) => fs.existsSync(file))];
107
- for (const file of rootFiles) if (!graph.modules[paths.idOf(file)]) changed.add(paths.idOf(file));
115
+ for (const file of rootFiles) {
116
+ const id = paths.idOf(file);
117
+ if (!graph.modules[id] && !(onlyRoots && (await this.#parked(graph, id)))) changed.add(id);
118
+ }
108
119
  const carried = onlyRoots ? [] : await this.#carriedRoots(graph, changed, present);
109
120
  const routesMoved =
110
121
  this.#bundler.reloadsOnEntryChange &&
@@ -124,15 +135,26 @@ export class CsrDevPatcher {
124
135
  runtimeResolved: graph.runtimeResolved,
125
136
  });
126
137
  const known = new Set(Object.keys(graph.modules).map((id) => paths.fileOf(id)));
127
- let result: CsrDevCompileResult;
128
- try {
129
- result = await this.#bundler.compiler(context, resolver).compile(
130
- [...changed].map((id) => paths.fileOf(id)),
138
+ const compile = async (ids: string[]) =>
139
+ await this.#bundler.compiler(context, resolver).compile(
140
+ ids.map((id) => paths.fileOf(id)),
131
141
  known,
132
142
  { refuseNewVendor: !allowWholeAppBuilds, refusePrepass: !allowWholeAppBuilds },
133
143
  );
144
+ let result: CsrDevCompileResult;
145
+ let orphans: string[];
146
+ try {
147
+ ({ result, orphans } = await this.#compileLeavingOrphans(compile, graph, changed));
148
+ for (const module of result.modules) known.add(module.file);
149
+ result = await this.#withTwinImporters(compile, graph, result);
134
150
  } catch (error) {
135
- graph.pending = [...new Set([...changed, ...carried])];
151
+ //? Joined, not replaced: a route's root another round failed stays pending for its own retry.
152
+ graph.pending = [...new Set([...graph.pending, ...changed, ...carried])];
153
+ if (!this.#resident)
154
+ graph.failed = {
155
+ roots: [...changed, ...carried],
156
+ files: await CsrDevPatcher.#hashesOf(paths, CsrDevPatcher.#implicatedFiles(error)),
157
+ };
136
158
  await writer.writeJson("graph.json", graph);
137
159
  throw error;
138
160
  }
@@ -146,13 +168,29 @@ export class CsrDevPatcher {
146
168
  //? The entry files are already rewritten, so the worker would see them unchanged and patch a moved route table:
147
169
  //? a graph whose entries name none makes it reload instead.
148
170
  if (routesMoved) await writer.writeJson("graph.json", { ...graph, entries: {} });
171
+ if (onlyRoots)
172
+ for (const id of changed)
173
+ if (!graph.modules[id])
174
+ this.#delegated.set(id, {
175
+ hash: await CsrDevPaths.hashOf(paths.fileOf(id)),
176
+ before: JSON.stringify(graph.failed ?? null),
177
+ });
149
178
  return this.#handBack(refusal, generation, context);
150
179
  }
151
180
  const compiled = result.modules;
152
181
  const vendorJoined = compiled.some((module) => module.vendor && !graph.modules[module.id]);
153
182
  //? Read before the merge: a new module's file is written after announcing, and one without a file is compiled now.
154
183
  const code = await this.#code(state);
184
+ for (const id of [...orphans, ...this.#caseTwins(graph, compiled)]) {
185
+ delete graph.modules[id];
186
+ resolver.forget(paths.fileOf(id));
187
+ code?.modules.delete(id);
188
+ forgotten.push(id);
189
+ }
190
+ graph.pending = graph.pending.filter((id) => !orphans.includes(id));
155
191
  this.#bundler.merge(graph, resolver, compiled);
192
+ if (!this.#resident) delete graph.failed;
193
+ for (const module of compiled) this.#delegated.delete(module.id);
156
194
  graph.entries = entryIds;
157
195
  for (const module of compiled) {
158
196
  if (module.vendor || !code) continue;
@@ -169,6 +207,13 @@ export class CsrDevPatcher {
169
207
  : constantId
170
208
  ? `${path.basename(constantId)} changed`
171
209
  : undefined;
210
+ //? Only orphans left the graph: nothing a tab holds changed, so no patch goes out.
211
+ if (compiled.length === 0 && !reason) {
212
+ await writer.writeJson("graph.json", graph);
213
+ await writer.forgetModules(forgotten);
214
+ if (carried.length === 0) return { kind: "unchanged" };
215
+ return await this.#retryCarried(context, state, carried, { announce, allowWholeAppBuilds });
216
+ }
172
217
  const previous = state.manifest;
173
218
  const next: CsrDevManifest = { ...previous, generation, entries: entryIds };
174
219
  const update = (patchUrl?: string): CsrDevUpdate => ({
@@ -241,6 +286,108 @@ export class CsrDevPatcher {
241
286
  return retried;
242
287
  }
243
288
 
289
+ //? One Bun.build fails whole: a module that fails to compile and that nothing reaches any more (its last importer
290
+ //? dropped the import in this very save) leaves the graph, where it would fail every later save until fixed itself.
291
+ //? Everything else the roots stopped reaching goes with it, or an importer left behind would bring it back.
292
+ async #compileLeavingOrphans(
293
+ compile: (ids: string[]) => Promise<CsrDevCompileResult>,
294
+ graph: CsrDevGraph,
295
+ changed: Set<string>,
296
+ ): Promise<{ result: CsrDevCompileResult; orphans: string[] }> {
297
+ const { paths } = this.#bundler;
298
+ try {
299
+ return { result: await compile([...changed]), orphans: [] };
300
+ } catch (error) {
301
+ const failing = new Set(CsrDevPatcher.#failingFiles(error).map((file) => paths.idOf(file)));
302
+ const broken = [...changed].filter((id) => failing.has(id) && graph.modules[id]);
303
+ if (broken.length === 0) throw error;
304
+ const rest = [...changed].filter((id) => !broken.includes(id));
305
+ const empty: CsrDevCompileResult = { modules: [], refusedVendors: [], unresolved: [] };
306
+ const result =
307
+ rest.length === 0
308
+ ? empty
309
+ : await compile(rest).catch(() => {
310
+ throw error;
311
+ });
312
+ const modules = { ...graph.modules };
313
+ for (const module of result.modules)
314
+ modules[module.id] = {
315
+ vendor: module.vendor,
316
+ mtimeMs: module.mtimeMs,
317
+ hash: module.hash,
318
+ deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : paths.idOf(dep))),
319
+ };
320
+ const reached = await this.#bundler.reachable({ ...graph, modules });
321
+ if (broken.some((id) => reached.has(id))) throw error;
322
+ const orphans = Object.keys(modules).filter((id) => !reached.has(id) && !modules[id]?.vendor);
323
+ return { result, orphans };
324
+ }
325
+ }
326
+
327
+ //? A case-only rename the patcher never saw (made while CSR was unarmed) compiles under the disk's spelling: the old
328
+ //? id goes, or its stale mtime would compile it again on every save. Only where the disk folds case: on ext4 the two
329
+ //? spellings are two files.
330
+ #caseTwins(graph: CsrDevGraph, compiled: CsrDevCompileResult["modules"]): string[] {
331
+ const { paths } = this.#bundler;
332
+ const idsByFold = new Map<string, string[]>();
333
+ for (const id of Object.keys(graph.modules))
334
+ idsByFold.set(id.toLowerCase(), [...(idsByFold.get(id.toLowerCase()) ?? []), id]);
335
+ return compiled.flatMap((module) =>
336
+ (idsByFold.get(module.id.toLowerCase()) ?? []).filter(
337
+ (id) => id !== module.id && !graph.modules[id]?.vendor && !CsrDevPaths.isNamed(paths.fileOf(id)),
338
+ ),
339
+ );
340
+ }
341
+
342
+ //? Their importers compile in the same round: one still importing the old spelling (valid on a disk that folds case)
343
+ //? would otherwise require a module app.js no longer defines.
344
+ async #withTwinImporters(
345
+ compile: (ids: string[]) => Promise<CsrDevCompileResult>,
346
+ graph: CsrDevGraph,
347
+ result: CsrDevCompileResult,
348
+ ): Promise<CsrDevCompileResult> {
349
+ const twins = new Set(this.#caseTwins(graph, result.modules));
350
+ if (twins.size === 0) return result;
351
+ const compiledIds = new Set(result.modules.map((module) => module.id));
352
+ const importers = Object.entries(graph.modules)
353
+ .filter(([id, module]) => !module.vendor && !compiledIds.has(id) && !twins.has(id))
354
+ .filter(([, module]) => module.deps.some((dep) => twins.has(dep)))
355
+ .map(([id]) => id);
356
+ if (importers.length === 0) return result;
357
+ const more = await compile(importers);
358
+ return {
359
+ modules: [...result.modules, ...more.modules],
360
+ refusedVendors: [...result.refusedVendors, ...more.refusedVendors],
361
+ unresolved: [...result.unresolved, ...more.unresolved],
362
+ };
363
+ }
364
+
365
+ //? Where it failed, and the files its messages name: Bun puts a missing export at the importer and names the module
366
+ //? that lacks it only in the text ("No matching export in \"../ui/C.tsx\""), relative to the working directory.
367
+ static #implicatedFiles(error: unknown): string[] {
368
+ const failing = CsrDevPatcher.#failingFiles(error);
369
+ const messages: unknown[] = error instanceof AggregateError ? error.errors : [];
370
+ const named = messages.flatMap((message) => {
371
+ const text = String((message as { message?: unknown }).message ?? "");
372
+ const bases = [process.cwd(), ...failing.map((file) => path.dirname(file))];
373
+ return [...text.matchAll(/"([^"\n]+)"/g)].flatMap(([, quoted = ""]) => {
374
+ const found = bases
375
+ .map((base) => path.resolve(base, quoted))
376
+ .find((file) => CsrDevPaths.isScript(file) && fs.existsSync(file));
377
+ return found ? [CsrDevPaths.realpath(found)] : [];
378
+ });
379
+ });
380
+ return [...new Set([...failing, ...named])];
381
+ }
382
+
383
+ static #failingFiles(error: unknown): string[] {
384
+ const messages: unknown[] = error instanceof AggregateError ? error.errors : [];
385
+ return messages
386
+ .map((message) => (message as { position?: { file?: unknown } | null }).position?.file)
387
+ .filter((file): file is string => typeof file === "string" && path.isAbsolute(file))
388
+ .map((file) => CsrDevPaths.realpath(file));
389
+ }
390
+
244
391
  //? A pending root outside the graph (a route's entry that failed to compile) is retried while a route still reaches
245
392
  //? it; one no route names any more (the page stopped importing it) leaves the pending list instead.
246
393
  async #carriedRoots(graph: CsrDevGraph, changed: Set<string>, present: (file: string) => boolean): Promise<string[]> {
@@ -251,11 +398,35 @@ export class CsrDevPatcher {
251
398
  outside.map((id) => paths.fileOf(id)),
252
399
  graph,
253
400
  );
254
- const carried = outside.filter((id) => wanted.has(paths.fileOf(id)));
255
- graph.pending = graph.pending.filter((id) => graph.modules[id] || carried.includes(id));
401
+ const kept = outside.filter((id) => wanted.has(paths.fileOf(id)));
402
+ graph.pending = graph.pending.filter((id) => graph.modules[id] || kept.includes(id));
403
+ const carried: string[] = [];
404
+ for (const id of kept) if (!(await this.#parked(graph, id))) carried.push(id);
256
405
  return carried;
257
406
  }
258
407
 
408
+ //? The worker's failure is read from the graph it wrote, once, and only a round that built this root. Until that
409
+ //? worker writes, the record on disk is the one from before the hand-over, and the root waits for it.
410
+ async #parked(graph: CsrDevGraph, id: string): Promise<boolean> {
411
+ const { paths } = this.#bundler;
412
+ const handedOver = this.#delegated.get(id);
413
+ if (!handedOver || handedOver.hash !== (await CsrDevPaths.hashOf(paths.fileOf(id)))) return false;
414
+ if (!handedOver.failed) {
415
+ if (JSON.stringify(graph.failed ?? null) === handedOver.before) return true;
416
+ if (graph.failed?.roots.includes(id)) handedOver.failed = graph.failed.files;
417
+ }
418
+ if (!handedOver.failed) return false;
419
+ for (const [failedId, hash] of Object.entries(handedOver.failed))
420
+ if ((await CsrDevPaths.hashOf(paths.fileOf(failedId))) !== hash) return false;
421
+ return true;
422
+ }
423
+
424
+ static async #hashesOf(paths: CsrDevPaths, files: string[]): Promise<Record<string, string>> {
425
+ return Object.fromEntries(
426
+ await Promise.all(files.map(async (file) => [paths.idOf(file), await CsrDevPaths.hashOf(file)] as const)),
427
+ );
428
+ }
429
+
259
430
  // Missing factory files are recompiled, not read: a registry whose graph outlived them would fail every read. The
260
431
  // resident patcher holds every factory it has read or written, so only a registry just read from disk is stat'ed.
261
432
  #withoutFactory({ graph, code }: CsrDevPatcherState, present: (file: string) => boolean): string[] {
@@ -268,7 +439,8 @@ export class CsrDevPatcher {
268
439
 
269
440
  //? A case-only rename is the one move `existsSync` misses (`ui/card.tsx` still exists after a rename to
270
441
  //? `ui/Card.tsx` on APFS and NTFS), and it reaches the patcher as a save of both names: the realpath of every
271
- //? module is paid for only then (2.2ms a save on a 385-module registry, against 0.3ms).
442
+ //? module is paid for only then (2.2ms a save on a 385-module registry, against 0.3ms). One it never saw goes with
443
+ //? the compile that meets the new spelling (#caseTwins).
272
444
  static #presence(changedFiles: string[]): (file: string) => boolean {
273
445
  const caseMoved = changedFiles.some((file) => fs.existsSync(file) && !CsrDevPaths.isNamed(file));
274
446
  return caseMoved ? (file) => CsrDevPaths.isNamed(file) : (file) => fs.existsSync(file);
@@ -327,7 +499,9 @@ export class CsrDevPatcher {
327
499
  if (changed.has(id)) continue;
328
500
  for (const dep of module.deps) {
329
501
  if (graph.modules[dep] || CsrDevPaths.isStub(dep)) continue;
330
- if (present(paths.fileOf(dep))) changed.add(dep);
502
+ //? Named, not only present: a dep a case rename replaced still exists under its old spelling.
503
+ const file = paths.fileOf(dep);
504
+ if (present(file) && CsrDevPaths.isNamed(file)) changed.add(dep);
331
505
  else if (!module.vendor) changed.add(id);
332
506
  }
333
507
  }
@@ -43,16 +43,18 @@ export class CsrDevPaths {
43
43
  return (await source.exists()) ? Bun.hash(await source.arrayBuffer()).toString(36) : "";
44
44
  }
45
45
 
46
+ //? `.native`: on Windows Bun's `fs.realpathSync` keeps the spelling it was given, where the native call returns the
47
+ //? disk's (as macOS's plain call does), and a module id must not depend on which spelling reached it first.
46
48
  static realpath(file: string): string {
47
49
  // A deleted file keeps its own path, so its id still matches the graph entry it leaves behind.
48
- return fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file);
50
+ return fs.existsSync(file) ? fs.realpathSync.native(file) : path.resolve(file);
49
51
  }
50
52
 
51
53
  //? Present under this very name: on a case-insensitive disk (APFS, NTFS) `ui/card.tsx` still exists after a rename to
52
54
  //? `ui/Card.tsx`, but the compiler names the module by the disk's spelling, so the old id is gone.
53
55
  static isNamed(file: string): boolean {
54
56
  try {
55
- return fs.realpathSync(file) === file;
57
+ return fs.realpathSync.native(file) === file;
56
58
  } catch {
57
59
  // Missing is an answer here.
58
60
  return false;
@@ -71,15 +73,22 @@ export class CsrDevPaths {
71
73
  return CsrDevPaths.#asFile(base, kind) ?? CsrDevPaths.#asFolder(base, kind);
72
74
  }
73
75
 
76
+ //? A specifier ending in `/`, `.` or `..` names the folder alone, for Bun too: `./Foo/` never takes a `Foo.tsx`.
77
+ static resolveRelative(dir: string, specifier: string, kind: "import" | "require" = "import"): string | null {
78
+ const base = path.resolve(dir, specifier);
79
+ const folder = /(^|[\\/])\.\.?$|[\\/]$/.test(specifier);
80
+ return folder ? CsrDevPaths.#asFolder(base, kind) : CsrDevPaths.resolveOnDisk(base, kind);
81
+ }
82
+
74
83
  static #asFile(base: string, kind: "import" | "require"): string | null {
75
84
  const isFile = (candidate: string) => fs.statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false;
76
85
  return [base, ...CsrDevPaths.#extensions[kind].map((extension) => `${base}${extension}`)].find(isFile) ?? null;
77
86
  }
78
87
 
79
- //? A folder's package.json names its entry as a browser build reads it: `browser` (the string form), then `module`,
80
- //? then `main`, which may itself name a folder; without one, its `index`.
88
+ //? A folder's package.json names its entry as a browser build reads it: `browser` (the string form), then `module`
89
+ //? (an import only), then `main`, which may itself name a folder; without one, its `index`.
81
90
  static #asFolder(dir: string, kind: "import" | "require"): string | null {
82
- const entry = CsrDevPaths.#packageEntryOf(dir);
91
+ const entry = CsrDevPaths.#packageEntryOf(dir, kind);
83
92
  const named = entry
84
93
  ? (CsrDevPaths.#asFile(path.join(dir, entry), kind) ?? CsrDevPaths.#indexOf(path.join(dir, entry), kind))
85
94
  : null;
@@ -90,10 +99,11 @@ export class CsrDevPaths {
90
99
  return CsrDevPaths.#asFile(path.join(dir, "index"), kind);
91
100
  }
92
101
 
93
- static #packageEntryOf(dir: string): string | null {
102
+ static #packageEntryOf(dir: string, kind: "import" | "require"): string | null {
94
103
  try {
95
104
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")) as Record<string, unknown>;
96
- const entry = [pkg.browser, pkg.module, pkg.main].find((field) => typeof field === "string");
105
+ const fields = kind === "require" ? [pkg.browser, pkg.main] : [pkg.browser, pkg.module, pkg.main];
106
+ const entry = fields.find((field) => typeof field === "string");
97
107
  return typeof entry === "string" ? entry : null;
98
108
  } catch {
99
109
  // No package.json, or one that names no entry: the folder's index answers.
@@ -101,10 +111,20 @@ export class CsrDevPaths {
101
111
  }
102
112
  }
103
113
 
104
- /** True when `known`, a file inside the folder `base` names, would lose to a file `base` names directly. */
105
- static isShadowed(base: string, known: string): boolean {
106
- const dir = CsrDevPaths.realpath(base);
107
- return known.startsWith(`${dir}${path.sep}`) && CsrDevPaths.#asFile(base, "import") !== null;
114
+ //? The package folder's ctime: Bun installs by cloning (macOS) or hard-linking (Linux) its cache, so a file keeps the
115
+ //? cache's mtime, and a hard link's ctime moves whenever another project links the same version; the folder is made
116
+ //? by this project's install alone.
117
+ /** A bare specifier whose package landed in a `node_modules` at or above `from` after `since`. */
118
+ static installedSince(specifier: string, from: string, since: number): boolean {
119
+ const [first = "", second = ""] = specifier.split("/");
120
+ const name = first.startsWith("@") ? `${first}/${second}` : first;
121
+ if (!name || name.startsWith(".")) return false;
122
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
123
+ const pkgDir = path.join(dir, "node_modules", name);
124
+ if (fs.existsSync(path.join(pkgDir, "package.json")))
125
+ return (fs.statSync(pkgDir, { throwIfNoEntry: false })?.ctimeMs ?? 0) > since;
126
+ if (path.dirname(dir) === dir) return false;
127
+ }
108
128
  }
109
129
 
110
130
  static tryResolve(specifier: string, from: string): string | null {
@@ -126,10 +126,29 @@ describe("CsrDevPaths.resolveOnDisk", () => {
126
126
  "dirmain/index.js",
127
127
  "shadow/Foo/index.tsx",
128
128
  "shadow/Page.tsx",
129
+ "sibling/x.ts",
130
+ "sibling/Page.tsx",
131
+ "slash/Foo/index.ts",
132
+ "slash/Foo.ts",
133
+ "slash/Page.tsx",
134
+ "req/index.js",
135
+ "req/main.js",
136
+ "req/m.js",
137
+ "modonly/index.js",
138
+ "modonly/m.js",
139
+ "dot/Foo.tsx",
140
+ "dot/Foo/index.tsx",
141
+ "dot/Foo/Sub/x.ts",
142
+ "bmap/lib/server.ts",
143
+ "bmap/lib/client.ts",
144
+ "bmap/entry.ts",
129
145
  ])
130
146
  await Bun.write(at(relative), "export {};\n");
131
147
  await Bun.write(at("fields/package.json"), '{ "browser": "b.js", "module": "m.js", "main": "main.js" }');
132
148
  await Bun.write(at("dirmain/package.json"), '{ "main": "lib" }');
149
+ await Bun.write(at("req/package.json"), '{ "module": "m.js", "main": "main.js" }');
150
+ await Bun.write(at("modonly/package.json"), '{ "module": "m.js" }');
151
+ await Bun.write(at("bmap/package.json"), '{ "browser": { "./lib/server.ts": "./lib/client.ts" } }');
133
152
  });
134
153
 
135
154
  afterAll(async () => {
@@ -146,7 +165,49 @@ describe("CsrDevPaths.resolveOnDisk", () => {
146
165
  expect(CsrDevPaths.resolveOnDisk(at("dirmain"))).toBe(at("dirmain/lib/index.js"));
147
166
  });
148
167
 
149
- test("a recorded resolution is overturned only by a file created beside the folder it went into", async () => {
168
+ test("a require call reads no module field: main when there is one, otherwise the index", () => {
169
+ expect(CsrDevPaths.resolveOnDisk(at("req"))).toBe(at("req/m.js"));
170
+ expect(CsrDevPaths.resolveOnDisk(at("req"), "require")).toBe(at("req/main.js"));
171
+ expect(CsrDevPaths.resolveOnDisk(at("modonly"), "require")).toBe(at("modonly/index.js"));
172
+ });
173
+
174
+ test("a bare import Bun cannot resolve here goes to a worker only when its package landed after this process began", async () => {
175
+ //? A package Bun's resolver misses in this process: it keeps node_modules listings, so one added by `bun add` while
176
+ //? the builder runs looks like this until a fresh process reads it.
177
+ await Bun.write(at("inst/node_modules/half/package.json"), '{ "name": "half", "main": "missing.js" }');
178
+ await Bun.write(at("inst/src/a.ts"), 'import "half";\n');
179
+ const started = (startedAt: number) =>
180
+ new CsrDevResolver({ paths: new CsrDevPaths(root), context, entryFiles: [], startedAt });
181
+ const before = started(Date.now() - 60_000);
182
+ expect(before.resolve(at("inst/src/a.ts"), "half")).toBeNull();
183
+ expect(before.resolve(at("inst/src/a.ts"), "half/formatt")).toBeNull();
184
+ expect(() => before.resolve(at("inst/src/a.ts"), "nowhere-to-be-found")).toThrow("cannot resolve");
185
+ //? Installed before the builder started: its listing is Bun's, so a miss is a typo or a half-typed import.
186
+ const after = started(Date.now() + 60_000);
187
+ expect(() => after.resolve(at("inst/src/a.ts"), "half/formatt")).toThrow("cannot resolve");
188
+ });
189
+
190
+ test('"." and ".." name the folder alone, as a trailing slash does', () => {
191
+ expect(CsrDevPaths.resolveRelative(at("dot/Foo"), ".")).toBe(at("dot/Foo/index.tsx"));
192
+ expect(CsrDevPaths.resolveRelative(at("dot/Foo/Sub"), "..")).toBe(at("dot/Foo/index.tsx"));
193
+ });
194
+
195
+ test("app code under a package.json that maps files with a browser object keeps the recorded answer", () => {
196
+ const resolver = new CsrDevResolver({
197
+ paths: new CsrDevPaths(root),
198
+ context,
199
+ entryFiles: [],
200
+ resolution: { "bmap/entry.ts": { "./lib/server": "bmap/lib/client.ts" } },
201
+ });
202
+ expect(resolver.resolve(at("bmap/entry.ts"), "./lib/server")).toBe(at("bmap/lib/client.ts"));
203
+ });
204
+
205
+ test("a specifier ending in a slash names the folder alone", () => {
206
+ expect(CsrDevPaths.resolveRelative(at("slash"), "./Foo/")).toBe(at("slash/Foo/index.ts"));
207
+ expect(CsrDevPaths.resolveRelative(at("slash"), "./Foo")).toBe(at("slash/Foo.ts"));
208
+ });
209
+
210
+ test("a recorded relative resolution stands while the disk resolves it the same way", async () => {
150
211
  const paths = new CsrDevPaths(root);
151
212
  const resolver = new CsrDevResolver({
152
213
  paths,
@@ -157,9 +218,22 @@ describe("CsrDevPaths.resolveOnDisk", () => {
157
218
  "pair/entry.ts": { "./x": "pair/x.js" },
158
219
  },
159
220
  });
160
- expect(resolver.resolve(at("pair/entry.ts"), "./x")).toBe(at("pair/x.js"));
221
+ expect(resolver.resolve(at("pair/entry.ts"), "./x", "require")).toBe(at("pair/x.js"));
222
+ expect(resolver.resolve(at("pair/entry.ts"), "./x")).toBe(at("pair/x.mjs"));
161
223
  expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo/index.tsx"));
162
224
  await Bun.write(at("shadow/Foo.tsx"), "export {};\n");
163
225
  expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo.tsx"));
164
226
  });
227
+
228
+ test("a sibling with a stronger extension takes over a recorded import", async () => {
229
+ const resolver = new CsrDevResolver({
230
+ paths: new CsrDevPaths(root),
231
+ context,
232
+ entryFiles: [],
233
+ resolution: { "sibling/Page.tsx": { "./x": "sibling/x.ts" } },
234
+ });
235
+ expect(resolver.resolve(at("sibling/Page.tsx"), "./x")).toBe(at("sibling/x.ts"));
236
+ await Bun.write(at("sibling/x.tsx"), "export {};\n");
237
+ expect(resolver.resolve(at("sibling/Page.tsx"), "./x")).toBe(at("sibling/x.tsx"));
238
+ });
165
239
  });
@@ -22,6 +22,8 @@ export interface CsrDevResolverOptions {
22
22
  resolution?: Record<string, Record<string, string>>;
23
23
  /** Specifiers per importer id whose recorded resolution came from Bun's runtime resolver, not the browser build. */
24
24
  runtimeResolved?: Record<string, string[]>;
25
+ /** When this process began: a package installed after it is one Bun's resolver here has not seen. */
26
+ startedAt?: number;
25
27
  }
26
28
 
27
29
  //* Resolution comes from a plain browser build: Bun's runtime resolver ignores the `browser` condition and picks Node
@@ -34,10 +36,20 @@ export class CsrDevResolver {
34
36
  readonly #resolution = new Map<string, Map<string, string>>();
35
37
  readonly #fallbacks = new Set<string>();
36
38
  readonly #runtimeResolved = new Map<string, Set<string>>();
39
+ readonly #browserMaps = new Map<string, boolean>();
40
+ readonly #startedAt: number;
37
41
  #prepassDone = false;
38
42
 
39
- constructor({ paths, context, entryFiles, resolution = {}, runtimeResolved = {} }: CsrDevResolverOptions) {
43
+ constructor({
44
+ paths,
45
+ context,
46
+ entryFiles,
47
+ resolution = {},
48
+ runtimeResolved = {},
49
+ startedAt = Date.now() - process.uptime() * 1000,
50
+ }: CsrDevResolverOptions) {
40
51
  this.#paths = paths;
52
+ this.#startedAt = startedAt;
41
53
  this.#context = context;
42
54
  this.#entryFiles = entryFiles;
43
55
  for (const [importer, specifiers] of Object.entries(runtimeResolved))
@@ -95,7 +107,7 @@ export class CsrDevResolver {
95
107
  resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
96
108
  if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
97
109
  const known = this.#resolution.get(importer)?.get(specifier);
98
- const usable = known && this.#stillAnswers(importer, specifier, known) ? known : null;
110
+ const usable = known && this.#stillAnswers(importer, specifier, known, kind) ? known : null;
99
111
  const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
100
112
  if (target === null || CsrDevPaths.isStub(target)) return target;
101
113
  if (!CsrDevPaths.isScript(target)) return CsrDevResolver.inline;
@@ -103,14 +115,18 @@ export class CsrDevResolver {
103
115
  return target;
104
116
  }
105
117
 
106
- //? A record stands until its file is gone, or until a file created beside the folder it went into takes the import
107
- //? over (`Foo.tsx` next to `Foo/index.tsx`), as it does for Bun and every other build. Nothing else overturns it: the
108
- //? prepass chose between sibling files and package fields as Bun does, which a disk lookup would only approximate.
109
- #stillAnswers(importer: string, specifier: string, known: string): boolean {
118
+ //? A relative record in app code stands while the disk still resolves it to the same file, as Bun would: a sibling
119
+ //? with a stronger extension (`x.tsx` beside `x.ts`), a file beside the folder it went into, or a record a CommonJS
120
+ //? re-export folded onto another file (`./a` → `x.js`) resolves anew. A package's files, and app code under a
121
+ //? package.json that maps files with a `browser` object, keep the prepass's answer, which a disk lookup would only
122
+ //? approximate.
123
+ #stillAnswers(importer: string, specifier: string, known: string, kind: "import" | "require"): boolean {
110
124
  if (CsrDevPaths.isStub(known)) return true;
111
125
  if (!fs.existsSync(known)) return false;
112
126
  if (!specifier.startsWith(".") || CsrDevPaths.isVendorFile(importer)) return true;
113
- return !CsrDevPaths.isShadowed(path.resolve(path.dirname(importer), specifier), known);
127
+ if (this.#hasBrowserMap(path.dirname(importer))) return true;
128
+ const onDisk = CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind);
129
+ return onDisk !== null && CsrDevPaths.realpath(onDisk) === known;
114
130
  }
115
131
 
116
132
  isRegistryModule(file: string): boolean {
@@ -123,6 +139,31 @@ export class CsrDevResolver {
123
139
  });
124
140
  }
125
141
 
142
+ #hasBrowserMap(dir: string): boolean {
143
+ const cached = this.#browserMaps.get(dir);
144
+ if (cached !== undefined) return cached;
145
+ const pkgFile = path.join(dir, "package.json");
146
+ const parent = path.dirname(dir);
147
+ let mapped: boolean;
148
+ if (fs.existsSync(pkgFile)) {
149
+ try {
150
+ const browser = (JSON.parse(fs.readFileSync(pkgFile, "utf8")) as { browser?: unknown }).browser;
151
+ mapped = typeof browser === "object" && browser !== null;
152
+ } catch {
153
+ // An unreadable package.json maps nothing; Bun's build fails on it anyway.
154
+ mapped = false;
155
+ }
156
+ } else mapped = parent !== dir && this.#hasBrowserMap(parent);
157
+ this.#browserMaps.set(dir, mapped);
158
+ return mapped;
159
+ }
160
+
161
+ /** A module that left the graph takes its records with it. */
162
+ forget(importer: string): void {
163
+ this.#resolution.delete(importer);
164
+ this.#runtimeResolved.delete(importer);
165
+ }
166
+
126
167
  serializeRuntimeResolved(): Record<string, string[]> {
127
168
  return Object.fromEntries(
128
169
  [...this.#runtimeResolved]
@@ -154,7 +195,7 @@ export class CsrDevResolver {
154
195
  const cannotResolve = () => new Error(`[csr-dev] cannot resolve "${specifier}" from ${this.#paths.idOf(importer)}`);
155
196
  if (relative) {
156
197
  const onDisk =
157
- CsrDevPaths.resolveOnDisk(path.resolve(path.dirname(importer), specifier), kind) ??
198
+ CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind) ??
158
199
  CsrDevResolver.#onDisk(CsrDevPaths.tryResolve(specifier, path.dirname(importer)));
159
200
  if (!onDisk) throw cannotResolve();
160
201
  return CsrDevPaths.realpath(onDisk);
@@ -165,13 +206,21 @@ export class CsrDevResolver {
165
206
  if (sibling) return sibling;
166
207
  const resolved = CsrDevPaths.tryResolve(specifier, path.dirname(importer));
167
208
  const moved = resolved !== null && path.isAbsolute(resolved) && !fs.existsSync(resolved);
209
+ //? Bun keeps a process's node_modules listings: a package installed while the builder runs (`bun add`) resolves only
210
+ //? in a fresh process, so one installed since this process began goes to a worker. Any other miss, a subpath typo
211
+ //? of a package installed long ago included, fails here as the worker would.
212
+ const installed =
213
+ resolved === null && CsrDevPaths.installedSince(specifier, path.dirname(importer), this.#startedAt);
168
214
  if (!this.#prepassDone || moved) {
169
215
  //? A specifier no resolver finds in the user's own code is a typo, failed here like a missing relative file: the
170
216
  //? resolution build a worker would run for it fails the same way. A package's `browser` field may map one away.
171
- if (resolved === null && !CsrDevPaths.isVendorFile(importer)) throw cannotResolve();
217
+ if (resolved === null && !installed && !CsrDevPaths.isVendorFile(importer)) throw cannotResolve();
172
218
  return null;
173
219
  }
174
- if (!resolved) throw cannotResolve();
220
+ if (!resolved) {
221
+ if (installed) return null;
222
+ throw cannotResolve();
223
+ }
175
224
  if (!path.isAbsolute(resolved)) return `${CsrDevPaths.stubPrefix}${specifier}`;
176
225
  this.#fallbacks.add(specifier);
177
226
  this.#runtimeResolved.set(importer, (this.#runtimeResolved.get(importer) ?? new Set<string>()).add(specifier));
@@ -18,6 +18,8 @@ export interface CsrDevGraph {
18
18
  /** Specifiers per importer id `resolution` holds from Bun's runtime resolver rather than the browser build. */
19
19
  runtimeResolved?: Record<string, string[]>;
20
20
  pending: string[];
21
+ /** A build worker's failed round: the ids it built, and the files it failed in or named, by hash. */
22
+ failed?: { roots: string[]; files: Record<string, string> };
21
23
  /** The stamp of the signal and dictionary sources the last whole build inlined; see `RegistryMetadataFingerprint`. */
22
24
  metadata?: string;
23
25
  }
@@ -81,12 +81,25 @@ export abstract class DevRegistryBundler {
81
81
  return update;
82
82
  }
83
83
 
84
- /** Of these roots outside the graph, the ones something still reaches (an entry, a module's import): those stay pending. */
84
+ /** Of these files, the ones the registry still reaches from its roots through imports: those it keeps building. */
85
85
  async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
86
- const reached = new Set([...Object.values(graph.entries), ...Object.values(graph.modules).flatMap((m) => m.deps)]);
86
+ const reached = await this.reachable(graph);
87
87
  return new Set(files.filter((file) => reached.has(this.paths.idOf(file))));
88
88
  }
89
89
 
90
+ /** The ids its roots reach through imports, the refresh runtime included. */
91
+ async reachable(graph: CsrDevGraph): Promise<Set<string>> {
92
+ const roots = await this.rootFiles(Object.values(graph.entries).map((id) => this.paths.fileOf(id)));
93
+ const reached = new Set<string>();
94
+ const stack = [graph.refresh, ...roots.map((file) => this.paths.idOf(file))];
95
+ for (let id = stack.pop(); id !== undefined; id = stack.pop()) {
96
+ if (reached.has(id)) continue;
97
+ reached.add(id);
98
+ stack.push(...(graph.modules[id]?.deps ?? []));
99
+ }
100
+ return reached;
101
+ }
102
+
90
103
  async holds(files: string[]): Promise<boolean> {
91
104
  const graph = await this.writer.readJson<CsrDevGraph>("graph.json");
92
105
  return graph !== null && files.every((file) => graph.modules[this.paths.idOf(file)] !== undefined);
@@ -166,7 +179,9 @@ export abstract class DevRegistryBundler {
166
179
  deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.paths.idOf(dep))),
167
180
  ...(module.helpers ? { helpers: module.helpers.hash } : {}),
168
181
  };
169
- graph.pending = [];
182
+ //? Only what this round compiled leaves pending: a root another route's check failed on stays for its retry.
183
+ const compiledIds = new Set(compiled.map((module) => module.id));
184
+ graph.pending = graph.pending.filter((id) => !compiledIds.has(id));
170
185
  graph.resolution = resolver.serialize();
171
186
  graph.runtimeResolved = resolver.serializeRuntimeResolved();
172
187
  }