@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
@@ -1,66 +1,34 @@
1
- import fs from "node:fs";
2
1
  import { mkdir } from "node:fs/promises";
3
2
  import path from "node:path";
4
- import { CSR_DEV_DIRNAME, CSR_DEV_MANIFEST_FILE, type CsrDevManifest } from "akanjs/server/hmr/csrDevManifest";
5
- import { isAkanRuntimeMetadataFile } from "akanjs/server/hmr/runtimeMetadataFile";
3
+ import { CSR_DEV_DIRNAME, CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
6
4
  import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
7
5
  import type { App } from "../commandDecorators";
6
+ import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
8
7
  import { bundleDefine } from "./bundleDefine";
9
- import { CsrArtifactBuilder } from "./csrArtifactBuilder";
10
- import { CsrDevArtifactWriter } from "./csrDevArtifactWriter";
11
- import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
12
8
  import { CsrDevPaths } from "./csrDevPaths";
13
- import { CsrDevResolver } from "./csrDevResolver";
14
- import type { CsrDevCompiledModule, CsrDevContext, CsrDevGraph } from "./csrDevTypes";
9
+ import type { CsrDevContext } from "./csrDevTypes";
10
+ import { CsrEntryFiles } from "./csrEntryFiles";
11
+ import { DevRegistryBundler } from "./devRegistryBundler";
15
12
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
16
13
 
17
- export interface CsrDevUpdate {
18
- generation: number;
19
- reload: boolean;
20
- reason?: string;
21
- patchUrl?: string;
22
- changedIds: string[];
23
- moduleCount: number;
24
- }
25
-
26
- //* Dev-only CSR as a module registry. The build worker is a fresh process per save, so the graph, every module's
27
- //* factory and the resolutions live on disk under `.akan/artifact/csr-dev`.
28
- export class CsrDevBundler {
14
+ //* Dev-only CSR as a module registry, under `.akan/artifact/csr-dev`: one entry per basePath boots every page.
15
+ export class CsrDevBundler extends DevRegistryBundler {
29
16
  static readonly #formatVersion = 3;
30
- readonly #app: App;
31
- readonly #paths: CsrDevPaths;
32
- readonly #outDir: string;
33
- readonly #entryDir: string;
34
- readonly #writer: CsrDevArtifactWriter;
17
+ readonly reloadsOnEntryChange = true;
18
+ //? Detecting a page's async default parses it with TypeScript: on apps/akan every page each save cost ~100ms.
19
+ readonly #asyncDefaults = new Map<string, { mtimeMs: number; detected: Promise<boolean> }>();
35
20
 
36
21
  constructor(app: App) {
37
- this.#app = app;
38
- this.#paths = new CsrDevPaths(app.workspace.workspaceRoot);
39
- this.#outDir = path.join(app.cwdPath, ".akan/artifact", CSR_DEV_DIRNAME);
40
- this.#entryDir = path.join(app.cwdPath, ".akan/generated", CSR_DEV_DIRNAME);
41
- this.#writer = new CsrDevArtifactWriter(this.#outDir);
22
+ super(app, { dirName: CSR_DEV_DIRNAME, routePrefix: CSR_DEV_ROUTE_PREFIX, library: false });
42
23
  }
43
24
 
44
- async update(changedFiles: string[] = []): Promise<CsrDevUpdate | null> {
45
- const context = await this.#context();
46
- if (context.pageEntries.length === 0) return null;
47
- const manifest = await this.#writer.readJson<CsrDevManifest>(CSR_DEV_MANIFEST_FILE);
48
- const graph = await this.#writer.readJson<CsrDevGraph>("graph.json");
49
- const generation = (manifest?.generation ?? 0) + 1;
50
- if (!manifest || !graph || graph.version !== 1 || graph.configKey !== context.configKey)
51
- return await this.#fullBuild(context, generation, manifest ? "the dev bundle config changed" : "first build");
52
- const metadataFile = changedFiles.find(isAkanRuntimeMetadataFile);
53
- // `lib/useClient.ts` inlines signal and dictionary metadata through a macro at build time.
54
- if (metadataFile) return await this.#fullBuild(context, generation, `${path.basename(metadataFile)} changed`);
55
- return await this.#incrementalBuild(context, graph, manifest, changedFiles, generation);
56
- }
57
-
58
- async #context(): Promise<CsrDevContext> {
59
- const akanConfig = await this.#app.getConfig();
60
- const pageEntries = await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys());
25
+ async context(): Promise<CsrDevContext | null> {
26
+ const akanConfig = await this.app.getConfig();
27
+ const pageEntries = await resolveSsrPageEntriesForApp(this.app, await this.app.getPageKeys());
28
+ if (pageEntries.length === 0) return null;
61
29
  const basePaths = [...akanConfig.basePaths];
62
30
  const htmlBasePaths = basePaths.length > 0 ? basePaths : [""];
63
- const define = bundleDefine(this.#app, "start", "csr");
31
+ const define = bundleDefine(this.app, "start", "csr");
64
32
  const optimizeImports = [...akanConfig.optimizeImports];
65
33
  const configKey = Bun.hash(
66
34
  JSON.stringify([CsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, htmlBasePaths]),
@@ -76,152 +44,21 @@ export class CsrDevBundler {
76
44
  };
77
45
  }
78
46
 
79
- #resolveRefreshRuntime(): string {
80
- const akanWebkit = CsrDevPaths.tryResolve("akanjs/webkit", this.#app.cwdPath);
81
- const bases = [
82
- this.#app.cwdPath,
83
- this.#paths.root,
84
- ...(akanWebkit ? [path.dirname(path.dirname(akanWebkit))] : []),
85
- ];
86
- for (const base of bases) {
87
- const resolved = CsrDevPaths.tryResolve("react-refresh/runtime", base);
88
- if (resolved) return CsrDevPaths.realpath(resolved);
89
- }
90
- throw new Error(`[csr-dev] react-refresh/runtime is not resolvable from ${bases.join(", ")}`);
91
- }
92
-
93
- async #fullBuild(context: CsrDevContext, generation: number, reason: string): Promise<CsrDevUpdate> {
94
- await this.#writer.reset();
95
- const entries = await this.#writeEntries(context);
96
- const entryFiles = Object.values(entries.files);
97
- const resolver = new CsrDevResolver({ paths: this.#paths, context, entryFiles });
98
- await resolver.prepass();
99
- const compiled = await this.#compiler(context, resolver).compile([...entryFiles, context.refreshFile], new Set());
100
- const graph: CsrDevGraph = {
101
- version: 1,
102
- configKey: context.configKey,
103
- refresh: this.#paths.idOf(context.refreshFile),
104
- entries: this.#entryIds(entries.files),
105
- modules: {},
106
- resolution: {},
107
- pending: [],
108
- };
109
- this.#merge(graph, resolver, compiled);
110
- await this.#writer.writeModules(compiled);
111
- const vendorFile = await this.#writer.writeVendor(graph);
112
- await this.#writer.writeApp(graph, generation);
113
- await this.#writer.writeState(graph, { version: 1, generation, vendorFile, entries: graph.entries });
114
- await this.#writer.prune(vendorFile, generation);
115
- return { generation, reload: true, reason, changedIds: [], moduleCount: Object.keys(graph.modules).length };
116
- }
117
-
118
- async #incrementalBuild(
119
- context: CsrDevContext,
120
- graph: CsrDevGraph,
121
- manifest: CsrDevManifest,
122
- changedFiles: string[],
123
- generation: number,
124
- ): Promise<CsrDevUpdate | null> {
125
- const entries = await this.#writeEntries(context);
126
- const entryIds = this.#entryIds(entries.files);
127
- await this.#forgetDeletedModules(graph);
128
- const changed = await this.#changedModules(graph, changedFiles);
129
- for (const file of entries.changed) changed.add(this.#paths.idOf(file));
130
- const routesMoved = entries.changed.length > 0 || JSON.stringify(entryIds) !== JSON.stringify(graph.entries);
131
- if (changed.size === 0) return null;
132
-
133
- const resolver = new CsrDevResolver({
134
- paths: this.#paths,
135
- context,
136
- entryFiles: Object.values(entries.files),
137
- resolution: graph.resolution,
138
- });
139
- const known = new Set(Object.keys(graph.modules).map((id) => this.#paths.fileOf(id)));
140
- let compiled: CsrDevCompiledModule[];
141
- try {
142
- compiled = await this.#compiler(context, resolver).compile(
143
- [...changed].map((id) => this.#paths.fileOf(id)),
144
- known,
145
- );
146
- } catch (error) {
147
- graph.pending = [...changed];
148
- await this.#writer.writeJson("graph.json", graph);
149
- throw error;
150
- }
151
- const vendorJoined = compiled.some((module) => module.vendor && !graph.modules[module.id]);
152
- this.#merge(graph, resolver, compiled);
153
- graph.entries = entryIds;
154
- await this.#writer.writeModules(compiled);
155
- const vendorFile = vendorJoined ? await this.#writer.writeVendor(graph) : manifest.vendorFile;
156
- await this.#writer.writeApp(graph, generation);
157
- const changedIds = compiled.filter((module) => !module.vendor).map((module) => module.id);
158
- const constantId = changedIds.find((id) => id.endsWith(".constant.ts"));
159
- // A model class swapped under live store state would mix old and new instances; reload instead.
160
- const reason = vendorJoined
161
- ? "an npm module joined the graph"
162
- : routesMoved
163
- ? "the route table changed"
164
- : constantId
165
- ? `${path.basename(constantId)} changed`
166
- : undefined;
167
- const patchUrl = reason
168
- ? undefined
169
- : await this.#writer.writePatch(
170
- generation,
171
- compiled.filter((module) => !module.vendor),
172
- );
173
- await this.#writer.writeState(graph, { ...manifest, generation, vendorFile, entries: entryIds });
174
- await this.#writer.prune(vendorFile, generation);
175
- return {
176
- generation,
177
- reload: !!reason,
178
- reason,
179
- patchUrl,
180
- changedIds,
181
- moduleCount: Object.keys(graph.modules).length,
182
- };
183
- }
184
-
185
- async #changedModules(graph: CsrDevGraph, changedFiles: string[]): Promise<Set<string>> {
186
- const changed = new Set<string>(graph.pending.filter((id) => graph.modules[id]));
187
- for (const file of changedFiles) {
188
- const id = this.#paths.idOf(file);
189
- if (graph.modules[id]) changed.add(id);
190
- }
191
- // Stat, not trust: a save made while CSR was unarmed, or a build that failed, never reached this graph. The hash
192
- // settles it because route discovery rewrites the generated layout wrappers, touching them on every call.
193
- for (const [id, module] of Object.entries(graph.modules)) {
194
- if (module.vendor || changed.has(id)) continue;
195
- const file = this.#paths.fileOf(id);
196
- if (CsrDevPaths.mtimeOf(file) === module.mtimeMs) continue;
197
- if ((await CsrDevPaths.hashOf(file)) !== module.hash) changed.add(id);
198
- }
199
- return changed;
200
- }
201
-
202
- // A deleted module leaves the graph instead of being rebuilt: rebuilding a missing file fails every save after it.
203
- // An importer that still names it fails on its own rebuild, with the resolution error that says why.
204
- async #forgetDeletedModules(graph: CsrDevGraph): Promise<void> {
205
- const deleted = Object.keys(graph.modules).filter(
206
- (id) => !graph.modules[id]?.vendor && !fs.existsSync(this.#paths.fileOf(id)),
207
- );
208
- for (const id of deleted) delete graph.modules[id];
209
- await this.#writer.forgetModules(deleted);
210
- }
211
-
212
- async #writeEntries(context: CsrDevContext): Promise<{ files: Record<string, string>; changed: string[] }> {
213
- await mkdir(this.#entryDir, { recursive: true });
47
+ async writeEntries(context: CsrDevContext): Promise<{ files: Record<string, string>; changed: string[] }> {
48
+ await mkdir(this.entryDir, { recursive: true });
214
49
  const files: Record<string, string> = {};
215
50
  const changed: string[] = [];
216
51
  for (const basePath of context.htmlBasePaths) {
217
- const file = path.join(this.#entryDir, CsrArtifactBuilder.entryFilename(basePath));
218
- const entryPages = CsrArtifactBuilder.pageEntriesForBasePath(context.pageEntries, basePath, context.basePaths);
219
- const generator = new PagesEntrySourceGenerator(entryPages);
220
- const pages = await generator.generateStatic({ fromDir: this.#entryDir });
52
+ const file = path.join(this.entryDir, CsrEntryFiles.entryFilename(basePath));
53
+ const entryPages = CsrEntryFiles.pageEntriesForBasePath(context.pageEntries, basePath, context.basePaths);
54
+ const generator = new PagesEntrySourceGenerator(entryPages, {
55
+ isAsyncDefault: (moduleAbsPath) => this.#isAsyncDefault(moduleAbsPath),
56
+ });
57
+ const pages = await generator.generateStatic({ fromDir: this.entryDir });
221
58
  const hot = await generator.generateHotReplace({
222
- fromDir: this.#entryDir,
223
- ownerId: this.#paths.idOf(file),
224
- moduleIds: entryPages.map(({ moduleAbsPath }) => this.#paths.idOf(moduleAbsPath)),
59
+ fromDir: this.entryDir,
60
+ ownerId: this.paths.idOf(file),
61
+ moduleIds: entryPages.map(({ moduleAbsPath }) => this.paths.idOf(moduleAbsPath)),
225
62
  });
226
63
  const source = `import * as akanWebkit from "akanjs/webkit";\n${pages}\nvoid akanWebkit.bootCsr(pages);\n${hot}`;
227
64
  const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
@@ -233,24 +70,26 @@ export class CsrDevBundler {
233
70
  return { files, changed };
234
71
  }
235
72
 
236
- #compiler(context: CsrDevContext, resolver: CsrDevResolver): CsrDevModuleCompiler {
237
- return new CsrDevModuleCompiler({ app: this.#app, paths: this.#paths, resolver, context, outDir: this.#outDir });
73
+ protected async rootFiles(entryFiles: string[]): Promise<string[]> {
74
+ return entryFiles;
238
75
  }
239
76
 
240
- #merge(graph: CsrDevGraph, resolver: CsrDevResolver, compiled: CsrDevCompiledModule[]): void {
241
- for (const module of compiled)
242
- graph.modules[module.id] = {
243
- vendor: module.vendor,
244
- mtimeMs: module.mtimeMs,
245
- hash: module.hash,
246
- deps: module.deps.map((dep) => (CsrDevPaths.isStub(dep) ? dep : this.#paths.idOf(dep))),
247
- ...(module.helpers ? { helpers: module.helpers.hash } : {}),
248
- };
249
- graph.pending = [];
250
- graph.resolution = resolver.serialize();
77
+ #isAsyncDefault(moduleAbsPath: string): Promise<boolean> {
78
+ const mtimeMs = CsrDevPaths.mtimeOf(moduleAbsPath);
79
+ const cached = this.#asyncDefaults.get(moduleAbsPath);
80
+ if (cached?.mtimeMs === mtimeMs) return cached.detected;
81
+ const detected = AsyncDefaultExportDetector.detect(moduleAbsPath);
82
+ this.#asyncDefaults.set(moduleAbsPath, { mtimeMs, detected });
83
+ return detected;
251
84
  }
252
85
 
253
- #entryIds(files: Record<string, string>): Record<string, string> {
254
- return Object.fromEntries(Object.entries(files).map(([basePath, file]) => [basePath, this.#paths.idOf(file)]));
86
+ #resolveRefreshRuntime(): string {
87
+ const akanWebkit = CsrDevPaths.tryResolve("akanjs/webkit", this.app.cwdPath);
88
+ const bases = [this.app.cwdPath, this.paths.root, ...(akanWebkit ? [path.dirname(path.dirname(akanWebkit))] : [])];
89
+ for (const base of bases) {
90
+ const resolved = CsrDevPaths.tryResolve("react-refresh/runtime", base);
91
+ if (resolved) return CsrDevPaths.realpath(resolved);
92
+ }
93
+ throw new Error(`[csr-dev] react-refresh/runtime is not resolvable from ${bases.join(", ")}`);
255
94
  }
256
95
  }
@@ -1,5 +1,4 @@
1
1
  import path from "node:path";
2
- import { CSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
3
2
  import type { BunPlugin } from "bun";
4
3
  import type { App } from "../commandDecorators";
5
4
  import { CsrDevPaths } from "./csrDevPaths";
@@ -23,6 +22,21 @@ export interface CsrDevModuleCompilerOptions {
23
22
  resolver: CsrDevResolver;
24
23
  context: CsrDevContext;
25
24
  outDir: string;
25
+ /** Where the dev server serves `outDir`: an imported asset's URL starts with it. */
26
+ routePrefix: string;
27
+ }
28
+
29
+ export interface CsrDevCompileOptions {
30
+ /** Stop before building an npm module the graph does not know yet, and name it instead. */
31
+ refuseNewVendor?: boolean;
32
+ /** Stop at an import no recorded resolution answers, instead of running the resolution build (a whole-app build). */
33
+ refusePrepass?: boolean;
34
+ }
35
+
36
+ export interface CsrDevCompileResult {
37
+ modules: CsrDevCompiledModule[];
38
+ refusedVendors: string[];
39
+ unresolved: string[];
26
40
  }
27
41
 
28
42
  //* Every module becomes its own CJS factory: one Bun.build with each import external, so a save re-runs only the
@@ -40,21 +54,37 @@ export class CsrDevModuleCompiler {
40
54
  readonly #resolver: CsrDevResolver;
41
55
  readonly #context: CsrDevContext;
42
56
  readonly #outDir: string;
57
+ readonly #routePrefix: string;
58
+ readonly #assets = new Set<string>();
43
59
 
44
- constructor({ app, paths, resolver, context, outDir }: CsrDevModuleCompilerOptions) {
60
+ constructor({ app, paths, resolver, context, outDir, routePrefix }: CsrDevModuleCompilerOptions) {
45
61
  this.#app = app;
46
62
  this.#paths = paths;
47
63
  this.#resolver = resolver;
48
64
  this.#context = context;
49
65
  this.#outDir = outDir;
66
+ this.#routePrefix = routePrefix;
67
+ }
68
+
69
+ /** Every asset file this compiler wrote into the registry directory. */
70
+ get assets(): string[] {
71
+ return [...this.#assets];
50
72
  }
51
73
 
52
- async compile(startFiles: string[], known: Set<string>): Promise<CsrDevCompiledModule[]> {
74
+ async compile(
75
+ startFiles: string[],
76
+ known: Set<string>,
77
+ { refuseNewVendor = false, refusePrepass = false }: CsrDevCompileOptions = {},
78
+ ): Promise<CsrDevCompileResult> {
53
79
  const compiled = new Map<string, CsrDevCompiledModule>();
54
80
  const seen = new Set([...known, ...startFiles]);
55
81
  let frontier = [...new Set(startFiles)];
56
82
  while (frontier.length > 0) {
83
+ const refusedVendors = refuseNewVendor ? frontier.filter((file) => CsrDevPaths.isVendorFile(file)) : [];
84
+ if (refusedVendors.length > 0) return { modules: [...compiled.values()], refusedVendors, unresolved: [] };
57
85
  const round = await this.#compileRound(frontier);
86
+ if (round.misses.length > 0 && !this.#resolver.prepassDone && refusePrepass)
87
+ return { modules: [...compiled.values()], refusedVendors: [], unresolved: round.misses };
58
88
  if (round.misses.length > 0 && !this.#resolver.prepassDone) {
59
89
  this.#app.verbose(`[csr-dev] ${round.misses.length} unresolved import(s); rerunning the resolution build`);
60
90
  await this.#resolver.prepass();
@@ -76,7 +106,7 @@ export class CsrDevModuleCompiler {
76
106
  this.#app.verbose(
77
107
  `[csr-dev] ${fallbacks.size} import(s) outside the browser build's graph resolved with Bun's runtime resolver: ${[...fallbacks].slice(0, 5).join(", ")}${fallbacks.size > 5 ? ", ..." : ""}`,
78
108
  );
79
- return [...compiled.values()];
109
+ return { modules: [...compiled.values()], refusedVendors: [], unresolved: [] };
80
110
  }
81
111
 
82
112
  async #compileRound(files: string[]): Promise<Omit<BuildRound, "tangled">> {
@@ -100,9 +130,12 @@ export class CsrDevModuleCompiler {
100
130
  }
101
131
 
102
132
  async #bunBuild(files: string[], vendor: boolean): Promise<BuildRound> {
103
- const mtimes = new Map(files.map((file) => [file, CsrDevPaths.mtimeOf(file)]));
133
+ //? Keyed as the output is: by the disk's spelling, which a case-only rename leaves apart from the requested path.
134
+ const mtimes = new Map(files.map((file) => [CsrDevPaths.realpath(file), CsrDevPaths.mtimeOf(file)]));
104
135
  const hashes = new Map(
105
- await Promise.all(files.map(async (file) => [file, vendor ? "" : await CsrDevPaths.hashOf(file)] as const)),
136
+ await Promise.all(
137
+ files.map(async (file) => [CsrDevPaths.realpath(file), vendor ? "" : await CsrDevPaths.hashOf(file)] as const),
138
+ ),
106
139
  );
107
140
  const misses: string[] = [];
108
141
  const result = await Bun.build({
@@ -113,7 +146,7 @@ export class CsrDevModuleCompiler {
113
146
  reactFastRefresh: !vendor,
114
147
  sourcemap: vendor ? "none" : "external",
115
148
  naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
116
- publicPath: CSR_DEV_ROUTE_PREFIX,
149
+ publicPath: this.#routePrefix,
117
150
  metafile: true,
118
151
  env: "AKAN_PUBLIC_*",
119
152
  define: this.#context.define,
@@ -128,7 +161,9 @@ export class CsrDevModuleCompiler {
128
161
  const tangled: string[] = [];
129
162
  for (const artifact of result.outputs) {
130
163
  if (artifact.kind === "asset") {
131
- await Bun.write(path.join(this.#outDir, artifact.path), artifact);
164
+ const assetPath = path.join(this.#outDir, artifact.path);
165
+ await Bun.write(assetPath, artifact);
166
+ this.#assets.add(assetPath);
132
167
  continue;
133
168
  }
134
169
  if (artifact.kind !== "entry-point") continue;
@@ -167,13 +202,20 @@ export class CsrDevModuleCompiler {
167
202
  if (CsrDevPaths.isVendorFile(args.path)) return undefined;
168
203
  const normalized = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(
169
204
  await Bun.file(args.path).text(),
205
+ { path: args.path },
170
206
  );
171
207
  return normalized ? { contents: normalized, loader: "tsx" } : undefined;
172
208
  });
173
209
  build.onResolve({ filter: /.*/ }, (args) => {
174
210
  if (args.kind === "entry-point-build" || !args.importer) return undefined;
211
+ if (this.#context.externals?.includes(args.path))
212
+ return { path: `${CsrDevPaths.vendorPrefix}${args.path}`, external: true };
175
213
  const importer = CsrDevPaths.realpath(args.importer);
176
- const target = this.#resolver.resolve(importer, args.path);
214
+ const target = this.#resolver.resolve(
215
+ importer,
216
+ args.path,
217
+ args.kind === "require-call" ? "require" : "import",
218
+ );
177
219
  if (target === CsrDevResolver.inline) return undefined;
178
220
  if (target === null) {
179
221
  misses.push(`${args.path} from ${this.#paths.idOf(importer)}`);