@akanjs/devkit 3.0.0-beta.23 → 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 (60) 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 -2
  8. package/csrE2e/csrE2eHarness.fixture.ts +455 -0
  9. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  10. package/csrE2e/csrPageStack.e2e.test.ts +187 -0
  11. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  12. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  13. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  14. package/fileSys.ts +16 -1
  15. package/frontendBuild/buildRouteClient.test.ts +61 -0
  16. package/frontendBuild/bundleDefine.test.ts +12 -0
  17. package/frontendBuild/bundleDefine.ts +5 -1
  18. package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
  19. package/frontendBuild/clientEntryDiscovery.ts +10 -5
  20. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  21. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  22. package/frontendBuild/csrDevArtifactWriter.ts +303 -0
  23. package/frontendBuild/csrDevBundler.test.ts +66 -0
  24. package/frontendBuild/csrDevBundler.ts +95 -0
  25. package/frontendBuild/csrDevModuleCompiler.ts +304 -0
  26. package/frontendBuild/csrDevPatcher.test.ts +340 -0
  27. package/frontendBuild/csrDevPatcher.ts +374 -0
  28. package/frontendBuild/csrDevPaths.ts +124 -0
  29. package/frontendBuild/csrDevResolver.test.ts +165 -0
  30. package/frontendBuild/csrDevResolver.ts +234 -0
  31. package/frontendBuild/csrDevTypes.ts +59 -0
  32. package/frontendBuild/csrEntryFiles.ts +24 -0
  33. package/frontendBuild/cssCompiler.ts +2 -4
  34. package/frontendBuild/devRegistryBundler.ts +177 -0
  35. package/frontendBuild/hmrWatcher.test.ts +51 -0
  36. package/frontendBuild/hmrWatcher.ts +17 -3
  37. package/frontendBuild/index.ts +4 -0
  38. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  39. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  40. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  41. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  42. package/frontendBuild/routeClientBuilder.ts +79 -18
  43. package/frontendBuild/serverGraphFile.test.ts +77 -0
  44. package/frontendBuild/serverGraphFile.ts +93 -0
  45. package/frontendBuild/ssrDevBundler.ts +91 -0
  46. package/incrementalBuilder/buildBatch.proc.ts +119 -6
  47. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  48. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  49. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  50. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  51. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  52. package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
  53. package/incrementalBuilder/incrementalBuilder.host.ts +33 -3
  54. package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
  55. package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
  56. package/integration/devStability.integration.test.ts +18 -7
  57. package/package.json +3 -3
  58. package/transforms/rscUseClientTransform.ts +7 -0
  59. package/transforms/transforms.test.ts +13 -0
  60. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -1,6 +1,8 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { Logger } from "akanjs/common";
3
4
  import type { BuilderMessage } from "akanjs/server";
5
+ import { CSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
4
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
5
7
  import type { App } from "../commandDecorators";
6
8
 
@@ -11,8 +13,11 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
11
13
  "invalidate",
12
14
  "css-updated",
13
15
  "pages-updated",
16
+ "csr-updated",
17
+ "ssr-updated",
14
18
  "build-status",
15
19
  "builder-metrics",
20
+ "boot-armed",
16
21
  ]);
17
22
  /** Prefer `"pipe"` under a TUI: a Bun child that inherits the terminal restores its spawn-time termios on exit. */
18
23
  export type DevStdioMode = "inherit" | "pipe";
