@akanjs/devkit 3.0.0-beta.24 → 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 (57) 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 -17
  8. package/csrE2e/csrE2eHarness.fixture.ts +66 -11
  9. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  10. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  11. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  12. package/fileSys.ts +16 -1
  13. package/frontendBuild/buildRouteClient.test.ts +61 -0
  14. package/frontendBuild/bundleDefine.test.ts +12 -0
  15. package/frontendBuild/bundleDefine.ts +5 -1
  16. package/frontendBuild/clientEntryDiscovery.test.ts +37 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +10 -5
  18. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  19. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  20. package/frontendBuild/csrDevArtifactWriter.ts +158 -24
  21. package/frontendBuild/csrDevBundler.ts +44 -205
  22. package/frontendBuild/csrDevModuleCompiler.ts +51 -9
  23. package/frontendBuild/csrDevPatcher.test.ts +340 -0
  24. package/frontendBuild/csrDevPatcher.ts +374 -0
  25. package/frontendBuild/csrDevPaths.ts +60 -0
  26. package/frontendBuild/csrDevResolver.test.ts +165 -0
  27. package/frontendBuild/csrDevResolver.ts +70 -18
  28. package/frontendBuild/csrDevTypes.ts +14 -1
  29. package/frontendBuild/csrEntryFiles.ts +24 -0
  30. package/frontendBuild/cssCompiler.ts +2 -4
  31. package/frontendBuild/devRegistryBundler.ts +177 -0
  32. package/frontendBuild/hmrWatcher.test.ts +51 -0
  33. package/frontendBuild/hmrWatcher.ts +17 -3
  34. package/frontendBuild/index.ts +3 -0
  35. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  36. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  37. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  38. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  39. package/frontendBuild/routeClientBuilder.ts +79 -18
  40. package/frontendBuild/serverGraphFile.test.ts +77 -0
  41. package/frontendBuild/serverGraphFile.ts +93 -0
  42. package/frontendBuild/ssrDevBundler.ts +91 -0
  43. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  44. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  45. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  46. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  47. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  48. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  49. package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
  50. package/incrementalBuilder/incrementalBuilder.host.ts +32 -3
  51. package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
  52. package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
  53. package/integration/devStability.integration.test.ts +18 -7
  54. package/package.json +2 -2
  55. package/transforms/rscUseClientTransform.ts +7 -0
  56. package/transforms/transforms.test.ts +13 -0
  57. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -22,7 +22,7 @@ crosses its ceiling is replaced when it is next idle — never mid-work.
22
22
 
23
23
  | process | explicit (MiB) | explicit (bytes) | share of the limit | fallback with no limit |
24
24
  |---|---|---|---|---|
25
- | incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** | 1200MB (dev) |
25
+ | incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** | 2048MB (dev) |
26
26
  | RSC worker | `AKAN_RSC_WORKER_MAX_RSS_MB` | `AKAN_RSC_WORKER_MAX_RSS` | **0.55** | 768MB (dev), unbounded (production) |
27
27
 
28
28
  `AKAN_BUILDER_MAX_RSS_MB=0` leaves the builder unbounded, which is the escape hatch for an app whose
@@ -65,10 +65,17 @@ which is the one case where replacing it again cannot help; it says that too, an
65
65
 
66
66
  | variable | default | what it does |
67
67
  |---|---|---|
68
- | `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. |
68
+ | `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. Set at boot, its CSR build runs beside the SSR registry's: on apps/akan the builder and its workers peak at about 2.7GB instead of 1.3GB, and the next boot wave waits for both builds. |
69
+ | `AKAN_DEV_CSR` | `registry` | How the dev CSR page is built. `registry` serves it as a module registry that patches the modules a save changed; `artifact` brings back the single-file bundle, which reloads on every save. |
70
+ | `AKAN_DEV_CSR_PATCHER` | on | `off` sends every save of a dev module registry — the CSR page's, and the one SSR pages load their client code from (`.akan/artifact/ssr-dev`) — to a disposable build worker instead of patching it in the resident builder. The patcher keeps each registry in memory (on minimal the CSR one adds about 170MB to the builder's median RSS and 210MB to its peak, flat across saves) and hands whole-app builds to a worker: a first build, a new npm module, a bare import no file of its package resolved before, a config change, a signal or dictionary save (their metadata is inlined at build time), and a registry whose last whole build was cut short. A misspelled import fails in the builder, like a missing file. A builder that dies mid-patch turns it off until the dev server next replaces its builder and backend together (a config, signal or dictionary change). |
71
+ | `AKAN_DEV_WATCH_DEBOUNCE_MS` | `30` | How long the file watcher collects changes before it hands a batch over. Every save waits this long before anything builds. A second write inside the window joins the same batch; one after it starts another batch, which reads the file's latest content, so a format-on-save costs a second build rather than a wrong one. |
69
72
  | `AKAN_BUILDER_RPC_TIMEOUT_MS` | `120000` | How long the backend waits for a builder answer. Generous on purpose: a cold CSR build of every page legitimately takes tens of seconds. |
