akanjs 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/client/csrTypes.ts +21 -0
- package/client/frameConfig.ts +7 -1
- package/package.json +5 -1
- package/runtime/logs/serverGet-local-local-2026-07-21-gateway-0001.log +4 -0
- package/runtime/logs/serverGet-local-local-2026-07-21-gateway-0002.log +5 -0
- package/runtime/logs/serverGet-local-local-2026-07-21-gateway-0003.log +7 -0
- package/runtime/logs/serverGet-local-local-2026-07-21-gateway-0004.log +2 -0
- package/server/akanApp.ts +128 -26
- package/server/akanServer.ts +46 -8
- package/server/lifecycle/portInUse.ts +11 -0
- package/server/routeElementComposer.tsx +22 -3
- package/server/routeTreeBuilder.ts +5 -0
- package/server/rscWorker.tsx +16 -0
- package/server/rscWorkerCache.ts +2 -0
- package/server/ssrFromRscRenderer.tsx +2 -1
- package/server/ssrTypes.ts +14 -0
- package/server/webRouter.ts +1 -0
- package/service/ipcTypes.ts +3 -1
- package/signal/guard.ts +3 -3
- package/signal/signalContext.ts +5 -0
- package/store/baseSt.ts +1 -0
- package/types/client/csrTypes.d.ts +19 -0
- package/types/server/lifecycle/portInUse.d.ts +5 -0
- package/types/server/routeElementComposer.d.ts +7 -3
- package/types/server/rscWorkerCache.d.ts +2 -0
- package/types/server/ssrTypes.d.ts +14 -0
- package/types/service/ipcTypes.d.ts +5 -1
- package/types/signal/guard.d.ts +2 -2
- package/types/signal/signalContext.d.ts +1 -0
- package/types/ui/Load/Units.d.ts +1 -1
- package/types/ui/Model/LoadView.d.ts +10 -0
- package/types/ui/Model/index.d.ts +1 -0
- package/types/ui/Model/index_.d.ts +1 -0
- package/types/webkit/index.d.ts +4 -0
- package/types/webkit/useCamera.d.ts +19 -0
- package/types/webkit/useCodepush.d.ts +16 -0
- package/types/webkit/useContact.d.ts +11 -0
- package/types/webkit/useGeoLocation.d.ts +8 -0
- package/types/webkit/usePurchase.d.ts +19 -0
- package/ui/Load/Units.tsx +2 -2
- package/ui/Model/LoadView.tsx +11 -0
- package/ui/Model/index.ts +2 -0
- package/ui/Model/index_.tsx +1 -0
- package/webkit/index.ts +4 -0
- package/webkit/useCamera.tsx +103 -0
- package/webkit/useCodepush.tsx +99 -0
- package/webkit/useContact.tsx +52 -0
- package/webkit/useGeoLocation.tsx +24 -0
- package/webkit/usePurchase.tsx +156 -0
package/client/csrTypes.ts
CHANGED
|
@@ -16,6 +16,20 @@ export type PageSafeAreaConfig =
|
|
|
16
16
|
bottom?: boolean;
|
|
17
17
|
android?: "auto" | "edge-to-edge" | "none";
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Server-render strategy for the initial full-document SSR pass.
|
|
21
|
+
* - `"stream"` (default): flush the shell — including any `Loading` Suspense
|
|
22
|
+
* fallback — as soon as it is ready, then stream the resolved page. Redirects
|
|
23
|
+
* decided in the shell still become real HTTP redirects; redirects thrown
|
|
24
|
+
* inside suspended content degrade to soft (client) redirects.
|
|
25
|
+
* - `"block"`: buffer the whole document until every Suspense boundary resolves
|
|
26
|
+
* before sending a byte. The `Loading` fallback never reaches the browser, but
|
|
27
|
+
* a non-redirect error thrown in slow content can still yield a clean error
|
|
28
|
+
* page. Use only for routes that need that guarantee and do not care about SEO
|
|
29
|
+
* or first-paint of the fallback.
|
|
30
|
+
*/
|
|
31
|
+
export type SsrRenderMode = "stream" | "block";
|
|
32
|
+
|
|
19
33
|
/** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
|
|
20
34
|
export interface PageConfig {
|
|
21
35
|
transition?: TransitionType;
|
|
@@ -26,6 +40,8 @@ export interface PageConfig {
|
|
|
26
40
|
bottomInset?: number | boolean;
|
|
27
41
|
gesture?: boolean;
|
|
28
42
|
cache?: boolean;
|
|
43
|
+
/** Initial full-document SSR strategy. Defaults to `"stream"`. */
|
|
44
|
+
ssr?: SsrRenderMode;
|
|
29
45
|
/**
|
|
30
46
|
* Opt in to guarded RSC page suffix commits when the page does not require
|
|
31
47
|
* head/metadata updates and the retained route chain head is invariant for
|
|
@@ -44,6 +60,7 @@ export interface CsrState {
|
|
|
44
60
|
bottomInset: number;
|
|
45
61
|
gesture: boolean;
|
|
46
62
|
cache: boolean;
|
|
63
|
+
ssr: SsrRenderMode;
|
|
47
64
|
topSafeAreaColor?: string;
|
|
48
65
|
bottomSafeAreaColor?: string;
|
|
49
66
|
}
|
|
@@ -99,6 +116,9 @@ export interface RouteRender {
|
|
|
99
116
|
render: LayoutRender | PageRender;
|
|
100
117
|
isAsync?: boolean;
|
|
101
118
|
Loading?: LayoutLoadingRender | PageLoadingRender;
|
|
119
|
+
/** Loads the module and populates `Loading` without running `render`/`resolveHead`.
|
|
120
|
+
* Used by the patch (suffix) compose path, which never calls `resolveHead`. */
|
|
121
|
+
resolveLoading?: () => void | Promise<void>;
|
|
102
122
|
NotFound?: LayoutNotFoundRender;
|
|
103
123
|
Error?: LayoutErrorRender;
|
|
104
124
|
resolveNotFound?: () => PromiseOrObject<LayoutNotFoundRender | undefined>;
|
|
@@ -242,6 +262,7 @@ export const defaultPageState: PageState = {
|
|
|
242
262
|
bottomInset: 0,
|
|
243
263
|
gesture: true,
|
|
244
264
|
cache: false,
|
|
265
|
+
ssr: "stream",
|
|
245
266
|
topSafeAreaColor: "var(--color-base-100, Canvas)",
|
|
246
267
|
bottomSafeAreaColor: "var(--color-base-100, Canvas)",
|
|
247
268
|
};
|
package/client/frameConfig.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PageConfig, PageSafeAreaConfig, PageState, TransitionType } from "./csrTypes";
|
|
1
|
+
import type { PageConfig, PageSafeAreaConfig, PageState, SsrRenderMode, TransitionType } from "./csrTypes";
|
|
2
2
|
|
|
3
3
|
export type DevicePlatform = "ios" | "android" | "web" | (string & {});
|
|
4
4
|
export type SafeAreaInsets = { top: number; bottom: number };
|
|
@@ -19,11 +19,13 @@ const pageConfigKeys = new Set<keyof PageConfig>([
|
|
|
19
19
|
"bottomInset",
|
|
20
20
|
"gesture",
|
|
21
21
|
"cache",
|
|
22
|
+
"ssr",
|
|
22
23
|
"rscPatchHeadSafe",
|
|
23
24
|
"topSafeAreaColor",
|
|
24
25
|
"bottomSafeAreaColor",
|
|
25
26
|
]);
|
|
26
27
|
const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
|
|
28
|
+
const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
|
|
27
29
|
const DEFAULT_BOOLEAN_INSET = 48;
|
|
28
30
|
|
|
29
31
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -43,6 +45,9 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
|
|
|
43
45
|
if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
|
|
44
46
|
throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
|
|
45
47
|
}
|
|
48
|
+
if (pageConfig.ssr !== undefined && !ssrRenderModes.has(pageConfig.ssr)) {
|
|
49
|
+
throw new Error(`[route-convention] unsupported pageConfig.ssr "${pageConfig.ssr}" in ${routeKey}`);
|
|
50
|
+
}
|
|
46
51
|
if (pageConfig.topInset !== undefined && !isValidInsetValue(pageConfig.topInset)) {
|
|
47
52
|
throw new Error(
|
|
48
53
|
`[route-convention] pageConfig.topInset in ${routeKey} must be a boolean or non-negative px number.`,
|
|
@@ -141,6 +146,7 @@ export function resolvePageState({
|
|
|
141
146
|
? false
|
|
142
147
|
: (config.gesture ?? false),
|
|
143
148
|
cache: config.cache ?? false,
|
|
149
|
+
ssr: config.ssr ?? "stream",
|
|
144
150
|
topSafeAreaColor: config.topSafeAreaColor ?? "var(--color-base-100, Canvas)",
|
|
145
151
|
bottomSafeAreaColor: config.bottomSafeAreaColor ?? "var(--color-base-100, Canvas)",
|
|
146
152
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akanjs",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -195,6 +195,7 @@
|
|
|
195
195
|
"bullmq": "^5.76.10",
|
|
196
196
|
"capacitor-plugin-safe-area": "^5.0.0",
|
|
197
197
|
"chance": "^1.1.13",
|
|
198
|
+
"cordova-plugin-purchase": "^13.16.0",
|
|
198
199
|
"croner": "^10.0.1",
|
|
199
200
|
"daisyui": "5.5.23",
|
|
200
201
|
"firebase": "^12.13.0",
|
|
@@ -279,6 +280,9 @@
|
|
|
279
280
|
"chance": {
|
|
280
281
|
"optional": true
|
|
281
282
|
},
|
|
283
|
+
"cordova-plugin-purchase": {
|
|
284
|
+
"optional": true
|
|
285
|
+
},
|
|
282
286
|
"croner": {
|
|
283
287
|
"optional": true
|
|
284
288
|
},
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:57 AM INFO AkanApp gateway is running on port http://localhost:32283 +499ms
|
|
2
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:57 AM VERBOSE All 1 child process(es) are ready +512ms
|
|
3
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:57 AM ERROR Child 0/federation failed (exit:1); restarting in 1000ms (attempt 1) +565ms
|
|
4
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:58 AM VERBOSE All 1 child process(es) are ready +1582ms
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:58 AM INFO AkanApp gateway is running on port http://localhost:30883 +1587ms
|
|
2
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:58 AM VERBOSE All 1 child process(es) are ready +1599ms
|
|
3
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:58 AM ERROR Child 0/federation upstream is unreachable (/Users/kangminseon/github3/leading-flight-guidance/pkgs/akanjs/runtime/akan-child-39364-mru5abmz-0.sock); restarting +1740ms
|
|
4
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:58 AM ERROR Child 0/federation failed (upstream-open-failed); restarting in 1000ms (attempt 1) +1740ms
|
|
5
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:59 AM VERBOSE All 1 child process(es) are ready +2760ms
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:59 AM INFO AkanApp gateway is running on port http://localhost:24583 +2773ms
|
|
2
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:59 AM ERROR intentional-boot-failure +2783ms
|
|
3
|
+
[AkanApp] 39364 - 07/21/2026, 04:19:59 AM ERROR Child 0/federation failed (child-error); restarting in 1000ms (attempt 1) +2783ms
|
|
4
|
+
[AkanApp] 39364 - 07/21/2026, 04:20:00 AM ERROR intentional-boot-failure +3799ms
|
|
5
|
+
[AkanApp] 39364 - 07/21/2026, 04:20:00 AM ERROR Child 0/federation failed (child-error); restarting in 2000ms (attempt 2) +3799ms
|
|
6
|
+
[AkanApp] 39364 - 07/21/2026, 04:20:02 AM ERROR intentional-boot-failure +5817ms
|
|
7
|
+
[AkanApp] 39364 - 07/21/2026, 04:20:02 AM ERROR [child-crash-loop] Backend replica 0/federation failed 3 consecutive boots; waiting for a code change to retry: intentional-boot-failure +5817ms
|
package/server/akanApp.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { mkdir, rm } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Logger } from "akanjs/common";
|
|
4
4
|
import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
|
|
5
5
|
import { isTraceEnabled } from "akanjs/signal";
|
|
6
6
|
import { makeAkanChildProxyHeaders } from "./akanAppHeaders";
|
|
7
7
|
import type { BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
|
|
8
|
+
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
8
9
|
import { RotatingLogWriter } from "./logging/rotatingLogWriter";
|
|
9
10
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
10
11
|
|
|
@@ -16,9 +17,11 @@ interface ChildState {
|
|
|
16
17
|
status: AkanChildStatus;
|
|
17
18
|
pid?: number;
|
|
18
19
|
upstream?: AkanUpstream;
|
|
20
|
+
wsUpstream?: Extract<AkanUpstream, { type: "tcp" }>;
|
|
19
21
|
healthPath?: string;
|
|
20
22
|
metrics: AkanMetricsReport;
|
|
21
|
-
|
|
23
|
+
/** Monotonic (`performance.now()`) so sleep/wake wall-clock jumps cannot fake a health timeout. */
|
|
24
|
+
lastPongAtMono?: number;
|
|
22
25
|
restartAttempts: number;
|
|
23
26
|
restartCount: number;
|
|
24
27
|
restartTimer: Timer | null;
|
|
@@ -26,6 +29,7 @@ interface ChildState {
|
|
|
26
29
|
lastExitCode?: number | null;
|
|
27
30
|
lastRestartAt?: number;
|
|
28
31
|
lastRestartReason?: string;
|
|
32
|
+
lastErrorMessage?: string;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
interface GatewayWsData {
|
|
@@ -62,8 +66,13 @@ export class AkanApp {
|
|
|
62
66
|
static readonly #childRestartBaseDelayMs = 1_000;
|
|
63
67
|
static readonly #childRestartMaxDelayMs = 30_000;
|
|
64
68
|
static readonly #childRestartGraceMs = 5_000;
|
|
69
|
+
/** In dev, stop restarting a replica that never boots after this many consecutive failures. */
|
|
70
|
+
static readonly #devMaxChildBootFailures = 3;
|
|
65
71
|
|
|
66
72
|
readonly logger = new Logger("AkanApp");
|
|
73
|
+
/** Hosted by `akan start`: crash loops should yield to the dev host, which restarts on file edits. */
|
|
74
|
+
readonly #devHosted = process.env.AKAN_COMMAND_TYPE === "start";
|
|
75
|
+
readonly #healthTimeoutMs = AkanApp.#parseHealthTimeoutMs();
|
|
67
76
|
readonly #serverPath: string;
|
|
68
77
|
readonly #artifactDir: string;
|
|
69
78
|
readonly #replica: AkanReplicaConfig;
|
|
@@ -149,15 +158,46 @@ export class AkanApp {
|
|
|
149
158
|
return Bun.main.endsWith(".js") ? "production" : "development";
|
|
150
159
|
}
|
|
151
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Dev builds and first-touch transpiles can stall a child's event loop well past the production
|
|
163
|
+
* pong budget, so `akan start` runs with a wider timeout to avoid restarting healthy replicas.
|
|
164
|
+
*/
|
|
165
|
+
static #parseHealthTimeoutMs() {
|
|
166
|
+
const configured = Number(process.env.AKAN_HEALTH_TIMEOUT_MS);
|
|
167
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
168
|
+
return process.env.AKAN_COMMAND_TYPE === "start" ? 15_000 : 5_000;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Must exceed the child's own shutdown timeout (see `AkanServer.#defaultShutdownTimeoutMs`) so
|
|
173
|
+
* children always get to exit on their own before this gateway stops waiting.
|
|
174
|
+
*/
|
|
175
|
+
static #childShutdownWaitMs() {
|
|
176
|
+
const configured = Number(process.env.AKAN_CHILD_SHUTDOWN_WAIT_MS);
|
|
177
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
178
|
+
return process.env.AKAN_COMMAND_TYPE === "start" ? 5_000 : 30_000;
|
|
179
|
+
}
|
|
180
|
+
|
|
152
181
|
async start() {
|
|
153
182
|
await this.#prepareRuntimeDir();
|
|
154
183
|
this.#startFileLogging();
|
|
155
184
|
for (let idx = 0; idx < this.#replica.total; idx++) this.#spawn(idx);
|
|
156
|
-
|
|
185
|
+
try {
|
|
186
|
+
this.#listen();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (isPortInUseError(error)) {
|
|
189
|
+
const message = `Port ${this.#port} is already in use — another \`akan start\` or an orphaned gateway may still be running (try: lsof -ti :${this.#port}).`;
|
|
190
|
+
this.logger.error(message);
|
|
191
|
+
this.#reportBackendBuildStatus({ ok: false, message });
|
|
192
|
+
}
|
|
193
|
+
await this.stop("listen-failed");
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
157
196
|
this.#snapshotTimer = setInterval(() => this.#requestRoomSnapshots(), 30_000);
|
|
158
197
|
this.#healthTimer = setInterval(() => this.#checkHealth(), 2_000);
|
|
159
198
|
this.#startMetricsReporting();
|
|
160
199
|
process.on("message", (message) => this.#handleHostMessage(message as BuilderMessage));
|
|
200
|
+
process.on("disconnect", () => this.#handleHostDisconnect());
|
|
161
201
|
process.on("SIGINT", () => this.#handleShutdownSignal("SIGINT"));
|
|
162
202
|
process.on("SIGTERM", () => this.#handleShutdownSignal("SIGTERM"));
|
|
163
203
|
await new Promise<void>((resolve) => {
|
|
@@ -192,7 +232,7 @@ export class AkanApp {
|
|
|
192
232
|
}
|
|
193
233
|
await Promise.race([
|
|
194
234
|
Promise.all([...this.#children.values()].map((child) => child.proc.exited.catch(() => undefined))),
|
|
195
|
-
new Promise((resolve) => setTimeout(resolve,
|
|
235
|
+
new Promise((resolve) => setTimeout(resolve, AkanApp.#childShutdownWaitMs())),
|
|
196
236
|
]);
|
|
197
237
|
for (const child of this.#children.values()) {
|
|
198
238
|
if (!child.proc.killed) child.proc.kill();
|
|
@@ -213,6 +253,18 @@ export class AkanApp {
|
|
|
213
253
|
});
|
|
214
254
|
}
|
|
215
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The IPC channel closes when the dev host dies (including SIGKILL). Exiting takes the children
|
|
258
|
+
* down too, so a dead host never strands a gateway tree that would block the next `akan start`.
|
|
259
|
+
*/
|
|
260
|
+
#handleHostDisconnect() {
|
|
261
|
+
if (this.#stopping) return;
|
|
262
|
+
this.logger.warn("Host IPC channel closed; shutting down gateway and children");
|
|
263
|
+
this.#exitAfterStop = true;
|
|
264
|
+
setTimeout(() => process.exit(1), AkanApp.#childShutdownWaitMs() + 5_000);
|
|
265
|
+
void this.stop("ipc-disconnect").catch(() => process.exit(1));
|
|
266
|
+
}
|
|
267
|
+
|
|
216
268
|
#spawn(idx: number) {
|
|
217
269
|
const role = this.#getRole(idx);
|
|
218
270
|
const upstream = this.#getChildUpstream(idx, role);
|
|
@@ -261,6 +313,7 @@ export class AkanApp {
|
|
|
261
313
|
#handleChildExit(idx: number, proc: Bun.Subprocess<"ignore", "pipe", "pipe">, code: number | null) {
|
|
262
314
|
const child = this.#children.get(idx);
|
|
263
315
|
if (!child || child.proc !== proc) return;
|
|
316
|
+
if (child.status === "crashed") return;
|
|
264
317
|
child.status = "exited";
|
|
265
318
|
child.lastExitCode = code;
|
|
266
319
|
this.#invalidateFederationChildCache();
|
|
@@ -276,11 +329,16 @@ export class AkanApp {
|
|
|
276
329
|
child.lastRestartReason = reason;
|
|
277
330
|
return;
|
|
278
331
|
}
|
|
332
|
+
if (this.#devHosted && child.restartAttempts >= AkanApp.#devMaxChildBootFailures - 1 && !child.ready) {
|
|
333
|
+
this.#markChildCrashed(child, reason);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
279
336
|
|
|
280
337
|
child.restartPending = true;
|
|
281
338
|
child.ready = false;
|
|
282
339
|
child.status = reason === "health-timeout" || reason === "upstream-open-failed" ? "unhealthy" : "exited";
|
|
283
340
|
child.upstream = undefined;
|
|
341
|
+
child.wsUpstream = undefined;
|
|
284
342
|
child.healthPath = undefined;
|
|
285
343
|
this.#invalidateFederationChildCache();
|
|
286
344
|
child.lastRestartReason = reason;
|
|
@@ -341,6 +399,41 @@ export class AkanApp {
|
|
|
341
399
|
this.#spawn(idx);
|
|
342
400
|
}
|
|
343
401
|
|
|
402
|
+
/**
|
|
403
|
+
* Dev-only terminal state: the same broken code fails every boot, so retrying is pure churn.
|
|
404
|
+
* The dev host replaces this whole gateway on the next server-side edit, which clears the state.
|
|
405
|
+
*/
|
|
406
|
+
#markChildCrashed(child: ChildState, reason: string) {
|
|
407
|
+
|
|
408
|
+
if (child.status === "crashed") return;
|
|
409
|
+
child.ready = false;
|
|
410
|
+
child.status = "crashed";
|
|
411
|
+
child.restartPending = false;
|
|
412
|
+
child.upstream = undefined;
|
|
413
|
+
child.wsUpstream = undefined;
|
|
414
|
+
child.healthPath = undefined;
|
|
415
|
+
child.lastRestartReason = reason;
|
|
416
|
+
this.#invalidateFederationChildCache();
|
|
417
|
+
this.#removeChildRooms(child.idx);
|
|
418
|
+
const attempts = child.restartAttempts + 1;
|
|
419
|
+
const detail = child.lastErrorMessage ?? reason;
|
|
420
|
+
const message = `Backend replica ${child.idx}/${child.role} failed ${attempts} consecutive boots; waiting for a code change to retry: ${detail}`;
|
|
421
|
+
this.logger.error(`[child-crash-loop] ${message}`);
|
|
422
|
+
this.#reportBackendBuildStatus({ ok: false, message });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Forwards a backend-phase build status to the dev host so failures reach the HMR overlay. */
|
|
426
|
+
#reportBackendBuildStatus({ ok, message }: { ok: boolean; message: string }) {
|
|
427
|
+
process.send?.({
|
|
428
|
+
type: "build-status",
|
|
429
|
+
data: { generation: -1, phase: "backend", ok, files: [], message },
|
|
430
|
+
} satisfies BuilderMessage);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#isChildUnavailable(child: ChildState): boolean {
|
|
434
|
+
return child.proc.killed || child.status === "exited" || child.status === "crashed";
|
|
435
|
+
}
|
|
436
|
+
|
|
344
437
|
async #stopChildForRestart(child: ChildState, proc: Bun.Subprocess<"ignore", "pipe", "pipe">, reason: string) {
|
|
345
438
|
if (reason.startsWith("exit:") || proc.killed) return;
|
|
346
439
|
if (!proc.killed) {
|
|
@@ -359,9 +452,13 @@ export class AkanApp {
|
|
|
359
452
|
|
|
360
453
|
async #prepareRuntimeDir() {
|
|
361
454
|
await mkdir(this.#runtimeDir, { recursive: true });
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
455
|
+
|
|
456
|
+
const entries = await readdir(this.#runtimeDir).catch(() => []);
|
|
457
|
+
await Promise.all(
|
|
458
|
+
entries
|
|
459
|
+
.filter((name) => /^akan-child-.*\.sock$/.test(name))
|
|
460
|
+
.map((name) => rm(path.join(this.#runtimeDir, name), { force: true }).catch(() => undefined)),
|
|
461
|
+
);
|
|
365
462
|
}
|
|
366
463
|
|
|
367
464
|
async #removeChildSocket(idx: number, role: AkanChildRole) {
|
|
@@ -455,11 +552,9 @@ export class AkanApp {
|
|
|
455
552
|
|
|
456
553
|
#upgradeWebSocket(req: Request, server: Bun.Server<GatewayWsData>): Response | undefined {
|
|
457
554
|
const child = this.#pickFederationChild();
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
}
|
|
461
|
-
const upstream = this.#getChildUpstream(child.idx, child.role).ws;
|
|
462
|
-
if (!upstream) return new Response("No websocket upstream is ready", { status: 503 });
|
|
555
|
+
|
|
556
|
+
const upstream = child?.upstream ? (child.wsUpstream ?? this.#getChildUpstream(child.idx, child.role).ws) : null;
|
|
557
|
+
if (!child || !upstream) return new Response("No websocket upstream is ready", { status: 503 });
|
|
463
558
|
const url = new URL(req.url);
|
|
464
559
|
const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
|
|
465
560
|
headers: this.#makeProxyHeaders(req, child.idx),
|
|
@@ -513,6 +608,7 @@ export class AkanApp {
|
|
|
513
608
|
#getHealthStatus() {
|
|
514
609
|
return {
|
|
515
610
|
status: this.#stopping ? "stopping" : "running",
|
|
611
|
+
pid: process.pid,
|
|
516
612
|
children: [...this.#children.values()].map((child) => ({
|
|
517
613
|
idx: child.idx,
|
|
518
614
|
role: child.role,
|
|
@@ -526,6 +622,7 @@ export class AkanApp {
|
|
|
526
622
|
lastExitCode: child.lastExitCode,
|
|
527
623
|
lastRestartAt: child.lastRestartAt,
|
|
528
624
|
lastRestartReason: child.lastRestartReason,
|
|
625
|
+
lastErrorMessage: child.lastErrorMessage,
|
|
529
626
|
})),
|
|
530
627
|
};
|
|
531
628
|
}
|
|
@@ -709,8 +806,7 @@ export class AkanApp {
|
|
|
709
806
|
(child.role === "federation" || child.role === "all") &&
|
|
710
807
|
child.ready &&
|
|
711
808
|
child.status !== "unhealthy" &&
|
|
712
|
-
child
|
|
713
|
-
!child.proc.killed,
|
|
809
|
+
!this.#isChildUnavailable(child),
|
|
714
810
|
);
|
|
715
811
|
this.#federationChildCache = candidates;
|
|
716
812
|
if (candidates.length === 0) return null;
|
|
@@ -767,6 +863,7 @@ export class AkanApp {
|
|
|
767
863
|
return;
|
|
768
864
|
case "error":
|
|
769
865
|
this.logger.error(message.message);
|
|
866
|
+
if (child) child.lastErrorMessage = message.message;
|
|
770
867
|
if (child && proc) void this.#scheduleChildRestart(child, proc, "child-error");
|
|
771
868
|
return;
|
|
772
869
|
case "build-route":
|
|
@@ -798,13 +895,19 @@ export class AkanApp {
|
|
|
798
895
|
child.status = "ready";
|
|
799
896
|
child.pid = message.pid;
|
|
800
897
|
child.upstream = message.upstream;
|
|
898
|
+
child.wsUpstream = message.wsUpstream;
|
|
801
899
|
child.healthPath = message.healthPath;
|
|
802
|
-
child.
|
|
900
|
+
child.lastPongAtMono = performance.now();
|
|
803
901
|
child.restartAttempts = 0;
|
|
804
902
|
child.restartPending = false;
|
|
903
|
+
child.lastErrorMessage = undefined;
|
|
805
904
|
this.#invalidateFederationChildCache();
|
|
806
|
-
|
|
905
|
+
|
|
906
|
+
const trafficChildren = [...this.#children.values()].filter((item) => item.role !== "batch");
|
|
907
|
+
if (child.role !== "batch" && trafficChildren.every((item) => item.ready)) {
|
|
807
908
|
process.send?.({ type: "backend-ready", pid: process.pid } satisfies AkanIpcMessage);
|
|
909
|
+
}
|
|
910
|
+
if ([...this.#children.values()].every((item) => item.ready)) {
|
|
808
911
|
this.logger.verbose(`All ${this.#children.size} child process(es) are ready`);
|
|
809
912
|
}
|
|
810
913
|
}
|
|
@@ -813,7 +916,7 @@ export class AkanApp {
|
|
|
813
916
|
const child = this.#children.get(idx);
|
|
814
917
|
if (!child) return;
|
|
815
918
|
child.status = "healthy";
|
|
816
|
-
child.
|
|
919
|
+
child.lastPongAtMono = performance.now();
|
|
817
920
|
this.#invalidateFederationChildCache();
|
|
818
921
|
}
|
|
819
922
|
|
|
@@ -828,7 +931,7 @@ export class AkanApp {
|
|
|
828
931
|
for (const childIdx of targets) {
|
|
829
932
|
if (childIdx === originIdx) continue;
|
|
830
933
|
const child = this.#children.get(childIdx);
|
|
831
|
-
if (!child || child
|
|
934
|
+
if (!child || this.#isChildUnavailable(child)) continue;
|
|
832
935
|
if (
|
|
833
936
|
!this.#sendToChild(child, {
|
|
834
937
|
type: "pubsub.deliver",
|
|
@@ -935,31 +1038,30 @@ export class AkanApp {
|
|
|
935
1038
|
|
|
936
1039
|
#requestRoomSnapshots() {
|
|
937
1040
|
for (const child of this.#children.values()) {
|
|
938
|
-
if (child
|
|
1041
|
+
if (this.#isChildUnavailable(child)) continue;
|
|
939
1042
|
this.#sendToChild(child, { type: "pubsub.snapshot.request" } satisfies AkanIpcMessage);
|
|
940
1043
|
}
|
|
941
1044
|
}
|
|
942
1045
|
|
|
943
1046
|
#checkHealth() {
|
|
944
|
-
const
|
|
1047
|
+
const nowMono = performance.now();
|
|
945
1048
|
for (const child of this.#children.values()) {
|
|
946
|
-
if (child
|
|
947
|
-
if (child.
|
|
1049
|
+
if (this.#isChildUnavailable(child)) continue;
|
|
1050
|
+
if (child.lastPongAtMono && nowMono - child.lastPongAtMono > this.#healthTimeoutMs) {
|
|
948
1051
|
child.status = "unhealthy";
|
|
949
1052
|
this.#invalidateFederationChildCache();
|
|
950
1053
|
void this.#scheduleChildRestart(child, child.proc, "health-timeout");
|
|
951
|
-
|
|
1054
|
+
continue;
|
|
952
1055
|
}
|
|
953
1056
|
const sent = this.#sendToChild(child, {
|
|
954
1057
|
type: "health.ping",
|
|
955
1058
|
nonce: crypto.randomUUID(),
|
|
956
|
-
sentAt: now,
|
|
1059
|
+
sentAt: Date.now(),
|
|
957
1060
|
} satisfies AkanIpcMessage);
|
|
958
1061
|
if (!sent) {
|
|
959
1062
|
child.status = "unhealthy";
|
|
960
1063
|
this.#invalidateFederationChildCache();
|
|
961
1064
|
void this.#scheduleChildRestart(child, child.proc, "health-send-failed");
|
|
962
|
-
return;
|
|
963
1065
|
}
|
|
964
1066
|
}
|
|
965
1067
|
}
|
|
@@ -1000,7 +1102,7 @@ export class AkanApp {
|
|
|
1000
1102
|
}
|
|
1001
1103
|
|
|
1002
1104
|
#sendToChild(child: ChildState, message: AkanIpcMessage | BuilderMessage): boolean {
|
|
1003
|
-
if (child
|
|
1105
|
+
if (this.#isChildUnavailable(child)) return false;
|
|
1004
1106
|
try {
|
|
1005
1107
|
child.proc.send(message);
|
|
1006
1108
|
return true;
|
package/server/akanServer.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { AkanLib, AkanLibProps } from "./akanLib";
|
|
|
16
16
|
import type { BuilderRpc } from "./artifact";
|
|
17
17
|
import { DiLifecycle } from "./di/diLifecycle";
|
|
18
18
|
import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
|
|
19
|
+
import { isPortInUseError } from "./lifecycle/portInUse";
|
|
19
20
|
import { ShutdownManager } from "./lifecycle/shutdownManager";
|
|
20
21
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
21
22
|
import { WebProxyRunner } from "./proxy";
|
|
@@ -74,7 +75,7 @@ export class AkanServer {
|
|
|
74
75
|
websocketPrefix = "/ws";
|
|
75
76
|
openapi = AkanServer.#isOpenApiEnvEnabled();
|
|
76
77
|
serverMode: "federation" | "batch" | "all";
|
|
77
|
-
shutdownTimeoutMs =
|
|
78
|
+
shutdownTimeoutMs = AkanServer.#defaultShutdownTimeoutMs();
|
|
78
79
|
|
|
79
80
|
#di: DiLifecycle;
|
|
80
81
|
#localPublish: ((roomId: string, data: object | object[]) => void) | null = null;
|
|
@@ -257,10 +258,10 @@ export class AkanServer {
|
|
|
257
258
|
websocket: websocketHandlers,
|
|
258
259
|
} as Parameters<typeof Bun.serve>[0]);
|
|
259
260
|
if (unix && process.env.AKAN_CHILD_WS_PORT) {
|
|
260
|
-
const
|
|
261
|
-
|
|
261
|
+
const preferredWsPort = Number(process.env.AKAN_CHILD_WS_PORT);
|
|
262
|
+
const wsServeOptions = (port: number) => ({
|
|
262
263
|
idleTimeout: 0,
|
|
263
|
-
port
|
|
264
|
+
port,
|
|
264
265
|
routes: ApiRouter.buildRoutes({
|
|
265
266
|
prefix: this.prefix,
|
|
266
267
|
websocketPrefix: this.websocketPrefix,
|
|
@@ -268,12 +269,20 @@ export class AkanServer {
|
|
|
268
269
|
builtinRoutes,
|
|
269
270
|
routeOptions,
|
|
270
271
|
renderEnvRoutes,
|
|
271
|
-
upgradeAppWs: (req, data) => this.#wsServer?.upgrade(req, { data }) ?? false,
|
|
272
|
+
upgradeAppWs: (req: Request, data: { createdAt: number }) => this.#wsServer?.upgrade(req, { data }) ?? false,
|
|
272
273
|
webProxyRunner,
|
|
273
274
|
}),
|
|
274
275
|
websocket: websocketHandlers,
|
|
275
276
|
});
|
|
276
|
-
|
|
277
|
+
try {
|
|
278
|
+
this.#wsServer = Bun.serve(wsServeOptions(preferredWsPort));
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (!isPortInUseError(error)) throw error;
|
|
281
|
+
|
|
282
|
+
this.logger.warn(`ws port ${preferredWsPort} is in use; falling back to an ephemeral port`);
|
|
283
|
+
this.#wsServer = Bun.serve(wsServeOptions(0));
|
|
284
|
+
}
|
|
285
|
+
this.logger.verbose(`${this.name} websocket fallback is serving on port ${this.#wsServer.port}`);
|
|
277
286
|
}
|
|
278
287
|
|
|
279
288
|
const server = this.#server;
|
|
@@ -300,12 +309,14 @@ export class AkanServer {
|
|
|
300
309
|
this.#startMetricsReporting();
|
|
301
310
|
this.#di.registerSchedule(this.serverMode);
|
|
302
311
|
this.logger.verbose(`🚀 ${this.name} is running on ${unix ? `unix://${unix}` : `port ${port}`}`);
|
|
312
|
+
const wsPort = this.#wsServer?.port;
|
|
303
313
|
process.send?.({
|
|
304
314
|
type: "ready",
|
|
305
315
|
pid: process.pid,
|
|
306
316
|
replicaIdx: Number(process.env.AKAN_REPLICA_IDX ?? 0),
|
|
307
317
|
role: this.serverMode,
|
|
308
318
|
upstream: unix ? { type: "unix", socketPath: unix } : { type: "tcp", host: "127.0.0.1", port: Number(port) },
|
|
319
|
+
wsUpstream: typeof wsPort === "number" ? { type: "tcp", host: "127.0.0.1", port: wsPort } : undefined,
|
|
309
320
|
healthPath: "/_akan/app/child-health",
|
|
310
321
|
} satisfies AkanIpcMessage);
|
|
311
322
|
await this.#di.runSchedulerInit();
|
|
@@ -324,7 +335,7 @@ export class AkanServer {
|
|
|
324
335
|
if (!isNoListenCommand) {
|
|
325
336
|
this.#startMetricsReporting();
|
|
326
337
|
this.#di.registerSchedule(this.serverMode);
|
|
327
|
-
|
|
338
|
+
this.#registerParentIpc();
|
|
328
339
|
process.send?.({
|
|
329
340
|
type: "ready",
|
|
330
341
|
pid: process.pid,
|
|
@@ -336,7 +347,7 @@ export class AkanServer {
|
|
|
336
347
|
}
|
|
337
348
|
return this;
|
|
338
349
|
}
|
|
339
|
-
|
|
350
|
+
this.#registerParentIpc();
|
|
340
351
|
return this.listen();
|
|
341
352
|
}
|
|
342
353
|
async stop() {
|
|
@@ -368,6 +379,23 @@ export class AkanServer {
|
|
|
368
379
|
}
|
|
369
380
|
}
|
|
370
381
|
|
|
382
|
+
#registerParentIpc() {
|
|
383
|
+
process.on("message", (message) => this.#handleIpcMessage(message as AkanIpcMessage));
|
|
384
|
+
process.on("disconnect", () => this.#handleParentDisconnect());
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The IPC channel closes when the parent gateway dies (including SIGKILL). Exiting here keeps a
|
|
389
|
+
* killed dev/gateway run from stranding replicas that would hold ports and break the next boot.
|
|
390
|
+
*/
|
|
391
|
+
#handleParentDisconnect() {
|
|
392
|
+
this.logger.warn("Parent IPC channel closed; shutting down to avoid an orphaned replica");
|
|
393
|
+
setTimeout(() => process.exit(1), this.shutdownTimeoutMs + 1_000);
|
|
394
|
+
void this.stop()
|
|
395
|
+
.then(() => process.exit(0))
|
|
396
|
+
.catch(() => process.exit(1));
|
|
397
|
+
}
|
|
398
|
+
|
|
371
399
|
#handleIpcMessage(message: AkanIpcMessage) {
|
|
372
400
|
if (!message || typeof message !== "object") return;
|
|
373
401
|
if (message.type === "pubsub.deliver") this.#localPublish?.(message.roomId, message.data as object | object[]);
|
|
@@ -461,6 +489,16 @@ export class AkanServer {
|
|
|
461
489
|
);
|
|
462
490
|
}
|
|
463
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Shutdown must finish inside the gateway's child-wait budget or the layer above SIGKILLs this
|
|
494
|
+
* process and strands its resources; dev (`akan start`) keeps it short so edit-restarts stay snappy.
|
|
495
|
+
*/
|
|
496
|
+
static #defaultShutdownTimeoutMs() {
|
|
497
|
+
const configured = Number(process.env.AKAN_SHUTDOWN_TIMEOUT_MS);
|
|
498
|
+
if (Number.isFinite(configured) && configured > 0) return configured;
|
|
499
|
+
return process.env.AKAN_COMMAND_TYPE === "start" ? 3_000 : 30_000;
|
|
500
|
+
}
|
|
501
|
+
|
|
464
502
|
async #withShutdownTimeout<T>(promise: Promise<T>) {
|
|
465
503
|
let timeout: Timer | null = null;
|
|
466
504
|
try {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects "address already in use" listen failures across the shapes Bun throws them in
|
|
3
|
+
* (`code: "EADDRINUSE"` on newer versions, message-only on older ones).
|
|
4
|
+
*/
|
|
5
|
+
export const isPortInUseError = (error: unknown): boolean => {
|
|
6
|
+
if (!error || typeof error !== "object") return false;
|
|
7
|
+
const candidate = error as { code?: unknown; message?: unknown };
|
|
8
|
+
if (candidate.code === "EADDRINUSE") return true;
|
|
9
|
+
const message = String(candidate.message ?? "");
|
|
10
|
+
return message.includes("EADDRINUSE") || message.toLowerCase().includes("in use");
|
|
11
|
+
};
|