@akanjs/devkit 2.4.0-rc.0 → 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.
|
@@ -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,6 +587,7 @@ export class AkanAppHost {
|
|
|
550
587
|
this.#sendToBackend(message);
|
|
551
588
|
}
|
|
552
589
|
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
590
|
+
this.#logDevPlan(message);
|
|
553
591
|
if (shouldRestartBuilderByDevPlan(message)) {
|
|
554
592
|
try {
|
|
555
593
|
await this.#restartDevChildren(message);
|
|
@@ -666,13 +704,18 @@ export class AkanAppHost {
|
|
|
666
704
|
this.#sendToBackend({ type: "build-status", data: status });
|
|
667
705
|
}
|
|
668
706
|
}
|
|
707
|
+
/** One log line per planned generation, regardless of which action branch handles it. */
|
|
708
|
+
#logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
709
|
+
if (!message.devPlan) return;
|
|
710
|
+
const { generation, roles, actions, reasonByFile } = message.devPlan;
|
|
711
|
+
this.logger.verbose(
|
|
712
|
+
`[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
669
716
|
async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
|
|
670
717
|
if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
|
|
671
718
|
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
719
|
const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
|
|
677
720
|
if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
|
|
678
721
|
return shouldRestart;
|
|
@@ -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();
|
|
@@ -368,6 +375,12 @@ class IncrementalBuilder {
|
|
|
368
375
|
return;
|
|
369
376
|
}
|
|
370
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
|
+
});
|
|
371
384
|
if (this.#watch) await this.installWatcher();
|
|
372
385
|
process.send?.({ type: "builder-ready" });
|
|
373
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.4.0-rc.
|
|
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.4.0-rc.
|
|
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",
|