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

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 (57) hide show
  1. package/DEV_RUNTIME_KNOBS.md +37 -22
  2. package/akanApp/akanApp.host.ts +34 -1
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +25 -0
  6. package/akanApp/devHostPolicy.ts +27 -2
  7. package/cloud/globalConfig.ts +3 -17
  8. package/csrE2e/csrE2eHarness.fixture.ts +66 -11
  9. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  10. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  11. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  12. package/fileSys.ts +16 -1
  13. package/frontendBuild/buildRouteClient.test.ts +61 -0
  14. package/frontendBuild/bundleDefine.test.ts +12 -0
  15. package/frontendBuild/bundleDefine.ts +5 -1
  16. package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +10 -5
  18. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  19. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  20. package/frontendBuild/csrDevArtifactWriter.ts +158 -24
  21. package/frontendBuild/csrDevBundler.ts +44 -205
  22. package/frontendBuild/csrDevModuleCompiler.ts +51 -9
  23. package/frontendBuild/csrDevPatcher.test.ts +340 -0
  24. package/frontendBuild/csrDevPatcher.ts +374 -0
  25. package/frontendBuild/csrDevPaths.ts +60 -0
  26. package/frontendBuild/csrDevResolver.test.ts +165 -0
  27. package/frontendBuild/csrDevResolver.ts +70 -18
  28. package/frontendBuild/csrDevTypes.ts +14 -1
  29. package/frontendBuild/csrEntryFiles.ts +24 -0
  30. package/frontendBuild/cssCompiler.ts +2 -4
  31. package/frontendBuild/devRegistryBundler.ts +177 -0
  32. package/frontendBuild/hmrWatcher.test.ts +51 -0
  33. package/frontendBuild/hmrWatcher.ts +17 -3
  34. package/frontendBuild/index.ts +3 -0
  35. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  36. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  37. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  38. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  39. package/frontendBuild/routeClientBuilder.ts +79 -18
  40. package/frontendBuild/serverGraphFile.test.ts +77 -0
  41. package/frontendBuild/serverGraphFile.ts +93 -0
  42. package/frontendBuild/ssrDevBundler.ts +91 -0
  43. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  44. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  45. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  46. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  47. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  48. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  49. package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
  50. package/incrementalBuilder/incrementalBuilder.host.ts +32 -3
  51. package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
  52. package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
  53. package/integration/devStability.integration.test.ts +18 -7
  54. package/package.json +2 -2
  55. package/transforms/rscUseClientTransform.ts +7 -0
  56. package/transforms/transforms.test.ts +13 -0
  57. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -0,0 +1,374 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { appGenerationOf, CSR_DEV_MANIFEST_FILE, type CsrDevManifest } from "akanjs/server/hmr/csrDevManifest";
