@akanjs/devkit 2.3.13 → 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.
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import {
3
3
  type App,
4
4
  AppExecutor,
5
+ AutoImportSync,
5
6
  type ChangeBatch,
6
7
  type ClientEntryDiscovery,
7
8
  CsrArtifactBuilder,
@@ -47,6 +48,7 @@ class IncrementalBuilder {
47
48
  #discovery: ClientEntryDiscovery;
48
49
  #changePlanner: DevChangePlanner;
49
50
  #generatedIndexSync: DevGeneratedIndexSync;
51
+ #autoImportSync: AutoImportSync;
50
52
  #generation = 0;
51
53
  #workQueue: Promise<void> = Promise.resolve();
52
54
  #cssRebuildQueue: Promise<void> = Promise.resolve();
@@ -62,6 +64,7 @@ class IncrementalBuilder {
62
64
  this.#discovery = options.discovery;
63
65
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
64
66
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
67
+ this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
65
68
  }
66
69
 
67
70
  async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
@@ -269,6 +272,13 @@ class IncrementalBuilder {
269
272
  const rawKinds = new Set(batch.kinds);
270
273
  if (rawKinds.size === 0) return;
271
274
  const generation = ++this.#generation;
275
+ //* Insert framework imports that are used but omitted (e.g. `Int` in *.constant.ts, `fetch` in
276
+ //* *.store.ts) before regenerating barrels. Edits land on files already in this batch, so they
277
+ //* rebuild in this same generation; the write is idempotent so it does not re-trigger the watcher.
278
+ const autoImport = await this.#autoImportSync.syncForBatch(batch.files);
279
+ for (const error of autoImport.errors) this.#logger.error(error);
280
+ if (autoImport.changedFiles.length > 0)
281
+ this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
272
282
  const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
273
283
  const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
274
284
  generation,
@@ -298,15 +308,22 @@ class IncrementalBuilder {
298
308
  }
299
309
  if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
300
310
 
301
- 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))) {
302
319
  const started = Date.now();
303
320
  await this.#app.getPageKeys({ refresh: true });
304
321
  this.#logger.verbose(`pageKeys updated, app pageKeys are refreshed (${Date.now() - started}ms)`);
305
- } else if (kinds.includes("code") && this.batchTouchesPagesTree(appDir, expandedBatch)) {
322
+ } else if (kinds.includes("code") && rebuildClient && this.batchTouchesPagesTree(appDir, expandedBatch)) {
306
323
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
307
324
  }
308
325
 
309
- if (kinds.includes("code") && this.#shouldRebuildCsr()) {
326
+ if (kinds.includes("code") && rebuildClient && this.#shouldRebuildCsr()) {
310
327
  try {
311
328
  const started = Date.now();
312
329
  await new CsrArtifactBuilder(this.#app).build();
@@ -317,13 +334,13 @@ class IncrementalBuilder {
317
334
  this.#logger.error(`csr-rebundle failed: ${message}`);
318
335
  this.#sendBuildStatus("csr", { generation, ok: false, files, message });
319
336
  }
320
- } else if (kinds.includes("code")) {
337
+ } else if (kinds.includes("code") && rebuildClient) {
321
338
  this.#logger.verbose(`csr-rebundle skipped; set AKAN_DEV_CSR_REBUILD=1 to enable per-save CSR rebuilds`);
322
339
  }
323
340
 
324
341
  process.send?.(event);
325
342
 
326
- if (kinds.includes("code")) {
343
+ if (kinds.includes("code") && rebuildClient) {
327
344
  try {
328
345
  const started = Date.now();
329
346
  const next = await new PagesBundleBuilder(this.#app).build();
@@ -358,6 +375,12 @@ class IncrementalBuilder {
358
375
  return;
359
376
  }
360
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
+ });
361
384
  if (this.#watch) await this.installWatcher();
362
385
  process.send?.({ type: "builder-ready" });
363
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.3.13",
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.3.13",
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",