@akanjs/devkit 2.4.1-rc.1 → 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.
@@ -4,14 +4,17 @@ import {
4
4
  backendRestartReasonFromMessage,
5
5
  buildStatusReplaySequence,
6
6
  createBackendBuildStatus,
7
+ decideBuilderRssRecycle,
7
8
  hasBuildFailureForGeneration,
8
9
  isLegacyBackendFallbackFile,
9
10
  mergeBackendRestartReasons,
10
11
  mergeInvalidateMessages,
11
12
  normalizeBackendReportedGeneration,
12
13
  shouldAbandonBackendRecovery,
14
+ shouldAbandonBuilderRssCeiling,
13
15
  shouldMarkBuildPhaseRecovered,
14
16
  shouldQueueBuildStatusReplay,
17
+ shouldRelayRecycledFrontendState,
15
18
  shouldReplaceLastGoodMessage,
16
19
  shouldRestartBackendByDevPlan,
17
20
  shouldRestartBuilderByDevPlan,
@@ -141,6 +144,87 @@ describe("last-good frontend helpers", () => {
141
144
  });
142
145
  });
143
146
 
147
+ describe("builder rss recycle", () => {
148
+ const ceiling = 1_200 * 1024 * 1024;
149
+ const decide = (over: Partial<Parameters<typeof decideBuilderRssRecycle>[0]>) =>
150
+ decideBuilderRssRecycle({
151
+ rssBytes: ceiling + 1,
152
+ ceilingBytes: ceiling,
153
+ buildFailed: false,
154
+ msSinceLastRecycle: null,
155
+ ...over,
156
+ });
157
+
158
+ test("recycles an idle builder that crossed the ceiling", () => {
159
+ expect(decide({})).toBe("recycle");
160
+ });
161
+
162
+ test("leaves a builder under the ceiling alone", () => {
163
+ expect(decide({ rssBytes: ceiling - 1 })).toBe("below-ceiling");
164
+ });
165
+
166
+ test("does nothing when no ceiling is configured", () => {
167
+ expect(decide({ ceilingBytes: null })).toBe("unbounded");
168
+ });
169
+
170
+ // Rebooting on a generation whose build failed strands the dev server: the replacement hits the
171
+ // same compile error and exits before builder-ready.
172
+ test("defers while the current generation has a failing build", () => {
173
+ expect(decide({ buildFailed: true })).toBe("build-failed");
174
+ });
175
+
176
+ test("refuses a second recycle inside the minimum interval", () => {
177
+ expect(decide({ msSinceLastRecycle: 5_000 })).toBe("too-soon");
178
+ expect(decide({ msSinceLastRecycle: 31_000 })).toBe("recycle");
179
+ expect(decide({ msSinceLastRecycle: 5_000, minIntervalMs: 1_000 })).toBe("recycle");
180
+ });
181
+
182
+ // An app whose fresh boot is already over the ceiling would otherwise be recycled forever.
183
+ test("abandons the ceiling once recycling stops buying relief", () => {
184
+ expect(shouldAbandonBuilderRssCeiling(1)).toBe(false);
185
+ expect(shouldAbandonBuilderRssCeiling(2)).toBe(true);
186
+ expect(shouldAbandonBuilderRssCeiling(1, 1)).toBe(true);
187
+ });
188
+ });
189
+
190
+ describe("recycled builder state announcements", () => {
191
+ const pages = (bundlePath: string): Extract<BuilderMessage, { type: "pages-updated" }> => ({
192
+ type: "pages-updated",
193
+ data: { bundlePath, buildId: 7, generation: 3, changedFiles: [], reason: "builder-recycle" },
194
+ });
195
+ const css = (cssUrl: string): Extract<BuilderMessage, { type: "css-updated" }> => ({
196
+ type: "css-updated",
197
+ data: {
198
+ cssAssets: { "": { cssUrl, cssRelPath: cssUrl.slice(1) } },
199
+ cssBase64ByUrl: { [cssUrl]: "" },
200
+ generation: 3,
201
+ changedFiles: [],
202
+ reason: "builder-recycle",
203
+ },
204
+ });
205
+
206
+ // Both identities are content hashes, so a recycle with no concurrent edit reproduces them exactly
207
+ // and must not reload the backend — that would refresh every browser on a memory recycle.
208
+ test("suppresses an unchanged pages announcement and relays a moved one", () => {
209
+ expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-abc.js"))).toBe(false);
210
+ expect(shouldRelayRecycledFrontendState(pages("/a/pages-abc.js"), pages("/a/pages-def.js"))).toBe(true);
211
+ });
212
+
213
+ test("suppresses an unchanged css announcement and relays a moved one", () => {
214
+ expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), css("/_akan/styles/root-abc.css"))).toBe(
215
+ false,
216
+ );
217
+ expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), css("/_akan/styles/root-def.css"))).toBe(
218
+ true,
219
+ );
220
+ });
221
+
222
+ test("relays when the backend has no state of that kind yet", () => {
223
+ expect(shouldRelayRecycledFrontendState(undefined, pages("/a/pages-abc.js"))).toBe(true);
224
+ expect(shouldRelayRecycledFrontendState(css("/_akan/styles/root-abc.css"), pages("/a/pages-abc.js"))).toBe(true);
225
+ });
226
+ });
227
+
144
228
  describe("build status helpers", () => {
145
229
  const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
146
230
  generation,
@@ -1,6 +1,13 @@
1
1
  import path from "node:path";
2
2
  import { Logger } from "akanjs/common";
3
- import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangePlan, DevChangeRole } from "akanjs/server";
3
+ import type {
4
+ BuilderMessage,
5
+ BuilderMetrics,
6
+ BuildPhase,
7
+ DevBuildStatus,
8
+ DevChangePlan,
9
+ DevChangeRole,
10
+ } from "akanjs/server";
4
11
  import type { App } from "../commandDecorators";
5
12
  import { createTunnel } from "../createTunnel";
6
13
  import { WorkspaceExecutor } from "../executors";
@@ -17,6 +24,11 @@ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
17
24
  const BACKEND_STDERR_TAIL_LIMIT = 40;
18
25
  const BUILDER_READY_TIMEOUT_MS = 150000;
19
26
  const BUILDER_START_MAX_ATTEMPTS = 3;
27
+ // Save-on-keystroke arrives as a burst of batches. Recycling mid-burst would drop the watcher events
28
+ // still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
29
+ const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
30
+ const BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30_000;
31
+ const BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT = 2;
20
32
  // The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
21
33
  // backend the recovery loop never gives up — it only backs off.
22
34
  const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
@@ -177,6 +189,68 @@ export const hasBuildFailureForGeneration = (
177
189
  return false;
178
190
  };
179
191
 
192
+ export type BuilderRssRecycleDecision = "unbounded" | "below-ceiling" | "build-failed" | "too-soon" | "recycle";
193
+
194
+ /**
195
+ * Whether an over-ceiling builder should be replaced now.
196
+ *
197
+ * `Bun.build` never returns its native arenas, so the builder's RSS only comes back when the process
198
+ * exits. Recycling it is therefore the only bound available — but it costs a boot build, so the two
199
+ * cases where a recycle cannot help are excluded: a generation whose build already failed (the
200
+ * replacement would hit the same compile error), and a recycle so soon after the last one that the
201
+ * ceiling is evidently unreachable for this app.
202
+ */
203
+ export const decideBuilderRssRecycle = ({
204
+ rssBytes,
205
+ ceilingBytes,
206
+ buildFailed,
207
+ msSinceLastRecycle,
208
+ minIntervalMs = BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
209
+ }: {
210
+ rssBytes: number;
211
+ ceilingBytes: number | null;
212
+ buildFailed: boolean;
213
+ msSinceLastRecycle: number | null;
214
+ minIntervalMs?: number;
215
+ }): BuilderRssRecycleDecision => {
216
+ if (!ceilingBytes) return "unbounded";
217
+ if (rssBytes < ceilingBytes) return "below-ceiling";
218
+ if (buildFailed) return "build-failed";
219
+ if (msSinceLastRecycle !== null && msSinceLastRecycle < minIntervalMs) return "too-soon";
220
+ return "recycle";
221
+ };
222
+
223
+ /**
224
+ * A builder whose fresh boot already exceeds the ceiling reports `too-soon` after every recycle and
225
+ * would be replaced forever without ever getting under it. After this many recycles bought no relief
226
+ * the host stops enforcing the ceiling and says so, rather than looping.
227
+ */
228
+ export const shouldAbandonBuilderRssCeiling = (
229
+ ineffectiveRecycles: number,
230
+ limit = BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT,
231
+ ): boolean => ineffectiveRecycles >= limit;
232
+
233
+ /**
234
+ * Whether a recycled builder's re-announced boot artifact actually differs from what the backend
235
+ * already has. Both payload identities are content hashes — `pages-[hash].js` and
236
+ * `<name>-[hash].css` — so an unchanged recycle produces identical ones and needs no reload. Only a
237
+ * save that raced the recycle moves them, and that is the case worth pushing.
238
+ */
239
+ export const shouldRelayRecycledFrontendState = (
240
+ current:
241
+ | Extract<BuilderMessage, { type: "pages-updated" }>
242
+ | Extract<BuilderMessage, { type: "css-updated" }>
243
+ | undefined,
244
+ next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
245
+ ): boolean => {
246
+ if (!current || current.type !== next.type) return true;
247
+ if (current.type === "pages-updated" && next.type === "pages-updated")
248
+ return current.data.bundlePath !== next.data.bundlePath;
249
+ if (current.type === "css-updated" && next.type === "css-updated")
250
+ return JSON.stringify(current.data.cssAssets) !== JSON.stringify(next.data.cssAssets);
251
+ return true;
252
+ };
253
+
180
254
  const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChangePlan | undefined => {
181
255
  if (!current) return next;
182
256
  if (!next) return current;
@@ -344,6 +418,11 @@ export class AkanAppHost {
344
418
  #backendBuildStatusGeneration = 0;
345
419
  #backendStderrTail: string[] = [];
346
420
  #lastGoodFrontend: LastGoodFrontendState = {};
421
+ #rssRecycleTimer: ReturnType<typeof setTimeout> | null = null;
422
+ #rssRecycleReason: string | null = null;
423
+ #lastRssRecycleAtMono: number | null = null;
424
+ #rssCeilingIneffective = 0;
425
+ #rssCeilingAbandoned = false;
347
426
  #buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
348
427
  #pendingBuildStatusReplay: DevBuildStatus[] = [];
349
428
  #builderMessageQueue: Promise<void> = Promise.resolve();
@@ -642,16 +721,105 @@ export class AkanAppHost {
642
721
  this.#reviveBackendAfterGreenBuild(message.data);
643
722
  return;
644
723
  }
645
- if (message.type === "pages-updated") this.#recordLastGood(message);
646
- if (message.type === "css-updated") this.#recordLastGood(message);
724
+ if (message.type === "builder-metrics") {
725
+ this.#handleBuilderMetrics(message.data);
726
+ return;
727
+ }
728
+ if (message.type === "pages-updated" || message.type === "css-updated") {
729
+ const recycled = message.data.reason === "builder-recycle";
730
+ if (recycled && !this.#shouldRelayRecycledState(message)) return;
731
+ this.#recordLastGood(message, { supersede: recycled });
732
+ }
647
733
  if (message.type === "invalidate") {
648
734
  await this.#handleInvalidate(message);
649
735
  return;
650
736
  }
651
737
  this.#sendToBackend(message);
652
738
  }
739
+ /**
740
+ * A recycled builder re-announces the artifact it booted with, because the backend read
741
+ * `base-artifact.json` once and never re-reads it. Dropping the announcement when the hashes match
742
+ * is what keeps the common case — a recycle with no concurrent edit — invisible to browsers.
743
+ */
744
+ #shouldRelayRecycledState(
745
+ message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
746
+ ): boolean {
747
+ const current = message.type === "pages-updated" ? this.#lastGoodFrontend.pages : this.#lastGoodFrontend.css;
748
+ if (shouldRelayRecycledFrontendState(current, message)) {
749
+ this.logger.verbose(`[builder-recycle] ${message.type} moved during the recycle; pushing it to the backend`);
750
+ return true;
751
+ }
752
+ this.logger.verbose(`[builder-recycle] ${message.type} unchanged after the recycle; backend left as is`);
753
+ return false;
754
+ }
755
+ #handleBuilderMetrics(metrics: BuilderMetrics): void {
756
+ if (this.#rssCeilingAbandoned) return;
757
+ const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
758
+ const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
759
+ const decision = decideBuilderRssRecycle({
760
+ rssBytes: metrics.rssBytes,
761
+ ceilingBytes,
762
+ buildFailed: hasBuildFailureForGeneration(this.#buildStatusByPhase, metrics.generation),
763
+ msSinceLastRecycle: this.#lastRssRecycleAtMono === null ? null : performance.now() - this.#lastRssRecycleAtMono,
764
+ });
765
+ if (decision === "below-ceiling") {
766
+ this.#rssCeilingIneffective = 0;
767
+ return;
768
+ }
769
+ if (decision === "unbounded") return;
770
+ if (decision === "build-failed") {
771
+ this.logger.verbose(
772
+ `[builder-recycle] deferred: generation=${metrics.generation} has a failing build, so a replacement would hit the same error`,
773
+ );
774
+ return;
775
+ }
776
+ if (decision === "too-soon") {
777
+ this.#rssCeilingIneffective += 1;
778
+ if (!shouldAbandonBuilderRssCeiling(this.#rssCeilingIneffective)) return;
779
+ this.#rssCeilingAbandoned = true;
780
+ this.logger.error(
781
+ `[builder-recycle] the builder is still at ${asMib(metrics.rssBytes)}MiB right after being recycled, so the ${asMib(ceilingBytes ?? 0)}MiB ceiling cannot be met for this app; no longer enforcing it this session. Raise AKAN_BUILDER_MAX_RSS_MB, or set it to 0 to leave the builder unbounded.`,
782
+ );
783
+ return;
784
+ }
785
+ this.#armRssRecycle(
786
+ `rss=${asMib(metrics.rssBytes)}MiB>=${asMib(ceilingBytes ?? 0)}MiB after ${metrics.workCount} build(s)`,
787
+ );
788
+ }
789
+ /** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
790
+ #armRssRecycle(reason: string): void {
791
+ if (this.#rssRecycleReason !== reason)
792
+ this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
793
+ this.#rssRecycleReason = reason;
794
+ if (this.#rssRecycleTimer) clearTimeout(this.#rssRecycleTimer);
795
+ this.#rssRecycleTimer = setTimeout(() => {
796
+ this.#rssRecycleTimer = null;
797
+ const pendingReason = this.#rssRecycleReason;
798
+ this.#rssRecycleReason = null;
799
+ if (pendingReason) this.#recycleBuilderForRss(pendingReason);
800
+ }, BUILDER_RSS_RECYCLE_QUIET_MS);
801
+ }
802
+ #cancelRssRecycle(): void {
803
+ this.#rssRecycleReason = null;
804
+ if (!this.#rssRecycleTimer) return;
805
+ clearTimeout(this.#rssRecycleTimer);
806
+ this.#rssRecycleTimer = null;
807
+ }
808
+ #recycleBuilderForRss(reason: string): void {
809
+ // A config or runtime-metadata change already replaces the builder along with the backend, and a
810
+ // pending backend restart is disruption enough on its own; either way, dropping the recycle here
811
+ // costs nothing — the next build re-reports an over-ceiling rss and arms it again.
812
+ if (this.#pendingRecycle || this.#restartTimer) {
813
+ this.logger.verbose(`[builder-recycle] skipped (${reason}); a dev restart is already pending`);
814
+ return;
815
+ }
816
+ if (!this.#builder?.recycle(reason)) return;
817
+ this.#lastRssRecycleAtMono = performance.now();
818
+ }
653
819
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
654
820
  this.#logDevPlan(message);
821
+ // More batches are on the way, so push the recycle out until the dev server settles.
822
+ if (this.#rssRecycleReason) this.#armRssRecycle(this.#rssRecycleReason);
655
823
  // Config changes subsume builder restarts: the dev-host restart recycles builder and backend
656
824
  // AND re-runs the prepare step, so check it first when a batch carries both actions.
657
825
  const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
@@ -811,18 +979,25 @@ export class AkanAppHost {
811
979
  await this.#startBuilder();
812
980
  this.#startBackend({ generation, files: message.files });
813
981
  }
982
+ /**
983
+ * `supersede` bypasses the generation check for a builder that just replaced another one. Its
984
+ * generation counter restarts at 0, so its announcement looks stale to `shouldReplaceLastGoodMessage`
985
+ * — and leaving the old payload cached would make the next backend restart replay an artifact the
986
+ * builder that produced it no longer serves.
987
+ */
814
988
  #recordLastGood(
815
989
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
990
+ { supersede = false }: { supersede?: boolean } = {},
816
991
  ): void {
817
992
  if (message.type === "pages-updated") {
818
- if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message)) return;
993
+ if (!supersede && !shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message)) return;
819
994
  this.#lastGoodFrontend.pages = message;
820
995
  this.logger.verbose(
821
996
  `[last-good] pages generation=${message.data.generation ?? "(unknown)"} buildId=${message.data.buildId}`,
822
997
  );
823
998
  return;
824
999
  }
825
- if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message)) return;
1000
+ if (!supersede && !shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message)) return;
826
1001
  this.#lastGoodFrontend.css = message;
