@akanjs/devkit 2.4.0-rc.0 → 2.4.0-rc.1

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.
@@ -6,6 +6,8 @@ import {
6
6
  createBackendBuildStatus,
7
7
  isLegacyBackendFallbackFile,
8
8
  mergeBackendRestartReasons,
9
+ normalizeBackendReportedGeneration,
10
+ shouldAbandonBackendRecovery,
9
11
  shouldMarkBuildPhaseRecovered,
10
12
  shouldQueueBuildStatusReplay,
11
13
  shouldReplaceLastGoodMessage,
@@ -209,3 +211,28 @@ describe("legacy backend graph fallback", () => {
209
211
  expect(isLegacyBackendFallbackFile(`${root}/apps/akan/page/_index.tsx`, root)).toBe(false);
210
212
  });
211
213
  });
214
+
215
+ describe("backend recovery abandonment", () => {
216
+ test("keeps retrying below the attempt ceiling and abandons at it", () => {
217
+ expect(shouldAbandonBackendRecovery(0)).toBe(false);
218
+ expect(shouldAbandonBackendRecovery(4)).toBe(false);
219
+ expect(shouldAbandonBackendRecovery(5)).toBe(true);
220
+ expect(shouldAbandonBackendRecovery(9)).toBe(true);
221
+ });
222
+
223
+ test("honors a custom attempt ceiling", () => {
224
+ expect(shouldAbandonBackendRecovery(2, 3)).toBe(false);
225
+ expect(shouldAbandonBackendRecovery(3, 3)).toBe(true);
226
+ });
227
+ });
228
+
229
+ describe("normalizeBackendReportedGeneration", () => {
230
+ test("drops the gateway's unknown-generation sentinel", () => {
231
+ expect(normalizeBackendReportedGeneration(-1)).toBeUndefined();
232
+ });
233
+
234
+ test("keeps real generations including zero", () => {
235
+ expect(normalizeBackendReportedGeneration(0)).toBe(0);
236
+ expect(normalizeBackendReportedGeneration(7)).toBe(7);
237
+ });
238
+ });
@@ -8,9 +8,12 @@ import { IncrementalBuilderHost } from "../incrementalBuilder";
8
8
 
9
9
  const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route"]);
10
10
  const BACKEND_RESTART_DEBOUNCE_MS = 120;
11
- const BACKEND_GRACEFUL_TIMEOUT_MS = 3000;
11
+ // Must exceed the gateway's child-wait budget (AkanApp child shutdown, ~5s in dev) so the gateway
12
+ // is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
13
+ const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
12
14
  const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
13
15
  const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
16
+ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
14
17
  const BACKEND_STDERR_TAIL_LIMIT = 40;
15
18
  const BUILDER_READY_TIMEOUT_MS = 150000;
16
19
  const BUILDER_START_MAX_ATTEMPTS = 3;
@@ -39,6 +42,17 @@ export const shouldRestartBackendByDevPlan = (
39
42
  export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
40
43
  message.devPlan?.actions.includes("restart-builder") ?? false;
41
44
 
45
+ /**
46
+ * A backend that keeps dying isn't going to heal by retrying the same code; after this many
47
+ * consecutive attempts the host idles and the next server-side edit triggers a fresh restart.
48
+ */
49
+ export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
50
+ attempts >= maxAttempts;
51
+
52
+ /** The gateway reports backend failures with `generation: -1`; the host assigns its own counter then. */
53
+ export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
54
+ generation >= 0 ? generation : undefined;
55
+
42
56
  export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
43
57
  message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
44
58
 
@@ -339,6 +353,18 @@ export class AkanAppHost {
339
353
  this.#replayBuilderState();
340
354
  return;
341
355
  }
356
+ if (msg.type === "build-status") {
357
+ // The gateway reports replica boot failures (crash loops, port conflicts) this way so
358
+ // they reach the build-status log and the HMR overlay like any other build failure.
359
+ const status = this.#recordBackendBuildStatus({
360
+ generation: normalizeBackendReportedGeneration(msg.data.generation),
361
+ ok: msg.data.ok,
362
+ files: msg.data.files,
363
+ message: msg.data.message,
364
+ });
365
+ this.#sendOrQueueBuildStatus(status);
366
+ return;
367
+ }
342
368
  if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
