@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26

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 (58) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/akanApp/akanApp.host.ts +113 -23
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +71 -1
  6. package/akanApp/devHostPolicy.ts +29 -4
  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 +112 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +117 -70
  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.test.ts +89 -0
  23. package/frontendBuild/csrDevModuleCompiler.ts +56 -10
  24. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  25. package/frontendBuild/csrDevPatcher.ts +548 -0
  26. package/frontendBuild/csrDevPaths.ts +81 -1
  27. package/frontendBuild/csrDevResolver.test.ts +239 -0
  28. package/frontendBuild/csrDevResolver.ts +119 -18
  29. package/frontendBuild/csrDevTypes.ts +16 -1
  30. package/frontendBuild/csrEntryFiles.ts +24 -0
  31. package/frontendBuild/cssCompiler.ts +2 -4
  32. package/frontendBuild/devRegistryBundler.ts +192 -0
  33. package/frontendBuild/hmrWatcher.test.ts +51 -0
  34. package/frontendBuild/hmrWatcher.ts +17 -3
  35. package/frontendBuild/index.ts +3 -0
  36. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  37. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  38. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  39. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  40. package/frontendBuild/routeClientBuilder.ts +79 -18
  41. package/frontendBuild/serverGraphFile.test.ts +77 -0
  42. package/frontendBuild/serverGraphFile.ts +93 -0
  43. package/frontendBuild/ssrDevBundler.ts +85 -0
  44. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  45. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  46. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  47. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  48. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  49. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  50. package/incrementalBuilder/incrementalBuilder.host.test.ts +35 -1
  51. package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
  52. package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
  53. package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
  54. package/integration/devStability.integration.test.ts +25 -7
  55. package/package.json +2 -2
  56. package/transforms/rscUseClientTransform.ts +7 -0
  57. package/transforms/transforms.test.ts +13 -0
  58. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -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,29 @@ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string)
112
120
  );
113
121
  };
114
122
 
123
+ //? The backend's rule (akanjs `DevBuildRecovery`), copied: no exported akanjs subpath reaches it from the CLI bundle.
115
124
  export const shouldMarkBuildPhaseRecovered = (
116
125
  previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
117
126
  status: DevBuildStatus,
118
127
  ): boolean => {
119
128
  const previous = previousByPhase.get(status.phase);
120
- return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
129
+ if (!previous || previous.ok || !status.ok) return false;
130
+ const generation = generationValue(status.generation);
131
+ if (status.phase === "route" && status.scope !== undefined && previous.scope !== undefined)
132
+ return status.scope === previous.scope && generation >= previous.generation;
133
+ if (generation !== previous.generation) return generation > previous.generation;
134
+ return status.phase === "backend";
121
135
  };
122
136
 
