@akanjs/devkit 2.3.13 → 2.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanApp/akanApp.host.test.ts +27 -0
- package/akanApp/akanApp.host.ts +48 -5
- package/akanConfig/akanConfig.test.ts +1 -0
- package/capacitorApp.ts +28 -7
- package/frontendBuild/autoImportSync.test.ts +357 -0
- package/frontendBuild/autoImportSync.ts +610 -0
- package/frontendBuild/index.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +28 -5
- package/integration/devStability.integration.test.ts +143 -11
- package/integration/devStabilityHarness.ts +18 -3
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import {
|
|
3
3
|
type App,
|
|
4
4
|
AppExecutor,
|
|
5
|
+
AutoImportSync,
|
|
5
6
|
type ChangeBatch,
|
|
6
7
|
type ClientEntryDiscovery,
|
|
7
8
|
CsrArtifactBuilder,
|
|
@@ -47,6 +48,7 @@ class IncrementalBuilder {
|
|
|
47
48
|
#discovery: ClientEntryDiscovery;
|
|
48
49
|
#changePlanner: DevChangePlanner;
|
|
49
50
|
#generatedIndexSync: DevGeneratedIndexSync;
|
|
51
|
+
#autoImportSync: AutoImportSync;
|
|
50
52
|
#generation = 0;
|
|
51
53
|
#workQueue: Promise<void> = Promise.resolve();
|
|
52
54
|
#cssRebuildQueue: Promise<void> = Promise.resolve();
|
|
@@ -62,6 +64,7 @@ class IncrementalBuilder {
|
|
|
62
64
|
this.#discovery = options.discovery;
|
|
63
65
|
this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
64
66
|
this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
67
|
+
this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
@@ -269,6 +272,13 @@ class IncrementalBuilder {
|
|
|
269
272
|
const rawKinds = new Set(batch.kinds);
|
|
270
273
|
if (rawKinds.size === 0) return;
|
|
271
274
|
const generation = ++this.#generation;
|
|
275
|
+
//* Insert framework imports that are used but omitted (e.g. `Int` in *.constant.ts, `fetch` in
|
|
276
|
+
//* *.store.ts) before regenerating barrels. Edits land on files already in this batch, so they
|
|
277
|
+
//* rebuild in this same generation; the write is idempotent so it does not re-trigger the watcher.
|
|
278
|
+
const autoImport = await this.#autoImportSync.syncForBatch(batch.files);
|
|
279
|
+
for (const error of autoImport.errors) this.#logger.error(error);
|
|
280
|
+
if (autoImport.changedFiles.length > 0)
|
|
281
|
+
this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
|
|
272
282
|
const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
|
|
273
283
|
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
274
284
|
generation,
|
|
@@ -298,15 +308,22 @@ class IncrementalBuilder {
|
|
|
298
308
|
}
|
|
299
309
|
if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
|
|
300
310
|
|
|
301
|
-
|
|
311
|
+
// Server-only generations (e.g. a .service.ts or srvkit edit) must not rebuild or refresh the
|
|
312
|
+
// client: a fresh pages buildId would broadcast rsc-refresh to browsers for no visible change.
|
|
313
|
+
const rebuildClient = devPlan.actions.includes("rebuild-client");
|
|
314
|
+
if (kinds.includes("code") && !rebuildClient) {
|
|
315
|
+
this.#logger.verbose(`client rebuild skipped; devPlan actions=${devPlan.actions.join(",") || "(none)"}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (kinds.includes("code") && rebuildClient && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
|
|
302
319
|
const started = Date.now();
|
|
303
320
|
await this.#app.getPageKeys({ refresh: true });
|
|
304
321
|
this.#logger.verbose(`pageKeys updated, app pageKeys are refreshed (${Date.now() - started}ms)`);
|
|
305
|
-
} else if (kinds.includes("code") && this.batchTouchesPagesTree(appDir, expandedBatch)) {
|
|
322
|
+
} else if (kinds.includes("code") && rebuildClient && this.batchTouchesPagesTree(appDir, expandedBatch)) {
|
|
306
323
|
this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
|
|
307
324
|
}
|
|
308
325
|
|
|
309
|
-
if (kinds.includes("code") && this.#shouldRebuildCsr()) {
|
|
326
|
+
if (kinds.includes("code") && rebuildClient && this.#shouldRebuildCsr()) {
|
|
310
327
|
try {
|
|
311
328
|
const started = Date.now();
|
|
312
329
|
await new CsrArtifactBuilder(this.#app).build();
|
|
@@ -317,13 +334,13 @@ class IncrementalBuilder {
|
|
|
317
334
|
this.#logger.error(`csr-rebundle failed: ${message}`);
|
|
318
335
|
this.#sendBuildStatus("csr", { generation, ok: false, files, message });
|
|
319
336
|
}
|
|
320
|
-
} else if (kinds.includes("code")) {
|
|
337
|
+
} else if (kinds.includes("code") && rebuildClient) {
|
|
321
338
|
this.#logger.verbose(`csr-rebundle skipped; set AKAN_DEV_CSR_REBUILD=1 to enable per-save CSR rebuilds`);
|
|
322
339
|
}
|
|
323
340
|
|
|
324
341
|
process.send?.(event);
|
|
325
342
|
|
|
326
|
-
if (kinds.includes("code")) {
|
|
343
|
+
if (kinds.includes("code") && rebuildClient) {
|
|
327
344
|
try {
|
|
328
345
|
const started = Date.now();
|
|
329
346
|
const next = await new PagesBundleBuilder(this.#app).build();
|
|
@@ -358,6 +375,12 @@ class IncrementalBuilder {
|
|
|
358
375
|
return;
|
|
359
376
|
}
|
|
360
377
|
});
|
|
378
|
+
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
379
|
+
// as an orphaned watcher that keeps rebuilding for nobody.
|
|
380
|
+
process.on("disconnect", () => {
|
|
381
|
+
this.#logger.warn("host IPC channel closed; exiting builder");
|
|
382
|
+
process.exit(0);
|
|
383
|
+
});
|
|
361
384
|
if (this.#watch) await this.installWatcher();
|
|
362
385
|
process.send?.({ type: "builder-ready" });
|
|
363
386
|
this.#logger.verbose(`ready (watch=${this.#watch})`);
|
|
@@ -45,6 +45,50 @@ const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5
|
|
|
45
45
|
return null;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
interface GatewayHealth {
|
|
49
|
+
status: string;
|
|
50
|
+
pid?: number;
|
|
51
|
+
children: Array<{ idx: number; role: string; status: string; ready: boolean; pid?: number }>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fetchGatewayHealth = async (port: number): Promise<GatewayHealth | null> => {
|
|
55
|
+
const res = await fetch(`http://127.0.0.1:${port}/_akan/app/health`).catch(() => null);
|
|
56
|
+
if (!res?.ok) return null;
|
|
57
|
+
return (await res.json()) as GatewayHealth;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const waitForGatewayHealth = async (
|
|
61
|
+
port: number,
|
|
62
|
+
predicate: (health: GatewayHealth) => boolean,
|
|
63
|
+
timeoutMs = 60_000,
|
|
64
|
+
): Promise<GatewayHealth> => {
|
|
65
|
+
const started = Date.now();
|
|
66
|
+
while (Date.now() - started < timeoutMs) {
|
|
67
|
+
const health = await fetchGatewayHealth(port);
|
|
68
|
+
if (health && predicate(health)) return health;
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Timed out waiting for gateway health on port ${port}`);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isProcessAlive = (pid: number): boolean => {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise<boolean> => {
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
while (Date.now() - started < timeoutMs) {
|
|
86
|
+
if (pids.every((pid) => !isProcessAlive(pid))) return true;
|
|
87
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
|
|
48
92
|
afterEach(async () => {
|
|
49
93
|
await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
|
|
50
94
|
});
|
|
@@ -205,16 +249,20 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
205
249
|
integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
|
|
206
250
|
const harness = await createHarness();
|
|
207
251
|
const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
252
|
+
// `common` barrels export camelCase names; `ui` barrels export PascalCase component names. Each facet's
|
|
253
|
+
// fixture file must follow its own casing convention or the barrel deliberately skips it.
|
|
254
|
+
const facets = [
|
|
255
|
+
{ facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
|
|
256
|
+
{ facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
|
|
257
|
+
] as const;
|
|
258
|
+
|
|
259
|
+
for (const { facet, moduleName, fileName, exportName } of facets) {
|
|
212
260
|
const indexPath = `${facet}/index.ts`;
|
|
213
|
-
const absChangedFile = `${harness.appDir}/${facet}
|
|
261
|
+
const absChangedFile = `${harness.appDir}/${facet}/${fileName}`;
|
|
214
262
|
const absIndexPath = `${harness.appDir}/${indexPath}`;
|
|
215
263
|
|
|
216
264
|
await harness.writeFile(
|
|
217
|
-
`${facet}
|
|
265
|
+
`${facet}/${fileName}`,
|
|
218
266
|
`export const ${exportName} = "added-${facet}-example";
|
|
219
267
|
`,
|
|
220
268
|
);
|
|
@@ -222,21 +270,105 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
222
270
|
const added = await sync.syncForBatch([absChangedFile]);
|
|
223
271
|
expect(added.errors).toEqual([]);
|
|
224
272
|
expect(added.changedFiles).toContain(absIndexPath);
|
|
225
|
-
const addedIndex = await waitForFileIncludes(absIndexPath,
|
|
273
|
+
const addedIndex = await waitForFileIncludes(absIndexPath, moduleName);
|
|
226
274
|
expect(addedIndex).not.toBeNull();
|
|
227
|
-
expect(addedIndex ?? "").toContain(
|
|
275
|
+
expect(addedIndex ?? "").toContain(moduleName);
|
|
228
276
|
|
|
229
|
-
await harness.removeFile(`${facet}
|
|
277
|
+
await harness.removeFile(`${facet}/${fileName}`);
|
|
230
278
|
const removed = await sync.syncForBatch([absChangedFile]);
|
|
231
279
|
expect(removed.errors).toEqual([]);
|
|
232
280
|
expect(removed.changedFiles).toContain(absIndexPath);
|
|
233
|
-
const deletedIndex = await waitForFileIncludes(absIndexPath,
|
|
234
|
-
if (deletedIndex) throw new Error(`${indexPath} still contains
|
|
281
|
+
const deletedIndex = await waitForFileIncludes(absIndexPath, moduleName, 1_000);
|
|
282
|
+
if (deletedIndex) throw new Error(`${indexPath} still contains ${moduleName} after delete`);
|
|
235
283
|
const finalIndex = await Bun.file(absIndexPath).text();
|
|
236
284
|
expect(finalIndex).toBeString();
|
|
237
285
|
}
|
|
238
286
|
});
|
|
239
287
|
|
|
288
|
+
integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
|
|
289
|
+
const harness = await createHarness();
|
|
290
|
+
const host = await harness.startHost();
|
|
291
|
+
const failureMark = host.markLog();
|
|
292
|
+
|
|
293
|
+
// The service file is part of the generated server graph (`akan start` regenerates server.ts
|
|
294
|
+
// from lib/), so a module-level throw here breaks every replica boot.
|
|
295
|
+
await harness.writeFile(
|
|
296
|
+
"lib/_fixture/fixture.service.ts",
|
|
297
|
+
`import { serve } from "akanjs/service";
|
|
298
|
+
|
|
299
|
+
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
300
|
+
|
|
301
|
+
throw new Error("intentional-backend-boot-crash");
|
|
302
|
+
`,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// The gateway abandons the replica after three failed boots instead of retrying forever...
|
|
306
|
+
await host.waitForLogSince(failureMark, /\[child-crash-loop\].*failed 3 consecutive boots/);
|
|
307
|
+
// ...and the failure reaches the dev host's build-status pipeline (HMR overlay path).
|
|
308
|
+
await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
|
|
309
|
+
expect(host.proc.killed).toBe(false);
|
|
310
|
+
|
|
311
|
+
const recoveryMark = host.markLog();
|
|
312
|
+
await harness.writeFile(
|
|
313
|
+
"lib/_fixture/fixture.service.ts",
|
|
314
|
+
`import { serve } from "akanjs/service";
|
|
315
|
+
|
|
316
|
+
export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
|
|
317
|
+
`,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
321
|
+
expect(host.proc.killed).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
integrationTest("SIGKILL'd gateway leaves no orphaned replicas and the host recovers", async () => {
|
|
325
|
+
const harness = await createHarness();
|
|
326
|
+
const host = await harness.startHost();
|
|
327
|
+
const port = await harness.resolvePort();
|
|
328
|
+
|
|
329
|
+
const healthy = await waitForGatewayHealth(
|
|
330
|
+
port,
|
|
331
|
+
(health) =>
|
|
332
|
+
typeof health.pid === "number" &&
|
|
333
|
+
health.children.length > 0 &&
|
|
334
|
+
health.children.every((child) => child.ready && typeof child.pid === "number"),
|
|
335
|
+
);
|
|
336
|
+
const gatewayPid = healthy.pid as number;
|
|
337
|
+
const childPids = healthy.children.map((child) => child.pid as number);
|
|
338
|
+
const mark = host.markLog();
|
|
339
|
+
|
|
340
|
+
process.kill(gatewayPid, "SIGKILL");
|
|
341
|
+
|
|
342
|
+
// Children must notice the closed IPC channel and exit instead of orphaning (they would
|
|
343
|
+
// otherwise keep holding their ws ports and break every subsequent boot).
|
|
344
|
+
expect(await waitForProcessesGone(childPids)).toBe(true);
|
|
345
|
+
|
|
346
|
+
await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
|
|
347
|
+
const recovered = await waitForGatewayHealth(
|
|
348
|
+
port,
|
|
349
|
+
(health) => typeof health.pid === "number" && health.pid !== gatewayPid && health.children.some((c) => c.ready),
|
|
350
|
+
);
|
|
351
|
+
expect(recovered.pid).not.toBe(gatewayPid);
|
|
352
|
+
expect(host.proc.killed).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
|
|
356
|
+
const harness = await createHarness();
|
|
357
|
+
const port = await harness.resolvePort();
|
|
358
|
+
// The gateway assigns child 0 the deterministic ws port `port + 10_000`; occupy it up front
|
|
359
|
+
// the way an orphaned replica from a killed run would.
|
|
360
|
+
const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
|
|
361
|
+
try {
|
|
362
|
+
const host = await harness.startHost();
|
|
363
|
+
await host.waitForLog(/falling back to an ephemeral port/);
|
|
364
|
+
const health = await waitForGatewayHealth(port, (h) => h.children.some((child) => child.ready));
|
|
365
|
+
expect(health.children.some((child) => child.ready)).toBe(true);
|
|
366
|
+
expect(host.proc.killed).toBe(false);
|
|
367
|
+
} finally {
|
|
368
|
+
blocker.stop(true);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
240
372
|
integrationTest("route and css phase-5 scope remains smoke-level in this harness", async () => {
|
|
241
373
|
const manualSmoke = [
|
|
242
374
|
"route add/delete should be covered by a later browser-driven test",
|
|
@@ -259,6 +259,9 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
259
259
|
env: {
|
|
260
260
|
...process.env,
|
|
261
261
|
AKAN_VERBOSE: "1",
|
|
262
|
+
// The dev-plan/hmr assertions match verbose-level Logger lines; without this the log
|
|
263
|
+
// stream only carries info/warn/error and those waits time out with empty tails.
|
|
264
|
+
AKAN_PUBLIC_LOG_LEVEL: "verbose",
|
|
262
265
|
NODE_NO_WARNINGS: "1",
|
|
263
266
|
PORT_OFFSET: String(this.portOffset),
|
|
264
267
|
},
|
|
@@ -422,9 +425,21 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
422
425
|
}
|
|
423
426
|
|
|
424
427
|
async resolvePort(): Promise<number> {
|
|
425
|
-
|
|
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.
|
|
3
|
+
"version": "2.4.0-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@langchain/openai": "^1.4.6",
|
|
33
33
|
"@tailwindcss/node": "^4.3.0",
|
|
34
34
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.
|
|
35
|
+
"akanjs": "2.4.0-rc.1",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|