@akanjs/devkit 2.4.1-rc.2 → 2.4.1-rc.3

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.
@@ -7,14 +7,10 @@ import {
7
7
  AutoImportSync,
8
8
  type ChangeBatch,
9
9
  type ClientEntryDiscovery,
10
- CsrArtifactBuilder,
11
- type CssCompiler,
12
10
  DevChangePlanner,
13
11
  DevGeneratedIndexSync,
14
- FontOptimizer,
15
12
  GraphClientEntryDiscovery,
16
13
  HmrWatcher,
17
- PagesBundleBuilder,
18
14
  RouteClientBuilder,
19
15
  SsrBaseArtifactBuilder,
20
16
  WatchRootResolver,
@@ -30,49 +26,53 @@ import type {
30
26
  BuildPhase,
31
27
  BuildRouteResultPayload,
32
28
  } from "akanjs/server";
29
+ import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
30
+ import { BuildBatchRunner } from "./buildBatchRunner";
33
31
  import { prepareDevWatchBatch } from "./devWatchBatch";
34
32
 
35
33
  interface IncrementalBuilderOptions {
36
34
  app: App;
37
35
  artifact: BaseBuildArtifact;
38
36
  watch: boolean;
39
- cssCompiler: CssCompiler;
40
- optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
37
+ optimizedFonts: OptimizedFonts;
41
38
  discovery: ClientEntryDiscovery;
42
39
  initialGeneration?: number;
43
40
  }
44
41
 
45
- type IncrementalBuilderBootDeps = Pick<
46
- IncrementalBuilderOptions,
47
- "artifact" | "cssCompiler" | "optimizedFonts" | "discovery"
48
- >;
42
+ type IncrementalBuilderBootDeps = Pick<IncrementalBuilderOptions, "artifact" | "optimizedFonts" | "discovery">;
49
43
 
50
44
  class IncrementalBuilder {
51
45
  #logger = new Logger("IncrementalBuilder");
52
46
  #app: App;
53
47
  #artifact: BaseBuildArtifact;
54
48
  #watch: boolean;
55
- #cssCompiler: CssCompiler;
56
- #optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
49
+ /** Kept by value, not as a live `FontOptimizer`: it has to travel to each disposable build worker. */
50
+ #optimizedFonts: OptimizedFonts;
57
51
  #discovery: ClientEntryDiscovery;
52
+ #batchRunner: BuildBatchRunner;
58
53
  #changePlanner: DevChangePlanner;
59
54
  #generatedIndexSync: DevGeneratedIndexSync;
60
55
  #autoImportSync: AutoImportSync;
61
56
  #generation = 0;
62
57
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
63
58
  #workQueue: Promise<void> = Promise.resolve();
59
+ #inFlight = 0;
60
+ #workCount = 0;
61
+ #shuttingDown = false;
64
62
  #cssRebuildQueue: Promise<void> = Promise.resolve();
65
63
  #cssRebuildTimer: ReturnType<typeof setTimeout> | null = null;
66
- #pendingCssRebuild: { artifactDir: string; refresh: boolean; generation?: number; changedFiles?: string[] } | null =
67
- null;
64
+ #pendingCssRebuild: { generation?: number; changedFiles?: string[] } | null = null;
68
65
  constructor(options: IncrementalBuilderOptions) {
69
66
  this.#app = options.app;
70
67
  this.#artifact = options.artifact;
71
68
  this.#watch = options.watch;
72
- this.#cssCompiler = options.cssCompiler;
73
69
  this.#optimizedFonts = options.optimizedFonts;
74
70
  this.#discovery = options.discovery;
75
71
  this.#generation = options.initialGeneration ?? 0;
72
+ this.#batchRunner = new BuildBatchRunner({
73
+ workspaceRoot: options.app.workspace.workspaceRoot,
74
+ cwd: options.app.cwdPath,
75
+ });
76
76
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
77
77
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
78
78
  this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
@@ -138,13 +138,62 @@ class IncrementalBuilder {
138
138
  }
139
139
  async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
140
140
  const started = Date.now();
141
+ this.#inFlight += 1;
141
142
  const run = this.#workQueue.then(fn, fn);
142
143
  this.#workQueue = run.then(() => undefined).catch(() => undefined);
143
144
  try {
144
145
  return await run;
145
146
  } finally {
147
+ this.#inFlight -= 1;
148
+ this.#workCount += 1;
146
149
  this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
150
+ this.#reportMetrics();
151
+ }
152
+ }
153
+
154
+ /** No queued work and no debounced css rebuild, so nothing is lost if the process exits now. */
155
+ get #idle(): boolean {
156
+ return this.#inFlight === 0 && this.#cssRebuildTimer === null;
157
+ }
158
+
159
+ /**
160
+ * Reported only when the builder is idle. The host's only lever against the bundler arenas
161
+ * `Bun.build` retains is to recycle this process, and a recycle decided while work is queued would
162
+ * either truncate that work or race the shutdown drain — so a busy builder simply says nothing.
163
+ */
164
+ #reportMetrics(): void {
165
+ if (!this.#idle || this.#shuttingDown) return;
166
+ process.send?.({
167
+ type: "builder-metrics",
168
+ data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
169
+ });
170
+ }
171
+
172
+ /**
173
+ * Finish queued work, then exit so the OS reclaims the bundler arenas. The host restarts a
174
+ * replacement; build requests that arrive during the drain are refused with the same retry error
175
+ * the backend already handles for any other builder restart.
176
+ */
177
+ async shutdown(reason: string): Promise<void> {
178
+ if (this.#shuttingDown) return;
179
+ this.#shuttingDown = true;
180
+ const started = Date.now();
181
+ this.#logger.info(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
182
+ if (this.#cssRebuildTimer) {
183
+ // Only reachable if a css batch landed between the idle report and this request: the fresh
184
+ // boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
185
+ clearTimeout(this.#cssRebuildTimer);
186
+ this.#cssRebuildTimer = null;
187
+ this.#pendingCssRebuild = null;
147
188
  }
189
+ await this.#workQueue.catch(() => undefined);
190
+ await this.#cssRebuildQueue.catch(() => undefined);
191
+ this.#logger.info(`drained in ${Date.now() - started}ms; exiting for recycle`);
192
+ process.exit(0);
193
+ }
194
+
195
+ get shuttingDown(): boolean {
196
+ return this.#shuttingDown;
148
197
  }
149
198
  batchTouchesPagesTree(appDir: string, batch: ChangeBatch): boolean {
150
199
  const absAppDir = path.resolve(appDir);
@@ -167,75 +216,23 @@ class IncrementalBuilder {
167
216
  }
168
217
  return false;
169
218
  }
170
- async rebuildCssArtifact(
171
- artifactDir: string,
172
- { refresh, generation, changedFiles }: { refresh: boolean; generation?: number; changedFiles?: string[] },
173
- ) {
174
- const cssStarted = Date.now();
175
- const cssByBasePathStarted = Date.now();
176
- const cssByBasePath = await this.#cssCompiler.getCssByBasePath({ refresh });
177
- this.#logger.verbose(`css-get-by-base-path ok (${Date.now() - cssByBasePathStarted}ms)`);
178
- const fontStarted = Date.now();
179
- const optimizedFonts = await this.#getOptimizedFonts(changedFiles ?? []);
180
- this.#logger.verbose(`font-assets ready (${Date.now() - fontStarted}ms)`);
181
- const cssAssetEntries: Array<[string, { cssUrl: string; cssRelPath: string }]> = [];
182
- const cssBase64ByUrl: Record<string, string> = {};
183
- await Promise.all(
184
- Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
185
- const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
186
- if (!cssText) return [];
187
- return [
188
- (async () => {
189
- const cssAssetName = basePath || "root";
190
- const cssHash = Bun.hash(`${basePath}\n${cssText}`).toString(36);
191
- const cssRelPath = `styles/${cssAssetName}-${cssHash}.css`;
192
- const cssUrl = `/_akan/styles/${cssAssetName}-${cssHash}.css`;
193
- await Bun.write(path.join(artifactDir, cssRelPath), cssText);
194
- cssAssetEntries.push([basePath, { cssUrl, cssRelPath }]);
195
- cssBase64ByUrl[cssUrl] = Buffer.from(new TextEncoder().encode(cssText)).toString("base64");
196
- })(),
197
- ];
198
- }),
199
- );
200
- const cssAssets = Object.fromEntries(cssAssetEntries);
201
- if (JSON.stringify(this.#artifact.cssAssets ?? {}) === JSON.stringify(cssAssets)) {
202
- this.#logger.verbose("css-rebuild unchanged assets; broadcast skipped");
203
- return;
204
- }
205
- this.#artifact = { ...this.#artifact, cssAssets };
206
- this.#logger.verbose(`css-compile ok assets=${Object.keys(cssAssets).length} (${Date.now() - cssStarted}ms)`);
207
- process.send?.({
208
- type: "css-updated",
209
- data: {
210
- cssAssets,
211
- cssBase64ByUrl,
212
- generation,
213
- changedFiles,
214
- },
215
- });
216
- }
217
-
218
- scheduleCssRebuild(
219
- artifactDir: string,
220
- { refresh, generation, changedFiles }: { refresh: boolean; generation?: number; changedFiles?: string[] },
221
- ) {
222
- this.#pendingCssRebuild = { artifactDir, refresh, generation, changedFiles };
219
+ /** Debounced css-only rebuild; the compile itself runs in a disposable worker like every other build. */
220
+ scheduleCssRebuild({ generation, changedFiles }: { generation?: number; changedFiles?: string[] }) {
221
+ this.#pendingCssRebuild = { generation, changedFiles };
223
222
  if (this.#cssRebuildTimer) clearTimeout(this.#cssRebuildTimer);
224
223
  this.#cssRebuildTimer = setTimeout(() => {
225
224
  this.#cssRebuildTimer = null;
226
225
  const next = this.#pendingCssRebuild;
227
226
  this.#pendingCssRebuild = null;
228
227
  if (!next) return;
228
+ this.#inFlight += 1;
229
229
  this.#cssRebuildQueue = this.#cssRebuildQueue
230
230
  .then(async () => {
231
- const started = Date.now();
232
- await this.rebuildCssArtifact(next.artifactDir, {
233
- refresh: next.refresh,
234
- generation: next.generation,
235
- changedFiles: next.changedFiles,
231
+ await this.#runBatch({
232
+ generation: next.generation ?? this.#generation,
233
+ needs: ["css"],
234
+ changedFiles: next.changedFiles ?? [],
236
235
  });
237
- this.#sendBuildStatus("css", { generation: next.generation, ok: true, files: next.changedFiles });
238
- this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
239
236
  })
240
237
  .catch((err) => {
241
238
  const message = err instanceof Error ? err.message : String(err);
@@ -246,29 +243,13 @@ class IncrementalBuilder {
246
243
  files: next.changedFiles,
247
244
  message,
248
245
  });
246
+ })
247
+ .finally(() => {
248
+ this.#inFlight -= 1;
249
+ this.#reportMetrics();
249
250
  });
250
251
  }, 150);
251
252
  }
