@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
@@ -0,0 +1,91 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { SSR_DEV_DIRNAME, SSR_DEV_ROUTE_PREFIX } from "akanjs/server/hmr/csrDevManifest";
4
+ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
+ import { computeRouteSeedIndex } from "../artifact/routeSeedIndex";
6
+ import type { App } from "../commandDecorators";
7
+ import { bundleDefine } from "./bundleDefine";
8
+ import type { ClientEntryDiscovery } from "./clientBuildTypes";
9
+ import { GraphClientEntryDiscovery } from "./clientEntryDiscovery";
10
+ import { CsrDevPaths } from "./csrDevPaths";
11
+ import type { CsrDevContext, CsrDevGraph } from "./csrDevTypes";
12
+ import { DevRegistryBundler } from "./devRegistryBundler";
13
+ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
14
+
15
+ //* Dev-only client code of SSR pages as a module registry, under `.akan/artifact/ssr-dev`. Its roots are a bootstrap
16
+ //* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
17
+ //* so the page keeps one React and one store registry.
18
+ export class SsrDevBundler extends DevRegistryBundler {
19
+ static readonly #formatVersion = 1;
20
+ static readonly bootstrapKey = "";
21
+ readonly reloadsOnEntryChange = false;
22
+ #discovery: ClientEntryDiscovery | null = null;
23
+ readonly #sharedDiscovery: (() => ClientEntryDiscovery) | null;
24
+
25
+ /** `discovery` is the resident builder's, which it keeps current with every save; a worker discovers afresh. */
26
+ constructor(app: App, { discovery }: { discovery?: () => ClientEntryDiscovery } = {}) {
27
+ super(app, { dirName: SSR_DEV_DIRNAME, routePrefix: SSR_DEV_ROUTE_PREFIX, library: true });
28
+ this.#sharedDiscovery = discovery ?? null;
29
+ }
30
+
31
+ async context(): Promise<CsrDevContext> {
32
+ const akanConfig = await this.app.getConfig();
33
+ const define = bundleDefine(this.app, "start", "ssr");
34
+ const optimizeImports = [...akanConfig.optimizeImports];
35
+ const configKey = Bun.hash(
36
+ JSON.stringify(["ssr", SsrDevBundler.#formatVersion, Bun.version, define, optimizeImports, VENDOR_SPECIFIERS]),
37
+ ).toString(36);
38
+ return {
39
+ pageEntries: [],
40
+ basePaths: [...akanConfig.basePaths],
41
+ htmlBasePaths: [],
42
+ define,
43
+ optimizeImports,
44
+ configKey,
45
+ refreshFile: null,
46
+ externals: VENDOR_SPECIFIERS,
47
+ };
48
+ }
49
+
50
+ //? The client runtime of an SSR page inlines no dictionary (`useClient.ts` reads one only when rendering csr), so
51
+ //? a dictionary save is none of this registry's business.
52
+ override isMetadataFile(file: string): boolean {
53
+ return super.isMetadataFile(file) && !file.endsWith(".dictionary.ts") && path.basename(file) !== "dict.ts";
54
+ }
55
+
56
+ // Run first in the tab, before any client module the payload names: the store registry must exist by then.
57
+ async writeEntries(): Promise<{ files: Record<string, string>; changed: string[] }> {
58
+ await mkdir(this.entryDir, { recursive: true });
59
+ const file = path.join(this.entryDir, "bootstrap.ts");
60
+ const hasStore = await Bun.file(path.join(this.app.cwdPath, "lib", "st.ts")).exists();
61
+ const source = hasStore ? `import ${JSON.stringify(`@apps/${this.app.name}/client`)};\n` : "export {};\n";
62
+ const unchanged = (await Bun.file(file).exists()) && (await Bun.file(file).text()) === source;
63
+ if (!unchanged) await Bun.write(file, source);
64
+ const entryFile = CsrDevPaths.realpath(file);
65
+ return { files: { [SsrDevBundler.bootstrapKey]: entryFile }, changed: unchanged ? [] : [entryFile] };
66
+ }
67
+
68
+ /** Every `"use client"` entry the app's routes reach, the ones a route build names in its manifest. */
69
+ async clientEntries(): Promise<string[]> {
70
+ const pageEntries = await resolveSsrPageEntriesForApp(this.app, await this.app.getPageKeys());
71
+ const seedIndex = computeRouteSeedIndex(pageEntries);
72
+ const seeds = [...new Set([...seedIndex.globalLayoutFiles, ...seedIndex.entries.flatMap((entry) => entry.seeds)])];
73
+ const discovery = this.#sharedDiscovery?.() ?? (await this.#ownDiscovery());
74
+ return (await discovery.discover(seeds)).map((file) => CsrDevPaths.realpath(file));
75
+ }
76
+
77
+ async #ownDiscovery(): Promise<ClientEntryDiscovery> {
78
+ this.#discovery ??= await GraphClientEntryDiscovery.create(this.app);
79
+ return this.#discovery;
80
+ }
81
+
82
+ override async wantedRoots(files: string[], graph: CsrDevGraph): Promise<Set<string>> {
83
+ const entries = new Set(await this.clientEntries());
84
+ const reached = await super.wantedRoots(files, graph);
85
+ return new Set(files.filter((file) => reached.has(file) || entries.has(CsrDevPaths.realpath(file))));
86
+ }
87
+
88
+ protected async rootFiles(entryFiles: string[]): Promise<string[]> {
89
+ return [...entryFiles, ...(await this.clientEntries())];
90
+ }
91
+ }
@@ -7,13 +7,17 @@ import type { App } from "@akanjs/devkit/commandDecorators";
7
7
  import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
8
8
  import {
9
9
  CsrArtifactBuilder,
10
+ CsrDevBundler,
10
11
  CssCompiler,
11
12
  FontOptimizer,
12
13
  PagesBundleBuilder,
14
+ ServerGraphFile,
13
15
  SsrBaseArtifactBuilder,
16
+ SsrDevBundler,
14
17
  } from "@akanjs/devkit/frontendBuild";
15
18
  import { Logger } from "akanjs/common";
16
- import type { BuilderMessage, BuildPhase } from "akanjs/server";
19
+ import type { BuilderMessage, BuildPhase, HmrTrace } from "akanjs/server";
20
+ import { resolveDevCsrMode } from "akanjs/server/hmr/csrDevManifest";
17
21
  import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
18
22
 
19
23
  // `Bun.build` keeps native bundler arenas that `Bun.gc(true)` cannot reclaim; only exiting returns them.
@@ -30,10 +34,11 @@ class BuildBatch {
30
34
 
31
35
  async run(): Promise<BuildBatchResult> {
32
36
  if (this.#request.needs.includes("base")) await this.#buildBase();
33
- // Order kept from the in-process build: csr before pages, css last because it depends on the rebuilt client.
34
37
  if (this.#request.needs.includes("csr")) await this.#buildCsr();
35
- if (this.#request.needs.includes("pages")) await this.#buildPages();
38
+ if (this.#request.needs.includes("ssr")) await this.#buildSsrRegistry();
39
+ // Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
36
40
  if (this.#request.needs.includes("css")) await this.#buildCss();
41
+ if (this.#request.needs.includes("pages")) await this.#buildPages();
37
42
  return this.#result;
38
43
  }
39
44
 
@@ -43,6 +48,12 @@ class BuildBatch {
43
48
  process.send?.(message);
44
49
  }
45
50
 
51
+ #sentTrace(): HmrTrace | undefined {
52
+ if (!this.#request.trace) return undefined;
53
+ const now = Date.now();
54
+ return { ...this.#request.trace, patchAt: now, sentAt: now };
55
+ }
56
+
46
57
  #emitStatus(phase: BuildPhase, message?: string): void {
47
58
  this.#emit({
48
59
  type: "build-status",
@@ -80,22 +91,108 @@ class BuildBatch {
80
91
  async #buildCsr(): Promise<void> {
81
92
  const started = Date.now();
82
93
  try {
83
- await new CsrArtifactBuilder(this.#app).build();
84
- this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
94
+ if (resolveDevCsrMode() === "registry") await this.#updateCsrRegistry(started);
95
+ else await this.#rebuildCsrArtifact(started);
85
96
  this.#emitStatus("csr");
86
97
  } catch (err) {
87
98
  this.#fail("csr", "csr-rebundle", err);
88
99
  }
89
100
  }
90
101
 
102
+ async #rebuildCsrArtifact(started: number): Promise<void> {
103
+ await new CsrArtifactBuilder(this.#app).build();
104
+ this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
105
+ // A CSR tab takes none of the SSR refresh messages, so a rebuilt artifact reaches it only as this reload.
106
+ if (this.#request.changedFiles.length === 0) return;
107
+ this.#emit({
108
+ type: "csr-updated",
109
+ data: { generation: Date.now(), mode: "artifact", reload: true, reason: "the CSR artifact was rebuilt" },
110
+ });
111
+ }
112
+
113
+ async #updateCsrRegistry(started: number): Promise<void> {
114
+ const update = await new CsrDevBundler(this.#app).update(this.#request.changedFiles, {
115
+ announce: (announced) =>
116
+ this.#emit({
117
+ type: "csr-updated",
118
+ data: {
119
+ generation: announced.generation,
120
+ mode: "registry",
121
+ reload: announced.reload,
122
+ reason: announced.reason,
123
+ patchUrl: announced.patchUrl,
124
+ changedIds: announced.changedIds,
125
+ trace: this.#sentTrace(),
126
+ },
127
+ }),
128
+ });
129
+ if (!update) {
130
+ this.#logger.verbose(`csr-dev unchanged (${Date.now() - started}ms)`);
131
+ return;
132
+ }
133
+ this.#logger.verbose(
134
+ `csr-dev generation=${update.generation} ${update.reload ? `reload (${update.reason})` : `patch modules=${update.changedIds.length}`} graph=${update.moduleCount} (${Date.now() - started}ms)`,
135
+ );
136
+ }
137
+
138
+ // Takes every entry the routes reach as a root, so a route build naming any of them finds it in the registry.
139
+ async #buildSsrRegistry(): Promise<void> {
140
+ const started = Date.now();
141
+ try {
142
+ const bundler = new SsrDevBundler(this.#app);
143
+ //? Runs before pages, so the graph is the last build's, as in the resident builder: a save it holds still waits.
144
+ const hold = await ServerGraphFile.touches(
145
+ await ServerGraphFile.read(this.#request.artifactDir),
146
+ this.#request.changedFiles,
147
+ (file) => ServerGraphFile.clientExportsOf(file),
148
+ );
149
+ const update = await bundler.update(this.#request.changedFiles, {
150
+ roots: await bundler.clientEntries(),
151
+ announce: (announced) =>
152
+ this.#emit({
153
+ type: "ssr-updated",
154
+ data: {
155
+ generation: announced.generation,
156
+ reload: announced.reload,
157
+ reason: announced.reason,
158
+ patchUrl: announced.patchUrl,
159
+ changedIds: announced.changedIds,
160
+ trace: this.#sentTrace(),
161
+ ...(announced.epoch !== undefined ? { epoch: announced.epoch } : {}),
162
+ ...(announced.first ? { first: true } : {}),
163
+ ...(hold && this.#request.changedFiles.length > 0
164
+ ? { hold, batchGeneration: this.#request.generation }
165
+ : {}),
166
+ },
167
+ }),
168
+ });
169
+ this.#emitStatus("ssr");
170
+ this.#logger.verbose(
171
+ update
172
+ ? `ssr-dev generation=${update.generation} ${update.reload ? `reload (${update.reason})` : `patch modules=${update.changedIds.length}`} graph=${update.moduleCount} (${Date.now() - started}ms)`
173
+ : `ssr-dev unchanged (${Date.now() - started}ms)`,
174
+ );
175
+ } catch (err) {
176
+ this.#fail("ssr", "ssr-dev", err);
177
+ }
178
+ }
179
+
91
180
  // Rewritten with the bundle: the backend rereads it on `pages-updated` to pick up added, moved or deleted routes.
92
181
  async #buildPages(): Promise<void> {
93
182
  const started = Date.now();
94
183
  try {
95
184
  const pageEntries = await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys());
96
185
  const seedIndex = computeRouteSeedIndex(pageEntries);
186
+ const previousGraph = await ServerGraphFile.read(this.#request.artifactDir);
97
187
  const next = await new PagesBundleBuilder(this.#app, "start", pageEntries).build();
98
188
  await saveRouteSeedIndex(this.#request.artifactDir, seedIndex);
189
+ const nextGraph = await ServerGraphFile.read(this.#request.artifactDir);
190
+ const serverTouched = await ServerGraphFile.touches(
191
+ previousGraph,
192
+ [...this.#request.changedFiles, ...(previousGraph?.carried ?? [])],
193
+ (file) => nextGraph?.clientExports[file] ?? null,
194
+ nextGraph,
195
+ );
99
196
  this.#emit({
100
197
  type: "pages-updated",
101
198
  data: {
@@ -103,12 +200,17 @@ class BuildBatch {
103
200
  buildId: next.buildId,
104
201
  generation: this.#request.generation,
105
202
  changedFiles: this.#request.changedFiles,
203
+ trace: this.#sentTrace(),
204
+ serverTouched,
106
205
  },
107
206
  });
108
207
  this.#emitStatus("pages");
109
208
  this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
110
209
  } catch (err) {
111
210
  this.#fail("pages", "pages-rebundle", err);
211
+ await ServerGraphFile.carry(this.#request.artifactDir, this.#request.changedFiles).catch((carryError: unknown) =>
212
+ this.#logger.warn(`server graph carry failed; the next save may skip its RSC refresh: ${String(carryError)}`),
213
+ );
112
214
  }
113
215
  }
114
216
 
@@ -178,7 +280,13 @@ class BuildBatch {
178
280
  static async main(): Promise<void> {
179
281
  const raw = process.argv[2];
180
282
  if (!raw) throw new Error("[build-batch] missing request argument");
181
- const request = JSON.parse(raw) as BuildBatchRequest;
283
+ const parsed = JSON.parse(raw) as BuildBatchRequest;
284
+ const request = parsed.trace
285
+ ? {
286
+ ...parsed,
287
+ trace: { ...parsed.trace, workerStartAt: Math.round(performance.timeOrigin), workerAt: Date.now() },
288
+ }
289
+ : parsed;
182
290
  const workspace = WorkspaceExecutor.fromRoot({
183
291
  workspaceRoot: request.workspaceRoot,
184
292
  repoName: request.repoName,
@@ -186,8 +294,13 @@ class BuildBatch {
186
294
  const app = AppExecutor.from(workspace, request.appName);
187
295
  // Seeded, not rediscovered: route discovery would be the largest cost of spawning this process.
188
296
  if (request.pageKeys) app.setPageKeys(request.pageKeys);
297
+ //? Exits with the builder that spawned it (a kill, a crash, a restart for a metadata save): left running, it would
298
+ //? write a registry its replacement is rebuilding. Removed before returning, since the listener keeps Bun's IPC open.
299
+ const orphaned = () => process.exit(1);
300
+ process.on("disconnect", orphaned);
189
301
  const result = await new BuildBatch(request, app).run();
190
302
  process.send?.({ type: "build-batch-result", data: result });
303
+ process.off("disconnect", orphaned);
191
304
  }
192
305
  }
193
306
 
@@ -1,10 +1,10 @@
1
1
  import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
2
- import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/server";
2
+ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from "akanjs/server";
3
3
 
4
4
  export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
5
5
 
6
6
  // `base` is the boot build: it always runs alone, before the builder can serve anything.
7
- export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
7
+ export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr";
8
8
 
9
9
  // JSON only: the worker is spawned per batch and receives this in its argv.
10
10
  export interface BuildBatchRequest {
@@ -18,6 +18,7 @@ export interface BuildBatchRequest {
18
18
  optimizedFonts: OptimizedFonts | null; // reused unless this batch touched one of its files
19
19
  cssAssets: PagesBatchCssAssets | null; // an unchanged compile skips the broadcast instead of busting hashes
20
20
  artifactDir: string;
21
+ trace?: HmrTrace;
21
22
  }
22
23
 
23
24
  export type PagesBatchCssAssets = CssPayload["cssAssets"];
@@ -29,7 +30,9 @@ export interface BuildBatchResult {
29
30
  optimizedFonts?: OptimizedFonts;
30
31
  artifact?: BaseBuildArtifact; // `base` batches only
31
32
  errors: Partial<Record<BuildBatchNeed, string>>;
32
- crashed?: boolean; // died before reporting, so it streamed no build-status for these needs
33
+ crashed?: boolean; // died before reporting its result
34
+ /** Of a crashed batch, the needs it died before reporting: the others streamed their build-status first. */
35
+ crashedNeeds?: BuildBatchNeed[];
33
36
  }
34
37
 
35
38
  export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
@@ -31,4 +31,35 @@ describe("BuildBatchRunner", () => {
31
31
 
32
32
  expect(result.errors.base).toContain('"./not-there" (apps/demo/page/_index.tsx:1:22)');
33
33
  }, 30_000);
34
+
35
+ test("a worker that dies mid-batch keeps what each need reported, and fails only the needs it never reached", async () => {
36
+ const { root } = track(await createTempApp("demo"));
37
+ const appDir = path.join(root, "apps/demo");
38
+ await writeText(
39
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
40
+ `const { generation } = JSON.parse(process.argv[2]);
41
+ const status = (phase, message) =>
42
+ process.send({ type: "build-status", data: { generation, phase, ok: !message, files: [], message } });
43
+ status("ssr");
44
+ status("css", "css broke");
45
+ setTimeout(() => process.exit(1), 50);
46
+ `,
47
+ );
48
+ const result = await new BuildBatchRunner({ workspaceRoot: root, cwd: appDir }).run({
49
+ appName: "demo",
50
+ workspaceRoot: root,
51
+ repoName: "repo",
52
+ generation: 7,
53
+ needs: ["ssr", "css", "pages"],
54
+ changedFiles: [],
55
+ pageKeys: null,
56
+ optimizedFonts: null,
57
+ cssAssets: null,
58
+ artifactDir: path.join(appDir, ".akan/artifact"),
59
+ });
60
+ expect(result).toMatchObject({ crashed: true, crashedNeeds: ["pages"] });
61
+ expect(result.errors.ssr).toBeUndefined();
62
+ expect(result.errors.css).toBe("css broke");
63
+ expect(result.errors.pages).toContain("exited with code 1");
64
+ }, 30_000);
34
65
  });
@@ -3,7 +3,8 @@ import { Logger } from "akanjs/common";
3
3
  import type { BuilderMessage } from "akanjs/server";
4
4
  import type { BuildBatchMessage, BuildBatchRequest, BuildBatchResult } from "./buildBatchProtocol";
5
5
 
6
- // No pool on purpose: one worker at a time, exiting after its batch, returns the arenas `Bun.build` never frees.
6
+ // No pool on purpose: a worker exiting after its batch returns the arenas `Bun.build` never frees. The callers order
7
+ // their own work (the slow lane, the css queue, the SSR registry's boot build beside them), so a few can overlap.
7
8
  // A worker that dies without reporting must not take the watcher down; its needs come back as errors.
8
9
  export class BuildBatchRunner {
9
10
  #logger = new Logger("BuildBatchRunner");
@@ -38,8 +39,10 @@ export class BuildBatchRunner {
38
39
  const started = Date.now();
39
40
  const entry = await this.#resolveEntry();
40
41
  let result: BuildBatchResult | null = null;
42
+ const reported = new Map<string, string | undefined>();
43
+ const payload = request.trace ? { ...request, trace: { ...request.trace, spawnAt: Date.now() } } : request;
41
44
  // argv rather than IPC, so the worker starts on its first tick instead of waiting for a handshake.
42
- const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
45
+ const proc = Bun.spawn(["bun", entry, JSON.stringify(payload)], {
43
46
  cwd: this.#cwd,
44
47
  env: process.env,
45
48
  stdio: ["ignore", "inherit", "inherit"],
@@ -47,7 +50,10 @@ export class BuildBatchRunner {
47
50
  ipc: (message: BuildBatchMessage | BuilderMessage) => {
48
51
  if (!message || typeof message !== "object") return;
49
52
  if (message.type === "build-batch-result") result = message.data;
50
- else onMessage(message);
53
+ else {
54
+ if (message.type === "build-status") reported.set(message.data.phase, message.data.message);
55
+ onMessage(message);
56
+ }
51
57
  },
52
58
  });
53
59
  const exitCode = await proc.exited;
@@ -64,10 +70,13 @@ export class BuildBatchRunner {
64
70
  }`
65
71
  : `build worker exited with code ${exitCode} before reporting a result`;
66
72
  this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
67
- return {
68
- generation: request.generation,
69
- errors: Object.fromEntries(request.needs.map((need) => [need, message])),
70
- crashed: true,
71
- };
73
+ //? A need that reported before the crash keeps what it reported: a worker killed during pages built ssr and css.
74
+ const crashedNeeds = request.needs.filter((need) => !reported.has(need));
75
+ const failed = request.needs.flatMap((need) => {
76
+ if (!reported.has(need)) return [[need, message] as const];
77
+ const error = reported.get(need);
78
+ return error ? [[need, error] as const] : [];
79
+ });
80
+ return { generation: request.generation, errors: Object.fromEntries(failed), crashed: true, crashedNeeds };
72
81
  }
73
82
  }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type BatchJob, BuilderWorkQueue } from "./builderWorkQueue";
3
+
4
+ const gate = () => {
5
+ const { promise: opened, resolve: open } = Promise.withResolvers<void>();
6
+ return { open, opened };
7
+ };
8
+
9
+ describe("BuilderWorkQueue", () => {
10
+ test("runs jobs one at a time, in the order they were queued", async () => {
11
+ const order: string[] = [];
12
+ const queue = new BuilderWorkQueue({ runBatch: async () => undefined });
13
+ const first = gate();
14
+ const a = queue.enqueue("a", async () => {
15
+ await first.opened;
16
+ order.push("a");
17
+ });
18
+ const b = queue.enqueue("b", async () => {
19
+ order.push("b");
20
+ return 2;
21
+ });
22
+ expect(queue.size).toBe(2);
23
+ first.open();
24
+ await a;
25
+ expect(await b).toBe(2);
26
+ expect(order).toEqual(["a", "b"]);
27
+ expect(queue.size).toBe(0);
28
+ });
29
+
30
+ test("folds a batch queued behind another batch into it, for the newest generation", async () => {
31
+ const ran: BatchJob[] = [];
32
+ const running = gate();
33
+ const queue = new BuilderWorkQueue({
34
+ runBatch: async (batch) => {
35
+ ran.push(batch);
36
+ if (ran.length === 1) await running.opened;
37
+ },
38
+ });
39
+ const first = queue.enqueueBatch({ generation: 1, needs: ["pages", "css"], changedFiles: ["a.tsx"] });
40
+ const second = queue.enqueueBatch({
41
+ generation: 2,
42
+ needs: ["pages", "css"],
43
+ changedFiles: ["b.tsx"],
44
+ discovery: { files: ["b.tsx"], refresh: false, generation: 2 },
45
+ });
46
+ const third = queue.enqueueBatch({ generation: 3, needs: ["csr", "pages"], changedFiles: ["a.tsx", "c.tsx"] });
47
+ expect(second).toBe(third);
48
+ running.open();
49
+ await Promise.all([first, second, third]);
50
+ expect(ran).toHaveLength(2);
51
+ expect(ran[1]).toEqual({
52
+ generation: 3,
53
+ needs: ["pages", "css", "csr"],
54
+ changedFiles: ["b.tsx", "a.tsx", "c.tsx"],
55
+ discovery: { files: ["b.tsx"], refresh: false, generation: 2 },
56
+ });
57
+ });
58
+
59
+ test("keeps a batch queued after a task behind that task", async () => {
60
+ const order: string[] = [];
61
+ const running = gate();
62
+ const queue = new BuilderWorkQueue({
63
+ runBatch: async (batch) => {
64
+ order.push(`batch:${batch.generation}`);
65
+ if (batch.generation === 1) await running.opened;
66
+ },
67
+ });
68
+ const first = queue.enqueueBatch({ generation: 1, needs: ["pages"], changedFiles: [] });
69
+ const route = queue.enqueue("route", async () => {
70
+ order.push("route");
71
+ });
72
+ const second = queue.enqueueBatch({ generation: 2, needs: ["pages"], changedFiles: [] });
73
+ running.open();
74
+ await Promise.all([first, route, second]);
75
+ expect(order).toEqual(["batch:1", "route", "batch:2"]);
76
+ });
77
+
78
+ test("drains jobs queued while it waits, and survives a job that throws", async () => {
79
+ const queue = new BuilderWorkQueue({ runBatch: async () => undefined });
80
+ const failing = queue.enqueue("fails", async () => {
81
+ throw new Error("boom");
82
+ });
83
+ let late = false;
84
+ void queue.enqueue("queues-more", async () => {
85
+ void queue.enqueue("late", async () => {
86
+ late = true;
87
+ });
88
+ });
89
+ await expect(failing).rejects.toThrow("boom");
90
+ await queue.drain();
91
+ expect(late).toBe(true);
92
+ expect(queue.size).toBe(0);
93
+ });
94
+ });
@@ -0,0 +1,126 @@
1
+ import type { HmrTrace } from "akanjs/server";
2
+ import type { BuildBatchNeed } from "./buildBatchProtocol";
3
+
4
+ export interface DiscoveryJob {
5
+ files: string[];
6
+ refresh: boolean;
7
+ /** The newest batch these files came from: a route build that starts after has taken it in. */
8
+ generation: number;
9
+ }
10
+
11
+ export interface BatchJob {
12
+ generation: number;
13
+ needs: BuildBatchNeed[];
14
+ changedFiles: string[];
15
+ trace?: HmrTrace;
16
+ /** Code files whose client-entry discovery goes stale before this batch builds; `refresh` rebuilds it whole. */
17
+ discovery?: DiscoveryJob;
18
+ }
19
+
20
+ interface QueuedJob {
21
+ label: string;
22
+ batch: BatchJob | null;
23
+ run: () => Promise<void>;
24
+ settle: PromiseWithResolvers<void>;
25
+ }
26
+
27
+ export interface BuilderWorkQueueOptions {
28
+ runBatch: (batch: BatchJob) => Promise<void>;
29
+ onSettled?: (label: string, ms: number) => void;
30
+ }
31
+
32
+ //* The builder's slow lane: build-worker batches, route builds and full CSR builds run here one at a time, so a save's
33
+ //* CSR patch never waits behind them. A batch queued behind another batch folds into it: a burst of saves builds pages
34
+ //* and css once for the newest generation instead of once per save.
35
+ export class BuilderWorkQueue {
36
+ readonly #runBatch: BuilderWorkQueueOptions["runBatch"];
37
+ readonly #onSettled: BuilderWorkQueueOptions["onSettled"];
38
+ readonly #pending: QueuedJob[] = [];
39
+ #running: QueuedJob | null = null;
40
+ #idle: Promise<void> = Promise.resolve();
41
+ #wakeIdle: (() => void) | null = null;
42
+
43
+ constructor({ runBatch, onSettled }: BuilderWorkQueueOptions) {
44
+ this.#runBatch = runBatch;
45
+ this.#onSettled = onSettled;
46
+ }
47
+
48
+ /** Jobs waiting or running. */
49
+ get size(): number {
50
+ return this.#pending.length + (this.#running ? 1 : 0);
51
+ }
52
+
53
+ enqueueBatch(batch: BatchJob): Promise<void> {
54
+ const tail = this.#pending.at(-1);
55
+ if (tail?.batch) {
56
+ tail.batch = BuilderWorkQueue.merge(tail.batch, batch);
57
+ return tail.settle.promise;
58
+ }
59
+ const job = this.#job("batch", batch, async () => undefined);
60
+ job.run = async () => await this.#runBatch(job.batch ?? batch);
61
+ return this.#push(job);
62
+ }
63
+
64
+ enqueue<T>(label: string, run: () => Promise<T>): Promise<T> {
65
+ let result: T | undefined;
66
+ const job = this.#job(label, null, async () => {
67
+ result = await run();
68
+ });
69
+ return this.#push(job).then(() => result as T);
70
+ }
71
+
72
+ /** Resolves once nothing is waiting or running, including jobs queued while it waits. */
73
+ async drain(): Promise<void> {
74
+ while (this.size > 0) await this.#idle;
75
+ }
76
+
77
+ static merge(into: BatchJob, next: BatchJob): BatchJob {
78
+ const discovery =
79
+ into.discovery || next.discovery
80
+ ? {
81
+ files: [...new Set([...(into.discovery?.files ?? []), ...(next.discovery?.files ?? [])])],
82
+ refresh: !!into.discovery?.refresh || !!next.discovery?.refresh,
83
+ generation: Math.max(into.discovery?.generation ?? 0, next.discovery?.generation ?? 0),
84
+ }
85
+ : undefined;
86
+ return {
87
+ generation: Math.max(into.generation, next.generation),
88
+ needs: [...new Set([...into.needs, ...next.needs])],
89
+ changedFiles: [...new Set([...into.changedFiles, ...next.changedFiles])],
90
+ trace: next.trace ?? into.trace,
91
+ ...(discovery ? { discovery } : {}),
92
+ };
93
+ }
94
+
95
+ #job(label: string, batch: BatchJob | null, run: () => Promise<void>): QueuedJob {
96
+ return { label, batch, run, settle: Promise.withResolvers<void>() };
97
+ }
98
+
99
+ #push(job: QueuedJob): Promise<void> {
100
+ if (this.size === 0)
101
+ this.#idle = new Promise<void>((resolve) => {
102
+ this.#wakeIdle = resolve;
103
+ });
104
+ this.#pending.push(job);
105
+ if (!this.#running) void this.#pump();
106
+ return job.settle.promise;
107
+ }
108
+
109
+ async #pump(): Promise<void> {
110
+ for (let job = this.#pending.shift(); job; job = this.#pending.shift()) {
111
+ this.#running = job;
112
+ const started = Date.now();
113
+ try {
114
+ await job.run();
115
+ job.settle.resolve();
116
+ } catch (error) {
117
+ job.settle.reject(error);
118
+ } finally {
119
+ this.#running = null;
120
+ this.#onSettled?.(job.label, Date.now() - started);
121
+ }
122
+ }
123
+ this.#wakeIdle?.();
124
+ this.#wakeIdle = null;
125
+ }
126
+ }
@@ -196,7 +196,7 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
196
196
  withEnv(
197
197
  { AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
198
198
  () => {
199
- expect(IncrementalBuilderHost.maxRssBytes()).toBe(1_200 * 1024 * 1024);
199
+ expect(IncrementalBuilderHost.maxRssBytes()).toBe(2_048 * 1024 * 1024);
200
200
  },
201
201
  );
202
202
  });