@akanjs/devkit 2.3.5 → 2.3.6-rc.0

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.
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { Logger } from "akanjs/common";
3
- import type { BuilderMessage } from "akanjs/server";
3
+ import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeRole } from "akanjs/server";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { createTunnel } from "../createTunnel";
6
6
  import { WorkspaceExecutor } from "../executors";
@@ -16,6 +16,9 @@ const BUILDER_START_MAX_ATTEMPTS = 3;
16
16
  const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
17
17
  const NON_SOURCE_EXT_RE =
18
18
  /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
19
+ const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
20
+ const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
21
+ const RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts", "useServer.ts"]);
19
22
  const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
20
23
  "import-statement",
21
24
  "require-call",
@@ -23,6 +26,122 @@ const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
23
26
  "dynamic-import",
24
27
  ]);
25
28
 
29
+ export const shouldRestartBackendByDevPlan = (
30
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
31
+ ): boolean | null => {
32
+ if (!message.devPlan) return null;
33
+ if (message.devPlan.actions.includes("report-error")) return false;
34
+ return message.devPlan.actions.includes("restart-backend");
35
+ };
36
+
37
+ export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
38
+ message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
39
+
40
+ export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "stopping" | "recovering" | "stopped";
41
+
42
+ export interface BackendRestartReason {
43
+ generation?: number;
44
+ files: string[];
45
+ roles: Extract<DevChangeRole, "server" | "shared" | "barrel" | "config">[];
46
+ }
47
+
48
+ interface LastGoodFrontendState {
49
+ pages?: Extract<BuilderMessage, { type: "pages-updated" }>;
50
+ css?: Extract<BuilderMessage, { type: "css-updated" }>;
51
+ }
52
+
53
+ const RESTART_ROLE_ORDER: BackendRestartReason["roles"] = ["server", "shared", "barrel", "config"];
54
+
55
+ const generationValue = (generation: number | undefined): number => generation ?? -1;
56
+
57
+ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string): boolean => {
58
+ const abs = path.resolve(file);
59
+ const ext = path.extname(abs).toLowerCase();
60
+ if (!SOURCE_EXTS.has(ext)) return false;
61
+ const rel = path.relative(path.resolve(workspaceRoot), abs);
62
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
63
+ const parts = rel.split(path.sep).filter(Boolean);
64
+ const [scope] = parts;
65
+ if (scope !== "apps" && scope !== "libs" && scope !== "pkgs") return false;
66
+
67
+ const base = path.basename(abs);
68
+ return (
69
+ parts.includes("srvkit") ||
70
+ parts.includes("common") ||
71
+ SERVER_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
72
+ SHARED_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
73
+ RUNTIME_METADATA_BASENAMES.has(base) ||
74
+ base === "main.ts" ||
75
+ base === "server.ts"
76
+ );
77
+ };
78
+
79
+ export const shouldMarkBuildPhaseRecovered = (
80
+ previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
81
+ status: DevBuildStatus,
82
+ ): boolean => {
83
+ const previous = previousByPhase.get(status.phase);
84
+ return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
85
+ };
86
+
87
+ export const createBackendBuildStatus = ({
88
+ generation,
89
+ ok,
90
+ files = [],
91
+ message,
92
+ }: {
93
+ generation: number;
94
+ ok: boolean;
95
+ files?: string[];
96
+ message?: string;
97
+ }): DevBuildStatus => ({
98
+ generation,
99
+ phase: "backend",
100
+ ok,
101
+ files,
102
+ message,
103
+ });
104
+
105
+ export const backendRestartReasonFromMessage = (
106
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
107
+ ): BackendRestartReason => {
108
+ const roleSet = new Set<BackendRestartReason["roles"][number]>();
109
+ for (const role of message.devPlan?.roles ?? []) {
110
+ if (role === "server" || role === "shared" || role === "barrel" || role === "config") roleSet.add(role);
111
+ }
112
+ return {
113
+ generation: message.devPlan?.generation ?? message.generation,
114
+ files: [...new Set(message.files)].sort(),
115
+ roles: RESTART_ROLE_ORDER.filter((role) => roleSet.has(role)),
116
+ };
117
+ };
118
+
119
+ export const mergeBackendRestartReasons = (
120
+ current: BackendRestartReason | null,
121
+ next: BackendRestartReason,
122
+ ): BackendRestartReason => ({
123
+ generation:
124
+ generationValue(next.generation) >= generationValue(current?.generation) ? next.generation : current?.generation,
125
+ files: [...new Set([...(current?.files ?? []), ...next.files])].sort(),
126
+ roles: RESTART_ROLE_ORDER.filter((role) => current?.roles.includes(role) || next.roles.includes(role)),
127
+ });
128
+
129
+ export const shouldReplaceLastGoodMessage = (
130
+ current:
131
+ | Extract<BuilderMessage, { type: "pages-updated" }>
132
+ | Extract<BuilderMessage, { type: "css-updated" }>
133
+ | undefined,
134
+ next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
135
+ ): boolean => !current || generationValue(next.data.generation) >= generationValue(current.data.generation);
136
+
137
+ export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
138
+ !backendReady || pendingReplayCount > 0;
139
+
140
+ export const buildStatusReplaySequence = (
141
+ pendingReplay: readonly DevBuildStatus[],
142
+ latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
143
+ ): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
144
+
26
145
  class BackendImportGraph {
27
146
  readonly #app: App;
28
147
  readonly #logger: Logger;
@@ -32,6 +151,7 @@ class BackendImportGraph {
32
151
  readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
33
152
  #files = new Set<string>();
34
153
  #ready = false;
154
+ #lastRefreshSucceeded = false;
35
155
 
36
156
  constructor(app: App, logger: Logger) {
37
157
  this.#app = app;
@@ -42,6 +162,10 @@ class BackendImportGraph {
42
162
  return this.#ready;
43
163
  }
44
164
 
165
+ get lastRefreshSucceeded() {
166
+ return this.#lastRefreshSucceeded;
167
+ }
168
+
45
169
  has(file: string) {
46
170
  return this.#files.has(path.resolve(file));
47
171
  }
@@ -51,10 +175,12 @@ class BackendImportGraph {
51
175
  const files = await this.#build();
52
176
  this.#files = files;
53
177
  this.#ready = true;
178
+ this.#lastRefreshSucceeded = true;
54
179
  this.#logger.verbose(`[backend-graph] scanned ${files.size} files`);
55
180
  return true;
56
181
  } catch (err) {
57
182
  this.#ready = this.#files.size > 0;
183
+ this.#lastRefreshSucceeded = false;
58
184
  this.#logger.warn(
59
185
  `[backend-graph] scan failed; ${this.#ready ? "using previous graph" : "using fallback rules"}: ${err instanceof Error ? err.message : String(err)}`,
60
186
  );
@@ -136,9 +262,13 @@ export class AkanAppHost {
136
262
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
137
263
  #backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
138
264
  #backendRecoveryAttempts = 0;
139
- #restartFiles = new Set<string>();
140
- #latestPagesUpdated: Extract<BuilderMessage, { type: "pages-updated" }> | null = null;
141
- #latestCssUpdated: Extract<BuilderMessage, { type: "css-updated" }> | null = null;
265
+ #backendLifecycleState: BackendLifecycleState = "stopped";
266
+ #pendingRestartReason: BackendRestartReason | null = null;
267
+ #backendStartStatus: { generation?: number; files: string[] } | null = null;
268
+ #backendBuildStatusGeneration = 0;
269
+ #lastGoodFrontend: LastGoodFrontendState = {};
270
+ #buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
271
+ #pendingBuildStatusReplay: DevBuildStatus[] = [];
142
272
  #builderMessageQueue: Promise<void> = Promise.resolve();
143
273
  #backendGraph: BackendImportGraph;
144
274
  constructor(
@@ -183,7 +313,9 @@ export class AkanAppHost {
183
313
  if (environment === "local") return "localhost";
184
314
  return await createTunnel(type, { app: this.app, environment });
185
315
  }
186
- #startBackend() {
316
+ #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
317
+ this.#backendStartStatus = startStatus;
318
+ this.#setBackendLifecycleState("starting");
187
319
  this.#backendReady = false;
188
320
  const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
189
321
  cwd: this.app.workspace.workspaceRoot,
@@ -194,6 +326,8 @@ export class AkanAppHost {
194
326
  if (msg.type === "backend-ready") {
195
327
  this.#backendReady = true;
196
328
  this.#backendRecoveryAttempts = 0;
329
+ this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
330
+ this.#recordBackendReadyStatus();
197
331
  this.logger.verbose(`backend ready pid=${msg.pid}`);
198
332
  this.#replayBuilderState();
199
333
  return;
@@ -214,9 +348,57 @@ export class AkanAppHost {
214
348
  this.#backend = backend;
215
349
  this.logger.verbose(`backend spawned pid=${backend.pid}`);
216
350
  }
351
+ #nextBackendBuildStatusGeneration(generation?: number): number {
352
+ if (typeof generation === "number") {
353
+ this.#backendBuildStatusGeneration = Math.max(this.#backendBuildStatusGeneration, generation);
354
+ return generation;
355
+ }
356
+ this.#backendBuildStatusGeneration += 1;
357
+ return this.#backendBuildStatusGeneration;
358
+ }
359
+ #recordBackendBuildStatus({
360
+ generation,
361
+ ok,
362
+ files,
363
+ message,
364
+ }: {
365
+ generation?: number;
366
+ ok: boolean;
367
+ files?: string[];
368
+ message?: string;
369
+ }): DevBuildStatus {
370
+ const status = createBackendBuildStatus({
371
+ generation: this.#nextBackendBuildStatusGeneration(generation),
372
+ ok,
373
+ files,
374
+ message,
375
+ });
376
+ this.#recordBuildStatus(status);
377
+ return status;
378
+ }
379
+ #recordBackendReadyStatus(): void {
380
+ const previous = this.#buildStatusByPhase.get("backend");
381
+ const startStatus = this.#backendStartStatus;
382
+ if (startStatus || previous?.ok === false) {
383
+ const status = this.#recordBackendBuildStatus({
384
+ generation: startStatus?.generation ?? previous?.generation,
385
+ ok: true,
386
+ files: startStatus?.files ?? previous?.files ?? [],
387
+ message: "Backend ready",
388
+ });
389
+ this.#sendOrQueueBuildStatus(status);
390
+ }
391
+ this.#backendStartStatus = null;
392
+ }
393
+ #setBackendLifecycleState(next: BackendLifecycleState, detail?: string): void {
394
+ if (this.#backendLifecycleState === next && !detail) return;
395
+ const prev = this.#backendLifecycleState;
396
+ this.#backendLifecycleState = next;
397
+ this.logger.verbose(`[backend-lifecycle] ${prev} -> ${next}${detail ? ` ${detail}` : ""}`);
398
+ }
217
399
  #sendToBackend(message: BuilderMessage) {
218
400
  if (!this.#backend || !this.#backendReady) {
219
- if (message.type === "css-updated" || message.type === "pages-updated") {
401
+ if (message.type === "css-updated" || message.type === "pages-updated" || message.type === "build-status") {
220
402
  this.logger.verbose(`backend is not ready; will replay ${message.type}`);
221
403
  return;
222
404
  }
@@ -236,6 +418,7 @@ export class AkanAppHost {
236
418
  const backend = this.#backend;
237
419
  this.#plannedBackendStops.add(backend);
238
420
  this.#backendReady = false;
421
+ this.#setBackendLifecycleState("stopping", `pid=${backend.pid}`);
239
422
  this.logger.verbose(`stopping backend pid=${backend.pid}`);
240
423
  try {
241
424
  backend.kill("SIGTERM");
@@ -250,10 +433,16 @@ export class AkanAppHost {
250
433
  }
251
434
  } finally {
252
435
  if (this.#backend === backend) this.#backend = null;
436
+ this.#setBackendLifecycleState("stopped", `pid=${backend.pid}`);
253
437
  }
254
438
  }
255
- #scheduleBackendRestart(files: string[]) {
256
- for (const file of files) this.#restartFiles.add(file);
439
+ #scheduleBackendRestart(reason: BackendRestartReason) {
440
+ this.#pendingRestartReason = mergeBackendRestartReasons(this.#pendingRestartReason, reason);
441
+ const pending = this.#pendingRestartReason;
442
+ this.#setBackendLifecycleState(
443
+ "restart-pending",
444
+ `generation=${pending.generation ?? "(unknown)"} files=${pending.files.length} roles=${pending.roles.join(",") || "(none)"}`,
445
+ );
257
446
  if (this.#backendRecoveryTimer) {
258
447
  clearTimeout(this.#backendRecoveryTimer);
259
448
  this.#backendRecoveryTimer = null;
@@ -261,22 +450,31 @@ export class AkanAppHost {
261
450
  if (this.#restartTimer) clearTimeout(this.#restartTimer);
262
451
  this.#restartTimer = setTimeout(() => {
263
452
  this.#restartTimer = null;
264
- const changed = [...this.#restartFiles];
265
- this.#restartFiles.clear();
266
- void this.#restartBackend(changed);
453
+ const next = this.#pendingRestartReason;
454
+ this.#pendingRestartReason = null;
455
+ if (next) void this.#restartBackend(next);
267
456
  }, BACKEND_RESTART_DEBOUNCE_MS);
268
457
  }
269
- async #restartBackend(files: string[]) {
270
- this.logger.verbose(`[backend-reload] restarting backend for ${files.length} file(s)`);
458
+ async #restartBackend(reason: BackendRestartReason) {
459
+ this.logger.verbose(
460
+ `[backend-reload] restarting backend generation=${reason.generation ?? "(unknown)"} files=${reason.files.length} roles=${reason.roles.join(",") || "(none)"}`,
461
+ );
271
462
  this.#backendRecoveryAttempts = 0;
272
463
  await Promise.all([this.#stopBackend(), this.#backendGraph.refresh()]);
273
- this.#startBackend();
464
+ this.#startBackend({ generation: reason.generation, files: reason.files });
274
465
  }
275
466
  #scheduleBackendRecovery(reason: string) {
276
467
  if (this.#backendRecoveryTimer || this.#backend) return;
468
+ this.#setBackendLifecycleState("recovering", reason);
277
469
  const attempt = this.#backendRecoveryAttempts;
278
470
  const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
279
471
  this.#backendRecoveryAttempts = attempt + 1;
472
+ const failureStatus = this.#recordBackendBuildStatus({
473
+ ok: false,
474
+ files: [],
475
+ message: `Backend exited unexpectedly (${reason}); restarting in ${delay}ms`,
476
+ });
477
+ this.#sendOrQueueBuildStatus(failureStatus);
280
478
  this.logger.warn(
281
479
  `[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
282
480
  );
@@ -284,7 +482,7 @@ export class AkanAppHost {
284
482
  this.#backendRecoveryTimer = null;
285
483
  if (this.#backend) return;
286
484
  void this.#backendGraph.refresh().finally(() => {
287
- if (!this.#backend) this.#startBackend();
485
+ if (!this.#backend) this.#startBackend({ generation: failureStatus.generation, files: failureStatus.files });
288
486
  });
289
487
  }, delay);
290
488
  }
@@ -296,8 +494,13 @@ export class AkanAppHost {
296
494
  });
297
495
  }
298
496
  async #handleBuilderMessage(message: BuilderMessage) {
299
- if (message.type === "pages-updated") this.#latestPagesUpdated = message;
300
- if (message.type === "css-updated") this.#latestCssUpdated = message;
497
+ if (message.type === "build-status") {
498
+ this.#recordBuildStatus(message.data);
499
+ this.#sendOrQueueBuildStatus(message.data);
500
+ return;
501
+ }
502
+ if (message.type === "pages-updated") this.#recordLastGood(message);
503
+ if (message.type === "css-updated") this.#recordLastGood(message);
301
504
  if (message.type === "invalidate") {
302
505
  await this.#handleInvalidate(message);
303
506
  return;
@@ -305,26 +508,107 @@ export class AkanAppHost {
305
508
  this.#sendToBackend(message);
306
509
  }
307
510
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
511
+ if (shouldRestartDevHostByDevPlan(message)) {
512
+ this.#recordDevHostRestartRequired(message);
513
+ return;
514
+ }
308
515
  if (await this.#shouldRestartBackend(message)) {
309
- this.#scheduleBackendRestart(message.files);
516
+ this.#scheduleBackendRestart(backendRestartReasonFromMessage(message));
310
517
  return;
311
518
  }
312
519
  this.#sendToBackend(message);
313
520
  }
314
- #replayBuilderState() {
521
+ #recordLastGood(
522
+ message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
523
+ ): void {
524
+ if (message.type === "pages-updated") {
525
+ if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message)) return;
526
+ this.#lastGoodFrontend.pages = message;
527
+ this.logger.verbose(
528
+ `[last-good] pages generation=${message.data.generation ?? "(unknown)"} buildId=${message.data.buildId}`,
529
+ );
530
+ return;
531
+ }
532
+ if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message)) return;
533
+ this.#lastGoodFrontend.css = message;
534
+ this.logger.verbose(
535
+ `[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
536
+ );
537
+ }
538
+ #recordDevHostRestartRequired(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
539
+ const generation = message.devPlan?.generation ?? message.generation;
540
+ const detail = `generation=${generation ?? "(unknown)"} files=${message.files.length}`;
541
+ this.logger.warn(
542
+ `[dev-host] config change requires a manual restart until controlled dev-host restart is implemented (${detail})`,
543
+ );
544
+ if (typeof generation === "number") {
545
+ const status: DevBuildStatus = {
546
+ generation,
547
+ phase: "scan",
548
+ ok: false,
549
+ files: message.files,
550
+ message: "Config change requires restarting `akan start` to apply.",
551
+ };
552
+ this.#recordBuildStatus(status);
553
+ this.#sendOrQueueBuildStatus(status);
554
+ }
555
+ }
556
+ #recordBuildStatus(status: DevBuildStatus): void {
557
+ const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
558
+ this.#buildStatusByPhase.set(status.phase, status);
559
+ const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
560
+ if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
561
+ else this.logger.warn(`${label}${status.message ? ` message=${status.message}` : ""}`);
562
+ }
563
+ #sendOrQueueBuildStatus(status: DevBuildStatus): void {
564
+ if (!this.#backend || shouldQueueBuildStatusReplay(this.#backendReady, this.#pendingBuildStatusReplay.length)) {
565
+ this.#pendingBuildStatusReplay.push(status);
566
+ this.logger.verbose(
567
+ `backend is not ready; will replay build-status generation=${status.generation} phase=${status.phase}`,
568
+ );
569
+ return;
570
+ }
571
+ this.#sendToBackend({ type: "build-status", data: status });
572
+ }
573
+ #replayBuilderState(): void {
315
574
  if (!this.#backendReady) return;
316
- if (this.#latestCssUpdated) this.#sendToBackend(this.#latestCssUpdated);
317
- if (this.#latestPagesUpdated) this.#sendToBackend(this.#latestPagesUpdated);
575
+ if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
576
+ if (this.#lastGoodFrontend.pages) this.#sendToBackend(this.#lastGoodFrontend.pages);
577
+ const queuedStatuses = this.#pendingBuildStatusReplay.splice(0);
578
+ for (const status of buildStatusReplaySequence(queuedStatuses, this.#buildStatusByPhase)) {
579
+ this.#sendToBackend({ type: "build-status", data: status });
580
+ }
318
581
  }
319
582
  async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
320
583
  if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
321
- if (!this.#backendGraph.ready && message.kinds.includes("code")) await this.#backendGraph.refresh();
322
- return message.files.some((file) => this.#isBackendFile(file));
584
+ if (message.devPlan) {
585
+ const { generation, roles, actions, reasonByFile } = message.devPlan;
586
+ this.logger.verbose(
587
+ `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
588
+ );
589
+ const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
590
+ if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
591
+ return shouldRestart;
592
+ }
593
+ if (message.kinds.includes("code")) await this.#backendGraph.refresh();
594
+ if (message.files.some((file) => this.#isBackendFile(file))) return true;
595
+ if (!this.#backendGraph.lastRefreshSucceeded) {
596
+ const fallbackFiles = message.files.filter((file) =>
597
+ isLegacyBackendFallbackFile(file, this.app.workspace.workspaceRoot),
598
+ );
599
+ if (fallbackFiles.length > 0) {
600
+ this.logger.warn(
601
+ `[backend-graph] using path-role fallback for legacy invalidate; restart files=${fallbackFiles.length}`,
602
+ );
603
+ return true;
604
+ }
605
+ }
606
+ return false;
323
607
  }
324
608
  #isBackendFile(file: string): boolean {
325
609
  return this.#backendGraph.has(file);
326
610
  }
327
- async #startBuilder() {
611
+ async #startBuilder(): Promise<IncrementalBuilderHost> {
328
612
  const startTime = Date.now();
329
613
  this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
330
614
  let lastError: unknown;
@@ -345,7 +629,7 @@ export class AkanAppHost {
345
629
  }
346
630
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
347
631
  }
348
- #waitForBuilderReady(attempt: number) {
632
+ #waitForBuilderReady(attempt: number): Promise<void> {
349
633
  return new Promise<void>((resolve, reject) => {
350
634
  if (!this.#builder) throw new Error("Builder Not Found");
351
635
  let settled = false;
@@ -372,7 +656,7 @@ export class AkanAppHost {
372
656
  });
373
657
  });
374
658
  }
375
- #sendToBuilder(message: BuilderMessage) {
659
+ #sendToBuilder(message: BuilderMessage): void {
376
660
  if (this.#builder?.send(message)) return;
377
661
  if (message.type === "build-route") {
378
662
  this.#sendToBackend({
@@ -385,7 +669,7 @@ export class AkanAppHost {
385
669
  }
386
670
  this.logger.warn("akanAppHost builder is not running");
387
671
  }
388
- #stopBuilder() {
672
+ #stopBuilder(): void {
389
673
  if (!this.#builder) return;
390
674
  this.#builder.stop();
391
675
  this.#builder = null;
@@ -0,0 +1,167 @@
1
+ import path from "node:path";
2
+ import type { ChangeKind, DevChangeAction, DevChangePlan, DevChangeRole } from "akanjs/server";
3
+
4
+ const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
5
+ const CONFIG_BASENAMES = new Set(["akan.config.ts", "bunfig.toml", "tsconfig.json", "package.json"]);
6
+ const BARREL_FACETS = new Set(["common", "srvkit", "ui", "webkit"]);
7
+ const CLIENT_SUFFIXES = [".Template.tsx", ".Unit.tsx", ".Util.tsx", ".View.tsx", ".Zone.tsx", ".store.ts"];
8
+ const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
9
+ const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
10
+ const RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts", "useServer.ts"]);
11
+
12
+ export interface DevChangePlannerOptions {
13
+ workspaceRoot: string;
14
+ }
15
+
16
+ export interface DevChangePlanInput {
17
+ generation: number;
18
+ files: string[];
19
+ kinds: Iterable<Exclude<ChangeKind, "ignore">>;
20
+ generatedFiles?: string[];
21
+ }
22
+
23
+ export class DevChangePlanner {
24
+ readonly #workspaceRoot: string;
25
+
26
+ constructor({ workspaceRoot }: DevChangePlannerOptions) {
27
+ this.#workspaceRoot = path.resolve(workspaceRoot);
28
+ }
29
+
30
+ plan({ generation, files, kinds, generatedFiles = [] }: DevChangePlanInput): DevChangePlan {
31
+ const fileList = uniqueResolved([...files, ...generatedFiles]);
32
+ const generatedSet = new Set(generatedFiles.map((file) => path.resolve(file)));
33
+ const kindSet = new Set(kinds);
34
+ const roles = new Set<DevChangeRole>();
35
+ const actions = new Set<DevChangeAction>();
36
+ const reasonByFile: Record<string, string[]> = {};
37
+
38
+ for (const kind of kindSet) {
39
+ if (kind === "css") {
40
+ roles.add("css");
41
+ actions.add("rebuild-css");
42
+ }
43
+ if (kind === "config") {
44
+ roles.add("config");
45
+ actions.add("restart-dev-host");
46
+ }
47
+ }
48
+
49
+ for (const file of fileList) {
50
+ const reasons = new Set<string>();
51
+ const fileRoles = this.#rolesForFile(file, { isGenerated: generatedSet.has(path.resolve(file)), reasons });
52
+ for (const role of fileRoles) roles.add(role);
53
+ if (reasons.size > 0) reasonByFile[path.resolve(file)] = [...reasons].sort();
54
+ }
55
+
56
+ if (roles.has("barrel")) actions.add("sync-generated");
57
+ if (roles.has("server") || roles.has("shared")) actions.add("restart-backend");
58
+ if (roles.has("client") || roles.has("shared")) actions.add("rebuild-client");
59
+ if (roles.has("css")) actions.add("rebuild-css");
60
+
61
+ return {
62
+ generation,
63
+ files: fileList,
64
+ generatedFiles: uniqueResolved(generatedFiles),
65
+ roles: [...roles].sort(),
66
+ actions: [...actions].sort(),
67
+ reasonByFile,
68
+ };
69
+ }
70
+
71
+ #rolesForFile(
72
+ file: string,
73
+ { isGenerated, reasons }: { isGenerated: boolean; reasons: Set<string> },
74
+ ): Set<DevChangeRole> {
75
+ const roles = new Set<DevChangeRole>();
76
+ const abs = path.resolve(file);
77
+ const base = path.basename(abs);
78
+ const ext = path.extname(abs).toLowerCase();
79
+ const isSource = SOURCE_EXTS.has(ext);
80
+ const rel = path.relative(this.#workspaceRoot, abs);
81
+ const parts = rel.split(path.sep).filter(Boolean);
82
+
83
+ if (CONFIG_BASENAMES.has(base)) {
84
+ roles.add("config");
85
+ reasons.add("config-file");
86
+ }
87
+ if (ext === ".css") {
88
+ roles.add("css");
89
+ reasons.add("css-file");
90
+ }
91
+ if (isGenerated || (isSource && this.#isBarrelFacetChild(parts))) {
92
+ roles.add("barrel");
93
+ reasons.add(isGenerated ? "generated-index" : "barrel-facet-child");
94
+ }
95
+ if (isSource && this.#isServerBiased(abs, parts)) {
96
+ roles.add("server");
97
+ reasons.add("server-path");
98
+ }
99
+ if (isSource && this.#isClientBiased(abs, parts)) {
100
+ roles.add("client");
101
+ reasons.add("client-path");
102
+ }
103
+ if (isSource && this.#isSharedBiased(abs, parts)) {
104
+ roles.add("shared");
105
+ reasons.add("shared-path");
106
+ }
107
+
108
+ if (roles.has("server") && roles.has("client")) {
109
+ roles.delete("server");
110
+ roles.delete("client");
111
+ roles.add("shared");
112
+ reasons.add("server-client-overlap");
113
+ }
114
+ if (roles.size === 0 && SOURCE_EXTS.has(ext) && this.#isWorkspaceSource(rel)) {
115
+ roles.add("shared");
116
+ reasons.add("workspace-source-fallback");
117
+ }
118
+
119
+ return roles;
120
+ }
121
+
122
+ #isServerBiased(abs: string, parts: string[]): boolean {
123
+ const base = path.basename(abs);
124
+ return (
125
+ parts.includes("srvkit") ||
126
+ SERVER_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
127
+ base === "main.ts" ||
128
+ base === "server.ts"
129
+ );
130
+ }
131
+
132
+ #isClientBiased(abs: string, parts: string[]): boolean {
133
+ const base = path.basename(abs);
134
+ return (
135
+ parts.includes("ui") ||
136
+ parts.includes("webkit") ||
137
+ parts.includes("page") ||
138
+ CLIENT_SUFFIXES.some((suffix) => base.endsWith(suffix))
139
+ );
140
+ }
141
+
142
+ #isSharedBiased(abs: string, parts: string[]): boolean {
143
+ const base = path.basename(abs);
144
+ return (
145
+ parts.includes("common") ||
146
+ SHARED_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
147
+ RUNTIME_METADATA_BASENAMES.has(base)
148
+ );
149
+ }
150
+
151
+ #isBarrelFacetChild(parts: string[]): boolean {
152
+ if (parts.length < 4) return false;
153
+ const [scope, , facet, child] = parts;
154
+ if (scope !== "apps" && scope !== "libs") return false;
155
+ if (!facet || !BARREL_FACETS.has(facet)) return false;
156
+ if (!child || child.startsWith(".") || child === "index.ts" || child === "index.tsx") return false;
157
+ return true;
158
+ }
159
+
160
+ #isWorkspaceSource(rel: string): boolean {
161
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
162
+ const [scope] = rel.split(path.sep);
163
+ return scope === "apps" || scope === "libs" || scope === "pkgs";
164
+ }
165
+ }
166
+
167
+ const uniqueResolved = (files: string[]) => [...new Set(files.map((file) => path.resolve(file)))].sort();