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

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,
@@ -23,56 +19,61 @@ import { Logger } from "akanjs/common";
23
19
  import type {
24
20
  BaseBuildArtifact,
25
21
  BuilderCsrReq,
26
- BuilderCsrRes,
27
22
  BuilderMessage,
28
23
  BuilderReq,
29
24
  BuilderRes,
30
25
  BuildPhase,
31
26
  BuildRouteResultPayload,
32
27
  } from "akanjs/server";
28
+ import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
29
+ import { BuildBatchRunner } from "./buildBatchRunner";
30
+ import { BuilderReply } from "./builderReply";
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;
56
+ #watcher: HmrWatcher | null = null;
61
57
  #generation = 0;
62
58
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
63
59
  #workQueue: Promise<void> = Promise.resolve();
60
+ #inFlight = 0;
61
+ #workCount = 0;
62
+ #shuttingDown = false;
64
63
  #cssRebuildQueue: Promise<void> = Promise.resolve();
65
64
  #cssRebuildTimer: ReturnType<typeof setTimeout> | null = null;
66
- #pendingCssRebuild: { artifactDir: string; refresh: boolean; generation?: number; changedFiles?: string[] } | null =
67
- null;
65
+ #pendingCssRebuild: { generation?: number; changedFiles?: string[] } | null = null;
68
66
  constructor(options: IncrementalBuilderOptions) {
69
67
  this.#app = options.app;
70
68
  this.#artifact = options.artifact;
71
69
  this.#watch = options.watch;
72
- this.#cssCompiler = options.cssCompiler;
73
70
  this.#optimizedFonts = options.optimizedFonts;
74
71
  this.#discovery = options.discovery;
75
72
  this.#generation = options.initialGeneration ?? 0;
73
+ this.#batchRunner = new BuildBatchRunner({
74
+ workspaceRoot: options.app.workspace.workspaceRoot,
75
+ cwd: options.app.cwdPath,
76
+ });
76
77
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
77
78
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
78
79
  this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
@@ -82,8 +83,14 @@ class IncrementalBuilder {
82
83
  return `${this.#app.cwdPath}/.akan/artifact`;
83
84
  }
84
85
 
85
- async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
86
- return this.#enqueueWork(`build-route:${msg.routeId}`, async () => this.#handleBuildRoute(msg));
86
+ /**
87
+ * Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
88
+ * work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
89
+ */
90
+ async handleBuildRoute(msg: BuilderReq): Promise<void> {
91
+ await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
92
+ BuilderReply.send(await this.#handleBuildRoute(msg)),
93
+ );
87
94
  }
88
95
 
89
96
  async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
@@ -138,14 +145,63 @@ class IncrementalBuilder {
138
145
  }
139
146
  async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
140
147
  const started = Date.now();
148
+ this.#inFlight += 1;
141
149
  const run = this.#workQueue.then(fn, fn);
142
150
  this.#workQueue = run.then(() => undefined).catch(() => undefined);
143
151
  try {
144
152
  return await run;
145
153
  } finally {
154
+ this.#inFlight -= 1;
155
+ this.#workCount += 1;
146
156
  this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
157
+ this.#reportMetrics();
147
158
  }
148
159
  }