343
369
  },
344
370
  serialization: "advanced",
@@ -504,6 +530,17 @@ export class AkanAppHost {
504
530
  }
505
531
  #scheduleBackendRecovery(reason: string) {
506
532
  if (this.#backendRecoveryTimer || this.#backend) return;
533
+ if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
534
+ const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for a server-side edit to retry.`;
535
+ this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
536
+ this.logger.error(`[backend-recovery] ${message}`);
537
+ if (this.#backendStderrTail.length > 0) {
538
+ this.logger.error(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
539
+ }
540
+ const abandonedStatus = this.#recordBackendBuildStatus({ ok: false, files: [], message });
541
+ this.#sendOrQueueBuildStatus(abandonedStatus);
542
+ return;
543
+ }
507
544
  this.#setBackendLifecycleState("recovering", reason);
508
545
  const attempt = this.#backendRecoveryAttempts;
509
546
  const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
@@ -550,6 +587,7 @@ export class AkanAppHost {
550
587
  this.#sendToBackend(message);
551
588
  }
552
589
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
590
+ this.#logDevPlan(message);
553
591
  if (shouldRestartBuilderByDevPlan(message)) {
554
592
  try {
555
593
  await this.#restartDevChildren(message);
@@ -666,13 +704,18 @@ export class AkanAppHost {
666
704
  this.#sendToBackend({ type: "build-status", data: status });
667
705
  }
668
706
  }
707
+ /** One log line per planned generation, regardless of which action branch handles it. */
708
+ #logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
709
+ if (!message.devPlan) return;
710
+ const { generation, roles, actions, reasonByFile } = message.devPlan;
711
+ this.logger.verbose(
712
+ `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
713
+ );
714
+ }
715
+
669
716
  async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
670
717
  if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
