@akanjs/devkit 2.4.0-rc.1 → 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.ts +46 -26
- package/akanConfig/akanConfig.test.ts +24 -0
- package/akanConfig/akanConfig.ts +19 -2
- package/executors.ts +3 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +3 -1
- package/integration/devStability.integration.test.ts +26 -0
- package/package.json +2 -2
- package/scanInfo.ts +2 -1
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -588,16 +588,22 @@ export class AkanAppHost {
|
|
|
588
588
|
}
|
|
589
589
|
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
590
590
|
this.#logDevPlan(message);
|
|
591
|
-
|
|
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)) {
|
|
592
594
|
try {
|
|
593
|
-
await this.#
|
|
595
|
+
await this.#restartDevHost(message);
|
|
594
596
|
} catch (err) {
|
|
595
|
-
this.#recordDevHostRestartFailure(message, err);
|
|
597
|
+
this.#recordDevHostRestartFailure(message, err, "Config");
|
|
596
598
|
}
|
|
597
599
|
return;
|
|
598
600
|
}
|
|
599
|
-
if (
|
|
600
|
-
|
|
601
|
+
if (shouldRestartBuilderByDevPlan(message)) {
|
|
602
|
+
try {
|
|
603
|
+
await this.#restartDevChildren(message);
|
|
604
|
+
} catch (err) {
|
|
605
|
+
this.#recordDevHostRestartFailure(message, err, "Runtime metadata");
|
|
606
|
+
}
|
|
601
607
|
return;
|
|
602
608
|
}
|
|
603
609
|
if (await this.#shouldRestartBackend(message)) {
|
|
@@ -611,6 +617,27 @@ export class AkanAppHost {
|
|
|
611
617
|
this.logger.warn(
|
|
612
618
|
`[dev-host] recycling builder/backend for runtime metadata generation=${generation ?? "(unknown)"} files=${message.files.length}`,
|
|
613
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;
|
|
614
641
|
if (this.#restartTimer) {
|
|
615
642
|
clearTimeout(this.#restartTimer);
|
|
616
643
|
this.#restartTimer = null;
|
|
@@ -625,6 +652,13 @@ export class AkanAppHost {
|
|
|
625
652
|
this.#pendingBuildStatusReplay = [];
|
|
626
653
|
await this.#stopBackend();
|
|
627
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
|
+
}
|
|
628
662
|
await this.#backendGraph.refresh();
|
|
629
663
|
await this.#startBuilder();
|
|
630
664
|
this.#startBackend({ generation, files: message.files });
|
|
@@ -646,34 +680,20 @@ export class AkanAppHost {
|
|
|
646
680
|
`[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
|
|
647
681
|
);
|
|
648
682
|
}
|
|
649
|
-
#
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
);
|
|
655
|
-
if (typeof generation === "number") {
|
|
656
|
-
const status: DevBuildStatus = {
|
|
657
|
-
generation,
|
|
658
|
-
phase: "scan",
|
|
659
|
-
ok: false,
|
|
660
|
-
files: message.files,
|
|
661
|
-
message: "Config change requires restarting `akan start` to apply.",
|
|
662
|
-
};
|
|
663
|
-
this.#recordBuildStatus(status);
|
|
664
|
-
this.#sendOrQueueBuildStatus(status);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
#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 {
|
|
668
688
|
const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
|
|
669
689
|
const detail = err instanceof Error ? err.message : String(err);
|
|
670
|
-
this.logger.warn(`[dev-host]
|
|
690
|
+
this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
|
|
671
691
|
const status: DevBuildStatus = {
|
|
672
692
|
generation,
|
|
673
693
|
phase: "scan",
|
|
674
694
|
ok: false,
|
|
675
695
|
files: message.files,
|
|
676
|
-
message:
|
|
696
|
+
message: `${kind} change requires restarting \`akan start\` to apply: ${detail}`,
|
|
677
697
|
};
|
|
678
698
|
this.#recordBuildStatus(status);
|
|
679
699
|
this.#sendOrQueueBuildStatus(status);
|
|
@@ -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
|
|
|
@@ -356,7 +356,9 @@ class IncrementalBuilder {
|
|
|
356
356
|
this.#sendBuildStatus("pages", { generation, ok: false, files, message });
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
|
-
|
|
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)) {
|
|
360
362
|
this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
|
|
361
363
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
362
364
|
}
|
|
@@ -203,6 +203,32 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
203
203
|
hmr?.close();
|
|
204
204
|
});
|
|
205
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
|
+
|
|
206
232
|
integrationTest("client build failure reports error and recovers after fix", async () => {
|
|
207
233
|
const harness = await createHarness();
|
|
208
234
|
const host = await harness.startHost();
|
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",
|