160
+
161
+ /** No queued work and no debounced css rebuild, so nothing is lost if the process exits now. */
162
+ get #idle(): boolean {
163
+ return this.#inFlight === 0 && this.#cssRebuildTimer === null;
164
+ }
165
+
166
+ /**
167
+ * Reported only when the builder is idle. The host's only lever against the bundler arenas
168
+ * `Bun.build` retains is to recycle this process, and a recycle decided while work is queued would
169
+ * either truncate that work or race the shutdown drain — so a busy builder simply says nothing.
170
+ */
171
+ #reportMetrics(): void {
172
+ if (!this.#idle || this.#shuttingDown) return;
173
+ process.send?.({
174
+ type: "builder-metrics",
175
+ data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Finish queued work, then exit so the OS reclaims the bundler arenas. The host restarts a
181
+ * replacement; build requests that arrive during the drain are refused with the same retry error
182
+ * the backend already handles for any other builder restart.
183
+ */
184
+ async shutdown(reason: string): Promise<void> {
185
+ if (this.#shuttingDown) return;
186
+ this.#shuttingDown = true;
187
+ const started = Date.now();
188
+ this.#logger.info(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
189
+ if (this.#cssRebuildTimer) {
190
+ // Only reachable if a css batch landed between the idle report and this request: the fresh
191
+ // boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
192
+ clearTimeout(this.#cssRebuildTimer);
193
+ this.#cssRebuildTimer = null;
194
+ this.#pendingCssRebuild = null;
195
+ }
196
+ await this.#workQueue.catch(() => undefined);
197
+ await this.#cssRebuildQueue.catch(() => undefined);
198
+ this.#logger.info(`drained in ${Date.now() - started}ms; exiting for recycle`);
199
+ process.exit(0);
200
+ }
201
+
202
+ get shuttingDown(): boolean {
203
+ return this.#shuttingDown;
204
+ }
149
205
  batchTouchesPagesTree(appDir: string, batch: ChangeBatch): boolean {
150
206
  const absAppDir = path.resolve(appDir);
151
207
  for (const f of batch.files) {
@@ -167,75 +223,23 @@ class IncrementalBuilder {
167
223
  }
168
224
  return false;
169
225
  }
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 };
226
+ /** Debounced css-only rebuild; the compile itself runs in a disposable worker like every other build. */
227
+ scheduleCssRebuild({ generation, changedFiles }: { generation?: number; changedFiles?: string[] }) {
228
+ this.#pendingCssRebuild = { generation, changedFiles };
223
229
  if (this.#cssRebuildTimer) clearTimeout(this.#cssRebuildTimer);
224
230
  this.#cssRebuildTimer = setTimeout(() => {
225
231
  this.#cssRebuildTimer = null;
226
232
  const next = this.#pendingCssRebuild;
227
233
  this.#pendingCssRebuild = null;
228
234
  if (!next) return;
235
+ this.#inFlight += 1;
229
236
  this.#cssRebuildQueue = this.#cssRebuildQueue
230
237
  .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,
238
+ await this.#runBatch({
239
+ generation: next.generation ?? this.#generation,
240
+ needs: ["css"],
241
+ changedFiles: next.changedFiles ?? [],
236
242
  });
237
- this.#sendBuildStatus("css", { generation: next.generation, ok: true, files: next.changedFiles });
238
- this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
239
243
  })
240
244
  .catch((err) => {
241
245
  const message = err instanceof Error ? err.message : String(err);
@@ -246,29 +250,13 @@ class IncrementalBuilder {
246
250
  files: next.changedFiles,
247
251
  message,
248
252
  });
253
+ })
254
+ .finally(() => {
255
+ this.#inFlight -= 1;
256
+ this.#reportMetrics();
249
257
  });
250
258
  }, 150);
251
259
  }
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
260
  async installWatcher() {
273
261
  const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
274
262
  const roots = await new WatchRootResolver(this.#app).resolve();
@@ -279,7 +267,8 @@ class IncrementalBuilder {
279
267
  await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
280
268
  },
281
269
  });
282
- watcher.start();
270
+ await watcher.start();
271
+ this.#watcher = watcher;
283
272
  this.#logger.verbose(`watching ${roots.length} roots`);
284
273
  }
285
274
 
