@akanjs/devkit 2.3.5-rc.9 → 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.
- package/CHANGELOG.md +7 -0
- package/akanApp/akanApp.host.test.ts +202 -0
- package/akanApp/akanApp.host.ts +311 -27
- package/frontendBuild/devChangePlanner.ts +167 -0
- package/frontendBuild/devGeneratedIndexSync.ts +157 -0
- package/frontendBuild/frontendBuild.test.ts +89 -1
- package/frontendBuild/index.ts +2 -0
- package/incrementalBuilder/devWatchBatch.test.ts +59 -0
- package/incrementalBuilder/devWatchBatch.ts +48 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +69 -13
- package/incrementalBuilder/index.ts +1 -0
- package/integration/devStability.integration.test.ts +214 -0
- package/integration/devStabilityHarness.ts +444 -0
- package/lint/no-deep-internal-import.grit +2 -2
- package/package.json +2 -2
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
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(
|
|
256
|
-
|
|
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
|
|
265
|
-
this.#
|
|
266
|
-
void this.#restartBackend(
|
|
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(
|
|
270
|
-
this.logger.verbose(
|
|
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 === "
|
|
300
|
-
|
|
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
|
|
516
|
+
this.#scheduleBackendRestart(backendRestartReasonFromMessage(message));
|
|
310
517
|
return;
|
|
311
518
|
}
|
|
312
519
|
this.#sendToBackend(message);
|
|
313
520
|
}
|
|
314
|
-
#
|
|
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.#
|
|
317
|
-
if (this.#
|
|
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 (
|
|
322
|
-
|
|
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();
|