671
718
  if (message.devPlan) {
672
- const { generation, roles, actions, reasonByFile } = message.devPlan;
673
- this.logger.verbose(
674
- `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
675
- );
676
719
  const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
677
720
  if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
678
721
  return shouldRestart;
@@ -308,15 +308,22 @@ class IncrementalBuilder {
308
308
  }
309
309
  if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
310
310
 
311
- if (kinds.includes("code") && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
311
+ // Server-only generations (e.g. a .service.ts or srvkit edit) must not rebuild or refresh the
312
+ // client: a fresh pages buildId would broadcast rsc-refresh to browsers for no visible change.
313
+ const rebuildClient = devPlan.actions.includes("rebuild-client");
314
+ if (kinds.includes("code") && !rebuildClient) {
315
+ this.#logger.verbose(`client rebuild skipped; devPlan actions=${devPlan.actions.join(",") || "(none)"}`);
316
+ }
317
+
318
+ if (kinds.includes("code") && rebuildClient && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
312
319
  const started = Date.now();
313
320
  await this.#app.getPageKeys({ refresh: true });
314
321
  this.#logger.verbose(`pageKeys updated, app pageKeys are refreshed (${Date.now() - started}ms)`);
315
- } else if (kinds.includes("code") && this.batchTouchesPagesTree(appDir, expandedBatch)) {
322
+ } else if (kinds.includes("code") && rebuildClient && this.batchTouchesPagesTree(appDir, expandedBatch)) {
316
323
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
317
324
  }
318
325
 
319
- if (kinds.includes("code") && this.#shouldRebuildCsr()) {
326
+ if (kinds.includes("code") && rebuildClient && this.#shouldRebuildCsr()) {
320
327
  try {
321
328
  const started = Date.now();
322
329
  await new CsrArtifactBuilder(this.#app).build();
@@ -327,13 +334,13 @@ class IncrementalBuilder {
327
334
  this.#logger.error(`csr-rebundle failed: ${message}`);
328
335
  this.#sendBuildStatus("csr", { generation, ok: false, files, message });
329
336
  }
330
- } else if (kinds.includes("code")) {
337
+ } else if (kinds.includes("code") && rebuildClient) {
331
338
  this.#logger.verbose(`csr-rebundle skipped; set AKAN_DEV_CSR_REBUILD=1 to enable per-save CSR rebuilds`);
332
339
  }
333
340
 
334
341
  process.send?.(event);
335
342
 
336
- if (kinds.includes("code")) {
343
+ if (kinds.includes("code") && rebuildClient) {
337
344
  try {
338
345
  const started = Date.now();
339
346
  const next = await new PagesBundleBuilder(this.#app).build();
@@ -368,6 +375,12 @@ class IncrementalBuilder {
368
375
  return;
369
376
  }
370
377
  });
378
+ // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
379
+ // as an orphaned watcher that keeps rebuilding for nobody.
380
+ process.on("disconnect", () => {
381
+ this.#logger.warn("host IPC channel closed; exiting builder");
382
+ process.exit(0);
383
+ });
371
384
  if (this.#watch) await this.installWatcher();
372
385
  process.send?.({ type: "builder-ready" });
373
386
  this.#logger.verbose(`ready (watch=${this.#watch})`);
@@ -45,6 +45,50 @@ const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5
45
45
  return null;
46
46
  };
47
47
 
48
+ interface GatewayHealth {
49
+ status: string;
50
+ pid?: number;
51
+ children: Array<{ idx: number; role: string; status: string; ready: boolean; pid?: number }>;
52
+ }
53
+
54
+ const fetchGatewayHealth = async (port: number): Promise<GatewayHealth | null> => {
55
+ const res = await fetch(`http://127.0.0.1:${port}/_akan/app/health`).catch(() => null);
56
+ if (!res?.ok) return null;
57
+ return (await res.json()) as GatewayHealth;
58
+ };
59
+
60
+ const waitForGatewayHealth = async (
61
+ port: number,
62
+ predicate: (health: GatewayHealth) => boolean,
63
+ timeoutMs = 60_000,
64
+ ): Promise<GatewayHealth> => {
65
+ const started = Date.now();
66
+ while (Date.now() - started < timeoutMs) {
67
+ const health = await fetchGatewayHealth(port);
68
+ if (health && predicate(health)) return health;
69
+ await new Promise((resolve) => setTimeout(resolve, 200));
70
+ }
71
+ throw new Error(`Timed out waiting for gateway health on port ${port}`);
72
+ };
73
+
74
+ const isProcessAlive = (pid: number): boolean => {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise<boolean> => {
84
+ const started = Date.now();
85
+ while (Date.now() - started < timeoutMs) {
86
+ if (pids.every((pid) => !isProcessAlive(pid))) return true;
87
+ await new Promise((resolve) => setTimeout(resolve, 200));
88
+ }
89
+ return false;
90
+ };
91
+
48
92
  afterEach(async () => {
49
93
  await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
50
94
  });
@@ -205,16 +249,20 @@ export const dictionary = serviceDictionary(["en", "ko"])
205
249
  integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
206
250
  const harness = await createHarness();
207
251
  const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
208
- const facets = ["common", "ui"] as const;
209
-
210
- for (const facet of facets) {
211
- const exportName = `${facet}TmpExample`;
252
+ // `common` barrels export camelCase names; `ui` barrels export PascalCase component names. Each facet's
253
+ // fixture file must follow its own casing convention or the barrel deliberately skips it.
254
+ const facets = [
255
+ { facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
256
+ { facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
257
+ ] as const;
258
+
259
+ for (const { facet, moduleName, fileName, exportName } of facets) {
212
260
  const indexPath = `${facet}/index.ts`;
213
- const absChangedFile = `${harness.appDir}/${facet}/tmpExample.ts`;
261
+ const absChangedFile = `${harness.appDir}/${facet}/${fileName}`;
214
262
  const absIndexPath = `${harness.appDir}/${indexPath}`;
215
263
 
216
264
  await harness.writeFile(
217
- `${facet}/tmpExample.ts`,
265
+ `${facet}/${fileName}`,
218
266
  `export const ${exportName} = "added-${facet}-example";
219
267
  `,
220
268
  );
@@ -222,21 +270,105 @@ export const dictionary = serviceDictionary(["en", "ko"])
222
270
  const added = await sync.syncForBatch([absChangedFile]);
223
271
  expect(added.errors).toEqual([]);
224
272
  expect(added.changedFiles).toContain(absIndexPath);
225
- const addedIndex = await waitForFileIncludes(absIndexPath, "tmpExample");
273
+ const addedIndex = await waitForFileIncludes(absIndexPath, moduleName);
226
274
  expect(addedIndex).not.toBeNull();
227
- expect(addedIndex ?? "").toContain("tmpExample");
275
+ expect(addedIndex ?? "").toContain(moduleName);
228
276
 
229
- await harness.removeFile(`${facet}/tmpExample.ts`);
277
+ await harness.removeFile(`${facet}/${fileName}`);
230
278
  const removed = await sync.syncForBatch([absChangedFile]);
231
279
  expect(removed.errors).toEqual([]);
232
280
  expect(removed.changedFiles).toContain(absIndexPath);
233
- const deletedIndex = await waitForFileIncludes(absIndexPath, "tmpExample", 1_000);
234
- if (deletedIndex) throw new Error(`${indexPath} still contains tmpExample after delete`);
281
+ const deletedIndex = await waitForFileIncludes(absIndexPath, moduleName, 1_000);
282
+ if (deletedIndex) throw new Error(`${indexPath} still contains ${moduleName} after delete`);
235
283
  const finalIndex = await Bun.file(absIndexPath).text();
236
284
  expect(finalIndex).toBeString();
237
285
  }
238
286
  });
239
287
 
288
+ integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
289
+ const harness = await createHarness();
290
+ const host = await harness.startHost();
291
+ const failureMark = host.markLog();
292
+
293
+ // The service file is part of the generated server graph (`akan start` regenerates server.ts
294
+ // from lib/), so a module-level throw here breaks every replica boot.
295
+ await harness.writeFile(
296
+ "lib/_fixture/fixture.service.ts",
297
+ `import { serve } from "akanjs/service";
298
+
299
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
300
+
301
+ throw new Error("intentional-backend-boot-crash");
302
+ `,
303
+ );
304
+
305
+ // The gateway abandons the replica after three failed boots instead of retrying forever...
306
+ await host.waitForLogSince(failureMark, /\[child-crash-loop\].*failed 3 consecutive boots/);
307
+ // ...and the failure reaches the dev host's build-status pipeline (HMR overlay path).
308
+ await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
309
+ expect(host.proc.killed).toBe(false);
310
+
311
+ const recoveryMark = host.markLog();
312
+ await harness.writeFile(
313
+ "lib/_fixture/fixture.service.ts",
314
+ `import { serve } from "akanjs/service";
315
+
316
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
317
+ `,
318
+ );
319
+
320
+ await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
321
+ expect(host.proc.killed).toBe(false);
322
+ });
323
+
324
+ integrationTest("SIGKILL'd gateway leaves no orphaned replicas and the host recovers", async () => {
325
+ const harness = await createHarness();
326
+ const host = await harness.startHost();
327
+ const port = await harness.resolvePort();
328
+
329
+ const healthy = await waitForGatewayHealth(
330
+ port,
331
+ (health) =>
332
+ typeof health.pid === "number" &&
333
+ health.children.length > 0 &&
334
+ health.children.every((child) => child.ready && typeof child.pid === "number"),
335
+ );
336
+ const gatewayPid = healthy.pid as number;
337
+ const childPids = healthy.children.map((child) => child.pid as number);
338
+ const mark = host.markLog();
339
+
340
+ process.kill(gatewayPid, "SIGKILL");
341
+
342
+ // Children must notice the closed IPC channel and exit instead of orphaning (they would
343
+ // otherwise keep holding their ws ports and break every subsequent boot).
344
+ expect(await waitForProcessesGone(childPids)).toBe(true);
345
+
346
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
347
+ const recovered = await waitForGatewayHealth(
348
+ port,
349
+ (health) => typeof health.pid === "number" && health.pid !== gatewayPid && health.children.some((c) => c.ready),
350
+ );
351
+ expect(recovered.pid).not.toBe(gatewayPid);
352
+ expect(host.proc.killed).toBe(false);
353
+ });
354
+
355
+ integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
356
+ const harness = await createHarness();
357
+ const port = await harness.resolvePort();
358
+ // The gateway assigns child 0 the deterministic ws port `port + 10_000`; occupy it up front
359
+ // the way an orphaned replica from a killed run would.
360
+ const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
361
+ try {
362
+ const host = await harness.startHost();
363
+ await host.waitForLog(/falling back to an ephemeral port/);
364
+ const health = await waitForGatewayHealth(port, (h) => h.children.some((child) => child.ready));
365
+ expect(health.children.some((child) => child.ready)).toBe(true);
366
+ expect(host.proc.killed).toBe(false);
367
+ } finally {
368
+ blocker.stop(true);
369
+ }
370
+ });
371
+
240
372
  integrationTest("route and css phase-5 scope remains smoke-level in this harness", async () => {
241
373
  const manualSmoke = [
242
374
  "route add/delete should be covered by a later browser-driven test",
@@ -259,6 +259,9 @@ export const dictionary = serviceDictionary(["en", "ko"])
259
259
  env: {
260
260
  ...process.env,
261
261
  AKAN_VERBOSE: "1",
262
+ // The dev-plan/hmr assertions match verbose-level Logger lines; without this the log
263
+ // stream only carries info/warn/error and those waits time out with empty tails.
264
+ AKAN_PUBLIC_LOG_LEVEL: "verbose",
262
265
  NODE_NO_WARNINGS: "1",
263
266
  PORT_OFFSET: String(this.portOffset),
264
267
  },
@@ -422,9 +425,21 @@ export const dictionary = serviceDictionary(["en", "ko"])
422
425
  }
423
426
 
424
427
  async resolvePort(): Promise<number> {
425
- const apps = await readdir(path.join(this.workspaceRoot, "apps")).catch(() => []);
426
- const appIndex = Math.max([...new Set([...apps, this.appName])].sort().indexOf(this.appName), 0);
427
- return 8282 + appIndex + this.portOffset;
428
+ // Mirror the CLI's `getDevPort()` exactly (apps = directories containing akan.config.ts,
429
+ // locale-sorted): counting stray entries like .DS_Store would put us one port off the gateway.
430
+ const appsDir = path.join(this.workspaceRoot, "apps");
431
+ const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
432
+ const checked = await Promise.all(
433
+ entries
434
+ .filter((entry) => entry.isDirectory())
435
+ .map(async (entry) =>
436
+ (await Bun.file(path.join(appsDir, entry.name, "akan.config.ts")).exists()) ? entry.name : null,
437
+ ),
438
+ );
439
+ const apps = [...new Set([...checked.filter((name): name is string => name !== null), this.appName])].sort((a, b) =>
440
+ a.localeCompare(b),
441
+ );
442
+ return 8282 + Math.max(apps.indexOf(this.appName), 0) + this.portOffset;
428
443
  }
429
444
  }
430
445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.0-rc.0",
3
+ "version": "2.4.0-rc.1",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.4.0-rc.0",
35
+ "akanjs": "2.4.0-rc.1",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",