252
-
253
- async #getOptimizedFonts(changedFiles: string[]) {
254
- if (!this.#shouldReoptimizeFonts(changedFiles)) {
255
- this.#logger.verbose(`font-optimize cached files=${this.#optimizedFonts.files.length}`);
256
- return this.#optimizedFonts;
257
- }
258
- const started = Date.now();
259
- this.#optimizedFonts = await new FontOptimizer(this.#app, "start").optimize();
260
- this.#logger.verbose(`font-optimize ok files=${this.#optimizedFonts.files.length} (${Date.now() - started}ms)`);
261
- return this.#optimizedFonts;
262
- }
263
-
264
- #shouldReoptimizeFonts(changedFiles: string[]) {
265
- if (changedFiles.length === 0) return false;
266
- return changedFiles.some((file) => {
267
- const normalized = path.resolve(file);
268
- if (/\.(woff2?|ttf|otf)$/i.test(normalized)) return true;
269
- return this.#optimizedFonts.files.some((fontFile) => path.resolve(fontFile) === normalized);
270
- });
271
- }
272
253
  async installWatcher() {
273
254
  const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
274
255
  const roots = await new WatchRootResolver(this.#app).resolve();
@@ -338,49 +319,86 @@ class IncrementalBuilder {
338
319
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
339
320
  }
340
321
 
341
- if (kinds.includes("code") && rebuildClient && this.#shouldRebuildCsr()) {
342
- try {
343
- const started = Date.now();
344
- await new CsrArtifactBuilder(this.#app).build();
345
- this.#sendBuildStatus("csr", { generation, ok: true, files });
346
- this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
347
- } catch (err) {
348
- const message = err instanceof Error ? err.message : String(err);
349
- this.#logger.error(`csr-rebundle failed: ${message}`);
350
- this.#sendBuildStatus("csr", { generation, ok: false, files, message });
351
- }
352
- } else if (kinds.includes("code") && rebuildClient) {
353
- this.#logger.verbose(
354
- `csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
355
- );
322
+ const needs: BuildBatchNeed[] = [];
323
+ if (kinds.includes("code") && rebuildClient) {
324
+ if (this.#shouldRebuildCsr()) needs.push("csr");
325
+ else
326
+ this.#logger.verbose(
327
+ `csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
328
+ );
329
+ needs.push("pages");
330
+ // Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
331
+ // client rebuild or a direct stylesheet edit can change the compiled CSS. Folded into this
332
+ // generation's batch rather than debounced separately: the work queue already serializes
333
+ // generations, so by the time a second batch runs the 150ms debounce would have fired anyway,
334
+ // and a second worker spawn per save costs more than the coalescing ever saved.
335
+ needs.push("css");
356
336
  }
357
337
 
358
338
  process.send?.(event);
359
339
 
360
- if (kinds.includes("code") && rebuildClient) {
361
- try {
362
- const started = Date.now();
363
- const next = await new PagesBundleBuilder(this.#app).build();
364
- process.send?.({
365
- type: "pages-updated",
366
- data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles: files },
367
- });
368
- this.#sendBuildStatus("pages", { generation, ok: true, files });
369
- this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
370
- } catch (err) {
371
- const message = err instanceof Error ? err.message : String(err);
372
- this.#logger.error(`pages-rebundle failed: ${message}`);
373
- this.#sendBuildStatus("pages", { generation, ok: false, files, message });
374
- }
375
- }
376
- // Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
377
- // client rebuild or a direct stylesheet edit can change the compiled CSS.
378
- if (kinds.includes("css") || (kinds.includes("code") && rebuildClient)) {
379
- this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
340
+ if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
341
+ // A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
342
+ // without a pages build in front of them there is nothing else to space them out.
343
+ else if (kinds.includes("css")) {
344
+ this.scheduleCssRebuild({ generation, changedFiles: files });
380
345
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
381
346
  }
382
347
  }
383
348
 
349
+ /**
350
+ * Run one generation of build work in a process that exits afterwards. The worker streams the
351
+ * messages the backend and the HMR overlay consume, which this relays untouched, so the sequence a
352
+ * browser observes is the same one the in-process build produced.
353
+ */
354
+ async #runBatch({
355
+ generation,
356
+ needs,
357
+ changedFiles,
358
+ }: {
359
+ generation: number;
360
+ needs: BuildBatchNeed[];
361
+ changedFiles: string[];
362
+ }): Promise<BuildBatchResult> {
363
+ const started = Date.now();
364
+ const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
365
+ process.send?.(msg),
366
+ );
367
+ if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
368
+ if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
369
+ // A worker that died before reporting streamed no build-status of its own, so report one per need
370
+ // it was given: the generation must go red rather than look like it silently succeeded.
371
+ if (result.crashed) {
372
+ for (const need of needs)
373
+ this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
374
+ }
375
+ if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
376
+ return result;
377
+ }
378
+
379
+ async #batchRequest({
380
+ generation,
381
+ needs,
382
+ changedFiles,
383
+ }: {
384
+ generation: number;
385
+ needs: BuildBatchNeed[];
386
+ changedFiles: string[];
387
+ }): Promise<BuildBatchRequest> {
388
+ return {
389
+ appName: this.#app.name,
390
+ workspaceRoot: this.#app.workspace.workspaceRoot,
391
+ repoName: this.#app.workspace.repoName,
392
+ generation,
393
+ needs,
394
+ changedFiles,
395
+ pageKeys: await this.#app.getPageKeys(),
396
+ optimizedFonts: this.#optimizedFonts,
397
+ cssAssets: this.#artifact.cssAssets ?? null,
398
+ artifactDir: path.resolve(this.#artifactDir),
399
+ };
400
+ }
401
+
384
402
  async boot(): Promise<void> {
385
403
  if (this.#watch) await this.installWatcher();
386
404
  process.send?.({ type: "builder-ready" });
@@ -394,20 +412,47 @@ class IncrementalBuilder {
394
412
  async announceRecoveredState(changedFiles: string[]): Promise<void> {
395
413
  const generation = ++this.#generation;
396
414
  await this.#enqueueWork("boot-recovered", async () => {
397
- try {
398
- const next = await new PagesBundleBuilder(this.#app).build();
399
- process.send?.({
400
- type: "pages-updated",
401
- data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles },
402
- });
403
- this.#sendBuildStatus("pages", { generation, ok: true, files: changedFiles, message: "Boot build recovered" });
404
- } catch (err) {
405
- const message = err instanceof Error ? err.message : String(err);
406
- this.#logger.error(`recovered pages rebundle failed: ${message}`);
407
- this.#sendBuildStatus("pages", { generation, ok: false, files: changedFiles, message });
408
- }
409
- this.scheduleCssRebuild(this.#artifactDir, { refresh: true, generation, changedFiles });
415
+ await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Re-announce the state this builder just booted with, after the host recycled the previous one.
421
+ *
422
+ * The backend reads `base-artifact.json` once at boot and never re-reads it, so a pages or css hash
423
+ * that moved while the builder was being replaced would otherwise leave it pointing at the previous
424
+ * artifact until the next save. Announced from the boot artifact rather than by rebuilding: the
425
+ * bundles are already on disk, and spending another ~200MB of bundler arena in a process that was
426
+ * just recycled to reclaim memory would defeat the purpose. The host suppresses the announcement
427
+ * when the hashes match, which is the common case, so a clean recycle never reloads a browser.
428
+ */
429
+ async announceBootState(): Promise<void> {
430
+ const generation = ++this.#generation;
431
+ const reason = "builder-recycle" as const;
432
+ process.send?.({
433
+ type: "pages-updated",
434
+ data: {
435
+ bundlePath: this.#artifact.pagesBundlePath,
436
+ buildId: this.#artifact.pagesBundleBuildId,
437
+ generation,
438
+ changedFiles: [],
439
+ reason,
440
+ },
410
441
  });
442
+ const cssAssets = this.#artifact.cssAssets ?? {};
443
+ const cssBase64ByUrl = Object.fromEntries(
444
+ await Promise.all(
445
+ Object.values(cssAssets).map(async ({ cssUrl, cssRelPath }) => [
446
+ cssUrl,
447
+ Buffer.from(await Bun.file(path.join(this.#artifactDir, cssRelPath)).arrayBuffer()).toString("base64"),
448
+ ]),
449
+ ),
450
+ );
451
+ process.send?.({
452
+ type: "css-updated",
453
+ data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
454
+ });
455
+ this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
411
456
  }
412
457
 
413
458
  /**
@@ -418,16 +463,19 @@ class IncrementalBuilder {
418
463
  async handleBuildCsr(msg: BuilderCsrReq): Promise<BuilderCsrRes> {
419
464
  return this.#enqueueWork("build-csr", async (): Promise<BuilderCsrRes> => {
420
465
  const started = Date.now();
421
- try {
422
- await new CsrArtifactBuilder(this.#app).build();
423
- this.#csrActive = true;
424
- this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
425
- return { type: "build-csr-res", id: msg.id, ok: true };
426
- } catch (err) {
427
- const message = err instanceof Error ? err.message : String(err);
428
- this.#logger.error(`csr-build failed: ${message}`);
429
- return { type: "build-csr-res", id: msg.id, ok: false, error: message };
466
+ // Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
467
+ // never carried a csr status for it before. The error travels in the response below.
468
+ const result = await this.#batchRunner.run(
469
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
470
+ );
471
+ const error = result.errors.csr;
472
+ if (error) {
473
+ this.#logger.error(`csr-build failed: ${error}`);
474
+ return { type: "build-csr-res", id: msg.id, ok: false, error };
430
475
  }
476
+ this.#csrActive = true;
477
+ this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
478
+ return { type: "build-csr-res", id: msg.id, ok: true };
431
479
  });
432
480
  }
433
481
 
@@ -440,12 +488,28 @@ class IncrementalBuilder {
440
488
  }
441
489
 
442
490
  static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
443
- const { artifact, cssCompiler, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
444
- //* A full minified browser-target build of every page costs ~350MB of bundler arena the process
445
- //* never returns, so skip it until a `/__csr` or `?csr=true` request arms it via `build-csr`.
446
- if (IncrementalBuilder.#csrArmedByEnv()) await new CsrArtifactBuilder(app).build();
491
+ const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
447
492
  const discovery = await GraphClientEntryDiscovery.create(app);
448
- return { artifact, cssCompiler, optimizedFonts, discovery };
493
+ return { artifact, optimizedFonts, discovery };
494
+ }
495
+
496
+ /**
497
+ * A session that already armed dev CSR keeps it armed across builder restarts through the env flag,
498
+ * so the artifact has to be rebuilt for the replacement. Queued after `builder-ready` and run in a
499
+ * disposable worker rather than inline: a full minified browser-target build of every page costs
500
+ * ~350MB of bundler arena that an inline build would never give back, and nothing serves CSR until a
501
+ * `/__csr` or `?csr=true` request arrives anyway.
502
+ */
503
+ async rearmCsrFromEnv(): Promise<void> {
504
+ if (!IncrementalBuilder.#csrArmedByEnv()) return;
505
+ this.#csrActive = true;
506
+ await this.#enqueueWork("build-csr-rearm", async () => {
507
+ const result = await this.#batchRunner.run(
508
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
509
+ );
510
+ if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
511
+ else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
512
+ });
449
513
  }
450
514
 
451
515
  /**
@@ -511,19 +575,30 @@ class IncrementalBuilder {
511
575
  // Registered before the boot build so backend requests get an error response (instead of hanging
512
576
  // the backend) while the builder is still booting or recovering from a failed build.
513
577
  const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
578
+ const recyclingError = "builder is recycling to release bundler memory; retry after it restarts";
514
579
  process.on("message", (msg: BuilderMessage) => {
515
580
  if (!msg || typeof msg !== "object") return;
516
- if (msg.type === "build-route") {
581
+ if (msg.type === "builder-shutdown") {
517
582
  if (!builder) {
518
- process.send?.({ type: "build-route-res", id: msg.id, ok: false, error: bootingError });
583
+ logger.warn(`ignoring shutdown request (${msg.reason}); builder is still recovering from a failed boot`);
584
+ return;
585
+ }
586
+ void builder.shutdown(msg.reason);
587
+ return;
588
+ }
589
+ if (msg.type === "build-route") {
590
+ const error = builder?.shuttingDown ? recyclingError : bootingError;
591
+ if (!builder || builder.shuttingDown) {
592
+ process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
519
593
  return;
520
594
  }
521
595
  void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
522
596
  return;
523
597
  }
524
598
  if (msg.type === "build-csr") {
525
- if (!builder) {
526
- process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error: bootingError });
599
+ const error = builder?.shuttingDown ? recyclingError : bootingError;
600
+ if (!builder || builder.shuttingDown) {
601
+ process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
527
602
  return;
528
603
  }
529
604
  void builder.handleBuildCsr(msg).then((res) => process.send?.(res));
@@ -546,6 +621,8 @@ class IncrementalBuilder {
546
621
  }
547
622
  await builder.boot();
548
623
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
624
+ else if (process.env.AKAN_BUILDER_RECYCLED === "1") await builder.announceBootState();
625
+ await builder.rearmCsrFromEnv();
549
626
  }
550
627
  }
551
628