4
+ import type { CsrDevCompileResult } from "./csrDevModuleCompiler";
5
+ import { CsrDevPaths } from "./csrDevPaths";
6
+ import { CsrDevResolver } from "./csrDevResolver";
7
+ import type { CsrDevCode, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
8
+ import type { CsrDevUpdate, CsrDevUpdateOptions, DevRegistryBundler } from "./devRegistryBundler";
9
+
10
+ interface CsrDevPatcherState {
11
+ graph: CsrDevGraph;
12
+ manifest: CsrDevManifest;
13
+ code: CsrDevCode | null;
14
+ }
15
+
16
+ export type CsrDevPatchResult =
17
+ | { kind: "unchanged" }
18
+ | { kind: "update"; update: CsrDevUpdate }
19
+ | { kind: "delegate"; reason: string; generation: number; context: CsrDevContext; first?: boolean };
20
+
21
+ export interface CsrDevPatchOptions extends CsrDevUpdateOptions {
22
+ /**
23
+ * Run the whole-app builds a save can call for in this process: an npm module the graph has not seen, and the
24
+ * resolution build for an import no recorded resolution answers. Without it the save is handed back to a build
25
+ * worker, which exits and returns their memory (a resident builder that ran them sat at 1.3-1.5GB).
26
+ */
27
+ allowWholeAppBuilds?: boolean;
28
+ }
29
+
30
+ //* The incremental path of a dev module registry (CSR, or the client code of SSR pages). The resident builder keeps
31
+ //* one, so the graph, the manifest and every module's factory stay in memory between saves; a build worker makes a
32
+ //* throwaway one that starts from the disk. A save it hands back leaves the graph on disk as it was, so a worker still
33
+ //* sees those files as changed.
34
+ export class CsrDevPatcher {
35
+ readonly #bundler: DevRegistryBundler;
36
+ readonly #resident: boolean;
37
+ #state: CsrDevPatcherState | null = null;
38
+
39
+ constructor(bundler: DevRegistryBundler, { resident = false }: { resident?: boolean } = {}) {
40
+ this.#bundler = bundler;
41
+ this.#resident = resident;
42
+ }
43
+
44
+ /** Drops what it holds, so the next update starts from the disk a build worker may have rewritten. */
45
+ forget(): void {
46
+ this.#state = null;
47
+ }
48
+
49
+ async update(
50
+ changedFiles: string[] = [],
51
+ { announce, roots = [], onlyRoots = false, allowWholeAppBuilds = false }: CsrDevPatchOptions = {},
52
+ ): Promise<CsrDevPatchResult> {
53
+ const context = await this.#bundler.context();
54
+ if (!context) return { kind: "unchanged" };
55
+ const resident = this.#state;
56
+ const state = resident ?? (await this.#load());
57
+ if (!state) {
58
+ const manifest = await this.#bundler.writer.readJson<CsrDevManifest>(CSR_DEV_MANIFEST_FILE);
59
+ const reason = !manifest
60
+ ? "first build"
61
+ : (await this.#bundler.writer.isBuilding())
62
+ ? "the last whole build was cut short"
63
+ : "the dev bundle config changed";
64
+ return this.#handBack(reason, (manifest?.generation ?? 0) + 1, context, { first: !manifest });
65
+ }
66
+ const generation = state.manifest.generation + 1;
67
+ if (state.graph.configKey !== context.configKey)
68
+ return this.#handBack("the dev bundle config changed", generation, context);
69
+ const metadataFile = changedFiles.find((file) => this.#bundler.isMetadataFile(file));
70
+ // `lib/useClient.ts` inlines signal and dictionary metadata through a macro at build time.
71
+ if (metadataFile) return this.#handBack(`${path.basename(metadataFile)} changed`, generation, context);
72
+ //? Only a registry read from disk: one held in memory saw every save since, and a metadata save was handed back.
73
+ if (!resident && state.graph.metadata !== (await this.#bundler.metadataFingerprint()))
74
+ return this.#handBack("signal or dictionary metadata changed since the registry was built", generation, context);
75
+ this.#state = this.#resident ? state : null;
76
+ //? Not over a module whose factory file is missing: this patch compiles it, and its commit writes app.js whole.
77
+ const healable =
78
+ appGenerationOf(state.manifest) < state.manifest.generation &&
79
+ this.#withoutFactory(state, (file) => fs.existsSync(file)).length === 0;
80
+ if (healable)
81
+ state.manifest = await this.#bundler.writer.healApp(state.graph, state.manifest, await this.#code(state));
82
+ return await this.#patch(context, state, changedFiles, generation, {
83
+ announce,
84
+ roots,
85
+ onlyRoots,
86
+ allowWholeAppBuilds,
87
+ });
88
+ }
89
+
90
+ async #patch(
91
+ context: CsrDevContext,
92
+ state: CsrDevPatcherState,
93
+ changedFiles: string[],
94
+ generation: number,
95
+ { announce, roots = [], onlyRoots = false, allowWholeAppBuilds }: CsrDevPatchOptions,
96
+ ): Promise<CsrDevPatchResult> {
97
+ const { paths, writer } = this.#bundler;
98
+ const { graph } = state;
99
+ const entries = await this.#bundler.writeEntries(context);
100
+ const entryIds = this.#bundler.entryIds(entries.files);
101
+ const present = CsrDevPatcher.#presence(changedFiles);
102
+ const forgotten = this.#forgetDeletedModules(state, present);
103
+ const changed = onlyRoots ? new Set<string>() : await this.#changedModules(graph, changedFiles, present);
104
+ for (const id of this.#withoutFactory(state, present)) changed.add(id);
105
+ for (const file of entries.changed) changed.add(paths.idOf(file));
106
+ 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));
108
+ const carried = onlyRoots ? [] : await this.#carriedRoots(graph, changed, present);
109
+ const routesMoved =
110
+ this.#bundler.reloadsOnEntryChange &&
111
+ (entries.changed.length > 0 || JSON.stringify(entryIds) !== JSON.stringify(graph.entries));
112
+ if (changed.size === 0) {
113
+ if (carried.length === 0) return { kind: "unchanged" };
114
+ return await this.#retryCarried(context, state, carried, { announce, allowWholeAppBuilds });
115
+ }
116
+
117
+ // A fresh resolver per save: one kept across saves would stay past its prepass, and resolve the next new bare
118
+ // import with Bun's runtime resolver, which ignores the `browser` condition.
119
+ const resolver = new CsrDevResolver({
120
+ paths,
121
+ context,
122
+ entryFiles: rootFiles.map((file) => CsrDevPaths.realpath(file)),
123
+ resolution: graph.resolution,
124
+ runtimeResolved: graph.runtimeResolved,
125
+ });
126
+ 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)),
131
+ known,
132
+ { refuseNewVendor: !allowWholeAppBuilds, refusePrepass: !allowWholeAppBuilds },
133
+ );
134
+ } catch (error) {
135
+ graph.pending = [...new Set([...changed, ...carried])];
136
+ await writer.writeJson("graph.json", graph);
137
+ throw error;
138
+ }
139
+ const refusal =
140
+ result.refusedVendors.length > 0
141
+ ? "an npm module joined the graph"
142
+ : result.unresolved.length > 0
143
+ ? `a new import needs the resolution build: ${result.unresolved[0]}`
144
+ : null;
145
+ if (refusal) {
146
+ //? The entry files are already rewritten, so the worker would see them unchanged and patch a moved route table:
147
+ //? a graph whose entries name none makes it reload instead.
148
+ if (routesMoved) await writer.writeJson("graph.json", { ...graph, entries: {} });
149
+ return this.#handBack(refusal, generation, context);
150
+ }
151
+ const compiled = result.modules;
152
+ const vendorJoined = compiled.some((module) => module.vendor && !graph.modules[module.id]);
153
+ //? Read before the merge: a new module's file is written after announcing, and one without a file is compiled now.
154
+ const code = await this.#code(state);
155
+ this.#bundler.merge(graph, resolver, compiled);
156
+ graph.entries = entryIds;
157
+ for (const module of compiled) {
158
+ if (module.vendor || !code) continue;
159
+ code.modules.set(module.id, module.factory);
160
+ if (module.helpers) code.helpers.set(module.helpers.hash, module.helpers.definition);
161
+ }
162
+ const changedIds = compiled.filter((module) => !module.vendor).map((module) => module.id);
163
+ const constantId = changedIds.find((id) => id.endsWith(".constant.ts"));
164
+ // A model class swapped under live store state would mix old and new instances; reload instead.
165
+ const reason = vendorJoined
166
+ ? "an npm module joined the graph"
167
+ : routesMoved
168
+ ? "the route table changed"
169
+ : constantId
170
+ ? `${path.basename(constantId)} changed`
171
+ : undefined;
172
+ const previous = state.manifest;
173
+ const next: CsrDevManifest = { ...previous, generation, entries: entryIds };
174
+ const update = (patchUrl?: string): CsrDevUpdate => ({
175
+ generation,
176
+ reload: !!reason,
177
+ reason,
178
+ patchUrl,
179
+ changedIds,
180
+ moduleCount: Object.keys(graph.modules).length,
181
+ epoch: previous.epoch,
182
+ });
183
+ // A reload rewrites app.js first: the tabs are about to boot from it.
184
+ let outcome: CsrDevPatchResult;
185
+ if (reason) {
186
+ await writer.forgetPatch(generation);
187
+ await writer.writeModules(compiled);
188
+ const vendorFile = vendorJoined ? await writer.writeVendor(graph) : previous.vendorFile;
189
+ await writer.writeApp(graph, { generation, vendorFile, epoch: previous.epoch }, code);
190
+ state.manifest = { ...next, vendorFile, appGeneration: generation };
191
+ await writer.writeState(graph, state.manifest);
192
+ await writer.prune(vendorFile, generation);
193
+ const reloaded = update();
194
+ announce?.(reloaded);
195
+ outcome = { kind: "update", update: reloaded };
196
+ } else {
197
+ const patched = update(
198
+ await writer.writePatch(
199
+ generation,
200
+ compiled.filter((module) => !module.vendor),
201
+ ),
202
+ );
203
+ await writer.commitPatch(
204
+ graph,
205
+ { ...next, appGeneration: appGenerationOf(previous) },
206
+ () => announce?.(patched),
207
+ { modules: compiled, code },
208
+ );
209
+ state.manifest = { ...next, appGeneration: generation };
210
+ await writer.prune(previous.vendorFile, generation);
211
+ outcome = { kind: "update", update: patched };
212
+ }
213
+ // Only now that graph.json no longer names them: a crash before it left a graph pointing at missing files.
214
+ await writer.forgetModules(forgotten);
215
+ if (carried.length === 0) return outcome;
216
+ const retried = await this.#retryCarried(context, state, carried, { announce, allowWholeAppBuilds });
217
+ return retried.kind === "delegate" ? retried : outcome;
218
+ }
219
+
220
+ //? Compiled apart from the save: a new root that failed to compile, and that no module imports, must not fail every
221
+ //? later save's round with it. Its error still reaches the overlay (this throws after the save's patch went out).
222
+ async #retryCarried(
223
+ context: CsrDevContext,
224
+ state: CsrDevPatcherState,
225
+ carried: string[],
226
+ { announce, allowWholeAppBuilds }: CsrDevPatchOptions,
227
+ ): Promise<CsrDevPatchResult> {
228
+ const { paths, writer } = this.#bundler;
229
+ const retried = await this.#patch(context, state, [], state.manifest.generation + 1, {
230
+ announce,
231
+ roots: carried.map((id) => paths.fileOf(id)),
232
+ onlyRoots: true,
233
+ allowWholeAppBuilds,
234
+ });
235
+ //? Handed to a worker (a new npm import in the fixed root): the graph keeps them pending, so the worker retries
236
+ //? them with whole-app builds allowed instead of never hearing of them.
237
+ if (retried.kind === "delegate") {
238
+ state.graph.pending = [...new Set([...state.graph.pending, ...carried])];
239
+ await writer.writeJson("graph.json", state.graph);
240
+ }
241
+ return retried;
242
+ }
243
+
244
+ //? A pending root outside the graph (a route's entry that failed to compile) is retried while a route still reaches
245
+ //? it; one no route names any more (the page stopped importing it) leaves the pending list instead.
246
+ async #carriedRoots(graph: CsrDevGraph, changed: Set<string>, present: (file: string) => boolean): Promise<string[]> {
247
+ const { paths } = this.#bundler;
248
+ const outside = graph.pending.filter((id) => !graph.modules[id] && !changed.has(id) && present(paths.fileOf(id)));
249
+ if (outside.length === 0) return [];
250
+ const wanted = await this.#bundler.wantedRoots(
251
+ outside.map((id) => paths.fileOf(id)),
252
+ graph,
253
+ );
254
+ const carried = outside.filter((id) => wanted.has(paths.fileOf(id)));
255
+ graph.pending = graph.pending.filter((id) => graph.modules[id] || carried.includes(id));
256
+ return carried;
257
+ }
258
+
259
+ // Missing factory files are recompiled, not read: a registry whose graph outlived them would fail every read. The
260
+ // resident patcher holds every factory it has read or written, so only a registry just read from disk is stat'ed.
261
+ #withoutFactory({ graph, code }: CsrDevPatcherState, present: (file: string) => boolean): string[] {
262
+ const { paths, writer } = this.#bundler;
263
+ const hasFactory = (id: string) => (code ? code.modules.has(id) : writer.hasModule(id));
264
+ return Object.entries(graph.modules)
265
+ .filter(([id, module]) => !module.vendor && !hasFactory(id) && present(paths.fileOf(id)))
266
+ .map(([id]) => id);
267
+ }
268
+
269
+ //? A case-only rename is the one move `existsSync` misses (`ui/card.tsx` still exists after a rename to
270
+ //? `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).
272
+ static #presence(changedFiles: string[]): (file: string) => boolean {
273
+ const caseMoved = changedFiles.some((file) => fs.existsSync(file) && !CsrDevPaths.isNamed(file));
274
+ return caseMoved ? (file) => CsrDevPaths.isNamed(file) : (file) => fs.existsSync(file);
275
+ }
276
+
277
+ #handBack(
278
+ reason: string,
279
+ generation: number,
280
+ context: CsrDevContext,
281
+ { first = false }: { first?: boolean } = {},
282
+ ): CsrDevPatchResult {
283
+ this.#state = null;
284
+ return { kind: "delegate", reason, generation, context, ...(first ? { first } : {}) };
285
+ }
286
+
287
+ async #load(): Promise<CsrDevPatcherState | null> {
288
+ const manifest = await this.#bundler.writer.readJson<CsrDevManifest>(CSR_DEV_MANIFEST_FILE);
289
+ const graph = await this.#bundler.writer.readJson<CsrDevGraph>("graph.json");
290
+ if (!manifest || !graph || graph.version !== 1 || (await this.#bundler.writer.isBuilding())) return null;
291
+ return { graph, manifest, code: null };
292
+ }
293
+
294
+ // Only the resident patcher keeps factories: a worker writes app.js once, reading them from disk after announcing.
295
+ async #code(state: CsrDevPatcherState): Promise<CsrDevCode | undefined> {
296
+ if (!this.#resident) return undefined;
297
+ state.code ??= await this.#bundler.writer.readCode(state.graph);
298
+ return state.code;
299
+ }
300
+
301
+ async #changedModules(
302
+ graph: CsrDevGraph,
303
+ changedFiles: string[],
304
+ present: (file: string) => boolean,
305
+ ): Promise<Set<string>> {
306
+ const { paths } = this.#bundler;
307
+ const changed = new Set<string>(graph.pending.filter((id) => graph.modules[id]));
308
+ for (const file of changedFiles) {
309
+ const id = paths.idOf(file);
310
+ if (graph.modules[id]) changed.add(id);
311
+ }
312
+ for (const id of this.#shadowedImporters(graph, changedFiles)) changed.add(id);
313
+ // Stat, not trust: a save made while CSR was unarmed, or a build that failed, never reached this graph. The hash
314
+ // settles it because route discovery rewrites the generated layout wrappers, touching them on every call.
315
+ for (const [id, module] of Object.entries(graph.modules)) {
316
+ if (module.vendor || changed.has(id)) continue;
317
+ const file = paths.fileOf(id);
318
+ if (CsrDevPaths.mtimeOf(file) === module.mtimeMs) continue;
319
+ if ((await CsrDevPaths.hashOf(file)) !== module.hash) changed.add(id);
320
+ }
321
+ // A dependency the graph lost: deleted and brought back (an undo in the file explorer), it compiles again; moved
322
+ // away (`Foo.tsx` → `Foo/index.tsx`), its importers do, since their own text did not change and app.js would name
323
+ // a module no longer defined.
324
+ //? Not through an importer compiling anyway: its deps in the graph are the ones before this save, and its own
325
+ //? compile follows whatever it still imports, where a dropped import would keep a broken file failing every save.
326
+ for (const [id, module] of Object.entries(graph.modules)) {
327
+ if (changed.has(id)) continue;
328
+ for (const dep of module.deps) {
329
+ if (graph.modules[dep] || CsrDevPaths.isStub(dep)) continue;
330
+ if (present(paths.fileOf(dep))) changed.add(dep);
331
+ else if (!module.vendor) changed.add(id);
332
+ }
333
+ }
334
+ return changed;
335
+ }
336
+
337
+ //? A new file can take over a relative import another module already resolved (`Foo.tsx` created beside
338
+ //? `Foo/index.tsx`), with no change to that module's own text: it compiles again, and resolves against the disk.
339
+ #shadowedImporters(graph: CsrDevGraph, changedFiles: string[]): string[] {
340
+ const { paths } = this.#bundler;
341
+ const bases = new Set(
342
+ changedFiles
343
+ .map((file) => CsrDevPaths.realpath(file))
344
+ .filter((file) => !graph.modules[paths.idOf(file)] && CsrDevPaths.isScript(file) && fs.existsSync(file))
345
+ .flatMap((file) => {
346
+ const stem = file.slice(0, file.length - path.extname(file).length);
347
+ return path.basename(stem) === "index" ? [stem, path.dirname(stem)] : [stem];
348
+ }),
349
+ );
350
+ if (bases.size === 0) return [];
351
+ return Object.entries(graph.resolution)
352
+ .filter(([importer, bySpecifier]) => {
353
+ if (!graph.modules[importer] || graph.modules[importer]?.vendor) return false;
354
+ const dir = path.dirname(paths.fileOf(importer));
355
+ return Object.keys(bySpecifier).some(
356
+ (specifier) => specifier.startsWith(".") && bases.has(path.resolve(dir, specifier)),
357
+ );
358
+ })
359
+ .map(([importer]) => importer);
360
+ }
361
+
362
+ // A deleted module leaves the graph instead of being rebuilt: rebuilding a missing file fails every save after it.
363
+ // Its importers compile again with the next save (#changedModules): a move resolves anew, a deletion fails with the
364
+ // error that says why. Its files stay until the graph on disk stops naming them.
365
+ #forgetDeletedModules({ graph, code }: CsrDevPatcherState, present: (file: string) => boolean): string[] {
366
+ const { paths } = this.#bundler;
367
+ const deleted = Object.keys(graph.modules).filter((id) => !graph.modules[id]?.vendor && !present(paths.fileOf(id)));
368
+ for (const id of deleted) {
369
+ delete graph.modules[id];
370
+ code?.modules.delete(id);
371
+ }
372
+ return deleted;
373
+ }
374
+ }
@@ -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 vendorPrefix = "vendor:";
7
8
  readonly root: string;