@@ -44,8 +49,9 @@ export class IncrementalBuilderHost {
44
49
  static readonly #restartBaseDelayMs = 1_000;
45
50
  static readonly #restartMaxDelayMs = 30_000;
46
51
  static readonly #recycleDrainTimeoutMs = 30_000;
47
- // A fresh boot is ~300-600MB; this leaves room for one full rebuild on top.
48
- static readonly #devMaxRssBytes = 1_200 * 1024 * 1024;
52
+ // apps/akan's builder peaks near 950MB holding the SSR registry's patcher through a route build, and arming CSR adds
53
+ // a second patcher: this recycles only a builder grown well past both.
54
+ static readonly #devMaxRssBytes = 2_048 * 1024 * 1024;
49
55
  logger = new Logger("IncrementalBuilderHost");
50
56
  entry: string;
51
57
  env: Record<string, string>;
@@ -61,6 +67,7 @@ export class IncrementalBuilderHost {
61
67
  #recycleTimer: ReturnType<typeof setTimeout> | null = null;
62
68
  #recycleRequested: boolean = false;
63
69
  #spawnAfterRecycle: boolean = false;
70
+ #patcherOff: boolean = false;
64
71
  #manualStop = false;
65
72
  // Nothing else answers a request whose builder exits holding it: a crash or kill sends nothing, and a drain races
66
73
  // its own exit, so an unanswered page request would spin forever.
@@ -94,10 +101,16 @@ export class IncrementalBuilderHost {
94
101
  this.ready = false;
95
102
  const afterRecycle = this.#spawnAfterRecycle;
96
103
  this.#spawnAfterRecycle = false;
104
+ this.#checkPatchingMarker(isRestart && !afterRecycle);
97
105
  let proc!: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe">;
98
106
  proc = Bun.spawn(["bun", this.entry], {
99
107
  cwd: this.app.cwdPath,
100
- env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
108
+ env: {
109
+ ...this.env,
110
+ AKAN_WATCH: "1",
111
+ ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}),
112
+ ...(this.#patcherOff ? { AKAN_DEV_CSR_PATCHER: "off" } : {}),
113
+ },
101
114
  stdio: ["ignore", this.#stdio, this.#stdio],
102
115
  ipc: (msg: BuilderMessage) => {
103
116
  if (this.#proc !== proc) return;
@@ -151,6 +164,23 @@ export class IncrementalBuilderHost {
151
164
  }
152
165
  this.logger.verbose(`builder spawned pid=${proc.pid} entry=${this.entry}${isRestart ? " restart=1" : ""}`);
153
166
  }
167
+ //? The resident builder patches the dev CSR bundle in its own process, so a bundler crash there takes the file watcher
168
+ //? with it. A builder that died holding the marker hands every later CSR save to build workers for this session;
169
+ //? a marker any other exit left behind (Ctrl-C mid-patch) is cleared so it cannot turn the patcher off next session.
170
+ #checkPatchingMarker(afterCrash: boolean): void {
171
+ const markers = [CSR_DEV_DIRNAME, SSR_DEV_DIRNAME]
172
+ .map((dirName) => path.join(this.app.cwdPath, ".akan/artifact", dirName, CSR_DEV_PATCHING_MARKER))
173
+ .filter((marker) => fs.existsSync(marker));
174
+ if (markers.length === 0) return;
175
+ if (afterCrash && !this.#patcherOff) {
176
+ this.#patcherOff = true;
177
+ this.logger.warn(
178
+ "the builder died while patching a dev module registry; registry saves go to a build worker until the next config or metadata restart (AKAN_DEV_CSR_PATCHER=off)",
179
+ );
180
+ }
181
+ for (const marker of markers) fs.rmSync(marker, { force: true });
182
+ }
183
+
154
184
  async #drain(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
155
185
  if (!stream) return;
156
186
  const decoder = new TextDecoder();
@@ -16,7 +16,7 @@ const until = async (proc: Bun.Subprocess, what: string, condition: () => boolea
16
16
  };
17
17
 
18
18
  describe("incremental builder process", () => {
19
- test("says why and where a route's client bundle failed, in its answer, its build status and its log", async () => {
19
+ test("says why and where a route's client bundle failed, in its answer, its build status and its log, at the builder's generation", async () => {
20
20
  const { root } = track(await createTempApp("demo"));
21
21
  const appDir = path.join(root, "apps/demo");
22
22
  // The batch runner looks under the workspace root first: a stand-in for the boot build, which needs a whole app.
@@ -44,6 +44,7 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
44
44
  AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
45
45
  AKAN_PUBLIC_ENV: "local",
46
46
  AKAN_WATCH: "0",
47
+ AKAN_BUILDER_INITIAL_GENERATION: "5",
47
48
  },
48
49
  stdio: ["ignore", "ignore", "pipe"],
49
50
  serialization: "advanced",
@@ -64,6 +65,8 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
64
65
  await until(proc, "build-route-res", () => messages.some((message) => message.type === "build-route-res"));
65
66
  } finally {
66
67
  proc.kill();
68
+ //? Windows refuses to remove a directory a live process has as its cwd.
69
+ await proc.exited;
67
70
  }
68
71
  const log = await new Response(proc.stderr).text();
69
72
 
@@ -74,8 +77,132 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
74
77
  error: expect.stringContaining(reason),
75
78
  });
76
79
  expect(messages.find((message) => message.type === "build-status")).toMatchObject({
77
- data: { generation: 3, phase: "route", ok: false, message: expect.stringContaining(reason) },
80
+ data: { generation: 6, phase: "route", ok: false, message: expect.stringContaining(reason) },
78
81
  });
79
82
  expect(log).toContain(reason);
80
83
  }, 30_000);
84
+
85
+ test("a killed boot build is tried again after the crash window, before boot-armed; a route build's check waits likewise", async () => {
86
+ // Inside the checkout so the route's client bundle resolves the framework from its source.
87
+ const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
88
+ const appDir = path.join(root, "apps/demo");
89
+ const runs = path.join(root, "ssr-runs.log");
90
+ // The first two registry builds are killed (the boot build and its retry), the third succeeds.
91
+ await writeText(
92
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
93
+ `import fs from "node:fs";
94
+ const { generation, needs } = JSON.parse(process.argv[2]);
95
+ if (needs.includes("ssr")) {
96
+ fs.appendFileSync(${JSON.stringify(runs)}, Date.now() + "\\n");
97
+ if (fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length <= 2) process.exit(1);
98
+ }
99
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
100
+ `,
101
+ );
102
+ const page = path.join(appDir, "page/_index.tsx");
103
+ await writeText(page, 'import { Card } from "../ui/Card";\nexport default Card;\n');
104
+ await writeText(path.join(appDir, "ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
105
+
106
+ const messages: BuilderMessage[] = [];
107
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
108
+ cwd: appDir,
109
+ env: {
110
+ ...process.env,
111
+ AKAN_WORKSPACE_ROOT: root,
112
+ AKAN_PUBLIC_APP_NAME: "demo",
113
+ AKAN_PUBLIC_REPO_NAME: "repo",
114
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
115
+ AKAN_PUBLIC_ENV: "local",
116
+ AKAN_WATCH: "0",
117
+ },
118
+ stdio: ["ignore", "ignore", "ignore"],
119
+ serialization: "advanced",
120
+ ipc: (message: BuilderMessage) => {
121
+ messages.push(message);
122
+ },
123
+ });
124
+ const ssrRuns = async () =>
125
+ (await Bun.file(runs).exists()) ? (await Bun.file(runs).text()).trim().split("\n").map(Number) : [];
126
+ try {
127
+ await until(proc, "boot-armed", () => messages.some((message) => message.type === "boot-armed"));
128
+ const [first = 0, second = 0] = await ssrRuns();
129
+ expect(await ssrRuns()).toHaveLength(2);
130
+ expect(second - first).toBeGreaterThanOrEqual(9_500);
131
+ proc.send({
132
+ type: "build-route",
133
+ id: 1,
134
+ routeId: "page:/",
135
+ seeds: [page],
136
+ knownEntries: [],
137
+ generation: 3,
138
+ } satisfies BuilderReq);
139
+ await until(proc, "build-route-res", () => messages.some((message) => message.type === "build-route-res"));
140
+ expect(messages.find((message) => message.type === "build-route-res")).toMatchObject({ ok: true });
141
+ const deadline = Date.now() + 15_000;
142
+ while ((await ssrRuns()).length < 3 && Date.now() < deadline) await wait(100);
143
+ const [, retried = 0, third = 0] = await ssrRuns();
144
+ expect(third - retried).toBeGreaterThanOrEqual(9_500);
145
+ } finally {
146
+ proc.kill();
147
+ await proc.exited;
148
+ }
149
+ }, 60_000);
150
+
151
+ test("a route build's registry check that has nothing to add reports no ssr status, so it cannot hide a failure", async () => {
152
+ const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
153
+ const appDir = path.join(root, "apps/demo");
154
+ // The registry's own build is the real worker's; the rest is stood in for, as a boot build needs a whole app.
155
+ await writeText(
156
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
157
+ `const { generation, needs } = JSON.parse(process.argv[2]);
158
+ if (needs.includes("ssr")) await import(${JSON.stringify(path.join(import.meta.dir, "buildBatch.proc.ts"))});
159
+ else process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
160
+ `,
161
+ );
162
+ await writeText(path.join(appDir, "env/env.client.ts"), "export const env = {} as const;\n");
163
+ const page = path.join(appDir, "page/_index.tsx");
164
+ await writeText(page, 'import { Card } from "../ui/Card";\nexport default Card;\n');
165
+ await writeText(path.join(appDir, "ui/Card.tsx"), '"use client";\nexport const Card = () => null;\n');
166
+
167
+ const messages: BuilderMessage[] = [];
168
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
169
+ cwd: appDir,
170
+ env: {
171
+ ...process.env,
172
+ AKAN_WORKSPACE_ROOT: root,
173
+ AKAN_PUBLIC_APP_NAME: "demo",
174
+ AKAN_PUBLIC_REPO_NAME: "repo",
175
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
176
+ AKAN_PUBLIC_ENV: "local",
177
+ AKAN_WATCH: "0",
178
+ },
179
+ stdio: ["ignore", "ignore", "ignore"],
180
+ serialization: "advanced",
181
+ ipc: (message: BuilderMessage) => {
182
+ messages.push(message);
183
+ },
184
+ });
185
+ const ssrStatuses = () =>
186
+ messages.filter((message) => message.type === "build-status" && message.data.phase === "ssr");
187
+ try {
188
+ await until(proc, "boot-armed", () => messages.some((message) => message.type === "boot-armed"));
189
+ expect(messages.some((message) => message.type === "ssr-updated")).toBe(true);
190
+ const before = ssrStatuses().length;
191
+ proc.send({
192
+ type: "build-route",
193
+ id: 1,
194
+ routeId: "page:/",
195
+ seeds: [page],
196
+ knownEntries: [],
197
+ generation: 3,
198
+ } satisfies BuilderReq);
199
+ await until(proc, "build-route-res", () => messages.some((message) => message.type === "build-route-res"));
200
+ expect(messages.find((message) => message.type === "build-route-res")).toMatchObject({ ok: true });
201
+ await wait(200);
202
+ expect(ssrStatuses().length).toBe(before);
203
+ } finally {
204
+ proc.kill();
205
+ await proc.exited;
206
+ }
207
+ }, 60_000);
81
208
  });