137
+ /** An ok that recovers nothing leaves the failure standing, for hello and for whatever waits on a clean build. */
138
+ export const shouldKeepBuildFailure = (
139
+ previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
140
+ status: DevBuildStatus,
141
+ ): boolean =>
142
+ status.ok &&
143
+ previousByPhase.get(status.phase)?.ok === false &&
144
+ !shouldMarkBuildPhaseRecovered(previousByPhase, status);
145
+
123
146
  export const createBackendBuildStatus = ({
124
147
  generation,
125
148
  ok,
@@ -180,7 +203,8 @@ export const hasBuildFailureForGeneration = (
180
203
  ): boolean => {
181
204
  if (typeof generation !== "number") return false;
182
205
  for (const status of statusByPhase.values()) {
183
- if (!status.ok && status.generation === generation) return true;
206
+ //? Not a route's: a replacement builder or a restarted dev host boots without building one, so it cannot hit it.
207
+ if (!status.ok && status.phase !== "route" && status.generation === generation) return true;
184
208
  }
185
209
  return false;
186
210
  };
@@ -268,8 +292,9 @@ export const resolveIdleSuspendMs = (raw: string | undefined): number | null =>
268
292
  return Math.round(parsed);
269
293
  };
270
294
 
295
+ //? Not a route's: a broken route builds again on its next request, which wakes a suspended builder anyway.
271
296
  export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
272
- [...statusByPhase.values()].some((status) => !status.ok);
297
+ [...statusByPhase.values()].some((status) => !status.ok && status.phase !== "route");
273
298
 
274
299
  export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
275
300
  !!batch && batch.kinds.has("config");
@@ -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 };
@@ -0,0 +1,87 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { CsrE2eHarness } from "./csrE2eHarness.fixture";
4
+
5
+ const workspaceRoot = path.resolve(import.meta.dir, "../../../..");
6
+ const probeFile = path.join(workspaceRoot, "apps/minimal/ui/StackProbe.tsx");
7
+ const port = Number(process.env.AKAN_CSR_E2E_PATCH_FIRST_PORT ?? 8493);
8
+
9
+ interface CsrWindow {
10
+ __akan?: { generation: number };
11
+ }
12
+
13
+ //? AKAN_CSR_DEV_APP_WRITE_DELAY_MS holds app.js back after the patch is announced, so a tab can boot inside the gap.
14
+ describe.skipIf(!CsrE2eHarness.enabled)("CSR patch before app.js (minimal, app.js written 1.5s late)", () => {
15
+ let csr: CsrE2eHarness | null = null;
16
+ const harness = () => {
17
+ if (!csr) throw new Error("[csr-e2e] the harness did not start");
18
+ return csr;
19
+ };
20
+
21
+ beforeAll(async () => {
22
+ csr = await CsrE2eHarness.start({
23
+ app: "minimal",
24
+ port,
25
+ workspaceRoot,
26
+ env: { AKAN_CSR_DEV_APP_WRITE_DELAY_MS: "1500" },
27
+ });
28
+ }, 240_000);
29
+
30
+ afterAll(async () => {
31
+ await csr?.close();
32
+ }, 60_000);
33
+
34
+ const generation = async () =>
35
+ await harness().evaluate(() => (window as unknown as CsrWindow).__akan?.generation ?? 0);
36
+ const hot = async () =>
37
+ await harness().evaluate(
38
+ () => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") ?? null,
39
+ );
40
+
41
+ test("an open tab takes the patch before app.js is rewritten", async () => {
42
+ await harness().open("/e2e/stack/tab-a");
43
+ const before = await generation();
44
+ await harness().editSource(
45
+ probeFile,
46
+ (source) => source.replace("data-e2e-probe={name}>", 'data-e2e-probe={name} data-e2e-hot="early">'),
47
+ async () => {
48
+ const startedAt = Date.now();
49
+ await harness().waitFor(
50
+ () => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") === "early",
51
+ { timeout: 20_000 },
52
+ );
53
+ expect(Date.now() - startedAt).toBeLessThan(1_500);
54
+ expect(await generation()).toBeGreaterThan(before);
55
+ },
56
+ );
57
+ expect(await harness().reloaded()).toBe(false);
58
+ }, 120_000);
59
+
60
+ test("a tab booting between the patch and app.js starts at the new generation with no second reload", async () => {
61
+ await harness().open("/e2e/stack/tab-a");
62
+ //? The previous test's restore may still be on its way: wait until the page shows it.
63
+ await harness().waitFor(
64
+ () => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") === null,
65
+ { timeout: 20_000 },
66
+ );
67
+ await harness().editSource(
68
+ probeFile,
69
+ (source) => source.replace("data-e2e-probe={name}>", 'data-e2e-probe={name} data-e2e-hot="booted">'),
70
+ async () => {
71
+ await harness().waitFor(
72
+ () => document.querySelector('[data-e2e-probe="tab-a"]')?.getAttribute("data-e2e-hot") === "booted",
73
+ { timeout: 20_000 },
74
+ );
75
+ const target = await generation();
76
+ //? The first document after the reload: a second reload inside the harness's settle would take the marker.
77
+ const booted = await harness().reload();
78
+ expect(await generation()).toBe(target);
79
+ expect(await hot()).toBe("booted");
80
+ await Bun.sleep(2_000);
81
+ expect(await harness().reloaded()).toBe(false);
82
+ expect(await harness().evaluate(() => performance.timeOrigin)).toBe(booted);
83
+ expect(await generation()).toBe(target);
84
+ },
85
+ );
86
+ }, 120_000);
87
+ });