70
73
  | `AKAN_SERVER_PAGES_SPLITTING` | off | Emit the server pages bundle as chunks instead of one file. Experimental — the memory/latency trade has not been measured on a real app. |
71
74
 
75
+ Two more are read by the dev server for its end-to-end tests only; never set them. `AKAN_DEV_SSR_ARM_DELAY_MS`
76
+ holds the SSR registry's boot build back, and `AKAN_CSR_DEV_APP_WRITE_DELAY_MS` holds a registry's `app.js`
77
+ back after a patch is announced.
78
+
72
79
  ## Observability
73
80
 
74
81
  | variable | default | what it does |
@@ -80,31 +87,36 @@ which is the one case where replacing it again cannot help; it says that too, an
80
87
  ## Several apps at once
81
88
 
82
89
  `akan start a,b` runs one dev host per app under a supervisor, so **every number above multiplies by the number
83
- of apps** — there is no shared builder and no shared RSC worker. Measured on this repo, right after both apps
90
+ of apps** — there is no shared builder and no shared RSC worker. Measured on this repo 8 seconds after both apps
84
91
  finished booting:
85
92
 
86
93
  | process | akan | minimal |
87
94
  |---|---|---|
88
- | dev host | 101MB | 94MB |
89
- | incremental builder | 596MB | 532MB |
90
- | backend | 35MB | 38MB |
91
- | RSC worker | 190MB | 81MB |
92
-
93
- Plus ~51MB for the supervisor itself: **~1.7GB for two apps at their peak.** The builders are almost all of
94
- that, and they are also the part that goes away — `AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so
95
- a session where you are editing one app settles to roughly one builder plus ~190MB per idle app. **Multi-app is
96
- sized against idle suspend being on**; setting it to `0` keeps every builder resident for the whole session.
95
+ | dev host | 279MB | 196MB |
96
+ | incremental builder | 182MB | 114MB |
97
+ | backend (gateway and replica) | 123MB | 130MB |
98
+ | RSC worker | 162MB | 75MB |
99
+
100
+ Plus ~75MB for the supervisor itself: **~1.3GB for two apps at rest.** The peak is the boot, not the rest: the
101
+ build workers — the base build, then the SSR registry's — take a whole akan dev host to ~1.9GB and minimal's to
102
+ ~1.4GB, and booted one after the other the two peaked at 2.2GB. The builders are also the part that goes away —
103
+ `AKAN_DEV_IDLE_SUSPEND_MS` releases each one independently, so a session where you are editing one app keeps one
104
+ builder, and every idle app keeps only its dev host, backend and RSC worker. **Multi-app is sized against idle
105
+ suspend being on**; setting it to `0` keeps every builder resident for the whole session.
97
106
 
98
107
  Two things bound the peak rather than the floor:
99
108
 
100
- - **`--concurrency` (default: what the machine allows).** Apps boot in waves, and the next wave starts only
101
- once the previous one reports ready. A cold boot build is the builder's peak, so booting `n` apps at once
102
- means `n` overlapping peaks — which is what OOM-kills a container that would have been fine with them
103
- staggered. Unset, the wave is `min(apps, half the memory budget / 900MB per app, cores / 4)`, never below
104
- one, and the session prints which — so a laptop boots its apps together and a 1.2GB container still
105
- staggers them. The memory budget is the smaller of the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup
106
- limit; `os.freemem()` is not consulted, because it counts free pages rather than reclaimable ones and
107
- reports ~0.3GB on an idle 48GB laptop.
109
+ - **`--concurrency` (default: what the machine allows).** Apps boot in waves. The next wave starts once each
110
+ app of the previous one serves and its builder's boot builds have settled — the SSR registry's, and CSR's when
111
+ `AKAN_DEV_CSR_REBUILD=1` arms it — since those workers are the largest processes of a boot. An app that never
112
+ reports them starts the next wave 30 seconds after it serves. It shows as ready, and `--open` opens it, as
113
+ soon as it serves. Booting `n` apps at once means `n` overlapping peaks — which is what OOM-kills a container
114
+ that would have been fine with them staggered. Unset, the wave is
115
+ `min(apps, half the memory budget / 1.8GB per app, cores / 4)`, never below one, and the session prints
116
+ which — so a laptop boots its apps together and a small container boots them one at a time. The 1.8GB is
117
+ apps/akan's boot peak across all its processes (measured 1.73–1.9GB). The memory budget is the smaller of
118
+ the host's RAM and `AKAN_MEMORY_LIMIT` / the cgroup limit; `os.freemem()` is not consulted, because it counts
119
+ free pages rather than reclaimable ones and reports ~0.3GB on an idle 48GB laptop.
108
120
  - **`AKAN_MEMORY_LIMIT` is per process, not per session.** Each dev host derives its builder and RSC-worker
109
121
  ceilings from it independently, so a limit sized for one app does not become a budget for four. Divide it
110
122
  yourself, or leave it unset on a laptop. The one place it is read as a session ceiling is the boot wave
@@ -113,7 +125,8 @@ Two things bound the peak rather than the floor:
113
125
 
114
126
  ## Sizing a small sandbox
115
127
 
116
- A worked example, for a 1.2GB container:
128
+ A worked example, for a 1.2GB container running an app the size of `apps/minimal` (apps/akan's boot alone
129
+ peaks near 1.8GB):
117
130
 
118
131
  ```bash
