@akanjs/devkit 2.4.0-rc.0 → 2.4.0-rc.2
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/akanApp/akanApp.host.test.ts +27 -0
- package/akanApp/akanApp.host.ts +94 -31
- package/akanConfig/akanConfig.test.ts +24 -0
- package/akanConfig/akanConfig.ts +19 -2
- package/executors.ts +3 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +21 -6
- package/integration/devStability.integration.test.ts +169 -11
- package/integration/devStabilityHarness.ts +18 -3
- package/package.json +2 -2
- package/scanInfo.ts +2 -1
|
@@ -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
|
+
});
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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
|
-
|
|
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,16 +587,23 @@ export class AkanAppHost {
|
|
|
550
587
|
this.#sendToBackend(message);
|
|
551
588
|
}
|
|
552
589
|
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
553
|
-
|
|
590
|
+
this.#logDevPlan(message);
|
|
591
|
+
// Config changes subsume builder restarts: the dev-host restart recycles builder and backend
|
|
592
|
+
// AND re-runs the prepare step, so check it first when a batch carries both actions.
|
|
593
|
+
if (shouldRestartDevHostByDevPlan(message)) {
|
|
554
594
|
try {
|
|
555
|
-
await this.#
|
|
595
|
+
await this.#restartDevHost(message);
|
|
556
596
|
} catch (err) {
|
|
557
|
-
this.#recordDevHostRestartFailure(message, err);
|
|
597
|
+
this.#recordDevHostRestartFailure(message, err, "Config");
|
|
558
598
|
}
|
|
559
599
|
return;
|
|
560
600
|
}
|
|
561
|
-
if (
|
|
562
|
-
|
|
601
|
+
if (shouldRestartBuilderByDevPlan(message)) {
|
|
602
|
+
try {
|
|
603
|
+
await this.#restartDevChildren(message);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
this.#recordDevHostRestartFailure(message, err, "Runtime metadata");
|
|
606
|
+
}
|
|
563
607
|
return;
|
|
564
608
|
}
|
|
565
609
|
if (await this.#shouldRestartBackend(message)) {
|
|
@@ -573,6 +617,27 @@ export class AkanAppHost {
|
|
|
573
617
|
this.logger.warn(
|
|
574
618
|
`[dev-host] recycling builder/backend for runtime metadata generation=${generation ?? "(unknown)"} files=${message.files.length}`,
|
|
575
619
|
);
|
|
620
|
+
await this.#recycleDevChildren(message);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Controlled dev-host restart for config changes (akan.config.ts, tsconfig, package.json):
|
|
624
|
+
* re-runs the prepare step so env and codegen reflect the new config, then recycles the builder
|
|
625
|
+
* and backend. The config module is re-imported with a cache-busting query; modules it imports
|
|
626
|
+
* keep their cached instances, so a change inside an imported plugin file still needs a manual
|
|
627
|
+
* `akan start` restart.
|
|
628
|
+
*/
|
|
629
|
+
async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
|
|
630
|
+
const generation = message.devPlan?.generation ?? message.generation;
|
|
631
|
+
this.logger.warn(
|
|
632
|
+
`[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
|
|
633
|
+
);
|
|
634
|
+
await this.#recycleDevChildren(message, { refreshConfig: true });
|
|
635
|
+
}
|
|
636
|
+
async #recycleDevChildren(
|
|
637
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
638
|
+
{ refreshConfig = false }: { refreshConfig?: boolean } = {},
|
|
639
|
+
): Promise<void> {
|
|
640
|
+
const generation = message.devPlan?.generation ?? message.generation;
|
|
576
641
|
if (this.#restartTimer) {
|
|
577
642
|
clearTimeout(this.#restartTimer);
|
|
578
643
|
this.#restartTimer = null;
|
|
@@ -587,6 +652,13 @@ export class AkanAppHost {
|
|
|
587
652
|
this.#pendingBuildStatusReplay = [];
|
|
588
653
|
await this.#stopBackend();
|
|
589
654
|
this.#stopBuilder();
|
|
655
|
+
if (refreshConfig) {
|
|
656
|
+
await this.app.getConfig({ refresh: true });
|
|
657
|
+
// Merge instead of replace: start() enriched this.env with values prepare doesn't produce
|
|
658
|
+
// (e.g. REDIS_HOST from the tunnel), and the spawned children must keep seeing them.
|
|
659
|
+
const { env } = await this.app.prepareCommand("start");
|
|
660
|
+
Object.assign(this.env, env);
|
|
661
|
+
}
|
|
590
662
|
await this.#backendGraph.refresh();
|
|
591
663
|
await this.#startBuilder();
|
|
592
664
|
this.#startBackend({ generation, files: message.files });
|
|
@@ -608,34 +680,20 @@ export class AkanAppHost {
|
|
|
608
680
|
`[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
|
|
609
681
|
);
|
|
610
682
|
}
|
|
611
|
-
#
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
);
|
|
617
|
-
if (typeof generation === "number") {
|
|
618
|
-
const status: DevBuildStatus = {
|
|
619
|
-
generation,
|
|
620
|
-
phase: "scan",
|
|
621
|
-
ok: false,
|
|
622
|
-
files: message.files,
|
|
623
|
-
message: "Config change requires restarting `akan start` to apply.",
|
|
624
|
-
};
|
|
625
|
-
this.#recordBuildStatus(status);
|
|
626
|
-
this.#sendOrQueueBuildStatus(status);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
#recordDevHostRestartFailure(message: Extract<BuilderMessage, { type: "invalidate" }>, err: unknown): void {
|
|
683
|
+
#recordDevHostRestartFailure(
|
|
684
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
685
|
+
err: unknown,
|
|
686
|
+
kind: "Config" | "Runtime metadata",
|
|
687
|
+
): void {
|
|
630
688
|
const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
|
|
631
689
|
const detail = err instanceof Error ? err.message : String(err);
|
|
632
|
-
this.logger.warn(`[dev-host]
|
|
690
|
+
this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
|
|
633
691
|
const status: DevBuildStatus = {
|
|
634
692
|
generation,
|
|
635
693
|
phase: "scan",
|
|
636
694
|
ok: false,
|
|
637
695
|
files: message.files,
|
|
638
|
-
message:
|
|
696
|
+
message: `${kind} change requires restarting \`akan start\` to apply: ${detail}`,
|
|
639
697
|
};
|
|
640
698
|
this.#recordBuildStatus(status);
|
|
641
699
|
this.#sendOrQueueBuildStatus(status);
|
|
@@ -666,13 +724,18 @@ export class AkanAppHost {
|
|
|
666
724
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
667
725
|
}
|
|
668
726
|
}
|
|
727
|
+
/** One log line per planned generation, regardless of which action branch handles it. */
|
|
728
|
+
#logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
729
|
+
if (!message.devPlan) return;
|
|
730
|
+
const { generation, roles, actions, reasonByFile } = message.devPlan;
|
|
731
|
+
this.logger.verbose(
|
|
732
|
+
`[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
669
736
|
async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
|
|
670
737
|
if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
|
|
671
738
|
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
739
|
const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
|
|
677
740
|
if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
|
|
678
741
|
return shouldRestart;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import type { PackageJson } from "../types";
|
|
@@ -344,3 +345,26 @@ describe("AkanLibConfig", () => {
|
|
|
344
345
|
expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
|
|
345
346
|
});
|
|
346
347
|
});
|
|
348
|
+
|
|
349
|
+
describe("AkanAppConfig.importConfigModule", () => {
|
|
350
|
+
test("busting the import cache re-evaluates an edited config module", async () => {
|
|
351
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-bust-"));
|
|
352
|
+
try {
|
|
353
|
+
const configPath = path.join(root, "akan.config.ts");
|
|
354
|
+
fs.writeFileSync(configPath, "export default { basePaths: ['first'] };\n");
|
|
355
|
+
const first = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
|
|
356
|
+
expect(first.basePaths).toEqual(["first"]);
|
|
357
|
+
|
|
358
|
+
fs.writeFileSync(configPath, "export default { basePaths: ['second'] };\n");
|
|
359
|
+
const stale = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
|
|
360
|
+
expect(stale.basePaths).toEqual(["first"]);
|
|
361
|
+
|
|
362
|
+
const busted = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root, {
|
|
363
|
+
bustImportCache: true,
|
|
364
|
+
});
|
|
365
|
+
expect(busted.basePaths).toEqual(["second"]);
|
|
366
|
+
} finally {
|
|
367
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -339,9 +339,26 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
339
339
|
command,
|
|
340
340
|
};
|
|
341
341
|
}
|
|
342
|
-
static
|
|
342
|
+
static #importGeneration = 0;
|
|
343
|
+
/**
|
|
344
|
+
* Bun caches dynamic imports by path, so a plain re-import after the user edits the config file
|
|
345
|
+
* returns the stale module. `bustImportCache` appends a fresh query string to force re-evaluation
|
|
346
|
+
* of the config module itself; modules it imports keep their cached instances.
|
|
347
|
+
*/
|
|
348
|
+
static async importConfigModule<T = unknown>(
|
|
349
|
+
cwdPath: string,
|
|
350
|
+
{ bustImportCache = false }: { bustImportCache?: boolean } = {},
|
|
351
|
+
): Promise<T> {
|
|
352
|
+
const configPath = `${cwdPath}/akan.config.ts`;
|
|
353
|
+
const importPath = bustImportCache
|
|
354
|
+
? `${configPath}?akanConfigGeneration=${++AkanAppConfig.#importGeneration}`
|
|
355
|
+
: configPath;
|
|
356
|
+
return (await import(importPath).then((mod: { default: T }) => mod.default)) as T;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
static async from(app: App, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
|
|
343
360
|
const [configImp, baseDevEnv, libs, rootPackageJson] = await Promise.all([
|
|
344
|
-
|
|
361
|
+
AkanAppConfig.importConfigModule(app.cwdPath, { bustImportCache }),
|
|
345
362
|
WorkspaceExecutor.getBaseDevEnv(path.join(app.workspace.workspaceRoot, ".env")),
|
|
346
363
|
app.workspace.getLibs(),
|
|
347
364
|
app.workspace.getPackageJson(),
|
package/executors.ts
CHANGED
|
@@ -1463,7 +1463,9 @@ export class AppExecutor extends SysExecutor {
|
|
|
1463
1463
|
#akanConfig: AkanAppConfig | null = null;
|
|
1464
1464
|
override async getConfig({ refresh }: { refresh?: boolean } = {}) {
|
|
1465
1465
|
if (this.#akanConfig && !refresh) return this.#akanConfig;
|
|
1466
|
-
|
|
1466
|
+
// A refresh means the config file may have been edited; bust the import cache so the fresh
|
|
1467
|
+
// module is evaluated instead of Bun's cached instance.
|
|
1468
|
+
this.#akanConfig = await AkanAppConfig.from(this, { bustImportCache: refresh });
|
|
1467
1469
|
return this.#akanConfig;
|
|
1468
1470
|
}
|
|
1469
1471
|
|
|
@@ -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
|
-
|
|
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();
|
|
@@ -349,7 +356,9 @@ class IncrementalBuilder {
|
|
|
349
356
|
this.#sendBuildStatus("pages", { generation, ok: false, files, message });
|
|
350
357
|
}
|
|
351
358
|
}
|
|
352
|
-
|
|
359
|
+
// Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
|
|
360
|
+
// client rebuild or a direct stylesheet edit can change the compiled CSS.
|
|
361
|
+
if (kinds.includes("css") || (kinds.includes("code") && rebuildClient)) {
|
|
353
362
|
this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
|
|
354
363
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
355
364
|
}
|
|
@@ -368,6 +377,12 @@ class IncrementalBuilder {
|
|
|
368
377
|
return;
|
|
369
378
|
}
|
|
370
379
|
});
|
|
380
|
+
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
381
|
+
// as an orphaned watcher that keeps rebuilding for nobody.
|
|
382
|
+
process.on("disconnect", () => {
|
|
383
|
+
this.#logger.warn("host IPC channel closed; exiting builder");
|
|
384
|
+
process.exit(0);
|
|
385
|
+
});
|
|
371
386
|
if (this.#watch) await this.installWatcher();
|
|
372
387
|
process.send?.({ type: "builder-ready" });
|
|
373
388
|
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
|
});
|
|
@@ -159,6 +203,32 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
159
203
|
hmr?.close();
|
|
160
204
|
});
|
|
161
205
|
|
|
206
|
+
integrationTest("config edits restart the dev host and keep serving", async () => {
|
|
207
|
+
const harness = await createHarness();
|
|
208
|
+
const host = await harness.startHost();
|
|
209
|
+
const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
|
|
210
|
+
if (!initialHtml) {
|
|
211
|
+
expect(host.proc.killed).toBe(false);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const mark = host.markLog();
|
|
215
|
+
|
|
216
|
+
await harness.writeFile(
|
|
217
|
+
"akan.config.ts",
|
|
218
|
+
`import type { AppConfig } from "akanjs";
|
|
219
|
+
|
|
220
|
+
const config: AppConfig = { externalLibs: [] };
|
|
221
|
+
export default config;
|
|
222
|
+
`,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
|
|
226
|
+
await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
|
|
227
|
+
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
228
|
+
await harness.waitForHttpText("initial-shared-marker");
|
|
229
|
+
expect(host.proc.killed).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
162
232
|
integrationTest("client build failure reports error and recovers after fix", async () => {
|
|
163
233
|
const harness = await createHarness();
|
|
164
234
|
const host = await harness.startHost();
|
|
@@ -205,16 +275,20 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
205
275
|
integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
|
|
206
276
|
const harness = await createHarness();
|
|
207
277
|
const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
278
|
+
// `common` barrels export camelCase names; `ui` barrels export PascalCase component names. Each facet's
|
|
279
|
+
// fixture file must follow its own casing convention or the barrel deliberately skips it.
|
|
280
|
+
const facets = [
|
|
281
|
+
{ facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
|
|
282
|
+
{ facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
|
|
283
|
+
] as const;
|
|
284
|
+
|
|
285
|
+
for (const { facet, moduleName, fileName, exportName } of facets) {
|
|
212
286
|
const indexPath = `${facet}/index.ts`;
|
|
213
|
-
const absChangedFile = `${harness.appDir}/${facet}
|
|
287
|
+
const absChangedFile = `${harness.appDir}/${facet}/${fileName}`;
|
|
214
288
|
const absIndexPath = `${harness.appDir}/${indexPath}`;
|
|
215
289
|
|
|
216
290
|
await harness.writeFile(
|
|
217
|
-
`${facet}
|
|
291
|
+
`${facet}/${fileName}`,
|
|
218
292
|
`export const ${exportName} = "added-${facet}-example";
|
|
219
293
|
`,
|
|
220
294
|
);
|
|
@@ -222,21 +296,105 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
222
296
|
const added = await sync.syncForBatch([absChangedFile]);
|
|
223
297
|
expect(added.errors).toEqual([]);
|
|
224
298
|
expect(added.changedFiles).toContain(absIndexPath);
|
|
225
|
-
const addedIndex = await waitForFileIncludes(absIndexPath,
|
|
299
|
+
const addedIndex = await waitForFileIncludes(absIndexPath, moduleName);
|
|
226
300
|
expect(addedIndex).not.toBeNull();
|
|
227
|
-
expect(addedIndex ?? "").toContain(
|
|
301
|
+
expect(addedIndex ?? "").toContain(moduleName);
|
|
228
302
|
|
|
229
|
-
await harness.removeFile(`${facet}
|
|
303
|
+
await harness.removeFile(`${facet}/${fileName}`);
|
|
230
304
|
const removed = await sync.syncForBatch([absChangedFile]);
|
|
231
305
|
expect(removed.errors).toEqual([]);
|
|
232
306
|
expect(removed.changedFiles).toContain(absIndexPath);
|
|
233
|
-
const deletedIndex = await waitForFileIncludes(absIndexPath,
|
|
234
|
-
if (deletedIndex) throw new Error(`${indexPath} still contains
|
|
307
|
+
const deletedIndex = await waitForFileIncludes(absIndexPath, moduleName, 1_000);
|
|
308
|
+
if (deletedIndex) throw new Error(`${indexPath} still contains ${moduleName} after delete`);
|
|
235
309
|
const finalIndex = await Bun.file(absIndexPath).text();
|
|
236
310
|
expect(finalIndex).toBeString();
|
|
237
311
|
}
|
|
238
312
|
});
|
|
239
313
|
|
|
314
|
+
integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
|
|
315
|
+
const harness = await createHarness();
|
|
316
|
+
const host = await harness.startHost();
|
|
317
|
+
const failureMark = host.markLog();
|
|
318
|
+
|
|
319
|
+
// The service file is part of the generated server graph (`akan start` regenerates server.ts
|
|
320
|
+
// from lib/), so a module-level throw here breaks every replica boot.
|
|
321
|
+
await harness.writeFile(
|
|
322
|
+
"lib/_fixture/fixture.service.ts",
|
|
323
|
+
`import { serve } from "akanjs/service";
|
|
324
|
+
|
|
325
|
+
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
326
|
+
|
|
327
|
+
throw new Error("intentional-backend-boot-crash");
|
|
328
|
+
`,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// The gateway abandons the replica after three failed boots instead of retrying forever...
|
|
332
|
+
await host.waitForLogSince(failureMark, /\[child-crash-loop\].*failed 3 consecutive boots/);
|
|
333
|
+
// ...and the failure reaches the dev host's build-status pipeline (HMR overlay path).
|
|
334
|
+
await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
|
|
335
|
+
expect(host.proc.killed).toBe(false);
|
|
336
|
+
|
|
337
|
+
const recoveryMark = host.markLog();
|
|
338
|
+
await harness.writeFile(
|
|
339
|
+
"lib/_fixture/fixture.service.ts",
|
|
340
|
+
`import { serve } from "akanjs/service";
|
|
341
|
+
|
|
342
|
+
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
343
|
+
`,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
347
|
+
expect(host.proc.killed).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
integrationTest("SIGKILL'd gateway leaves no orphaned replicas and the host recovers", async () => {
|
|
351
|
+
const harness = await createHarness();
|
|
352
|
+
const host = await harness.startHost();
|
|
353
|
+
const port = await harness.resolvePort();
|
|
354
|
+
|
|
355
|
+
const healthy = await waitForGatewayHealth(
|
|
356
|
+
port,
|
|
357
|
+
(health) =>
|
|
358
|
+
typeof health.pid === "number" &&
|
|
359
|
+
health.children.length > 0 &&
|
|
360
|
+
health.children.every((child) => child.ready && typeof child.pid === "number"),
|
|
361
|
+
);
|
|
362
|
+
const gatewayPid = healthy.pid as number;
|
|
363
|
+
const childPids = healthy.children.map((child) => child.pid as number);
|
|
364
|
+
const mark = host.markLog();
|
|
365
|
+
|
|
366
|
+
process.kill(gatewayPid, "SIGKILL");
|
|
367
|
+
|
|
368
|
+
// Children must notice the closed IPC channel and exit instead of orphaning (they would
|
|
369
|
+
// otherwise keep holding their ws ports and break every subsequent boot).
|
|
370
|
+
expect(await waitForProcessesGone(childPids)).toBe(true);
|
|
371
|
+
|
|
372
|
+
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
373
|
+
const recovered = await waitForGatewayHealth(
|
|
374
|
+
port,
|
|
375
|
+
(health) => typeof health.pid === "number" && health.pid !== gatewayPid && health.children.some((c) => c.ready),
|
|
376
|
+
);
|
|
377
|
+
expect(recovered.pid).not.toBe(gatewayPid);
|
|
378
|
+
expect(host.proc.killed).toBe(false);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
|
|
382
|
+
const harness = await createHarness();
|
|
383
|
+
const port = await harness.resolvePort();
|
|
384
|
+
// The gateway assigns child 0 the deterministic ws port `port + 10_000`; occupy it up front
|
|
385
|
+
// the way an orphaned replica from a killed run would.
|
|
386
|
+
const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
|
|
387
|
+
try {
|
|
388
|
+
const host = await harness.startHost();
|
|
389
|
+
await host.waitForLog(/falling back to an ephemeral port/);
|
|
390
|
+
const health = await waitForGatewayHealth(port, (h) => h.children.some((child) => child.ready));
|
|
391
|
+
expect(health.children.some((child) => child.ready)).toBe(true);
|
|
392
|
+
expect(host.proc.killed).toBe(false);
|
|
393
|
+
} finally {
|
|
394
|
+
blocker.stop(true);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
240
398
|
integrationTest("route and css phase-5 scope remains smoke-level in this harness", async () => {
|
|
241
399
|
const manualSmoke = [
|
|
242
400
|
"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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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.
|
|
3
|
+
"version": "2.4.0-rc.2",
|
|
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.
|
|
35
|
+
"akanjs": "2.4.0-rc.2",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|
package/scanInfo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
3
|
import type {
|
|
4
4
|
AppConfigResult,
|
|
5
5
|
AppScanResult,
|
|
@@ -71,6 +71,7 @@ const appRootAllowedDirs = new Set([
|
|
|
71
71
|
"srvkit",
|
|
72
72
|
"webkit",
|
|
73
73
|
"common",
|
|
74
|
+
"secrets",
|
|
74
75
|
]);
|
|
75
76
|
const libRootAllowedFiles = new Set([
|
|
76
77
|
"cnst.ts",
|