@@ -295,6 +284,10 @@ class IncrementalBuilder {
295
284
  if (autoImport.changedFiles.length > 0)
296
285
  this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
297
286
  const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
287
+ //* Both passes above write source files, and this generation's build consumes what they wrote. Hand
288
+ //* them to the watcher so its verification scan does not read them back as a user edit and spend a
289
+ //* second generation rebuilding identical content.
290
+ await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
298
291
  const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
299
292
  generation,
300
293
  batch,
@@ -338,49 +331,86 @@ class IncrementalBuilder {
338
331
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
339
332
  }
340
333
 
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
- );
334
+ const needs: BuildBatchNeed[] = [];
335
+ if (kinds.includes("code") && rebuildClient) {
336
+ if (this.#shouldRebuildCsr()) needs.push("csr");
337
+ else
338
+ this.#logger.verbose(
339
+ `csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
340
+ );
341
+ needs.push("pages");
342
+ // Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
343
+ // client rebuild or a direct stylesheet edit can change the compiled CSS. Folded into this
344
+ // generation's batch rather than debounced separately: the work queue already serializes
345
+ // generations, so by the time a second batch runs the 150ms debounce would have fired anyway,
346
+ // and a second worker spawn per save costs more than the coalescing ever saved.
347
+ needs.push("css");
356
348
  }
357
349
 
358
350
  process.send?.(event);
359
351
 
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 });
352
+ if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
353
+ // A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
354
+ // without a pages build in front of them there is nothing else to space them out.
355
+ else if (kinds.includes("css")) {
356
+ this.scheduleCssRebuild({ generation, changedFiles: files });
380
357
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
381
358
  }
382
359
  }
383
360
 
361
+ /**
362
+ * Run one generation of build work in a process that exits afterwards. The worker streams the
363
+ * messages the backend and the HMR overlay consume, which this relays untouched, so the sequence a
364
+ * browser observes is the same one the in-process build produced.
365
+ */
366
+ async #runBatch({
367
+ generation,
368
+ needs,
369
+ changedFiles,
370
+ }: {
371
+ generation: number;
372
+ needs: BuildBatchNeed[];
373
+ changedFiles: string[];
374
+ }): Promise<BuildBatchResult> {
375
+ const started = Date.now();
376
+ const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
377
+ process.send?.(msg),
378
+ );
379
+ if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
380
+ if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
381
+ // A worker that died before reporting streamed no build-status of its own, so report one per need
382
+ // it was given: the generation must go red rather than look like it silently succeeded.
383
+ if (result.crashed) {
384
+ for (const need of needs)
385
+ this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
386
+ }
387
+ if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
388
+ return result;
389
+ }
390
+
391
+ async #batchRequest({
392
+ generation,
393
+ needs,
394
+ changedFiles,
395
+ }: {
396
+ generation: number;
397
+ needs: BuildBatchNeed[];
398
+ changedFiles: string[];
399
+ }): Promise<BuildBatchRequest> {
400
+ return {
401
+ appName: this.#app.name,
402
+ workspaceRoot: this.#app.workspace.workspaceRoot,
403
+ repoName: this.#app.workspace.repoName,
404
+ generation,
405
+ needs,
406
+ changedFiles,
407
+ pageKeys: await this.#app.getPageKeys(),
408
+ optimizedFonts: this.#optimizedFonts,
409
+ cssAssets: this.#artifact.cssAssets ?? null,
410
+ artifactDir: path.resolve(this.#artifactDir),
411
+ };
412
+ }
413
+
384
414
  async boot(): Promise<void> {
385
415
  if (this.#watch) await this.installWatcher();
386
416
  process.send?.({ type: "builder-ready" });
@@ -394,20 +424,47 @@ class IncrementalBuilder {
394
424
  async announceRecoveredState(changedFiles: string[]): Promise<void> {
395
425
  const generation = ++this.#generation;
396
426
  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 });
427
+ await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
428
+ });
429
+ }
430
+
431
+ /**
432
+ * Re-announce the state this builder just booted with, after the host recycled the previous one.
433
+ *
434
+ * The backend reads `base-artifact.json` once at boot and never re-reads it, so a pages or css hash
435
+ * that moved while the builder was being replaced would otherwise leave it pointing at the previous
436
+ * artifact until the next save. Announced from the boot artifact rather than by rebuilding: the
437
+ * bundles are already on disk, and spending another ~200MB of bundler arena in a process that was
438
+ * just recycled to reclaim memory would defeat the purpose. The host suppresses the announcement
439
+ * when the hashes match, which is the common case, so a clean recycle never reloads a browser.
440
+ */
441
+ async announceBootState(): Promise<void> {
442
+ const generation = ++this.#generation;
443
+ const reason = "builder-recycle" as const;
444
+ process.send?.({
445
+ type: "pages-updated",
446
+ data: {
447
+ bundlePath: this.#artifact.pagesBundlePath,
448
+ buildId: this.#artifact.pagesBundleBuildId,
449
+ generation,
450
+ changedFiles: [],
451
+ reason,
452
+ },
453
+ });
454
+ const cssAssets = this.#artifact.cssAssets ?? {};
455
+ const cssBase64ByUrl = Object.fromEntries(
456
+ await Promise.all(
457
+ Object.values(cssAssets).map(async ({ cssUrl, cssRelPath }) => [
458
+ cssUrl,
459
+ Buffer.from(await Bun.file(path.join(this.#artifactDir, cssRelPath)).arrayBuffer()).toString("base64"),
460
+ ]),
461
+ ),
462
+ );
463
+ process.send?.({
464
+ type: "css-updated",
465
+ data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
410
466
  });
467
+ this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
411
468
  }
412
469
 
413
470
  /**
@@ -415,19 +472,23 @@ class IncrementalBuilder {
415
472
  * dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
416
473
  * points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
417
474
  */
418
- async handleBuildCsr(msg: BuilderCsrReq): Promise<BuilderCsrRes> {
419
- return this.#enqueueWork("build-csr", async (): Promise<BuilderCsrRes> => {
475
+ async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
476
+ await this.#enqueueWork("build-csr", async (): Promise<void> => {
420
477
  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 };
478
+ // Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
479
+ // never carried a csr status for it before. The error travels in the response below.
480
+ const result = await this.#batchRunner.run(
481
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
482
+ );
483
+ const error = result.errors.csr;
484
+ if (error) {
485
+ this.#logger.error(`csr-build failed: ${error}`);
486
+ await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: false, error });
487
+ return;
430
488
  }
489
+ this.#csrActive = true;
490
+ this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
491
+ await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: true });
431
492
  });
432
493
  }
433
494
 
@@ -440,12 +501,28 @@ class IncrementalBuilder {
440
501
  }
441
502
 
442
503
  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();
504
+ const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
447
505
  const discovery = await GraphClientEntryDiscovery.create(app);
448
- return { artifact, cssCompiler, optimizedFonts, discovery };
506
+ return { artifact, optimizedFonts, discovery };
507
+ }
508
+
509
+ /**
510
+ * A session that already armed dev CSR keeps it armed across builder restarts through the env flag,
511
+ * so the artifact has to be rebuilt for the replacement. Queued after `builder-ready` and run in a
512
+ * disposable worker rather than inline: a full minified browser-target build of every page costs
513
+ * ~350MB of bundler arena that an inline build would never give back, and nothing serves CSR until a
514
+ * `/__csr` or `?csr=true` request arrives anyway.
515
+ */
516
+ async rearmCsrFromEnv(): Promise<void> {
517
+ if (!IncrementalBuilder.#csrArmedByEnv()) return;
518
+ this.#csrActive = true;
519
+ await this.#enqueueWork("build-csr-rearm", async () => {
520
+ const result = await this.#batchRunner.run(
521
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
522
+ );
523
+ if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
524
+ else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
525
+ });
449
526
  }
450
527
 
451
528
  /**
@@ -494,7 +571,7 @@ class IncrementalBuilder {
494
571
  }
495
572
  },
496
573
  });
497
- watcher.start();
574
+ await watcher.start();
498
575
  logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
499
576
  })().catch(reject);
500
577
  });
@@ -511,22 +588,33 @@ class IncrementalBuilder {
511
588
  // Registered before the boot build so backend requests get an error response (instead of hanging
512
589
  // the backend) while the builder is still booting or recovering from a failed build.
513
590
  const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
591
+ const recyclingError = "builder is recycling to release bundler memory; retry after it restarts";
514
592
  process.on("message", (msg: BuilderMessage) => {
515
593
  if (!msg || typeof msg !== "object") return;
516
- if (msg.type === "build-route") {
594
+ if (msg.type === "builder-shutdown") {
517
595
  if (!builder) {
518
- process.send?.({ type: "build-route-res", id: msg.id, ok: false, error: bootingError });
596
+ logger.warn(`ignoring shutdown request (${msg.reason}); builder is still recovering from a failed boot`);
519
597
  return;
520
598
  }
521
- void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
599
+ void builder.shutdown(msg.reason);
600
+ return;
601
+ }
602
+ if (msg.type === "build-route") {
603
+ const error = builder?.shuttingDown ? recyclingError : bootingError;
604
+ if (!builder || builder.shuttingDown) {
605
+ process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
606
+ return;
607
+ }
608
+ void builder.handleBuildRoute(msg);
522
609
  return;
523
610
  }
524
611
  if (msg.type === "build-csr") {
525
- if (!builder) {
526
- process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error: bootingError });
612
+ const error = builder?.shuttingDown ? recyclingError : bootingError;
613
+ if (!builder || builder.shuttingDown) {
614
+ process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
527
615
  return;
528
616
  }
529
- void builder.handleBuildCsr(msg).then((res) => process.send?.(res));
617
+ void builder.handleBuildCsr(msg);
530
618
  }
531
619
  });
532
620
  // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
@@ -546,6 +634,8 @@ class IncrementalBuilder {
546
634
  }
547
635
  await builder.boot();
548
636
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
637
+ else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
638
+ await builder.rearmCsrFromEnv();
549
639
  }
550
640
  }
551
641