119
132
  AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
@@ -124,7 +137,9 @@ Two things to expect at that size. The builder crosses 420MB during ordinary wor
124
137
  costs ~247MB on top of its floor — so it is replaced roughly once per 30s while you keep building, each
125
138
  replacement costing a boot build that requests wait through rather than fail. And the build worker's
126
139
  peak is not covered by any of these ceilings; if the kernel OOM-kills it, the dev server survives with a
127
- red build for that generation and the log names the signal.
140
+ red build for that generation and the log names the signal. On a boot whose `.akan` was cleared, the SSR
141
+ registry's first build is such a worker: a killed one leaves no registry, and every later save retries that
142
+ same whole build, so a container that cannot fit it once cannot fit it at all.
128
143
 
129
144
  Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
130
145
  it to `0` trades the bound away entirely, which on a container this size means the kernel decides
@@ -9,6 +9,7 @@ import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
9
9
  import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
10
10
  import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
11
11
  import { BackendImportGraph } from "./BackendImportGraph";
12
+ import { DevBootLatch } from "./devBootLatch";
12
13
  import {
13
14
  type BackendLifecycleState,
14
15
  type BackendRestartReason,
@@ -34,6 +35,7 @@ import {
34
35
  type SourceFingerprints,
35
36
  shouldAbandonBackendRecovery,
36
37
  shouldHoldForReturningBuilder,
38
+ shouldKeepBuildFailure,
37
39
  shouldMarkBuildPhaseRecovered,
38
40
  shouldQueueBuildStatusReplay,
39
41
  shouldRefreshConfigOnIdleWake,
@@ -88,6 +90,7 @@ export class AkanAppHost {
88
90
  readonly env: Record<string, string>;
89
91
  readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
90
92
  #lastDevState: DevHostState | null = null;
93
+ readonly #bootLatch = new DevBootLatch(() => this.#onDevEvent?.({ app: this.app.name, booted: true }));
91
94
  #backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
92
95
  #builder: IncrementalBuilderHost | null = null;
93
96
  #backendReady = false;
@@ -126,6 +129,7 @@ export class AkanAppHost {
126
129
  #builderGapStamp: Promise<SourceFingerprints | null> | null = null;
127
130
  #pendingBuilderMessages: BuilderMessage[] = [];
128
131
  readonly #builderRequests = new BuilderRequestRouter();
132
+ #builderGeneration = 0;
129
133
  constructor(
130
134
  private readonly app: App,
131
135
  {
@@ -143,6 +147,7 @@ export class AkanAppHost {
143
147
  if (!this.#onDevEvent || state === this.#lastDevState) return;
144
148
  this.#lastDevState = state;
145
149
  this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
150
+ if (state === "ready") this.#bootLatch.ready();
146
151
  }
147
152
  async start() {
148
153
  if (this.#backend) await this.#stopBackend();
@@ -202,6 +207,7 @@ export class AkanAppHost {
202
207
  this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
203
208
  this.#recordBackendReadyStatus();
204
209
  this.logger.verbose(`backend ready pid=${msg.pid}`);
210
+ this.#forgetRouteBuildStatus();
205
211
  this.#replayBuilderState();
206
212
  return;
207
213
  }
@@ -425,6 +431,7 @@ export class AkanAppHost {
425
431
  }
426
432
  async #handleBuilderMessage(message: BuilderMessage) {
427
433
  this.#markDevActivity();
434
+ this.#trackBuilderGeneration(message);
428
435
  if (message.type === "build-status") {
429
436
  this.#recordBuildStatus(message.data);
430
437
  this.#sendOrQueueBuildStatus(message.data);
@@ -435,6 +442,11 @@ export class AkanAppHost {
435
442
  this.#handleBuilderMetrics(message.data);
436
443
  return;
437
444
  }
445
+ if (message.type === "boot-armed") {
446
+ this.logger.verbose("[builder] boot builds settled");
447
+ this.#bootLatch.armed();
448
+ return;
449
+ }
438
450
  if (message.type === "pages-updated" || message.type === "css-updated") {
439
451
  const recycled = message.data.reason === "builder-recycle";
440
452
  if (recycled && !this.#shouldRelayRecycledState(message)) return;
@@ -455,6 +467,20 @@ export class AkanAppHost {
455
467
  }
456
468
  this.#sendToBackend(message);
457
469
  }
470
+ // In `env`, which every builder spawn re-reads: a replacement (recycled, crashed, restarted) continues from here.
471
+ #trackBuilderGeneration(message: BuilderMessage): void {
472
+ const generation = AkanAppHost.#builderGenerationOf(message);
473
+ if (generation === undefined || generation <= this.#builderGeneration) return;
474
+ this.#builderGeneration = generation;
475
+ Object.assign(this.env, { AKAN_BUILDER_INITIAL_GENERATION: String(generation) });
476
+ }
477
+ // Not `csr-updated` / `ssr-updated`: their generation is the registry's.
478
+ static #builderGenerationOf(message: BuilderMessage): number | undefined {
479
+ if (message.type === "invalidate") return message.generation;
480
+ if (message.type === "build-status" || message.type === "builder-metrics") return message.data.generation;
481
+ if (message.type === "pages-updated" || message.type === "css-updated") return message.data.generation;
482
+ return undefined;
483
+ }
458
484
  // The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
459
485
  #shouldRelayRecycledState(
460
486
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
@@ -995,7 +1021,7 @@ export class AkanAppHost {
995
1021
  await this.#startBuilder();
996
1022
  this.#startBackend({ generation, files: message.files });
997
1023
  }
998
- // `supersede`: a replacement builder's generation restarts at 0, so its announcement would look stale.
1024
+ // `supersede`: a recycled builder's re-announcement is what is on disk now, whatever generation it carries.
999
1025
  #recordLastGood(
1000
1026
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
1001
1027
  { supersede = false }: { supersede?: boolean } = {},
@@ -1034,6 +1060,7 @@ export class AkanAppHost {
1034
1060
  }
1035
1061
  #recordBuildStatus(status: DevBuildStatus): void {
1036
1062
  const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
1063
+ if (shouldKeepBuildFailure(this.#buildStatusByPhase, status)) return;
1037
1064
  this.#buildStatusByPhase.set(status.phase, status);
1038
1065
  const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
1039
1066
  if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
@@ -1049,6 +1076,12 @@ export class AkanAppHost {
1049
1076
  }
1050
1077
  this.#sendToBackend({ type: "build-status", data: status });
1051
1078
  }
1079
+ //? A route status belongs to the route cache of the backend that asked for the build: a new backend builds its routes
1080
+ //? again, and reports each one itself.
1081
+ #forgetRouteBuildStatus(): void {
1082
+ this.#buildStatusByPhase.delete("route");
1083
+ this.#pendingBuildStatusReplay = this.#pendingBuildStatusReplay.filter((status) => status.phase !== "route");
1084
+ }
1052
1085
  #replayBuilderState(): void {
1053
1086
  if (!this.#backendReady) return;
1054
1087
  if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DevBootLatch } from "./devBootLatch";
3
+
4
+ const latch = (waitMs = 60_000) => {
5
+ const booted: number[] = [];
6
+ return { booted, latch: new DevBootLatch(() => booted.push(Date.now()), { waitMs }) };
7
+ };
8
+
9
+ describe("DevBootLatch", () => {
10
+ test("opens once the app serves and its boot builds have settled, in either order", () => {
11
+ const readyFirst = latch();
12
+ readyFirst.latch.ready();
13
+ expect(readyFirst.booted).toHaveLength(0);
14
+ readyFirst.latch.armed();
15
+ expect(readyFirst.booted).toHaveLength(1);
16
+
17
+ const armedFirst = latch();
18
+ armedFirst.latch.armed();
19
+ expect(armedFirst.booted).toHaveLength(0);
20
+ armedFirst.latch.ready();
21
+ expect(armedFirst.booted).toHaveLength(1);
22
+ });
23
+
24
+ test("opens once per session, however often the app restarts or the builder re-arms", () => {
25
+ const { booted, latch: boot } = latch();
26
+ boot.armed();
27
+ boot.ready();
28
+ boot.ready();
29
+ boot.armed();
30
+ expect(booted).toHaveLength(1);
31
+ });
32
+
33
+ test("opens after its wait past ready when the builder never reports", async () => {
34
+ const { booted, latch: boot } = latch(20);
35
+ boot.ready();
36
+ await Bun.sleep(60);
37
+ expect(booted).toHaveLength(1);
38
+ });
39
+ });
@@ -0,0 +1,39 @@
1
+ //* An app's boot is over for the supervisor once it serves and its builder's boot builds have settled (the SSR
2
+ //* registry's, and CSR's when the env arms it): those workers are a boot's largest processes, so the next boot wave
3
+ //* starts here, while the browser opens on the ready that comes first.
4
+ export class DevBootLatch {
5
+ readonly #onBooted: () => void;
6
+ readonly #waitMs: number;
7
+ #ready = false;
8
+ #armed = false;
9
+ #booted = false;
10
+ #timer: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ /** `waitMs` bounds the wait past ready: a builder that never reports (killed mid-boot) must not stall the waves. */
13
+ constructor(onBooted: () => void, { waitMs = 30_000 }: { waitMs?: number } = {}) {
14
+ this.#onBooted = onBooted;
15
+ this.#waitMs = waitMs;
16
+ }
17
+
18
+ ready(): void {
19
+ this.#ready = true;
20
+ if (!this.#armed && !this.#timer) {
21
+ this.#timer = setTimeout(() => this.armed(), this.#waitMs);
22
+ this.#timer.unref?.();
23
+ }
24
+ this.#release();
25
+ }
26
+
27
+ armed(): void {
28
+ this.#armed = true;
29
+ this.#release();
30
+ }
31
+
32
+ #release(): void {
33
+ if (this.#booted || !this.#ready || !this.#armed) return;
34
+ this.#booted = true;
35
+ if (this.#timer) clearTimeout(this.#timer);
36
+ this.#timer = null;
37
+ this.#onBooted();
38
+ }
39
+ }
@@ -17,6 +17,7 @@ import {
17
17
  resolveIdleSuspendMs,
18
18
  shouldAbandonBackendRecovery,
19
19
  shouldHoldForReturningBuilder,
20
+ shouldKeepBuildFailure,
20
21
  shouldMarkBuildPhaseRecovered,
21
22
  shouldQueueBuildStatusReplay,
22
23
  shouldRefreshConfigOnIdleWake,
@@ -355,6 +356,30 @@ describe("build status helpers", () => {
355
356
  expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("pages", 12, true))).toBe(true);
356
357
  });
357
358
 
359
+ test("an ok of the failure's own generation neither recovers nor replaces it, except a backend's", () => {
360
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
361
+ ["ssr", status("ssr", 7, false)],
362
+ ["backend", status("backend", 7, false)],
363
+ ]);
364
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("ssr", 7, true))).toBe(false);
365
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, true))).toBe(true);
366
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 8, true))).toBe(false);
367
+ expect(shouldKeepBuildFailure(previousByPhase, status("ssr", 7, false))).toBe(false);
368
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("backend", 7, true))).toBe(true);
369
+ });
370
+
371
+ test("a route built again and a builder that came back recover on the failure's own generation", () => {
372
+ const previousByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
373
+ ["route", status("route", 4, false)],
374
+ ["scan", status("scan", 42, false)],
375
+ ]);
376
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("route", 4, true))).toBe(true);
377
+ expect(shouldKeepBuildFailure(previousByPhase, status("route", 4, true))).toBe(false);
378
+ expect(shouldMarkBuildPhaseRecovered(previousByPhase, status("scan", 42, true))).toBe(true);
379
+ expect(shouldKeepBuildFailure(previousByPhase, status("scan", 42, true))).toBe(false);
380
+ expect(shouldKeepBuildFailure(previousByPhase, status("route", 3, true))).toBe(true);
381
+ });
382
+
358
383
  test("creates backend build-status payloads for lifecycle test hooks", () => {
359
384
  expect(
360
385
  createBackendBuildStatus({
@@ -61,12 +61,20 @@ export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "
61
61
  /** Reported, not scraped: in interleaved child output "this app is up" reads like any line mentioning it. */
62
62
  export type DevHostState = "starting" | "ready" | "restarting" | "recovering" | "suspended" | "failed" | "stopped";
63
63
 
64
- export interface DevHostEvent {
64
+ export interface DevHostStateEvent {
65
65
  app: string;
66
66
  state: DevHostState;
67
67
  detail?: string;
68
68
  }
69
69
 
70
+ /** Sent once: the app serves and its boot builds have settled, which is what the next boot wave waits for. */
71
+ export interface DevHostBootEvent {
72
+ app: string;
73
+ booted: true;
74
+ }
75
+
76
+ export type DevHostEvent = DevHostStateEvent | DevHostBootEvent;
77
+
70
78
  const devHostStateByBackendState = {
71
79
  starting: "starting",
72
80
  ready: "ready",
@@ -112,14 +120,31 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
112
120
  );
113
121
  };
114
122
 
123
+ const recoversOnSameGeneration = (phase: BuildPhase): boolean =>
124
+ phase === "backend" || phase === "scan" || phase === "route";
125
+
126
+ //? A newer generation, as the backend and the tabs judge it: an ok of the failure's own generation (a route build's
127
+ //? registry check that had nothing to add) says nothing about the failure. The phases whose retry reports the
128
+ //? failure's generation again (a backend or builder that came back, a route built again) recover on it.
115
129
  export const shouldMarkBuildPhaseRecovered = (
116
130
  previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
117
131
  status: DevBuildStatus,
118
132
  ): boolean => {
119
133
  const previous = previousByPhase.get(status.phase);
120
- return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
134
+ if (!previous || !status.ok || previous.ok) return false;
135
+ const generation = generationValue(status.generation);
136
+ return recoversOnSameGeneration(status.phase) ? generation >= previous.generation : generation > previous.generation;
121
137
  };
122
138
 
139
+ /** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
140
+ export const shouldKeepBuildFailure = (
141
+ previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
142
+ status: DevBuildStatus,
143
+ ): boolean =>
144
+ status.ok &&
145
+ previousByPhase.get(status.phase)?.ok === false &&
146
+ !shouldMarkBuildPhaseRecovered(previousByPhase, status);
147
+
123
148
  export const createBackendBuildStatus = ({
124
149
  generation,
125
150
  ok,
@@ -1,4 +1,4 @@
1
- import { mkdir, rename, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, rm, writeFile } from "node:fs/promises";
2
2
  import dayjs from "dayjs";
3
3
  import { FileSys } from "../fileSys";
4
4
  import { ConfigLock } from "./configLock";
@@ -39,27 +39,13 @@ export class GlobalConfig {
39
39
  const temp = `${configPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
40
40
  try {
41
41
  await writeFile(temp, JSON.stringify(akanConfig, null, 2), { mode: 0o600 });
42
- await GlobalConfig.#replace(temp, configPath);
42
+ // Every CLI reads this file outside the lock, which on Windows makes the rename wait for the reader.
43
+ await FileSys.replace(temp, configPath);
43
44
  } catch (error) {
44
45
  await rm(temp, { force: true });
45
46
  throw error;
46
47
  }
47
48
  }
48
- static readonly #replaceAttempts = 100;
49
- // Windows refuses to rename over a file another process has open (EPERM, or EACCES/EBUSY), and every CLI reads this
50
- // one outside the lock. A reader holds it for one read, so the rename is retried for a few seconds instead of failing.
51
- static async #replace(temp: string, target: string) {
52
- for (let attempt = 1; ; attempt++) {
53
- try {
54
- return await rename(temp, target);
55
- } catch (error) {
56
- const code = (error as NodeJS.ErrnoException).code;
57
- const busy = process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY");
58
- if (!busy || attempt >= GlobalConfig.#replaceAttempts) throw error;
59
- await Bun.sleep(Math.min(10 * attempt, 50));
60
- }
61
- }
62
- }
63
49
  static readonly #lockPath = `${configPath}.lock`;
64
50
  // Every write re-reads under the lock: one built on an earlier read could restore a rotated-away refresh token.
65
51
  static async #update(mutate: (akanConfig: AkanGlobalConfig) => AkanGlobalConfig) {
@@ -46,7 +46,7 @@ const RELOAD_MARKER = "__akanE2eBootMarker";
46
46
  //? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrValues.ts`).
47
47
  const SETTLE_MS = 450;
48
48
 
49
- /** Drives a CSR page (`?csr=true` + a mobile target) of a running `akan start` in a headless `Bun.WebView`. */
49
+ /** Drives a page of a running `akan start` in a headless `Bun.WebView`: CSR (`?csr=true` + a mobile target) or SSR. */
50
50
  export class CsrE2eHarness {
51
51
  readonly origin: string;
52
52
  readonly #view: Bun.WebView;
@@ -55,6 +55,7 @@ export class CsrE2eHarness {
55
55
  readonly #mobileTarget: string;
56
56
  readonly #edited = new Map<string, string>();
57
57
  #marker = "";
58
+ #csr: boolean = true;
58
59
 
59
60
  private constructor(options: {
60
61
  origin: string;
@@ -78,6 +79,11 @@ export class CsrE2eHarness {
78
79
  static async start(options: CsrE2eOptions): Promise<CsrE2eHarness> {
79
80
  const workspaceRoot = options.workspaceRoot ?? CsrE2eHarness.#findWorkspaceRoot();
80
81
  const url = options.url ?? process.env.AKAN_CSR_E2E_URL;
82
+ //? A running server took its env when it started: a suite's hooks would be dropped and its checks pass for nothing.
83
+ if (url && Object.keys(options.env ?? {}).length > 0)
84
+ throw new Error(
85
+ `[csr-e2e] this suite starts its dev server with ${Object.keys(options.env ?? {}).join(", ")}, which the server at ${url} does not have; unset AKAN_CSR_E2E_URL`,
86
+ );
81
87
  const port = options.port ?? Number(process.env.AKAN_CSR_E2E_PORT ?? DEFAULT_PORT);
82
88
  const server = url ? null : await CsrE2eHarness.#startServer({ ...options, workspaceRoot, port });
83
89
  const origin = new URL(url ?? `http://localhost:${port}`).origin;
@@ -90,6 +96,8 @@ export class CsrE2eHarness {
90
96
  width,
91
97
  height,
92
98
  backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
99
+ //? AKAN_CSR_E2E_CONSOLE=1 prints the page's console: the only view of a hydration warning or a failed patch.
100
+ ...(process.env.AKAN_CSR_E2E_CONSOLE === "1" ? { console: globalThis.console } : {}),
93
101
  });
94
102
  return new CsrE2eHarness({
95
103
  origin,
@@ -104,28 +112,43 @@ export class CsrE2eHarness {
104
112
  }
105
113
  }
106
114
 
107
- /** Loads `path` (without the locale) as a CSR page and plants the marker `reloaded()` checks. */
108
- async open(path: string) {
115
+ /** Loads `path` (without the locale), as CSR unless `csr: false`, and plants the marker `reloaded()` checks. */
116
+ async open(path: string, { csr = true }: { csr?: boolean } = {}) {
109
117
  const url = new URL(`/${this.#lang}${path === "/" ? "" : path}`, this.origin);
110
- url.searchParams.set("csr", "true");
111
- url.searchParams.set("akanMobileTarget", this.#mobileTarget);
118
+ if (csr) {
119
+ url.searchParams.set("csr", "true");
120
+ url.searchParams.set("akanMobileTarget", this.#mobileTarget);
121
+ }
122
+ this.#csr = csr;
112
123
  await this.#view.navigate(url.toString());
113
124
  await this.#markBoot();
114
125
  }
115
126
 
116
- /** Reloads the page where it is, as a WebView does after its content process died, and marks the new boot. */
117
- async reload() {
127
+ /**
128
+ * Reloads the page where it is, as a WebView does after its content process died, and marks the new boot. Returns
129
+ * the `performance.timeOrigin` of the first document seen after it: one that differs later was reloaded again.
130
+ */
131
+ async reload(): Promise<number> {
118
132
  await this.#view.reload();
119
133
  //? On the WebKit backend `reload()` resolves before the old document is gone; its marker going is the reload.
120
134
  await this.waitFor(
121
135
  (key: string, previous: string) => (window as unknown as Record<string, string | undefined>)[key] !== previous,
122
136
  { args: [RELOAD_MARKER, this.#marker], timeout: 30_000 },
123
137
  );
138
+ const timeOrigin = await this.evaluate(() => performance.timeOrigin);
124
139
  await this.#markBoot();
140
+ return timeOrigin;
125
141
  }
126
142
 
143
+ //? An SSR page has no page stack; `rscClient` installs its refresh hook in the root's layout effect, once hydrated.
127
144
  async #markBoot() {
128
- await this.waitFor(() => document.querySelector('[id^="pageContainer-"]') !== null, { timeout: 30_000 });
145
+ if (this.#csr)
146
+ await this.waitFor(() => document.querySelector('[id^="pageContainer-"]') !== null, { timeout: 30_000 });
147
+ else
148
+ await this.waitFor(
149
+ () => typeof (globalThis as unknown as { __AKAN_RSC_REFRESH__?: unknown }).__AKAN_RSC_REFRESH__ === "function",
150
+ { timeout: 30_000 },
151
+ );
129
152
  await Bun.sleep(SETTLE_MS);
130
153
  this.#marker = Math.random().toString(36).slice(2);
131
154
  await this.evaluate(
@@ -296,6 +319,33 @@ export class CsrE2eHarness {
296
319
  await this.#view.type(text);
297
320
  }
298
321
 
322
+ /** The process group of the `akan start` this harness spawned; null when it drives a running one. */
323
+ get serverGroup(): number | null {
324
+ return this.#server?.proc.pid ?? null;
325
+ }
326
+
327
+ /** Where the spawned `akan start`'s stdout and stderr end now, for `serverLogSince`. */
328
+ async serverLogMark(): Promise<{ out: number; err: number }> {
329
+ const [out, err] = await this.#serverLogs();
330
+ return { out: out.length, err: err.length };
331
+ }
332
+
333
+ /** What the spawned `akan start` wrote to stdout and stderr since `mark`; empty when it drives a running one. */
334
+ async serverLogSince(mark: { out: number; err: number } = { out: 0, err: 0 }): Promise<string> {
335
+ const [out, err] = await this.#serverLogs();
336
+ return `${out.slice(mark.out)}${err.slice(mark.err)}`;
337
+ }
338
+
339
+ async #serverLogs(): Promise<[string, string]> {
340
+ if (!this.#server) return ["", ""];
341
+ const read = async (file: string) =>
342
+ await Bun.file(file)
343
+ .text()
344
+ .catch(() => "");
345
+ const { logPath } = this.#server;
346
+ return [await read(logPath), await read(logPath.replace(/\.log$/, ".err.log"))];
347
+ }
348
+
299
349
  /** Whether the page reloaded since `open()`: the boot marker lives only in the page `open()` loaded. */
300
350
  async reloaded() {
301
351
  const marker = await this.evaluate(
@@ -350,18 +400,23 @@ export class CsrE2eHarness {
350
400
  env = {},
351
401
  workspaceRoot,
352
402
  }: CsrE2eOptions & { port: number; workspaceRoot: string }): Promise<CsrE2eServer> {
403
+ //? The dev host runs from the CLI's dist bundle while the builder runs from source: a stale dist tests an old host.
404
+ //? The build is a no-op when its stamp matches the sources.
405
+ const built = Bun.spawnSync(["bun", path.join(workspaceRoot, "pkgs/@akanjs/cli/build.ts")], { cwd: workspaceRoot });
406
+ if (built.exitCode !== 0) throw new Error(`[csr-e2e] building the CLI failed: ${built.stderr.toString()}`);
353
407
  const cli = path.join(workspaceRoot, "dist/pkgs/@akanjs/cli/index.js");
354
- if (!(await Bun.file(cli).exists()))
355
- throw new Error(`[csr-e2e] ${cli} is missing; build the CLI first (bun pkgs/@akanjs/cli/build.ts)`);
356
408
  const logDir = path.join(workspaceRoot, "local", "csr-e2e");
357
409
  await mkdir(logDir, { recursive: true });
358
410
  const logPath = path.join(logDir, `${app}-${port}.log`);
411
+ const errPath = logPath.replace(/\.log$/, ".err.log");
412
+ //? A `Bun.file` sink writes from offset 0 without truncating, so the last run's tail would outlive this one's start.
413
+ await Promise.all([Bun.write(logPath, ""), Bun.write(errPath, "")]);
359
414
  //? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned.
360
415
  const proc = Bun.spawn(["bun", cli, "start", app, "--plain"], {
361
416
  cwd: workspaceRoot,
362
417
  env: { ...process.env, AKAN_DEV_PORT: String(port), ...env },
363
418
  stdout: Bun.file(logPath),
364
- stderr: Bun.file(logPath.replace(/\.log$/, ".err.log")),
419
+ stderr: Bun.file(errPath),
365
420
  detached: true,
366
421
  });
367
422
  return { proc, logPath };