@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
@@ -162,6 +162,7 @@ describe("route client store bootstrap", () => {
162
162
  knownEntries,
163
163
  discovery,
164
164
  artifact: {} as never,
165
+ browser: "chunks",
165
166
  }).build();
166
167
 
167
168
  const first = await build([pageA], new Set());
@@ -234,3 +235,63 @@ describe("route client store bootstrap", () => {
234
235
  });
235
236
  });
236
237
  });
238
+
239
+ describe("fast refresh named default function", () => {
240
+ test("hoists a named default function into a declaration plus a trailing default export", () => {
241
+ const source = `export default async function Page<T>(props: T) {\n return null;\n}\n`;
242
+ const next = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(source, { path: "/app/page.tsx" });
243
+ expect(next).toBe(`async function Page<T>(props: T) {\n return null;\n}\n\nexport default Page;\n`);
244
+ });
245
+
246
+ test("leaves code quoted in a template literal or a comment alone", () => {
247
+ const source = [
248
+ `import { page } from "akanjs/client";`,
249
+ `const snippet = \``,
250
+ `export default function Page() {`,
251
+ ` return <div />;`,
252
+ `}\`;`,
253
+ `/*`,
254
+ `export default function Commented() {}`,
255
+ `*/`,
256
+ `export default page().render(() => <pre>{snippet}</pre>);`,
257
+ "",
258
+ ].join("\n");
259
+ expect(
260
+ RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(source, { path: "/app/page.tsx" }),
261
+ ).toBeNull();
262
+ });
263
+
264
+ test("rewrites only the real declaration when the same name is also quoted", () => {
265
+ const source = [
266
+ "const snippet = `",
267
+ "export default function Page() {}",
268
+ "`;",
269
+ "export default function Page() {",
270
+ " return snippet;",
271
+ "}",
272
+ "",
273
+ ].join("\n");
274
+ const next = RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh(source, { path: "/app/page.tsx" });
275
+ expect(next).toBe(
276
+ [
277
+ "const snippet = `",
278
+ "export default function Page() {}",
279
+ "`;",
280
+ "function Page() {",
281
+ " return snippet;",
282
+ "}",
283
+ "",
284
+ "export default Page;",
285
+ "",
286
+ ].join("\n"),
287
+ );
288
+ });
289
+
290
+ test("leaves a file it cannot parse to the bundler", () => {
291
+ expect(
292
+ RouteClientBuilder.normalizeNamedDefaultFunctionForFastRefresh("export default function Page( {", {
293
+ path: "/app/page.tsx",
294
+ }),
295
+ ).toBeNull();
296
+ });
297
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { App } from "../commandDecorators";
3
+ import { bundleDefine } from "./bundleDefine";
4
+
5
+ describe("bundleDefine", () => {
6
+ test("spells the public env in one order whatever order the process holds it in", () => {
7
+ const appWith = (env: Record<string, string>) => ({ getPublicEnv: () => env }) as unknown as App;
8
+ const builder = bundleDefine(appWith({ AKAN_PUBLIC_Z: "1", AKAN_PUBLIC_A: "2" }), "start", "ssr");
9
+ const worker = bundleDefine(appWith({ AKAN_PUBLIC_A: "2", AKAN_PUBLIC_Z: "1" }), "start", "ssr");
10
+ expect(JSON.stringify(builder)).toBe(JSON.stringify(worker));
11
+ });
12
+ });
@@ -5,8 +5,12 @@ export const bundleDefine = (app: App, command: "build" | "start", renderEnv: "s
5
5
  return {
6
6
  "process.env.NODE_ENV": JSON.stringify(nodeEnv),
7
7
  "process.env.AKAN_PUBLIC_RENDER_ENV": JSON.stringify(renderEnv),
8
+ //? Sorted: Windows hands a child its environment sorted, while keys the config sets at runtime trail the parent's,
9
+ //? and the dev registries hash this object to tell a worker's build from the builder's.
8
10
  ...Object.fromEntries(
9
- Object.entries(app.getPublicEnv()).map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
11
+ Object.entries(app.getPublicEnv())
12
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
13
+ .map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
10
14
  ),
11
15
  };
12
16
  };
@@ -0,0 +1,37 @@
1
+ import { afterAll, 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 { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
6
+
7
+ const dirs: string[] = [];
8
+
9
+ afterAll(async () => {
10
+ await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
11
+ });
12
+
13
+ describe("GraphClientEntryDiscovery", () => {
14
+ test("a walk an invalidation overtook answers its caller but leaves nothing stale for the next one", async () => {
15
+ for (const yields of [0, 5, 10, 20, 40, 80]) {
16
+ const dir = await mkdtemp(path.join(os.tmpdir(), "akan-discovery-"));
17
+ dirs.push(dir);
18
+ const file = (name: string) => path.join(dir, name);
19
+ await Bun.write(file("S.tsx"), 'import "./A";\n');
20
+ await Bun.write(file("A.tsx"), 'import "./B";\nimport "./D";\n');
21
+ await Bun.write(file("B.tsx"), "export const b = 1;\n");
22
+ await Bun.write(file("C.tsx"), '"use client";\nexport const C = () => null;\n');
23
+ await Bun.write(file("D.tsx"), "export const d = 1;\n");
24
+ const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
25
+ expect(await discovery.discover([file("S.tsx")])).toEqual([]);
26
+
27
+ await Bun.write(file("B.tsx"), 'import "./C";\nexport const b = 1;\n');
28
+ await Bun.write(file("D.tsx"), "export const d = 2;\n");
29
+ discovery.invalidate([file("D.tsx")]);
30
+ const walk = discovery.discover([file("S.tsx")]);
31
+ for (let tick = 0; tick < yields; tick++) await Promise.resolve();
32
+ discovery.invalidate([file("B.tsx")]);
33
+ await walk;
34
+ expect(await discovery.discover([file("S.tsx")])).toEqual([file("C.tsx")]);
35
+ }
36
+ });
37
+ });
@@ -34,6 +34,9 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
34
34
  #missingFiles = new Set<string>();
35
35
  #unresolvedPaths = new Set<string>();
36
36
  #unresolvedSpecifiers = new Set<string>();
37
+ //? Bumped by every invalidation: a walk that began before one computed from facts it may have read stale, so it
38
+ //? answers its caller but caches nothing (a registry check walks this instance beside the slow lane's route builds).
39
+ #epoch = 0;
37
40
 
38
41
  constructor(akanConfig: Pick<AkanConfig, "barrelImports">, resolvePackage: PackageResolver) {
39
42
  this.#akanConfig = akanConfig;
@@ -61,6 +64,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
61
64
  this.#reachableEntriesCache.delete(absPath);
62
65
  }
63
66
  if (files.length === 0) return;
67
+ this.#epoch += 1;
64
68
  // Reachable-entry results are transitive, so a changed child invalidates every ancestor.
65
69
  this.#reachableEntriesCache.clear();
66
70
  this.#forgetMissing();
@@ -165,6 +169,7 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
165
169
  }
166
170
 
167
171
  async #discoverFromFile(file: string, visiting: Set<string>): Promise<Set<string>> {
172
+ const epoch = this.#epoch;
168
173
  const absPath = path.resolve(file);
169
174
  const cached = this.#reachableEntriesCache.get(absPath);
170
175
  if (cached) return new Set(cached);
@@ -173,11 +178,11 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
173
178
  visiting.add(absPath);
174
179
  const entries = new Set<string>();
175
180
  const facts = await this.#facts(absPath);
176
- if (!facts) return this.#finishDiscovery(absPath, visiting, entries);
181
+ if (!facts) return this.#finishDiscovery(absPath, visiting, entries, epoch);
177
182
 
178
183
  if (facts.isClientEntry) {
179
184
  entries.add(absPath);
180
- return this.#finishDiscovery(absPath, visiting, entries);
185
+ return this.#finishDiscovery(absPath, visiting, entries, epoch);
181
186
  }
182
187
 
183
188
  const importerDir = path.dirname(absPath);
@@ -191,12 +196,12 @@ export class GraphClientEntryDiscovery implements ClientEntryDiscovery {
191
196
  for (const entry of await this.#discoverFromFile(resolved, visiting)) entries.add(entry);
192
197
  }
193
198
 
194
- return this.#finishDiscovery(absPath, visiting, entries);
199
+ return this.#finishDiscovery(absPath, visiting, entries, epoch);
195
200
  }
196
201
 
197
- #finishDiscovery(absPath: string, visiting: Set<string>, entries: Set<string>): Set<string> {
202
+ #finishDiscovery(absPath: string, visiting: Set<string>, entries: Set<string>, epoch: number): Set<string> {
198
203
  visiting.delete(absPath);
199
- this.#reachableEntriesCache.set(absPath, entries);
204
+ if (epoch === this.#epoch) this.#reachableEntriesCache.set(absPath, entries);
200
205
  return new Set(entries);
201
206
  }
202
207
  }
@@ -4,7 +4,7 @@ import type { BaseBuildArtifact } from "akanjs/server";
4
4
  import { type PageEntry, resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { bundleDefine } from "./bundleDefine";
7
- import { getPageKeyBasePath } from "./cssCompiler";
7
+ import { CsrEntryFiles } from "./csrEntryFiles";
8
8
  import { PagesBundleBuilder } from "./pagesBundleBuilder";
9
9
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
10
10
 
@@ -80,10 +80,7 @@ export class CsrArtifactBuilder {
80
80
 
81
81
  /** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
82
82
  static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
83
- return pageEntries.filter((entry) => {
84
- const entryBasePath = getPageKeyBasePath(entry.key, basePaths);
85
- return entryBasePath === null || entryBasePath === basePath;
86
- });
83
+ return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
87
84
  }
88
85
 
89
86
  static htmlFilename(basePath: string): string {
@@ -91,7 +88,7 @@ export class CsrArtifactBuilder {
91
88
  }
92
89
 
93
90
  static entryFilename(basePath: string): string {
94
- return `${basePath || "index"}.csr.tsx`;
91
+ return CsrEntryFiles.entryFilename(basePath);
95
92
  }
96
93
 
97
94
  static basePathOfHtml(htmlPath: string): string {
@@ -0,0 +1,143 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ CSR_DEV_MANIFEST_FILE,
8
+ CSR_DEV_ROUTE_PREFIX,
9
+ type CsrDevManifest,
10
+ csrDevModuleFile,
11
+ } from "akanjs/server/hmr/csrDevManifest";
12
+ import { CsrDevArtifactWriter, type CsrDevArtifactWriterOptions } from "./csrDevArtifactWriter";
13
+ import type { CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
14
+
15
+ const dirs: string[] = [];
16
+ const module: CsrDevCompiledModule = {
17
+ id: "a.ts",
18
+ file: "/a.ts",
19
+ vendor: false,
20
+ factory: "function () {}",
21
+ helpers: null,
22
+ deps: [],
23
+ mtimeMs: 0,
24
+ hash: "",
25
+ };
26
+ const readModule = (dir: string) => readFileSync(path.join(dir, csrDevModuleFile("a.ts", ".js")), "utf8");
27
+ const makeWriter = async (
28
+ options: CsrDevArtifactWriterOptions = { routePrefix: CSR_DEV_ROUTE_PREFIX },
29
+ entries: Record<string, string> = {},
30
+ ) => {
31
+ const dir = await mkdtemp(path.join(os.tmpdir(), "akan-csr-dev-writer-"));
32
+ dirs.push(dir);
33
+ const writer = new CsrDevArtifactWriter(dir, options);
34
+ const graph: CsrDevGraph = {
35
+ version: 1,
36
+ configKey: "key",
37
+ refresh: "refresh.js",
38
+ entries,
39
+ modules: { "a.ts": { vendor: false, mtimeMs: 0, hash: "", deps: [] } },
40
+ resolution: {},
41
+ pending: [],
42
+ };
43
+ await writer.writeModules([module]);
44
+ const read = () => ({
45
+ manifest: JSON.parse(readFileSync(path.join(dir, CSR_DEV_MANIFEST_FILE), "utf8")) as CsrDevManifest,
46
+ app: readFileSync(path.join(dir, "app.js"), "utf8"),
47
+ });
48
+ return { writer, graph, read, dir };
49
+ };
50
+
51
+ afterAll(async () => {
52
+ await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
53
+ });
54
+
55
+ describe("CsrDevArtifactWriter", () => {
56
+ test("announces a patch before the module files, graph.json and app.js catch up", async () => {
57
+ const { writer, graph, read, dir } = await makeWriter();
58
+ await writer.writeApp(graph, { generation: 4, vendorFile: "v.js" });
59
+ const edited = { ...module, factory: "function () { edited(); }" };
60
+ let atAnnounce: (ReturnType<typeof read> & { graph: boolean; module: string }) | null = null;
61
+ await writer.commitPatch(
62
+ graph,
63
+ { version: 1, generation: 5, appGeneration: 4, vendorFile: "v.js", entries: {} },
64
+ () => {
65
+ atAnnounce = { ...read(), graph: existsSync(path.join(dir, "graph.json")), module: readModule(dir) };
66
+ },
67
+ { modules: [edited] },
68
+ );
69
+ expect(atAnnounce).toMatchObject({ manifest: { generation: 5, appGeneration: 4 }, graph: false });
70
+ expect((atAnnounce as { app: string } | null)?.app).toContain('"generation":4');
71
+ expect((atAnnounce as { module: string } | null)?.module).not.toContain("edited");
72
+ const after = read();
73
+ expect(after.manifest).toMatchObject({ generation: 5, appGeneration: 5 });
74
+ expect(after.app).toContain('"generation":5');
75
+ expect(after.app).toContain("edited()");
76
+ expect(existsSync(path.join(dir, "graph.json"))).toBe(true);
77
+ });
78
+
79
+ test("rewrites an app.js a crash left behind the manifest's generation", async () => {
80
+ const { writer, graph, read } = await makeWriter();
81
+ await writer.writeApp(graph, { generation: 5, vendorFile: "v.js" });
82
+ const behind: CsrDevManifest = { version: 1, generation: 6, appGeneration: 5, vendorFile: "v.js", entries: {} };
83
+ await writer.writeJson(CSR_DEV_MANIFEST_FILE, behind);
84
+ const healed = await writer.healApp(graph, behind);
85
+ expect(healed.appGeneration).toBe(6);
86
+ expect(read().manifest.appGeneration).toBe(6);
87
+ expect(read().app).toContain('"generation":6');
88
+ });
89
+
90
+ test("leaves an app.js that already holds the manifest's generation alone", async () => {
91
+ const { writer, graph } = await makeWriter();
92
+ const current: CsrDevManifest = { version: 1, generation: 3, vendorFile: "v.js", entries: {} };
93
+ expect(await writer.healApp(graph, current)).toBe(current);
94
+ });
95
+
96
+ test("a library app.js starts its bootstrap module, and a patch is announced under the writer's route", async () => {
97
+ const { writer, graph, dir } = await makeWriter(
98
+ { routePrefix: "/_akan/ssr-dev/", library: true },
99
+ { "": "boot.ts" },
100
+ );
101
+ await writer.writeApp(graph, { generation: 2, vendorFile: "v.js", epoch: 7 });
102
+ expect(readFileSync(path.join(dir, "app.js"), "utf8")).toContain(
103
+ '__akan.startLibrary({"generation":2,"refresh":"refresh.js","bootstrap":"boot.ts","vendorFile":"v.js","epoch":7});',
104
+ );
105
+ expect(await writer.writePatch(3, [module])).toBe("/_akan/ssr-dev/patch-3.js");
106
+ });
107
+
108
+ test("after a whole build, clears what the registry before it left and keeps what the build wrote", async () => {
109
+ const { writer, graph, dir } = await makeWriter();
110
+ const gone = { ...module, id: "gone.ts" };
111
+ await writer.writeModules([gone]);
112
+ await writer.writePatch(7, [module]);
113
+ await Bun.write(path.join(dir, "assets/old-abc.png"), "old");
114
+ await Bun.write(path.join(dir, ".patching"), "123");
115
+ await Bun.write(path.join(dir, "manifest.json.4242.tmp"), "{");
116
+ await Bun.write(path.join(dir, "assets/new-def.png"), "new");
117
+ await writer.pruneAfterFullBuild(graph, "vendor-a.js", {
118
+ generation: 8,
119
+ assets: [path.join(dir, "assets/new-def.png")],
120
+ });
121
+ expect(existsSync(path.join(dir, csrDevModuleFile("a.ts", ".js")))).toBe(true);
122
+ expect(existsSync(path.join(dir, csrDevModuleFile("gone.ts", ".js")))).toBe(false);
123
+ expect(existsSync(path.join(dir, "patch-7.js"))).toBe(false);
124
+ expect(existsSync(path.join(dir, "patch-7.js.layout.json"))).toBe(false);
125
+ expect(existsSync(path.join(dir, "assets/old-abc.png"))).toBe(false);
126
+ expect(existsSync(path.join(dir, "assets/new-def.png"))).toBe(true);
127
+ expect(existsSync(path.join(dir, ".patching"))).toBe(false);
128
+ expect(existsSync(path.join(dir, "manifest.json.4242.tmp"))).toBe(false);
129
+ });
130
+
131
+ test("a reload drops a patch file left under its generation, and a whole build is marked while it writes", async () => {
132
+ const { writer, dir } = await makeWriter();
133
+ await writer.writePatch(6, [module]);
134
+ await writer.forgetPatch(6);
135
+ expect(existsSync(path.join(dir, "patch-6.js"))).toBe(false);
136
+ expect(existsSync(path.join(dir, "patch-6.js.layout.json"))).toBe(false);
137
+ expect(await writer.isBuilding()).toBe(false);
138
+ await writer.markBuilding();
139
+ expect(await writer.isBuilding()).toBe(true);
140
+ await writer.clearBuilding();
141
+ expect(await writer.isBuilding()).toBe(false);
142
+ });
143
+ });
@@ -1,28 +1,62 @@
1
- import { mkdir, readdir, rename, rm } from "node:fs/promises";
1
+ import fs from "node:fs";
2
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import {
5
+ appGenerationOf,
4
6
  CSR_DEV_APP_FILE,
7
+ CSR_DEV_KEPT_PATCHES,
5
8
  CSR_DEV_MANIFEST_FILE,
6
- CSR_DEV_ROUTE_PREFIX,
9
+ CSR_DEV_PATCHING_MARKER,
7
10
  type CsrDevLayout,
8
11
  type CsrDevManifest,
9
12
  csrDevModuleFile,
10
13
  } from "akanjs/server/hmr/csrDevManifest";
14
+ import { FileSys } from "../fileSys";
11
15
  import { CsrDevPaths } from "./csrDevPaths";
12
- import type { CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
16
+ import type { CsrDevCode, CsrDevCompiledModule, CsrDevGraph } from "./csrDevTypes";
17
+
18
+ export interface CsrDevArtifactWriterOptions {
19
+ /** Where the dev server serves `outDir`; a patch is announced by its URL. */
20
+ routePrefix: string;
21
+ /** `app.js` starts no entry: an SSR page requires the modules its RSC payload names, one at a time. */
22
+ library?: boolean;
23
+ }
13
24
 
14
25
  export class CsrDevArtifactWriter {
15
- static readonly #keptPatches = 40;
16
26
  static readonly #keptVendors = 3;
27
+ static readonly #buildingMarker = ".building";
17
28
  readonly #outDir: string;
29
+ readonly #routePrefix: string;
30
+ readonly #library: boolean;
18
31
 
19
- constructor(outDir: string) {
32
+ constructor(outDir: string, { routePrefix, library = false }: CsrDevArtifactWriterOptions) {
20
33
  this.#outDir = outDir;
34
+ this.#routePrefix = routePrefix;
35
+ this.#library = library;
21
36
  }
22
37
 
23
- async reset(): Promise<void> {
24
- await rm(this.#outDir, { recursive: true, force: true });
25
- await mkdir(path.join(this.#outDir, "modules"), { recursive: true });
38
+ //? Set between a whole build's first write and its last: module files are named by id and overwritten in place, so a
39
+ //? build cut short there leaves new modules under the previous graph and app.js, which no patch may read.
40
+ async markBuilding(): Promise<void> {
41
+ await Bun.write(path.join(this.#outDir, CsrDevArtifactWriter.#buildingMarker), String(process.pid));
42
+ }
43
+
44
+ async clearBuilding(): Promise<void> {
45
+ await CsrDevArtifactWriter.#remove(path.join(this.#outDir, CsrDevArtifactWriter.#buildingMarker));
46
+ }
47
+
48
+ async isBuilding(): Promise<boolean> {
49
+ return await Bun.file(path.join(this.#outDir, CsrDevArtifactWriter.#buildingMarker)).exists();
50
+ }
51
+
52
+ //? A reload's generation has no patch: one an attempt cut short left under that number would be applied by a tab
53
+ //? catching up (catchUp reloads only when the file is missing).
54
+ async forgetPatch(generation: number): Promise<void> {
55
+ await Promise.all(
56
+ [`patch-${generation}.js`, `patch-${generation}.js.layout.json`].map((name) =>
57
+ CsrDevArtifactWriter.#remove(path.join(this.#outDir, name)),
58
+ ),
59
+ );
26
60
  }
27
61
 
28
62
  async readJson<T>(name: string): Promise<T | null> {
@@ -49,11 +83,26 @@ export class CsrDevArtifactWriter {
49
83
  ]);
50
84
  }
51
85
 
86
+ //? A module whose file is missing is left out rather than failing the read: the patcher compiles it again.
87
+ async readCode(graph: CsrDevGraph): Promise<CsrDevCode> {
88
+ const ids = Object.keys(graph.modules).filter((id) => !graph.modules[id]?.vendor && this.hasModule(id));
89
+ const hashes = [...new Set(ids.flatMap((id) => graph.modules[id]?.helpers ?? []))];
90
+ const [modules, helpers] = await Promise.all([
91
+ Promise.all(ids.map(async (id) => [id, await Bun.file(this.#modulePath(id, ".js")).text()] as const)),
92
+ Promise.all(hashes.map(async (hash) => [hash, await Bun.file(this.#helpersPath(hash)).text()] as const)),
93
+ ]);
94
+ return { modules: new Map(modules), helpers: new Map(helpers) };
95
+ }
96
+
97
+ hasModule(id: string): boolean {
98
+ return fs.existsSync(this.#modulePath(id, ".js"));
99
+ }
100
+
52
101
  async forgetModules(ids: string[]): Promise<void> {
53
102
  await Promise.all(
54
103
  ids.flatMap((id) => [
55
- rm(this.#modulePath(id, ".js"), { force: true }),
56
- rm(this.#modulePath(id, ".js.map"), { force: true }),
104
+ CsrDevArtifactWriter.#remove(this.#modulePath(id, ".js")),
105
+ CsrDevArtifactWriter.#remove(this.#modulePath(id, ".js.map")),
57
106
  ]),
58
107
  );
59
108
  }
@@ -76,14 +125,22 @@ export class CsrDevArtifactWriter {
76
125
  return vendorFile;
77
126
  }
78
127
 
79
- async writeApp(graph: CsrDevGraph, generation: number): Promise<void> {
128
+ //? A library app.js names the vendor file and epoch it was built with: a tab that booted it beside another build's
129
+ //? vendor file (a whole build landing between its render and its load) tells, and keeps the right epoch for hello.
130
+ async writeApp(
131
+ graph: CsrDevGraph,
132
+ { generation, vendorFile, epoch }: Pick<CsrDevManifest, "generation" | "vendorFile" | "epoch">,
133
+ code?: CsrDevCode,
134
+ ): Promise<void> {
80
135
  const ids = Object.keys(graph.modules)
81
136
  .filter((id) => !graph.modules[id]?.vendor)
82
137
  .sort();
83
- const lines = await this.#defineLines(ids);
138
+ const lines = await this.#defineLines(ids, code);
84
139
  const blocks = ids.map((id, index) => [id, lines[index] ?? ""] as [string, string]);
85
- const start = `__akan.start(${JSON.stringify({ generation, refresh: graph.refresh })});\n`;
86
- await this.#writeWithSourceMap(CSR_DEV_APP_FILE, await this.#helperDefinitions(graph, ids), blocks, start);
140
+ const start = this.#library
141
+ ? `__akan.startLibrary(${JSON.stringify({ generation, refresh: graph.refresh, bootstrap: graph.entries[""], vendorFile, epoch })});\n`
142
+ : `__akan.start(${JSON.stringify({ generation, refresh: graph.refresh })});\n`;
143
+ await this.#writeWithSourceMap(CSR_DEV_APP_FILE, await this.#helperDefinitions(graph, ids, code), blocks, start);
87
144
  }
88
145
 
89
146
  async writePatch(generation: number, modules: CsrDevCompiledModule[]): Promise<string> {
@@ -100,7 +157,7 @@ export class CsrDevArtifactWriter {
100
157
  );
101
158
  const header = `${[...helpers.values()].map((shared) => shared.definition).join("")}__akan.update(${generation}, {\n`;
102
159
  await this.#writeWithSourceMap(patchFile, header, blocks, "});\n");
103
- return `${CSR_DEV_ROUTE_PREFIX}${patchFile}`;
160
+ return `${this.#routePrefix}${patchFile}`;
104
161
  }
105
162
 
106
163
  // The manifest goes last: the server renders shells from it, so it must never name a file not yet written.
@@ -109,11 +166,39 @@ export class CsrDevArtifactWriter {
109
166
  await this.writeJson(CSR_DEV_MANIFEST_FILE, manifest);
110
167
  }
111
168
 
169
+ //? The open tabs need only the patch file and the manifest naming it, so they hear of a patch before the rest lands:
170
+ //? the module files and graph.json (read only by the next build), then app.js (a full rewrite). The manifest says
171
+ //? which generation app.js holds, and the shell holds a tab booting in that gap until it does. A crash in the gap is
172
+ //? recovered by the next build, which sees the edited files' mtimes differ from graph.json and compiles them again.
173
+ async commitPatch(
174
+ graph: CsrDevGraph,
175
+ manifest: CsrDevManifest,
176
+ announce: () => void,
177
+ { modules, code }: { modules: CsrDevCompiledModule[]; code?: CsrDevCode },
178
+ ): Promise<void> {
179
+ await this.writeJson(CSR_DEV_MANIFEST_FILE, manifest);
180
+ announce();
181
+ await CsrDevArtifactWriter.#appWriteDelay();
182
+ await this.writeModules(modules);
183
+ await this.writeJson("graph.json", graph);
184
+ await this.writeApp(graph, manifest, code);
185
+ await this.writeJson(CSR_DEV_MANIFEST_FILE, { ...manifest, appGeneration: manifest.generation });
186
+ }
187
+
188
+ // A process that died between announcing a patch and rewriting app.js left every booting tab one generation behind.
189
+ async healApp(graph: CsrDevGraph, manifest: CsrDevManifest, code?: CsrDevCode): Promise<CsrDevManifest> {
190
+ if (appGenerationOf(manifest) >= manifest.generation) return manifest;
191
+ await this.writeApp(graph, manifest, code);
192
+ const healed = { ...manifest, appGeneration: manifest.generation };
193
+ await this.writeJson(CSR_DEV_MANIFEST_FILE, healed);
194
+ return healed;
195
+ }
196
+
112
197
  async prune(vendorFile: string, generation: number): Promise<void> {
113
198
  const names = await readdir(this.#outDir);
114
199
  const stale = names.filter((name) => {
115
200
  const patch = /^patch-(\d+)\.js(?:\.layout\.json)?$/.exec(name);
116
- return patch ? Number(patch[1]) <= generation - CsrDevArtifactWriter.#keptPatches : false;
201
+ return patch ? Number(patch[1]) <= generation - CSR_DEV_KEPT_PATCHES : false;
117
202
  });
118
203
  const vendors = names
119
204
  .filter((name) => /^vendor-[\w-]+\.js$/.test(name) && name !== vendorFile)
@@ -121,19 +206,55 @@ export class CsrDevArtifactWriter {
121
206
  .sort((a, b) => b.mtimeMs - a.mtimeMs)
122
207
  .slice(CsrDevArtifactWriter.#keptVendors - 1)
123
208
  .map(({ name }) => name);
124
- await Promise.all([...stale, ...vendors].map((name) => rm(path.join(this.#outDir, name), { force: true })));
209
+ await Promise.all(
210
+ [...stale, ...vendors].map((name) => CsrDevArtifactWriter.#remove(path.join(this.#outDir, name))),
211
+ );
125
212
  }
126
213
 
127
- async #helperDefinitions(graph: CsrDevGraph, ids: string[]): Promise<string> {
214
+ //? A whole build writes over the registry before it instead of clearing it first, so it clears what that one left:
215
+ //? the modules and helpers it no longer holds, every patch (each was made against the previous registry), the assets
216
+ //? this build did not write (by path: a file's mtime and this process's clock need not agree on a mounted volume),
217
+ //? temp files of a write cut short, and the marker of a builder that died mid-patch.
218
+ async pruneAfterFullBuild(
219
+ graph: CsrDevGraph,
220
+ vendorFile: string,
221
+ { generation, assets }: { generation: number; assets: string[] },
222
+ ): Promise<void> {
223
+ const kept = new Set(
224
+ Object.keys(graph.modules).flatMap((id) => [this.#modulePath(id, ".js"), this.#modulePath(id, ".js.map")]),
225
+ );
226
+ for (const module of Object.values(graph.modules)) if (module.helpers) kept.add(this.#helpersPath(module.helpers));
227
+ for (const asset of assets) kept.add(path.resolve(asset));
228
+ const listed = async (dir: string) =>
229
+ (await readdir(path.join(this.#outDir, dir)).catch(() => [] as string[])).map((name) =>
230
+ path.join(this.#outDir, dir, name),
231
+ );
232
+ const stale = [
233
+ ...(await listed("modules")).filter((file) => !kept.has(file)),
234
+ ...(await listed("assets")).filter((file) => !kept.has(file)),
235
+ ...(await listed(".")).filter((file) => {
236
+ const name = path.basename(file);
237
+ return /^patch-\d+\.js(?:\.layout\.json)?$/.test(name) || name.endsWith(".tmp");
238
+ }),
239
+ path.join(this.#outDir, CSR_DEV_PATCHING_MARKER),
240
+ ];
241
+ await Promise.all(stale.map((file) => CsrDevArtifactWriter.#remove(file)));
242
+ await this.prune(vendorFile, generation);
243
+ }
244
+
245
+ async #helperDefinitions(graph: CsrDevGraph, ids: string[], code?: CsrDevCode): Promise<string> {
128
246
  const hashes = [...new Set(ids.flatMap((id) => graph.modules[id]?.helpers ?? []))].sort();
129
- return (await Promise.all(hashes.map((hash) => Bun.file(this.#helpersPath(hash)).text()))).join("");
247
+ return (
248
+ await Promise.all(hashes.map(async (hash) => code?.helpers.get(hash) ?? Bun.file(this.#helpersPath(hash)).text()))
249
+ ).join("");
130
250
  }
131
251
 
132
- async #defineLines(ids: string[]): Promise<string[]> {
252
+ async #defineLines(ids: string[], code?: CsrDevCode): Promise<string[]> {
133
253
  return await Promise.all(
134
- ids.map(
135
- async (id) => `__akan.define(${JSON.stringify(id)}, ${await Bun.file(this.#modulePath(id, ".js")).text()});\n`,
136
- ),
254
+ ids.map(async (id) => {
255
+ const factory = code?.modules.get(id) ?? (await Bun.file(this.#modulePath(id, ".js")).text());
256
+ return `__akan.define(${JSON.stringify(id)}, ${factory});\n`;
257
+ }),
137
258
  );
138
259
  }
139
260
 
@@ -156,7 +277,20 @@ export class CsrDevArtifactWriter {
156
277
  const target = path.join(this.#outDir, name);
157
278
  const temp = `${target}.${process.pid}.tmp`;
158
279
  await Bun.write(temp, content);
159
- await rename(temp, target);
280
+ await FileSys.replace(temp, target);
281
+ }
282
+
283
+ //? Best effort, after the tabs already have the update: Windows refuses to delete a file a reader holds open (the
284
+ //? server streaming it to a tab), and throwing here would send a patch that already went out to a build worker.
285
+ //? A file that stays is removed by a later prune.
286
+ static async #remove(file: string): Promise<void> {
287
+ await rm(file, { force: true }).catch(() => undefined);
288
+ }
289
+
290
+ //? A test-only hook that widens the gap between the patch and app.js, so an E2E can boot a tab inside it.
291
+ static async #appWriteDelay(): Promise<void> {
292
+ const ms = Number(process.env.AKAN_CSR_DEV_APP_WRITE_DELAY_MS);
293
+ if (Number.isInteger(ms) && ms > 0) await Bun.sleep(ms);
160
294
  }
161
295
 
162
296
  #modulePath(id: string, extension: ".js" | ".js.map"): string {