8
9
 
9
10
  constructor(workspaceRoot: string) {
@@ -47,6 +48,65 @@ export class CsrDevPaths {
47
48
  return fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file);
48
49
  }
49
50
 
51
+ //? Present under this very name: on a case-insensitive disk (APFS, NTFS) `ui/card.tsx` still exists after a rename to
52
+ //? `ui/Card.tsx`, but the compiler names the module by the disk's spelling, so the old id is gone.
53
+ static isNamed(file: string): boolean {
54
+ try {
55
+ return fs.realpathSync(file) === file;
56
+ } catch {
57
+ // Missing is an answer here.
58
+ return false;
59
+ }
60
+ }
61
+
62
+ //? Bun's own precedence for a browser build, measured (Bun 1.4.2): the registry must pick the file every other build
63
+ //? picks, and a `require` call weighs the extensions differently from an import.
64
+ static readonly #extensions = {
65
+ import: [".tsx", ".jsx", ".mts", ".ts", ".mjs", ".js", ".cts", ".cjs"],
66
+ require: [".tsx", ".ts", ".jsx", ".cts", ".cjs", ".js", ".mjs", ".mts"],
67
+ } as const;
68
+
69
+ /** A relative import resolved against the disk as it is now: the file, a script extension, or a folder's entry. */
70
+ static resolveOnDisk(base: string, kind: "import" | "require" = "import"): string | null {
71
+ return CsrDevPaths.#asFile(base, kind) ?? CsrDevPaths.#asFolder(base, kind);
72
+ }
73
+
74
+ static #asFile(base: string, kind: "import" | "require"): string | null {
75
+ const isFile = (candidate: string) => fs.statSync(candidate, { throwIfNoEntry: false })?.isFile() ?? false;
76
+ return [base, ...CsrDevPaths.#extensions[kind].map((extension) => `${base}${extension}`)].find(isFile) ?? null;
77
+ }
78
+
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`.
81
+ static #asFolder(dir: string, kind: "import" | "require"): string | null {
82
+ const entry = CsrDevPaths.#packageEntryOf(dir);
83
+ const named = entry
84
+ ? (CsrDevPaths.#asFile(path.join(dir, entry), kind) ?? CsrDevPaths.#indexOf(path.join(dir, entry), kind))
85
+ : null;
86
+ return named ?? CsrDevPaths.#indexOf(dir, kind);
87
+ }
88
+
89
+ static #indexOf(dir: string, kind: "import" | "require"): string | null {
90
+ return CsrDevPaths.#asFile(path.join(dir, "index"), kind);
91
+ }
92
+
93
+ static #packageEntryOf(dir: string): string | null {
94
+ try {
95
+ 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");
97
+ return typeof entry === "string" ? entry : null;
98
+ } catch {
99
+ // No package.json, or one that names no entry: the folder's index answers.
100
+ return null;
101
+ }
102
+ }
103
+
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;
108
+ }
109
+
50
110
  static tryResolve(specifier: string, from: string): string | null {
51
111
  try {
52
112
  return Bun.resolveSync(specifier, from);
@@ -0,0 +1,165 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { CsrDevPaths } from "./csrDevPaths";
6
+ import { CsrDevResolver } from "./csrDevResolver";
7
+ import type { CsrDevContext } from "./csrDevTypes";
8
+
9
+ const context: CsrDevContext = {
10
+ pageEntries: [],
11
+ basePaths: [],
12
+ htmlBasePaths: [],
13
+ define: {},
14
+ optimizeImports: [],
15
+ configKey: "key",
16
+ refreshFile: null,
17
+ };
18
+
19
+ describe("CsrDevResolver before its resolution build", () => {
20
+ let root: string;
21
+ let paths: CsrDevPaths;
22
+ const file = (relative: string) => path.join(paths.root, relative);
23
+ const resolver = (runtimeResolved: Record<string, string[]> = {}) =>
24
+ new CsrDevResolver({
25
+ paths,
26
+ context,
27
+ entryFiles: [],
28
+ resolution: { "node_modules/pkg/a.js": { dep: "node_modules/dep/index.js" } },
29
+ runtimeResolved,
30
+ });
31
+
32
+ beforeAll(async () => {
33
+ root = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-resolver-"));
34
+ for (const relative of [
35
+ "apps/demo/ui/Card.tsx",
36
+ "node_modules/pkg/a.js",
37
+ "node_modules/pkg/b.js",
38
+ "node_modules/dep/index.js",
39
+ "node_modules/fresh/index.js",
40
+ ])
41
+ await Bun.write(path.join(root, relative), "export {};\n");
42
+ await Bun.write(path.join(root, "node_modules/fresh/package.json"), '{ "name": "fresh", "main": "index.js" }');
43
+ paths = new CsrDevPaths(root);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await rm(root, { recursive: true, force: true });
48
+ });
49
+
50
+ test("a file of the same package reuses what the browser build resolved for a sibling", () => {
51
+ expect(resolver().resolve(file("node_modules/pkg/b.js"), "dep")).toBe(file("node_modules/dep/index.js"));
52
+ });
53
+
54
+ test("but not what Bun's runtime resolver found for it: that ignores the browser condition", () => {
55
+ const withFallback = resolver({ "node_modules/pkg/a.js": ["dep"] });
56
+ expect(withFallback.resolve(file("node_modules/pkg/b.js"), "dep")).toBeNull();
57
+ expect(withFallback.serializeRuntimeResolved()).toEqual({ "node_modules/pkg/a.js": ["dep"] });
58
+ });
59
+
60
+ test("the resolution build clears a runtime resolution it records itself, so a sibling may reuse it again", async () => {
61
+ await Bun.write(
62
+ file("apps/demo/ui/Fresh.tsx"),
63
+ 'import * as fresh from "fresh";\nexport const Fresh = () => fresh;\n',
64
+ );
65
+ const prepassed = new CsrDevResolver({
66
+ paths,
67
+ context,
68
+ entryFiles: [file("apps/demo/ui/Fresh.tsx")],
69
+ runtimeResolved: { "apps/demo/ui/Fresh.tsx": ["fresh"] },
70
+ });
71
+ await prepassed.prepass();
72
+ expect(prepassed.serializeRuntimeResolved()).toEqual({});
73
+ });
74
+
75
+ test("a relative import follows a file moved into a folder or renamed, which Bun's resolver cache does not", async () => {
76
+ const card = file("apps/demo/ui/Card.tsx");
77
+ await Bun.write(file("apps/demo/ui/Moved.tsx"), "export {};\n");
78
+ await Bun.write(file("apps/demo/ui/renamed.ts"), "export {};\n");
79
+ expect(CsrDevPaths.tryResolve("./Moved", path.dirname(card))).toBe(file("apps/demo/ui/Moved.tsx"));
80
+ expect(CsrDevPaths.tryResolve("./renamed", path.dirname(card))).toBe(file("apps/demo/ui/renamed.ts"));
81
+ await rm(file("apps/demo/ui/Moved.tsx"));
82
+ await Bun.write(file("apps/demo/ui/Moved/index.tsx"), "export {};\n");
83
+ await rm(file("apps/demo/ui/renamed.ts"));
84
+ await Bun.write(file("apps/demo/ui/renamed.tsx"), "export {};\n");
85
+
86
+ expect(resolver().resolve(card, "./Moved")).toBe(file("apps/demo/ui/Moved/index.tsx"));
87
+ expect(resolver().resolve(card, "./renamed")).toBe(file("apps/demo/ui/renamed.tsx"));
88
+ expect(() => resolver().resolve(card, "./Missing")).toThrow('cannot resolve "./Missing"');
89
+ });
90
+
91
+ test("a recorded target moved away since is reused by no sibling, nor by its importer, nor kept in the graph", () => {
92
+ const stale = new CsrDevResolver({
93
+ paths,
94
+ context,
95
+ entryFiles: [],
96
+ resolution: {
97
+ "node_modules/pkg/a.js": { dep: "node_modules/dep/old.js" },
98
+ "node_modules/pkg/b.js": { dep: "node_modules/dep/old.js" },
99
+ },
100
+ });
101
+ expect(stale.resolve(file("node_modules/pkg/b.js"), "dep")).toBeNull();
102
+ expect(stale.serialize()).toEqual({ "node_modules/pkg/a.js": {}, "node_modules/pkg/b.js": {} });
103
+ });
104
+
105
+ test("a new package in the user's code asks for the resolution build, and a typo fails like a missing file", () => {
106
+ expect(resolver().resolve(file("apps/demo/ui/Card.tsx"), "fresh")).toBeNull();
107
+ expect(() => resolver().resolve(file("apps/demo/ui/Card.tsx"), "frseh")).toThrow('cannot resolve "frseh"');
108
+ expect(resolver().resolve(file("node_modules/pkg/b.js"), "frseh")).toBeNull();
109
+ });
110
+ });
111
+
112
+ describe("CsrDevPaths.resolveOnDisk", () => {
113
+ let root: string;
114
+ const at = (relative: string) => path.join(root, relative);
115
+
116
+ beforeAll(async () => {
117
+ root = CsrDevPaths.realpath(await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-disk-")));
118
+ for (const relative of [
119
+ "pair/x.js",
120
+ "pair/x.mjs",
121
+ "fields/b.js",
122
+ "fields/m.js",
123
+ "fields/main.js",
124
+ "fields/index.js",
125
+ "dirmain/lib/index.js",
126
+ "dirmain/index.js",
127
+ "shadow/Foo/index.tsx",
128
+ "shadow/Page.tsx",
129
+ ])
130
+ await Bun.write(at(relative), "export {};\n");
131
+ await Bun.write(at("fields/package.json"), '{ "browser": "b.js", "module": "m.js", "main": "main.js" }');
132
+ await Bun.write(at("dirmain/package.json"), '{ "main": "lib" }');
133
+ });
134
+
135
+ afterAll(async () => {
136
+ await rm(root, { recursive: true, force: true });
137
+ });
138
+
139
+ test("weighs extensions as Bun's browser build does, a require call apart from an import", () => {
140
+ expect(CsrDevPaths.resolveOnDisk(at("pair/x"))).toBe(at("pair/x.mjs"));
141
+ expect(CsrDevPaths.resolveOnDisk(at("pair/x"), "require")).toBe(at("pair/x.js"));
142
+ });
143
+
144
+ test("enters a folder through browser, then module, then main (which may name a folder), then index", () => {
145
+ expect(CsrDevPaths.resolveOnDisk(at("fields"))).toBe(at("fields/b.js"));
146
+ expect(CsrDevPaths.resolveOnDisk(at("dirmain"))).toBe(at("dirmain/lib/index.js"));
147
+ });
148
+
149
+ test("a recorded resolution is overturned only by a file created beside the folder it went into", async () => {
150
+ const paths = new CsrDevPaths(root);
151
+ const resolver = new CsrDevResolver({
152
+ paths,
153
+ context,
154
+ entryFiles: [],
155
+ resolution: {
156
+ "shadow/Page.tsx": { "./Foo": "shadow/Foo/index.tsx" },
157
+ "pair/entry.ts": { "./x": "pair/x.js" },
158
+ },
159
+ });
160
+ expect(resolver.resolve(at("pair/entry.ts"), "./x")).toBe(at("pair/x.js"));
161
+ expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo/index.tsx"));
162
+ await Bun.write(at("shadow/Foo.tsx"), "export {};\n");
163
+ expect(resolver.resolve(at("shadow/Page.tsx"), "./Foo")).toBe(at("shadow/Foo.tsx"));
164
+ });
165
+ });