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

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.
Files changed (34) hide show
  1. package/DEV_RUNTIME_KNOBS.md +9 -6
  2. package/akanApp/akanApp.host.ts +79 -22
  3. package/akanApp/devHostPolicy.test.ts +56 -11
  4. package/akanApp/devHostPolicy.ts +10 -10
  5. package/csrE2e/csrE2eHarness.fixture.ts +55 -5
  6. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  7. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  8. package/frontendBuild/clientEntryDiscovery.test.ts +75 -0
  9. package/frontendBuild/clientEntryDiscovery.ts +112 -70
  10. package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
  11. package/frontendBuild/csrDevArtifactWriter.ts +4 -0
  12. package/frontendBuild/csrDevBundler.ts +1 -1
  13. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  14. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  15. package/frontendBuild/csrDevModuleCompiler.test.ts +261 -0
  16. package/frontendBuild/csrDevModuleCompiler.ts +95 -20
  17. package/frontendBuild/csrDevPatcher.test.ts +234 -2
  18. package/frontendBuild/csrDevPatcher.ts +184 -10
  19. package/frontendBuild/csrDevPaths.ts +32 -11
  20. package/frontendBuild/csrDevResolver.test.ts +76 -2
  21. package/frontendBuild/csrDevResolver.ts +141 -12
  22. package/frontendBuild/csrDevTypes.ts +2 -0
  23. package/frontendBuild/devRegistryBundler.ts +18 -3
  24. package/frontendBuild/ssrDevBundler.ts +2 -8
  25. package/incrementalBuilder/incrementalBuilder.host.test.ts +34 -0
  26. package/incrementalBuilder/incrementalBuilder.host.ts +18 -3
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +62 -1
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +58 -24
  29. package/integration/devStability.integration.test.ts +9 -2
  30. package/mobile/nativeApp.test.ts +15 -0
  31. package/mobile/nativeApp.ts +54 -14
  32. package/mobile/nativeAppLine.test.ts +62 -0
  33. package/mobile/nativeAppLine.ts +63 -0
  34. package/package.json +2 -2
@@ -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
  }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  export class CsrDevPaths {
5
5
  static readonly modulePrefix = "akan-module:";
6
6
  static readonly stubPrefix = "stub:";
7
+ static readonly emptyStubPrefix = "stub:empty:";
7
8
  static readonly vendorPrefix = "vendor:";
8
9
  readonly root: string;
9
10
 
@@ -43,16 +44,18 @@ export class CsrDevPaths {
43
44
  return (await source.exists()) ? Bun.hash(await source.arrayBuffer()).toString(36) : "";
44
45
  }
45
46
 
47
+ //? `.native`: on Windows Bun's `fs.realpathSync` keeps the spelling it was given, where the native call returns the
48
+ //? disk's (as macOS's plain call does), and a module id must not depend on which spelling reached it first.
46
49
  static realpath(file: string): string {
47
50
  // 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);
51
+ return fs.existsSync(file) ? fs.realpathSync.native(file) : path.resolve(file);
49
52
  }
50
53
 
51
54
  //? Present under this very name: on a case-insensitive disk (APFS, NTFS) `ui/card.tsx` still exists after a rename to
52
55
  //? `ui/Card.tsx`, but the compiler names the module by the disk's spelling, so the old id is gone.
53
56
  static isNamed(file: string): boolean {
54
57
  try {
55
- return fs.realpathSync(file) === file;
58
+ return fs.realpathSync.native(file) === file;
56
59
  } catch {
57
60
  // Missing is an answer here.
58
61
  return false;
@@ -71,15 +74,22 @@ export class CsrDevPaths {
71
74
  return CsrDevPaths.#asFile(base, kind) ?? CsrDevPaths.#asFolder(base, kind);
72
75
  }
73
76
 
77
+ //? A specifier ending in `/`, `.` or `..` names the folder alone, for Bun too: `./Foo/` never takes a `Foo.tsx`.
78
+ static resolveRelative(dir: string, specifier: string, kind: "import" | "require" = "import"): string | null {
79
+ const base = path.resolve(dir, specifier);
80
+ const folder = /(^|[\\/])\.\.?$|[\\/]$/.test(specifier);
81
+ return folder ? CsrDevPaths.#asFolder(base, kind) : CsrDevPaths.resolveOnDisk(base, kind);
82
+ }
83
+
74
84
  static #asFile(base: string, kind: "import" | "require"): string | null {
75
85
  const isFile = (candidate: string) => fs.statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false;
76
86
  return [base, ...CsrDevPaths.#extensions[kind].map((extension) => `${base}${extension}`)].find(isFile) ?? null;
77
87
  }
78
88
 
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`.
89
+ //? A folder's package.json names its entry as a browser build reads it: `browser` (the string form), then `module`
90
+ //? (an import only), then `main`, which may itself name a folder; without one, its `index`.
81
91
  static #asFolder(dir: string, kind: "import" | "require"): string | null {
82
- const entry = CsrDevPaths.#packageEntryOf(dir);
92
+ const entry = CsrDevPaths.#packageEntryOf(dir, kind);
83
93
  const named = entry
84
94
  ? (CsrDevPaths.#asFile(path.join(dir, entry), kind) ?? CsrDevPaths.#indexOf(path.join(dir, entry), kind))
85
95
  : null;
@@ -90,10 +100,11 @@ export class CsrDevPaths {
90
100
  return CsrDevPaths.#asFile(path.join(dir, "index"), kind);
91
101
  }
92
102
 
93
- static #packageEntryOf(dir: string): string | null {
103
+ static #packageEntryOf(dir: string, kind: "import" | "require"): string | null {
94
104
  try {
95
105
  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");
106
+ const fields = kind === "require" ? [pkg.browser, pkg.main] : [pkg.browser, pkg.module, pkg.main];
107
+ const entry = fields.find((field) => typeof field === "string");
97
108
  return typeof entry === "string" ? entry : null;
98
109
  } catch {
99
110
  // No package.json, or one that names no entry: the folder's index answers.
@@ -101,10 +112,20 @@ export class CsrDevPaths {
101
112
  }
102
113
  }
103
114
 
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;
115
+ //? The package folder's ctime: Bun installs by cloning (macOS) or hard-linking (Linux) its cache, so a file keeps the
116
+ //? cache's mtime, and a hard link's ctime moves whenever another project links the same version; the folder is made
117
+ //? by this project's install alone.
118
+ /** A bare specifier whose package landed in a `node_modules` at or above `from` after `since`. */
119
+ static installedSince(specifier: string, from: string, since: number): boolean {
120
+ const [first = "", second = ""] = specifier.split("/");
121
+ const name = first.startsWith("@") ? `${first}/${second}` : first;
122
+ if (!name || name.startsWith(".")) return false;
123
+ for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
124
+ const pkgDir = path.join(dir, "node_modules", name);
125
+ if (fs.existsSync(path.join(pkgDir, "package.json")))
126
+ return (fs.statSync(pkgDir, { throwIfNoEntry: false })?.ctimeMs ?? 0) > since;
127
+ if (path.dirname(dir) === dir) return false;
128
+ }
108
129
  }
109
130
 
110
131
  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
  });