@teamscala/service-boot 0.23.385 → 0.23.387
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/package.json +39 -7
- package/src/page-server-orpc/server-client.test.ts +44 -0
- package/src/page-server-orpc/server-client.ts +33 -0
- package/src/register-page-server-client.ts +3 -0
- package/src/{two-phase-boot.test.ts → two-phase-full-app.test.ts} +1 -1
- package/src/two-phase-full-app.ts +138 -0
- package/src/two-phase-health-gate-client-only.test.ts +1 -1
- package/src/two-phase-health-gate.test.ts +1 -1
- package/src/{two-phase-boot.ts → two-phase-minimal-app.ts} +7 -276
- package/src/two-phase-readiness.ts +171 -0
- package/src/ui-service.ts +2 -1
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.387",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -418,6 +418,22 @@
|
|
|
418
418
|
"types": "./src/handlers/bun-stable-watch.d.ts",
|
|
419
419
|
"default": "./src/handlers/bun-stable-watch.ts"
|
|
420
420
|
},
|
|
421
|
+
"./handlers/messaging-send-recording-logic": {
|
|
422
|
+
"types": "./src/handlers/messaging-send-recording-logic.d.ts",
|
|
423
|
+
"default": "./src/handlers/messaging-send-recording-logic.ts"
|
|
424
|
+
},
|
|
425
|
+
"./handlers/messaging-send-recording-logic.ts": {
|
|
426
|
+
"types": "./src/handlers/messaging-send-recording-logic.d.ts",
|
|
427
|
+
"default": "./src/handlers/messaging-send-recording-logic.ts"
|
|
428
|
+
},
|
|
429
|
+
"./handlers/messaging-send-recording-watch": {
|
|
430
|
+
"types": "./src/handlers/messaging-send-recording-watch.d.ts",
|
|
431
|
+
"default": "./src/handlers/messaging-send-recording-watch.ts"
|
|
432
|
+
},
|
|
433
|
+
"./handlers/messaging-send-recording-watch.ts": {
|
|
434
|
+
"types": "./src/handlers/messaging-send-recording-watch.d.ts",
|
|
435
|
+
"default": "./src/handlers/messaging-send-recording-watch.ts"
|
|
436
|
+
},
|
|
421
437
|
"./handlers/pane-outbox-drain": {
|
|
422
438
|
"types": "./src/handlers/pane-outbox-drain.d.ts",
|
|
423
439
|
"default": "./src/handlers/pane-outbox-drain.ts"
|
|
@@ -922,13 +938,29 @@
|
|
|
922
938
|
"types": "./src/server-error-page.d.ts",
|
|
923
939
|
"default": "./src/server-error-page.ts"
|
|
924
940
|
},
|
|
925
|
-
"./two-phase-
|
|
926
|
-
"types": "./src/two-phase-
|
|
927
|
-
"default": "./src/two-phase-
|
|
941
|
+
"./two-phase-full-app": {
|
|
942
|
+
"types": "./src/two-phase-full-app.d.ts",
|
|
943
|
+
"default": "./src/two-phase-full-app.ts"
|
|
944
|
+
},
|
|
945
|
+
"./two-phase-full-app.ts": {
|
|
946
|
+
"types": "./src/two-phase-full-app.d.ts",
|
|
947
|
+
"default": "./src/two-phase-full-app.ts"
|
|
948
|
+
},
|
|
949
|
+
"./two-phase-minimal-app": {
|
|
950
|
+
"types": "./src/two-phase-minimal-app.d.ts",
|
|
951
|
+
"default": "./src/two-phase-minimal-app.ts"
|
|
952
|
+
},
|
|
953
|
+
"./two-phase-minimal-app.ts": {
|
|
954
|
+
"types": "./src/two-phase-minimal-app.d.ts",
|
|
955
|
+
"default": "./src/two-phase-minimal-app.ts"
|
|
956
|
+
},
|
|
957
|
+
"./two-phase-readiness": {
|
|
958
|
+
"types": "./src/two-phase-readiness.d.ts",
|
|
959
|
+
"default": "./src/two-phase-readiness.ts"
|
|
928
960
|
},
|
|
929
|
-
"./two-phase-
|
|
930
|
-
"types": "./src/two-phase-
|
|
931
|
-
"default": "./src/two-phase-
|
|
961
|
+
"./two-phase-readiness.ts": {
|
|
962
|
+
"types": "./src/two-phase-readiness.d.ts",
|
|
963
|
+
"default": "./src/two-phase-readiness.ts"
|
|
932
964
|
},
|
|
933
965
|
"./types": {
|
|
934
966
|
"types": "./src/types.d.ts",
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
20
20
|
import {
|
|
21
21
|
getServerClient,
|
|
22
|
+
getServerClientCapabilityFault,
|
|
22
23
|
isServerClientRegistered,
|
|
24
|
+
recordServerClientCapability,
|
|
23
25
|
setServerClient,
|
|
24
26
|
} from "./server-client.ts";
|
|
25
27
|
|
|
@@ -66,3 +68,45 @@ describe("page-server server client", () => {
|
|
|
66
68
|
expect(isServerClientRegistered()).toBe(true);
|
|
67
69
|
});
|
|
68
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("declared-vs-present capability probe (/health)", () => {
|
|
73
|
+
test("no fault when every declared model is servable", () => {
|
|
74
|
+
setServerClient({
|
|
75
|
+
assessments: { findFirst: async () => null },
|
|
76
|
+
assessment_responses: { findFirst: async () => null },
|
|
77
|
+
} as never);
|
|
78
|
+
recordServerClientCapability(["assessments", "assessment_responses"]);
|
|
79
|
+
expect(getServerClientCapabilityFault()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("names the models the client cannot serve", () => {
|
|
83
|
+
// THE OUTAGE, AS A HEALTH ANSWER. A client was registered and carried only
|
|
84
|
+
// the platform routers, so every process-existence signal was green while
|
|
85
|
+
// every data-backed page 500d. /health must be able to say this.
|
|
86
|
+
setServerClient({ puckPages: {}, userPreferences: {} } as never);
|
|
87
|
+
recordServerClientCapability(["assessments", "assessment_responses"]);
|
|
88
|
+
const fault = getServerClientCapabilityFault();
|
|
89
|
+
expect(fault).toContain("assessments");
|
|
90
|
+
expect(fault).toContain("assessment_responses");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a service declaring no models is never faulted", () => {
|
|
94
|
+
// Daemon/portal shells own no DB schema and are healthy with an empty client.
|
|
95
|
+
setServerClient({ puckPages: {} } as never);
|
|
96
|
+
recordServerClientCapability([]);
|
|
97
|
+
expect(getServerClientCapabilityFault()).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an unregistered client is itself the fault", () => {
|
|
101
|
+
setServerClient(null as never);
|
|
102
|
+
recordServerClientCapability(["assessments"]);
|
|
103
|
+
expect(getServerClientCapabilityFault()).toContain("no ORPC server client registered");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a model present without findFirst still counts as missing", () => {
|
|
107
|
+
// The generated router shape is what matters, not mere key presence.
|
|
108
|
+
setServerClient({ assessments: {} } as never);
|
|
109
|
+
recordServerClientCapability(["assessments"]);
|
|
110
|
+
expect(getServerClientCapabilityFault()).toContain("assessments");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -55,3 +55,36 @@ export function setServerClient(
|
|
|
55
55
|
): void {
|
|
56
56
|
serverClient = client;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Declared-vs-present capability, recorded once at boot by
|
|
61
|
+
* registerPageServerClient and read by /health.
|
|
62
|
+
*
|
|
63
|
+
* The outage this exists for: a client WAS registered, so every
|
|
64
|
+
* process-existence signal was green, and it carried no CRUD models. Liveness
|
|
65
|
+
* must be a capability probe, never a registration check
|
|
66
|
+
* (`liveness-by-capability-not-process-existence`).
|
|
67
|
+
*/
|
|
68
|
+
let capabilityFault: string | null = null;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record whether the registered client can actually serve the models the
|
|
72
|
+
* service declares. Property lookups only — no I/O — so /health stays cheap.
|
|
73
|
+
*/
|
|
74
|
+
export function recordServerClientCapability(declaredModels: readonly string[]): void {
|
|
75
|
+
if (!serverClient) {
|
|
76
|
+
capabilityFault = "no ORPC server client registered";
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const client = serverClient as unknown as Record<string, { findFirst?: unknown } | undefined>;
|
|
80
|
+
const missing = declaredModels.filter((m) => typeof client[m]?.findFirst !== "function");
|
|
81
|
+
capabilityFault =
|
|
82
|
+
missing.length === 0
|
|
83
|
+
? null
|
|
84
|
+
: `generated client exposes no findFirst for ${missing.length} declared model(s): ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The capability fault, or null when the service can serve its declared models. */
|
|
88
|
+
export function getServerClientCapabilityFault(): string | null {
|
|
89
|
+
return capabilityFault;
|
|
90
|
+
}
|
|
@@ -148,6 +148,9 @@ export async function registerPageServerClient(
|
|
|
148
148
|
} as never,
|
|
149
149
|
);
|
|
150
150
|
mod.setServerClient(client);
|
|
151
|
+
// Record declared-vs-present capability for /health. Property lookups only.
|
|
152
|
+
(mod as unknown as { recordServerClientCapability?: (m: readonly string[]) => void })
|
|
153
|
+
.recordServerClientCapability?.(declaredModels);
|
|
151
154
|
// State the model count. "registered" alone was true in the outage and told
|
|
152
155
|
// nobody anything; a zero here is now visible in the boot log.
|
|
153
156
|
log.info(
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { describe, expect, test } from "bun:test";
|
|
19
19
|
|
|
20
|
-
const src = await Bun.file(new URL("./two-phase-
|
|
20
|
+
const src = await Bun.file(new URL("./two-phase-full-app.ts", import.meta.url)).text();
|
|
21
21
|
|
|
22
22
|
/** The body of buildFullApp, where phase-2 ordering lives. */
|
|
23
23
|
function buildFullAppBody(): string {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Phase 2 of the two-phase boot: the full app. Adds the expensive operations
|
|
7
|
+
* deferred from phase 1 (two-phase-minimal-app.ts) — client asset building
|
|
8
|
+
* (Vite build), pg-boss, the readiness/page-handler flip — and is swapped
|
|
9
|
+
* in atomically once complete.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
13
|
+
import type { Hono } from "hono";
|
|
14
|
+
import { buildClientAssets } from "./build-client-assets.ts";
|
|
15
|
+
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
16
|
+
import { swapApp } from "./mutable-app-ref.ts";
|
|
17
|
+
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
18
|
+
import { markFullAppReady, setPageHandler } from "./two-phase-readiness.ts";
|
|
19
|
+
import type { PageServerDeps } from "./ui-service.ts";
|
|
20
|
+
|
|
21
|
+
export interface FullAppResult {
|
|
22
|
+
app: Hono;
|
|
23
|
+
blocks: { slug: string; packagePath: string }[];
|
|
24
|
+
routes: string[];
|
|
25
|
+
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the full app with hydration. This adds the expensive operations that
|
|
30
|
+
* were deferred from the minimal app:
|
|
31
|
+
* - Client asset building (Vite build)
|
|
32
|
+
* - Boot hooks (may have expensive initialization)
|
|
33
|
+
* - Scheduled jobs (can start later)
|
|
34
|
+
* - pg-boss (can start later)
|
|
35
|
+
*
|
|
36
|
+
* This runs in the background after the minimal app is already serving.
|
|
37
|
+
* NOTE: All routes (including page routes) are already registered in Phase 1,
|
|
38
|
+
* so we don't need to modify the app here. The app reference stays the same.
|
|
39
|
+
*/
|
|
40
|
+
export async function buildFullApp(
|
|
41
|
+
minimalResult: MinimalAppResult,
|
|
42
|
+
deps: PageServerDeps,
|
|
43
|
+
): Promise<FullAppResult> {
|
|
44
|
+
const log = getAppLogger();
|
|
45
|
+
const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
|
|
46
|
+
|
|
47
|
+
// Client asset building (expensive: Vite build).
|
|
48
|
+
//
|
|
49
|
+
// ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
|
|
50
|
+
// This was awaited unguarded, so a throw here aborted buildFullApp before
|
|
51
|
+
// mountPgBoss ran, and the caller's catch logged
|
|
52
|
+
// "Failed to build full app, continuing with minimal app" — which reads as a
|
|
53
|
+
// UI-only degradation while having silently killed every pg-boss handler on
|
|
54
|
+
// the service.
|
|
55
|
+
//
|
|
56
|
+
// Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
|
|
57
|
+
// the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
|
|
58
|
+
// scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
|
|
59
|
+
// across repeated boots, the pane-outbox drain never mounted, and the
|
|
60
|
+
// operator's WhatsApp assistant went silent — because of a stylesheet. The
|
|
61
|
+
// service stayed `online` and healthy throughout, so nothing surfaced it.
|
|
62
|
+
//
|
|
63
|
+
// The steps after this one do not depend on client assets: pg-boss needs the
|
|
64
|
+
// registry and the DB, not a bundle. Ordering it behind a Vite build was
|
|
65
|
+
// incidental, so the coupling was never intended.
|
|
66
|
+
try {
|
|
67
|
+
await buildClientAssets(serviceSlug);
|
|
68
|
+
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// Loud but non-fatal: the tombstone manifest written by buildClientAssets
|
|
71
|
+
// already makes SSR serve the prior bundle, so degrading here costs the
|
|
72
|
+
// new bundle, never the background work.
|
|
73
|
+
log.error(
|
|
74
|
+
`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
|
|
75
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// pg-boss (mount for UI services - wasn't mounted in minimal app)
|
|
80
|
+
const { jobs: pgBossJobs } = await mountPgBoss({
|
|
81
|
+
databaseUrl: minimalResult.databaseUrl,
|
|
82
|
+
serviceSlug,
|
|
83
|
+
packages: config.server_packages ?? [],
|
|
84
|
+
});
|
|
85
|
+
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
|
|
86
|
+
if (pgBossJobs.length > 0) {
|
|
87
|
+
log.info(
|
|
88
|
+
`[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
log.info(
|
|
93
|
+
`[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
|
|
94
|
+
`${Object.keys(components).length} component(s)`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
|
|
98
|
+
// Client-only services (render_mode=client) have no SSR page handler — skip
|
|
99
|
+
// (they serve via the client bundle, never SSR). Guarded so undefined is a
|
|
100
|
+
// no-op rather than a crash.
|
|
101
|
+
if (deps.createPageHandler) {
|
|
102
|
+
setPageHandler(deps.createPageHandler);
|
|
103
|
+
log.info(
|
|
104
|
+
`[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
|
|
105
|
+
);
|
|
106
|
+
} else {
|
|
107
|
+
// The full app IS built — this service simply never renders server-side,
|
|
108
|
+
// so readiness is flipped directly rather than as a side effect of
|
|
109
|
+
// registering an SSR handler that will never exist. Without this a
|
|
110
|
+
// client-only service is permanently un-ready (see markFullAppReady).
|
|
111
|
+
markFullAppReady();
|
|
112
|
+
log.info(
|
|
113
|
+
`[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const resolvedBlocks = loaded.map((slug) => ({
|
|
118
|
+
slug,
|
|
119
|
+
packagePath: "block_packages",
|
|
120
|
+
}));
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
app: minimalApp, // Same app reference, all routes already registered
|
|
124
|
+
blocks: resolvedBlocks,
|
|
125
|
+
routes: [], // Routes already registered in Phase 1
|
|
126
|
+
pgBossJobs,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Swap the minimal app for the full app. This atomically replaces the
|
|
132
|
+
* app reference that Bun.serve is using.
|
|
133
|
+
*/
|
|
134
|
+
export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
|
|
135
|
+
const oldLabel = swapApp(fullApp, "full");
|
|
136
|
+
const log = getAppLogger();
|
|
137
|
+
log.info(`[two-phase-boot:${serviceSlug}] Swapped from ${oldLabel} app to full app`);
|
|
138
|
+
}
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
getPageHandlerState,
|
|
35
35
|
healthHandler,
|
|
36
36
|
markFullAppReady,
|
|
37
|
-
} from "./two-phase-
|
|
37
|
+
} from "./two-phase-readiness.ts";
|
|
38
38
|
|
|
39
39
|
/** Mount the real handler exactly as buildMinimalApp does. */
|
|
40
40
|
const app = new Hono().get("/health", healthHandler);
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
getPageHandlerState,
|
|
35
35
|
healthHandler,
|
|
36
36
|
setPageHandler,
|
|
37
|
-
} from "./two-phase-
|
|
37
|
+
} from "./two-phase-readiness.ts";
|
|
38
38
|
|
|
39
39
|
/** Mount the real handler exactly as buildMinimalApp does. */
|
|
40
40
|
const app = new Hono().get("/health", healthHandler);
|
|
@@ -3,21 +3,16 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* The minimal app starts serving immediately (<10s), then the full app is
|
|
12
|
-
* built in the background and atomically swapped in. This eliminates the
|
|
13
|
-
* ~25s channel outage window during scala-agents restart.
|
|
6
|
+
* Phase 1 of the two-phase boot: the minimal tool-only app (ORPC, data
|
|
7
|
+
* routes, auth gates) that serves immediately (<10s); phase 2
|
|
8
|
+
* (two-phase-full-app.ts) builds the full app in the background and swaps
|
|
9
|
+
* it in atomically — eliminating the ~25s boot outage window.
|
|
14
10
|
*/
|
|
15
11
|
|
|
16
12
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
17
|
-
import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
|
|
18
13
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
19
14
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
20
|
-
import { Hono
|
|
15
|
+
import { Hono } from "hono";
|
|
21
16
|
import { bodyLimit } from "hono/body-limit";
|
|
22
17
|
import { compress } from "hono/compress";
|
|
23
18
|
import { cors } from "hono/cors";
|
|
@@ -37,7 +32,6 @@ import { attachRequestLogger } from "./install-logging.ts";
|
|
|
37
32
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
38
33
|
import { mountOrpcRouter } from "./mount-orpc-router.ts";
|
|
39
34
|
import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
|
|
40
|
-
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
41
35
|
import { registerApiRoutes } from "./register-api-routes.ts";
|
|
42
36
|
import { readinessGate } from "./readiness-gate.ts";
|
|
43
37
|
import { registerStaticAssets } from "./register-static-assets";
|
|
@@ -48,129 +42,11 @@ import {
|
|
|
48
42
|
resolveErrorPageLogo,
|
|
49
43
|
} from "./resource-not-found.ts";
|
|
50
44
|
import { injectSecurityHeaders } from "./security-headers.ts";
|
|
51
|
-
import type { PageServerDeps } from "./ui-service.ts";
|
|
52
45
|
import { registerPageRoutes } from "./register-page-routes.ts";
|
|
53
46
|
import { runBootHookList } from "./run-boot-hook-list.ts";
|
|
54
|
-
import { buildClientAssets } from "./build-client-assets.ts";
|
|
55
47
|
import { discoverBlocks } from "./discover-blocks.ts";
|
|
56
48
|
import { registerPageServerClient } from "./register-page-server-client.ts";
|
|
57
|
-
import {
|
|
58
|
-
import type { CreatePageHandler } from "./types.ts";
|
|
59
|
-
|
|
60
|
-
// Module-level storage for the real page handler, updated when Phase 2 completes.
|
|
61
|
-
// This allows the temporary Phase 1 handler to delegate to the real handler once ready.
|
|
62
|
-
let realPageHandler: CreatePageHandler | null = null;
|
|
63
|
-
let fullAppReady = false;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
67
|
-
*
|
|
68
|
-
* Registering the SSR handler implies the full app is built, so this also flips
|
|
69
|
-
* readiness — but the reverse does NOT hold, which is why `markFullAppReady`
|
|
70
|
-
* exists separately. See its note.
|
|
71
|
-
*/
|
|
72
|
-
export function setPageHandler(handler: CreatePageHandler): void {
|
|
73
|
-
realPageHandler = handler;
|
|
74
|
-
markFullAppReady();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Flip readiness once phase 2 has built the full app.
|
|
79
|
-
*
|
|
80
|
-
* SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
|
|
81
|
-
* means "phase 2 finished"; the page handler means "this service renders pages
|
|
82
|
-
* server-side". Every service was SSR, so one flag honestly carried both — until
|
|
83
|
-
* `render_mode=client` (`granular runtime`) made a service that completes phase 2
|
|
84
|
-
* and never registers an SSR handler.
|
|
85
|
-
*
|
|
86
|
-
* Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
|
|
87
|
-
* un-ready: `/health` answered `{"status":"starting"}` forever while the process
|
|
88
|
-
* was healthy and serving. That is the failure `readiness-gated-boot` is written
|
|
89
|
-
* against — a service must be "binary: fully-ready or cleanly-unavailable", and
|
|
90
|
-
* a never-ready health signal makes the activator's wake gate and every
|
|
91
|
-
* health-gated router treat a working service as down.
|
|
92
|
-
*
|
|
93
|
-
* Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
|
|
94
|
-
* built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
|
|
95
|
-
* still reported starting — while three untouched UI services reported ok.
|
|
96
|
-
*/
|
|
97
|
-
export function markFullAppReady(): void {
|
|
98
|
-
fullAppReady = true;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Reset both phase-2 flags. TEST-ONLY.
|
|
103
|
-
*
|
|
104
|
-
* `fullAppReady` is module state that only ever transitions false -> true in
|
|
105
|
-
* production, and Bun shares a module instance across every test file in a run —
|
|
106
|
-
* so the SSR readiness test and the client-only readiness test contaminate each
|
|
107
|
-
* other purely by file order. Splitting them across files does NOT isolate them;
|
|
108
|
-
* it only hides the coupling until CI runs them together.
|
|
109
|
-
*
|
|
110
|
-
* A test that passes alone and fails in a suite is worse than no test, so the
|
|
111
|
-
* reset is exported rather than left as an ordering convention nobody can see.
|
|
112
|
-
* Never called from production code — `setPageHandler`/`markFullAppReady` are
|
|
113
|
-
* the only writers on the boot path.
|
|
114
|
-
*/
|
|
115
|
-
export function __resetPhase2StateForTest(): void {
|
|
116
|
-
realPageHandler = null;
|
|
117
|
-
fullAppReady = false;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Get the current page handler state.
|
|
122
|
-
*/
|
|
123
|
-
export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
124
|
-
return { ready: fullAppReady, hasHandler: realPageHandler !== null };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* `/health` for a UI service — 200 ONLY once the full app can actually render
|
|
129
|
-
* a page, 503 before that.
|
|
130
|
-
*
|
|
131
|
-
* This endpoint is not decoration: it is the wake gate for every scale-to-zero
|
|
132
|
-
* app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
|
|
133
|
-
* connection through a cold wake and pipes it the instant `isHealthy(port)` —
|
|
134
|
-
* a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
|
|
135
|
-
* on-demand` means by "`/health`-gates the wake (the client never sees a boot
|
|
136
|
-
* 503)".
|
|
137
|
-
*
|
|
138
|
-
* It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
|
|
139
|
-
* serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
|
|
140
|
-
* surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
|
|
141
|
-
* handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
|
|
142
|
-
* `/health` reported ready while every page 503'd, the activator believed the
|
|
143
|
-
* wake had landed, and it piped the customer's first request straight into that
|
|
144
|
-
* window — the customer got the 503.
|
|
145
|
-
*
|
|
146
|
-
* Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
|
|
147
|
-
* scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
|
|
148
|
-
* — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
|
|
149
|
-
* such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
|
|
150
|
-
* placeholder, which does no render work). scala-public logged ZERO, which is
|
|
151
|
-
* the whole reason it looked healthy while the other two did not: the gap is a
|
|
152
|
-
* RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
|
|
153
|
-
* not a config difference between the apps.
|
|
154
|
-
*
|
|
155
|
-
* Gating this on `fullAppReady` makes the signal honest for every consumer at
|
|
156
|
-
* once (the activator's wake gate, `poller-post-restart-health-gate`, any
|
|
157
|
-
* health-gated router) rather than teaching each one its own probe, and it is
|
|
158
|
-
* what `readiness-gated-boot` already requires: "/health returns 503 until
|
|
159
|
-
* ready, 200 after — so it is the real readiness signal", a service being
|
|
160
|
-
* "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
|
|
161
|
-
* is not ready, per `liveness-by-capability-not-process-existence` — liveness is
|
|
162
|
-
* a probe of the capability being REQUESTED, never of process existence or of
|
|
163
|
-
* some other surface that happens to answer.
|
|
164
|
-
*
|
|
165
|
-
* The early readiness-gate flip is deliberately left alone: it gates the ORPC
|
|
166
|
-
* tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
|
|
167
|
-
* one consumer contract is "can you serve me a page", is moved to phase 2.
|
|
168
|
-
*/
|
|
169
|
-
export function healthHandler(c: Context): Response {
|
|
170
|
-
return fullAppReady
|
|
171
|
-
? c.json({ status: "ok" })
|
|
172
|
-
: c.json({ status: "starting" }, 503, { "Retry-After": "1" });
|
|
173
|
-
}
|
|
49
|
+
import { createPhase1PageHandler, healthHandler } from "./two-phase-readiness.ts";
|
|
174
50
|
|
|
175
51
|
export interface MinimalAppResult {
|
|
176
52
|
app: Hono;
|
|
@@ -187,13 +63,6 @@ export interface MinimalAppResult {
|
|
|
187
63
|
skipped: string[]; // Skipped block names
|
|
188
64
|
}
|
|
189
65
|
|
|
190
|
-
export interface FullAppResult {
|
|
191
|
-
app: Hono;
|
|
192
|
-
blocks: { slug: string; packagePath: string }[];
|
|
193
|
-
routes: string[];
|
|
194
|
-
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
66
|
/**
|
|
198
67
|
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
199
68
|
* - Client asset building (Vite build)
|
|
@@ -437,39 +306,13 @@ export async function buildMinimalApp(
|
|
|
437
306
|
// Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
|
|
438
307
|
// All route paths must be registered before serving to avoid Hono
|
|
439
308
|
// RegExpRouter compilation issues (matcher becomes immutable).
|
|
440
|
-
//
|
|
441
|
-
// CRITICAL: During Phase 1 (minimal app), page requests MUST return 503 BEFORE
|
|
442
|
-
// attempting to resolve page data. The config object doesn't have proper loadData
|
|
443
|
-
// set up until Phase 2, and attempting to resolve undefined loadData crashes
|
|
444
|
-
// resolveDataGraph with "undefined is not an object (evaluating 'Object.keys(spec)')".
|
|
445
309
|
const registeredPageRoutes = await runStep("register-page-routes", () =>
|
|
446
310
|
registerPageRoutes({
|
|
447
311
|
app,
|
|
448
312
|
config,
|
|
449
313
|
components,
|
|
450
314
|
serviceSlug,
|
|
451
|
-
createPageHandler: (
|
|
452
|
-
// Return a handler that checks if the full app is ready BEFORE processing
|
|
453
|
-
return async (req, extras) => {
|
|
454
|
-
// If full app is NOT ready yet, return the branded waking page
|
|
455
|
-
// (the same pulsating-logo holding page scale-to-zero uses) —
|
|
456
|
-
// never bare plain text (loading-via-logo-loading).
|
|
457
|
-
if (!fullAppReady || !realPageHandler) {
|
|
458
|
-
const logo = await resolveErrorPageLogo(serviceSlug);
|
|
459
|
-
return new Response(buildWakingPageHtml({ logoUrl: logo }), {
|
|
460
|
-
status: 503,
|
|
461
|
-
headers: {
|
|
462
|
-
"content-type": "text/html; charset=utf-8",
|
|
463
|
-
"cache-control": "no-store, no-transform",
|
|
464
|
-
"retry-after": "5",
|
|
465
|
-
},
|
|
466
|
-
});
|
|
467
|
-
}
|
|
468
|
-
// Full app is ready - delegate to the real handler
|
|
469
|
-
const handler = realPageHandler(pageConfig);
|
|
470
|
-
return handler(req, extras);
|
|
471
|
-
};
|
|
472
|
-
},
|
|
315
|
+
createPageHandler: createPhase1PageHandler(serviceSlug),
|
|
473
316
|
}),
|
|
474
317
|
);
|
|
475
318
|
const pageRouteCount = registeredPageRoutes.length;
|
|
@@ -504,115 +347,3 @@ export async function buildMinimalApp(
|
|
|
504
347
|
skipped,
|
|
505
348
|
};
|
|
506
349
|
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Build the full app with hydration. This adds the expensive operations that
|
|
510
|
-
* were deferred from the minimal app:
|
|
511
|
-
* - Client asset building (Vite build)
|
|
512
|
-
* - Boot hooks (may have expensive initialization)
|
|
513
|
-
* - Scheduled jobs (can start later)
|
|
514
|
-
* - pg-boss (can start later)
|
|
515
|
-
*
|
|
516
|
-
* This runs in the background after the minimal app is already serving.
|
|
517
|
-
* NOTE: All routes (including page routes) are already registered in Phase 1,
|
|
518
|
-
* so we don't need to modify the app here. The app reference stays the same.
|
|
519
|
-
*/
|
|
520
|
-
export async function buildFullApp(
|
|
521
|
-
minimalResult: MinimalAppResult,
|
|
522
|
-
deps: PageServerDeps,
|
|
523
|
-
): Promise<FullAppResult> {
|
|
524
|
-
const log = getAppLogger();
|
|
525
|
-
const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
|
|
526
|
-
|
|
527
|
-
// Client asset building (expensive: Vite build).
|
|
528
|
-
//
|
|
529
|
-
// ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
|
|
530
|
-
// This was awaited unguarded, so a throw here aborted buildFullApp before
|
|
531
|
-
// mountPgBoss ran, and the caller's catch logged
|
|
532
|
-
// "Failed to build full app, continuing with minimal app" — which reads as a
|
|
533
|
-
// UI-only degradation while having silently killed every pg-boss handler on
|
|
534
|
-
// the service.
|
|
535
|
-
//
|
|
536
|
-
// Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
|
|
537
|
-
// the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
|
|
538
|
-
// scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
|
|
539
|
-
// across repeated boots, the pane-outbox drain never mounted, and the
|
|
540
|
-
// operator's WhatsApp assistant went silent — because of a stylesheet. The
|
|
541
|
-
// service stayed `online` and healthy throughout, so nothing surfaced it.
|
|
542
|
-
//
|
|
543
|
-
// The steps after this one do not depend on client assets: pg-boss needs the
|
|
544
|
-
// registry and the DB, not a bundle. Ordering it behind a Vite build was
|
|
545
|
-
// incidental, so the coupling was never intended.
|
|
546
|
-
try {
|
|
547
|
-
await buildClientAssets(serviceSlug);
|
|
548
|
-
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
549
|
-
} catch (error) {
|
|
550
|
-
// Loud but non-fatal: the tombstone manifest written by buildClientAssets
|
|
551
|
-
// already makes SSR serve the prior bundle, so degrading here costs the
|
|
552
|
-
// new bundle, never the background work.
|
|
553
|
-
log.error(
|
|
554
|
-
`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
|
|
555
|
-
{ error: error instanceof Error ? error.message : String(error) },
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// pg-boss (mount for UI services - wasn't mounted in minimal app)
|
|
560
|
-
const { jobs: pgBossJobs } = await mountPgBoss({
|
|
561
|
-
databaseUrl: minimalResult.databaseUrl,
|
|
562
|
-
serviceSlug,
|
|
563
|
-
packages: config.server_packages ?? [],
|
|
564
|
-
});
|
|
565
|
-
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: mountPgBoss DONE (${pgBossJobs.length} jobs)`);
|
|
566
|
-
if (pgBossJobs.length > 0) {
|
|
567
|
-
log.info(
|
|
568
|
-
`[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
log.info(
|
|
573
|
-
`[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
|
|
574
|
-
`${Object.keys(components).length} component(s)`,
|
|
575
|
-
);
|
|
576
|
-
|
|
577
|
-
// Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
|
|
578
|
-
// Client-only services (render_mode=client) have no SSR page handler — skip
|
|
579
|
-
// (they serve via the client bundle, never SSR). Guarded so undefined is a
|
|
580
|
-
// no-op rather than a crash.
|
|
581
|
-
if (deps.createPageHandler) {
|
|
582
|
-
setPageHandler(deps.createPageHandler);
|
|
583
|
-
log.info(
|
|
584
|
-
`[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
|
|
585
|
-
);
|
|
586
|
-
} else {
|
|
587
|
-
// The full app IS built — this service simply never renders server-side,
|
|
588
|
-
// so readiness is flipped directly rather than as a side effect of
|
|
589
|
-
// registering an SSR handler that will never exist. Without this a
|
|
590
|
-
// client-only service is permanently un-ready (see markFullAppReady).
|
|
591
|
-
markFullAppReady();
|
|
592
|
-
log.info(
|
|
593
|
-
`[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const resolvedBlocks = loaded.map((slug) => ({
|
|
598
|
-
slug,
|
|
599
|
-
packagePath: "block_packages",
|
|
600
|
-
}));
|
|
601
|
-
|
|
602
|
-
return {
|
|
603
|
-
app: minimalApp, // Same app reference, all routes already registered
|
|
604
|
-
blocks: resolvedBlocks,
|
|
605
|
-
routes: [], // Routes already registered in Phase 1
|
|
606
|
-
pgBossJobs,
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Swap the minimal app for the full app. This atomically replaces the
|
|
612
|
-
* app reference that Bun.serve is using.
|
|
613
|
-
*/
|
|
614
|
-
export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
|
|
615
|
-
const oldLabel = swapApp(fullApp, "full");
|
|
616
|
-
const log = getAppLogger();
|
|
617
|
-
log.info(`[two-phase-boot:${serviceSlug}] Swapped from ${oldLabel} app to full app`);
|
|
618
|
-
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Phase-2 readiness state box for the two-phase boot: the real page handler +
|
|
7
|
+
* readiness flags (private lets), the writers/flippers, the `/health` endpoint
|
|
8
|
+
* that gates on them, and the phase-1 page handler that delegates to the real
|
|
9
|
+
* handler once phase 2 completes. Every reader goes through these exports.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
|
|
13
|
+
import type { Context } from "hono";
|
|
14
|
+
import { getServerClientCapabilityFault } from "./page-server-orpc/server-client.ts";
|
|
15
|
+
import { resolveErrorPageLogo } from "./resource-not-found.ts";
|
|
16
|
+
import type { CreatePageHandler } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
// Module-level storage for the real page handler, updated when Phase 2 completes.
|
|
19
|
+
// This allows the temporary Phase 1 handler to delegate to the real handler once ready.
|
|
20
|
+
let realPageHandler: CreatePageHandler | null = null;
|
|
21
|
+
let fullAppReady = false;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
25
|
+
*
|
|
26
|
+
* Registering the SSR handler implies the full app is built, so this also flips
|
|
27
|
+
* readiness — but the reverse does NOT hold, which is why `markFullAppReady`
|
|
28
|
+
* exists separately. See its note.
|
|
29
|
+
*/
|
|
30
|
+
export function setPageHandler(handler: CreatePageHandler): void {
|
|
31
|
+
realPageHandler = handler;
|
|
32
|
+
markFullAppReady();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Flip readiness once phase 2 has built the full app.
|
|
37
|
+
*
|
|
38
|
+
* SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
|
|
39
|
+
* means "phase 2 finished"; the page handler means "this service renders pages
|
|
40
|
+
* server-side". Every service was SSR, so one flag honestly carried both — until
|
|
41
|
+
* `render_mode=client` (`granular runtime`) made a service that completes phase 2
|
|
42
|
+
* and never registers an SSR handler.
|
|
43
|
+
*
|
|
44
|
+
* Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
|
|
45
|
+
* un-ready: `/health` answered `{"status":"starting"}` forever while the process
|
|
46
|
+
* was healthy and serving. That is the failure `readiness-gated-boot` is written
|
|
47
|
+
* against — a service must be "binary: fully-ready or cleanly-unavailable", and
|
|
48
|
+
* a never-ready health signal makes the activator's wake gate and every
|
|
49
|
+
* health-gated router treat a working service as down.
|
|
50
|
+
*
|
|
51
|
+
* Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
|
|
52
|
+
* built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
|
|
53
|
+
* still reported starting — while three untouched UI services reported ok.
|
|
54
|
+
*/
|
|
55
|
+
export function markFullAppReady(): void {
|
|
56
|
+
fullAppReady = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reset both phase-2 flags. TEST-ONLY.
|
|
61
|
+
*
|
|
62
|
+
* `fullAppReady` is module state that only ever transitions false -> true in
|
|
63
|
+
* production, and Bun shares a module instance across every test file in a run —
|
|
64
|
+
* so the SSR readiness test and the client-only readiness test contaminate each
|
|
65
|
+
* other purely by file order. Splitting them across files does NOT isolate them;
|
|
66
|
+
* it only hides the coupling until CI runs them together.
|
|
67
|
+
*
|
|
68
|
+
* A test that passes alone and fails in a suite is worse than no test, so the
|
|
69
|
+
* reset is exported rather than left as an ordering convention nobody can see.
|
|
70
|
+
* Never called from production code — `setPageHandler`/`markFullAppReady` are
|
|
71
|
+
* the only writers on the boot path.
|
|
72
|
+
*/
|
|
73
|
+
export function __resetPhase2StateForTest(): void {
|
|
74
|
+
realPageHandler = null;
|
|
75
|
+
fullAppReady = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get the current page handler state.
|
|
80
|
+
*/
|
|
81
|
+
export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
82
|
+
return { ready: fullAppReady, hasHandler: realPageHandler !== null };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `/health` for a UI service — 200 ONLY once the full app can actually render
|
|
87
|
+
* a page, 503 before that.
|
|
88
|
+
*
|
|
89
|
+
* This endpoint is not decoration: it is the wake gate for every scale-to-zero
|
|
90
|
+
* app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
|
|
91
|
+
* connection through a cold wake and pipes it the instant `isHealthy(port)` —
|
|
92
|
+
* a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
|
|
93
|
+
* on-demand` means by "`/health`-gates the wake (the client never sees a boot
|
|
94
|
+
* 503)".
|
|
95
|
+
*
|
|
96
|
+
* It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
|
|
97
|
+
* serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
|
|
98
|
+
* surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
|
|
99
|
+
* handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
|
|
100
|
+
* `/health` reported ready while every page 503'd, the activator believed the
|
|
101
|
+
* wake had landed, and it piped the customer's first request straight into that
|
|
102
|
+
* window — the customer got the 503.
|
|
103
|
+
*
|
|
104
|
+
* Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
|
|
105
|
+
* scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
|
|
106
|
+
* — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
|
|
107
|
+
* such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
|
|
108
|
+
* placeholder, which does no render work). scala-public logged ZERO, which is
|
|
109
|
+
* the whole reason it looked healthy while the other two did not: the gap is a
|
|
110
|
+
* RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
|
|
111
|
+
* not a config difference between the apps.
|
|
112
|
+
*
|
|
113
|
+
* Gating this on `fullAppReady` makes the signal honest for every consumer at
|
|
114
|
+
* once (the activator's wake gate, `poller-post-restart-health-gate`, any
|
|
115
|
+
* health-gated router) rather than teaching each one its own probe, and it is
|
|
116
|
+
* what `readiness-gated-boot` already requires: "/health returns 503 until
|
|
117
|
+
* ready, 200 after — so it is the real readiness signal", a service being
|
|
118
|
+
* "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
|
|
119
|
+
* is not ready, per `liveness-by-capability-not-process-existence` — liveness is
|
|
120
|
+
* a probe of the capability being REQUESTED, never of process existence or of
|
|
121
|
+
* some other surface that happens to answer.
|
|
122
|
+
*
|
|
123
|
+
* The early readiness-gate flip is deliberately left alone: it gates the ORPC
|
|
124
|
+
* tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
|
|
125
|
+
* one consumer contract is "can you serve me a page", is moved to phase 2.
|
|
126
|
+
*/
|
|
127
|
+
export function healthHandler(c: Context): Response {
|
|
128
|
+
if (!fullAppReady) return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
|
|
129
|
+
// Assert the CAPABILITY, not just the phase flag. On 2026-08-20 this returned
|
|
130
|
+
// 200 through a total outage: the process was up and phase 2 had completed
|
|
131
|
+
// while the registered client carried no CRUD models, so every data-backed
|
|
132
|
+
// page 500d and nothing alerted
|
|
133
|
+
// (`liveness-by-capability-not-process-existence`).
|
|
134
|
+
const fault = getServerClientCapabilityFault();
|
|
135
|
+
if (fault) return c.json({ status: "degraded", reason: fault }, 503);
|
|
136
|
+
return c.json({ status: "ok" });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The page handler `buildMinimalApp` registers on every page route during
|
|
141
|
+
* phase 1: 503 + the branded waking page until the full app is ready, then
|
|
142
|
+
* delegation to the real handler. Lives here, beside the state it reads —
|
|
143
|
+
* the flags stay private to this module.
|
|
144
|
+
*/
|
|
145
|
+
export function createPhase1PageHandler(serviceSlug: string): CreatePageHandler {
|
|
146
|
+
// CRITICAL: During Phase 1 (minimal app), page requests MUST return 503 BEFORE
|
|
147
|
+
// attempting to resolve page data. The config object doesn't have proper loadData
|
|
148
|
+
// set up until Phase 2, and attempting to resolve undefined loadData crashes
|
|
149
|
+
// resolveDataGraph with "undefined is not an object (evaluating 'Object.keys(spec)')".
|
|
150
|
+
// Return a handler that checks if the full app is ready BEFORE processing
|
|
151
|
+
return (pageConfig) =>
|
|
152
|
+
async (req, extras) => {
|
|
153
|
+
// If full app is NOT ready yet, return the branded waking page
|
|
154
|
+
// (the same pulsating-logo holding page scale-to-zero uses) —
|
|
155
|
+
// never bare plain text (loading-via-logo-loading).
|
|
156
|
+
if (!fullAppReady || !realPageHandler) {
|
|
157
|
+
const logo = await resolveErrorPageLogo(serviceSlug);
|
|
158
|
+
return new Response(buildWakingPageHtml({ logoUrl: logo }), {
|
|
159
|
+
status: 503,
|
|
160
|
+
headers: {
|
|
161
|
+
"content-type": "text/html; charset=utf-8",
|
|
162
|
+
"cache-control": "no-store, no-transform",
|
|
163
|
+
"retry-after": "5",
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Full app is ready - delegate to the real handler
|
|
168
|
+
const handler = realPageHandler(pageConfig);
|
|
169
|
+
return handler(req, extras);
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/ui-service.ts
CHANGED
|
@@ -15,7 +15,8 @@ import { Hono } from "hono";
|
|
|
15
15
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
16
16
|
import { setReady } from "./readiness-gate.ts";
|
|
17
17
|
import { resolveServicePort } from "./resolve-service-port.ts";
|
|
18
|
-
import { buildMinimalApp
|
|
18
|
+
import { buildMinimalApp } from "./two-phase-minimal-app.ts";
|
|
19
|
+
import { buildFullApp, swapToFullApp } from "./two-phase-full-app.ts";
|
|
19
20
|
import type { UpgradeWebSocket } from "./register-ws-routes.ts";
|
|
20
21
|
|
|
21
22
|
export interface ResolvedBlock {
|