827
1002
  this.logger.verbose(
828
1003
  `[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
@@ -985,6 +1160,7 @@ export class AkanAppHost {
985
1160
  this.logger.warn("akanAppHost builder is not running");
986
1161
  }
987
1162
  #stopBuilder(): void {
1163
+ this.#cancelRssRecycle();
988
1164
  if (!this.#builder) return;
989
1165
  this.#builder.stop();
990
1166
  this.#builder = null;
@@ -0,0 +1,194 @@
1
+ import path from "node:path";
2
+ import type { App } from "@akanjs/devkit/commandDecorators";
3
+ // Subpath imports only, and as few as possible: this process is spawned once per generation, so every
4
+ // eager import is paid on every save. Measured on a 177-route app: `executors` 24ms, `frontendBuild`
5
+ // ~110ms, app config 5ms.
6
+ import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
7
+ import { CsrArtifactBuilder, CssCompiler, FontOptimizer, PagesBundleBuilder } from "@akanjs/devkit/frontendBuild";
8
+ import { Logger } from "akanjs/common";
9
+ import type { BuilderMessage, BuildPhase } from "akanjs/server";
10
+ import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
11
+
12
+ /**
13
+ * One generation of frontend build work, in a process that exits when it is done.
14
+ *
15
+ * This exists for one reason: `Bun.build` retains native bundler arenas that the process never returns
16
+ * to the OS — `Bun.gc(true)` reclaims nothing and the JS heap stays flat while RSS climbs ~250MB per
17
+ * save. Exit is the only mechanism that gives that memory back, so the work that scales per save lives
18
+ * here rather than in the long-lived watcher.
19
+ *
20
+ * Nothing is cached here, by design. That costs less than it appears to: `CssCompiler` rebuilds its
21
+ * tailwind compilers on every `compileCss` call, and the watcher always asked for `refresh: true`, so
22
+ * there was no warm state to lose. What genuinely had to be preserved travels in the request — the
23
+ * validated page keys and the previous font optimization.
24
+ */
25
+ class BuildBatch {
26
+ #logger = new Logger("BuildBatch");
27
+ #request: BuildBatchRequest;
28
+ #app: App;
29
+ #result: BuildBatchResult;
30
+ constructor(request: BuildBatchRequest, app: App) {
31
+ this.#request = request;
32
+ this.#app = app;
33
+ this.#result = { generation: request.generation, errors: {} };
34
+ }
35
+
36
+ async run(): Promise<BuildBatchResult> {
37
+ // Ordered the way the watcher used to run them: csr before pages so a csr failure cannot delay the
38
+ // pages bundle the browser is waiting on, and css last because it depends on the rebuilt client.
39
+ if (this.#request.needs.includes("csr")) await this.#buildCsr();
40
+ if (this.#request.needs.includes("pages")) await this.#buildPages();
41
+ if (this.#request.needs.includes("css")) await this.#buildCss();
42
+ return this.#result;
43
+ }
44
+
45
+ /**
46
+ * Broadcast as soon as a need finishes rather than when the batch does. The browser is waiting on the
47
+ * pages bundle; making it wait for the css compile behind it would add latency the in-process version
48
+ * never had, and it would move every artifact write into the window right before the watcher reports
49
+ * the generation complete — which is where a save issued immediately afterwards gets dropped by Bun's
50
+ * recursive `fs.watch` (`local/optimize-resource/06-watcher-dropped-event.md`).
51
+ */
52
+ #emit(message: BuilderMessage): void {
53
+ process.send?.(message);
54
+ }
55
+
56
+ #emitStatus(phase: BuildPhase, message?: string): void {
57
+ this.#emit({
58
+ type: "build-status",
59
+ data: {
60
+ generation: this.#request.generation,
61
+ phase,
62
+ ok: !message,
63
+ files: this.#request.changedFiles,
64
+ message,
65
+ },
66
+ });
67
+ }
68
+
69
+ async #buildCsr(): Promise<void> {
70
+ const started = Date.now();
71
+ try {
72
+ await new CsrArtifactBuilder(this.#app).build();
73
+ this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
74
+ this.#emitStatus("csr");
75
+ } catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ this.#logger.error(`csr-rebundle failed: ${message}`);
78
+ this.#result.errors.csr = message;
79
+ this.#emitStatus("csr", message);
80
+ }
81
+ }
82
+
83
+ async #buildPages(): Promise<void> {
84
+ const started = Date.now();
85
+ try {
86
+ const next = await new PagesBundleBuilder(this.#app).build();
87
+ this.#emit({
88
+ type: "pages-updated",
89
+ data: {
90
+ bundlePath: next.bundlePath,
91
+ buildId: next.buildId,
92
+ generation: this.#request.generation,
93
+ changedFiles: this.#request.changedFiles,
94
+ },
95
+ });
96
+ this.#emitStatus("pages");
97
+ this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
98
+ } catch (err) {
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ this.#logger.error(`pages-rebundle failed: ${message}`);
101
+ this.#result.errors.pages = message;
102
+ this.#emitStatus("pages", message);
103
+ }
104
+ }
105
+
106
+ async #buildCss(): Promise<void> {
107
+ const started = Date.now();
108
+ try {
109
+ const cssByBasePath = await new CssCompiler(this.#app).getCssByBasePath({ refresh: true });
110
+ const optimizedFonts = await this.#optimizeFonts();
111
+ const cssAssetEntries: Array<[string, { cssUrl: string; cssRelPath: string }]> = [];
112
+ const cssBase64ByUrl: Record<string, string> = {};
113
+ await Promise.all(
114
+ Object.entries(cssByBasePath).map(async ([basePath, baseCssText]) => {
115
+ const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
116
+ if (!cssText) return;
117
+ const cssAssetName = basePath || "root";
118
+ const cssHash = Bun.hash(`${basePath}\n${cssText}`).toString(36);
119
+ const cssRelPath = `styles/${cssAssetName}-${cssHash}.css`;
120
+ const cssUrl = `/_akan/styles/${cssAssetName}-${cssHash}.css`;
121
+ await Bun.write(path.join(this.#request.artifactDir, cssRelPath), cssText);
122
+ cssAssetEntries.push([basePath, { cssUrl, cssRelPath }]);
123
+ cssBase64ByUrl[cssUrl] = Buffer.from(new TextEncoder().encode(cssText)).toString("base64");
124
+ }),
125
+ );
126
+ const cssAssets = Object.fromEntries(cssAssetEntries) as PagesBatchCssAssets;
127
+ this.#result.cssAssets = cssAssets;
128
+ this.#emitStatus("css");
129
+ if (JSON.stringify(this.#request.cssAssets ?? {}) === JSON.stringify(cssAssets)) {
130
+ this.#logger.verbose("css-rebuild unchanged assets; broadcast skipped");
131
+ return;
132
+ }
133
+ this.#emit({
134
+ type: "css-updated",
135
+ data: {
136
+ cssAssets,
137
+ cssBase64ByUrl,
138
+ generation: this.#request.generation,
139
+ changedFiles: this.#request.changedFiles,
140
+ },
141
+ });
142
+ this.#logger.verbose(`css-compile ok assets=${Object.keys(cssAssets).length} (${Date.now() - started}ms)`);
143
+ } catch (err) {
144
+ const message = err instanceof Error ? err.message : String(err);
145
+ this.#logger.error(`css-rebuild failed: ${message}`);
146
+ this.#result.errors.css = message;
147
+ this.#emitStatus("css", message);
148
+ }
149
+ }
150
+
151
+ /** Fonts are expensive and rarely change, so the previous result is reused unless this batch touched it. */
152
+ async #optimizeFonts(): Promise<OptimizedFonts> {
153
+ const previous = this.#request.optimizedFonts;
154
+ if (previous && !BuildBatch.#shouldReoptimizeFonts(previous, this.#request.changedFiles)) {
155
+ this.#logger.verbose(`font-optimize cached files=${previous.files.length}`);
156
+ return previous;
157
+ }
158
+ const started = Date.now();
159
+ const optimizedFonts = await new FontOptimizer(this.#app, "start").optimize();
160
+ this.#result.optimizedFonts = optimizedFonts;
161
+ this.#logger.verbose(`font-optimize ok files=${optimizedFonts.files.length} (${Date.now() - started}ms)`);
162
+ return optimizedFonts;
163
+ }
164
+
165
+ static #shouldReoptimizeFonts(previous: OptimizedFonts, changedFiles: string[]): boolean {
166
+ if (changedFiles.length === 0) return false;
167
+ return changedFiles.some((file) => {
168
+ const normalized = path.resolve(file);
169
+ if (/\.(woff2?|ttf|otf)$/i.test(normalized)) return true;
170
+ return previous.files.some((fontFile) => path.resolve(fontFile) === normalized);
171
+ });
172
+ }
173
+
174
+ static async main(): Promise<void> {
175
+ const raw = process.argv[2];
176
+ if (!raw) throw new Error("[build-batch] missing request argument");
177
+ const request = JSON.parse(raw) as BuildBatchRequest;
178
+ const workspace = WorkspaceExecutor.fromRoot({
179
+ workspaceRoot: request.workspaceRoot,
180
+ repoName: request.repoName,
181
+ });
182
+ const app = AppExecutor.from(workspace, request.appName);
183
+ // Seeded rather than rediscovered: the watcher already globbed and validated every route source,
184
+ // and repeating that here would be the single largest cost of spawning this process.
185
+ if (request.pageKeys) app.setPageKeys(request.pageKeys);
186
+ const result = await new BuildBatch(request, app).run();
187
+ process.send?.({ type: "build-batch-result", data: result });
188
+ }
189
+ }
190
+
191
+ void BuildBatch.main().catch((err) => {
192
+ console.error(err);
193
+ process.exit(1);
194
+ });
@@ -0,0 +1,53 @@
1
+ import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
2
+ import type { CssPayload, PagesBundlePayload } from "akanjs/server";
3
+
4
+ export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
5
+
6
+ export type BuildBatchNeed = "pages" | "css" | "csr";
7
+
8
+ /**
9
+ * One generation of build work, handed to a process that exits when it is done.
10
+ *
11
+ * Everything here is JSON: the worker is spawned per batch, so state that the long-lived watcher
12
+ * caches for the session has to travel by value. Two fields exist purely to keep behaviour identical
13
+ * to the in-process version — `pageKeys` because rediscovering them costs ~220ms on a 177-route app
14
+ * (route-export validation), and `optimizedFonts` because fonts are only re-optimized when a font file
15
+ * actually changed.
16
+ */
17
+ export interface BuildBatchRequest {
18
+ appName: string;
19
+ workspaceRoot: string;
20
+ repoName: string;
21
+ generation: number;
22
+ needs: BuildBatchNeed[];
23
+ changedFiles: string[];
24
+ /** Page keys the watcher already validated, or null to make the worker discover them itself. */
25
+ pageKeys: string[] | null;
26
+ /** Previous font optimization, reused unless this batch touched one of its files. */
27
+ optimizedFonts: OptimizedFonts | null;
28
+ /** Previous css assets, so an unchanged compile skips the broadcast instead of busting hashes. */
29
+ cssAssets: PagesBatchCssAssets | null;
30
+ /** Absolute artifact directory; the worker writes css assets under it. */
31
+ artifactDir: string;
32
+ }
33
+
34
+ export type PagesBatchCssAssets = CssPayload["cssAssets"];
35
+
36
+ /**
37
+ * What the watcher folds back into its own state once the worker is done.
38
+ *
39
+ * Deliberately small: the payloads browsers are waiting on are *streamed* as each need finishes
40
+ * (`pages-updated`, `css-updated`, `build-status`, relayed straight through), so a page reload is not
41
+ * held back by a css compile that has not started yet. Only state the next batch needs travels here.
42
+ */
43
+ export interface BuildBatchResult {
44
+ generation: number;
45
+ cssAssets?: PagesBatchCssAssets;
46
+ optimizedFonts?: OptimizedFonts;
47
+ errors: Partial<Record<BuildBatchNeed, string>>;
48
+ /** The worker died before reporting, so it streamed no `build-status` of its own for these needs. */
49
+ crashed?: boolean;
50
+ }
51
+
52
+ export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
53
+ export type { PagesBundlePayload };