@teamscala/service-boot 0.23.302 → 0.23.303
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
CHANGED
package/src/two-phase-boot.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* built in the background and atomically swapped in. This eliminates the
|
|
13
13
|
* ~25s channel outage window during scala-agents restart.
|
|
14
14
|
*/
|
|
15
|
-
import { Hono } from "hono";
|
|
15
|
+
import { Hono, type Context } from "hono";
|
|
16
16
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
17
17
|
import { mountServerPackages } from "./mount-server-packages.ts";
|
|
18
18
|
import type { PageServerDeps } from "./ui-service.ts";
|
|
@@ -28,6 +28,49 @@ export declare function getPageHandlerState(): {
|
|
|
28
28
|
ready: boolean;
|
|
29
29
|
hasHandler: boolean;
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* `/health` for a UI service — 200 ONLY once the full app can actually render
|
|
33
|
+
* a page, 503 before that.
|
|
34
|
+
*
|
|
35
|
+
* This endpoint is not decoration: it is the wake gate for every scale-to-zero
|
|
36
|
+
* app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
|
|
37
|
+
* connection through a cold wake and pipes it the instant `isHealthy(port)` —
|
|
38
|
+
* a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
|
|
39
|
+
* on-demand` means by "`/health`-gates the wake (the client never sees a boot
|
|
40
|
+
* 503)".
|
|
41
|
+
*
|
|
42
|
+
* It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
|
|
43
|
+
* serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
|
|
44
|
+
* surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
|
|
45
|
+
* handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
|
|
46
|
+
* `/health` reported ready while every page 503'd, the activator believed the
|
|
47
|
+
* wake had landed, and it piped the customer's first request straight into that
|
|
48
|
+
* window — the customer got the 503.
|
|
49
|
+
*
|
|
50
|
+
* Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
|
|
51
|
+
* scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
|
|
52
|
+
* — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
|
|
53
|
+
* such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
|
|
54
|
+
* placeholder, which does no render work). scala-public logged ZERO, which is
|
|
55
|
+
* the whole reason it looked healthy while the other two did not: the gap is a
|
|
56
|
+
* RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
|
|
57
|
+
* not a config difference between the apps.
|
|
58
|
+
*
|
|
59
|
+
* Gating this on `fullAppReady` makes the signal honest for every consumer at
|
|
60
|
+
* once (the activator's wake gate, `poller-post-restart-health-gate`, any
|
|
61
|
+
* health-gated router) rather than teaching each one its own probe, and it is
|
|
62
|
+
* what `readiness-gated-boot` already requires: "/health returns 503 until
|
|
63
|
+
* ready, 200 after — so it is the real readiness signal", a service being
|
|
64
|
+
* "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
|
|
65
|
+
* is not ready, per `liveness-by-capability-not-process-existence` — liveness is
|
|
66
|
+
* a probe of the capability being REQUESTED, never of process existence or of
|
|
67
|
+
* some other surface that happens to answer.
|
|
68
|
+
*
|
|
69
|
+
* The early readiness-gate flip is deliberately left alone: it gates the ORPC
|
|
70
|
+
* tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
|
|
71
|
+
* one consumer contract is "can you serve me a page", is moved to phase 2.
|
|
72
|
+
*/
|
|
73
|
+
export declare function healthHandler(c: Context): Response;
|
|
31
74
|
export interface MinimalAppResult {
|
|
32
75
|
app: Hono;
|
|
33
76
|
mountRegistry: Awaited<ReturnType<typeof mountServerPackages>>;
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
|
17
17
|
import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
|
|
18
18
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
19
19
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
20
|
-
import { Hono } from "hono";
|
|
20
|
+
import { Hono, type Context } from "hono";
|
|
21
21
|
import { compress } from "hono/compress";
|
|
22
22
|
import { cors } from "hono/cors";
|
|
23
23
|
import { timing } from "hono/timing";
|
|
@@ -77,6 +77,54 @@ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
|
|
|
77
77
|
return { ready: fullAppReady, hasHandler: realPageHandler !== null };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* `/health` for a UI service — 200 ONLY once the full app can actually render
|
|
82
|
+
* a page, 503 before that.
|
|
83
|
+
*
|
|
84
|
+
* This endpoint is not decoration: it is the wake gate for every scale-to-zero
|
|
85
|
+
* app. `scala-activator` (`@teamscala/scale-to-zero`) holds the client's TCP
|
|
86
|
+
* connection through a cold wake and pipes it the instant `isHealthy(port)` —
|
|
87
|
+
* a plain `GET /health` — returns ok, which is what `scale-to-zero-apps-sleep-
|
|
88
|
+
* on-demand` means by "`/health`-gates the wake (the client never sees a boot
|
|
89
|
+
* 503)".
|
|
90
|
+
*
|
|
91
|
+
* It previously answered a flat `{status:"ok"}` as soon as the MINIMAL app was
|
|
92
|
+
* serving, because the readiness gate flips ready after phase 1 so the ORPC/MCP
|
|
93
|
+
* surface can accept traffic early. But phase 1 CANNOT render: the phase-1 page
|
|
94
|
+
* handler below returns 503 until `setPageHandler` runs at the phase-2 swap. So
|
|
95
|
+
* `/health` reported ready while every page 503'd, the activator believed the
|
|
96
|
+
* wake had landed, and it piped the customer's first request straight into that
|
|
97
|
+
* window — the customer got the 503.
|
|
98
|
+
*
|
|
99
|
+
* Measured 2026-07-31 on quiz-dev/sign-dev: the activator logged `awake` for
|
|
100
|
+
* scala-quiz at 05:58:25.446 and the app logged `GET / 503 3ms` at 05:58:25.452
|
|
101
|
+
* — 6ms later, and 1004ms BEFORE "Swapped from minimal app to full app". Nine
|
|
102
|
+
* such 503s on scala-quiz and two on scala-sign, every one 0-23ms (the
|
|
103
|
+
* placeholder, which does no render work). scala-public logged ZERO, which is
|
|
104
|
+
* the whole reason it looked healthy while the other two did not: the gap is a
|
|
105
|
+
* RACE whose width is the phase-1→phase-2 build time (1420ms cold, 137ms warm),
|
|
106
|
+
* not a config difference between the apps.
|
|
107
|
+
*
|
|
108
|
+
* Gating this on `fullAppReady` makes the signal honest for every consumer at
|
|
109
|
+
* once (the activator's wake gate, `poller-post-restart-health-gate`, any
|
|
110
|
+
* health-gated router) rather than teaching each one its own probe, and it is
|
|
111
|
+
* what `readiness-gated-boot` already requires: "/health returns 503 until
|
|
112
|
+
* ready, 200 after — so it is the real readiness signal", a service being
|
|
113
|
+
* "binary: fully-ready or cleanly-unavailable". A UI service that cannot render
|
|
114
|
+
* is not ready, per `liveness-by-capability-not-process-existence` — liveness is
|
|
115
|
+
* a probe of the capability being REQUESTED, never of process existence or of
|
|
116
|
+
* some other surface that happens to answer.
|
|
117
|
+
*
|
|
118
|
+
* The early readiness-gate flip is deliberately left alone: it gates the ORPC
|
|
119
|
+
* tool surface, which genuinely IS usable at phase 1. Only this endpoint, whose
|
|
120
|
+
* one consumer contract is "can you serve me a page", is moved to phase 2.
|
|
121
|
+
*/
|
|
122
|
+
export function healthHandler(c: Context): Response {
|
|
123
|
+
return fullAppReady
|
|
124
|
+
? c.json({ status: "ok" })
|
|
125
|
+
: c.json({ status: "starting" }, 503, { "Retry-After": "1" });
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
export interface MinimalAppResult {
|
|
81
129
|
app: Hono;
|
|
82
130
|
mountRegistry: Awaited<ReturnType<typeof mountServerPackages>>;
|
|
@@ -161,7 +209,7 @@ export async function buildMinimalApp(
|
|
|
161
209
|
|
|
162
210
|
// Core middleware
|
|
163
211
|
app.use("*", readinessGate);
|
|
164
|
-
app.get("/health",
|
|
212
|
+
app.get("/health", healthHandler);
|
|
165
213
|
app.use("*", injectSecurityHeaders);
|
|
166
214
|
const cookieDomain = await getCookieDomain();
|
|
167
215
|
app.use(
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The wake gate for every scale-to-zero app: `/health` must not report ready
|
|
7
|
+
* while the service still 503s pages.
|
|
8
|
+
*
|
|
9
|
+
* Exercised through a real Hono request against the REAL exported handler, not
|
|
10
|
+
* asserted against source text — `proxy-is-not-behaviour`: a source grep proves
|
|
11
|
+
* a line exists, never that the endpoint answers 503. This test fails if anyone
|
|
12
|
+
* restores the flat `{status:"ok"}`, which is the regression it locks.
|
|
13
|
+
*
|
|
14
|
+
* THE MEASURED OUTAGE (2026-07-31, quiz-dev + sign-dev returning 503 to
|
|
15
|
+
* customers). `scala-activator` gates a cold wake on `isHealthy(port)` — a
|
|
16
|
+
* plain `GET /health` — then pipes the held client connection the moment it
|
|
17
|
+
* returns ok. `/health` answered ok as soon as the MINIMAL app served, but the
|
|
18
|
+
* phase-1 page handler returns 503 until the phase-2 swap, so the activator
|
|
19
|
+
* piped the customer's first request into that window:
|
|
20
|
+
*
|
|
21
|
+
* 05:58:25.446 activator: awake scala-quiz (4227ms)
|
|
22
|
+
* 05:58:25.452 scala-quiz: GET / 503 3ms <- the customer's request
|
|
23
|
+
* 05:58:26.456 scala-quiz: Swapped from minimal app to full app
|
|
24
|
+
*
|
|
25
|
+
* Nine such 503s on scala-quiz, two on scala-sign, zero on scala-public — the
|
|
26
|
+
* gap is a race whose width is the phase-1→phase-2 build time (1420ms cold,
|
|
27
|
+
* 137ms warm), which is why one app looked healthy and the others did not.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, expect, test } from "bun:test";
|
|
31
|
+
import { Hono } from "hono";
|
|
32
|
+
import { healthHandler, setPageHandler, getPageHandlerState } from "./two-phase-boot.ts";
|
|
33
|
+
|
|
34
|
+
/** Mount the real handler exactly as buildMinimalApp does. */
|
|
35
|
+
const app = new Hono().get("/health", healthHandler);
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* Ordered, and deliberately so: `fullAppReady` is module state that only ever
|
|
39
|
+
* transitions false -> true (via setPageHandler at the phase-2 swap), so the
|
|
40
|
+
* pre-swap assertion has to run before the swap is simulated. There is no reset
|
|
41
|
+
* hook and adding one would exist only for tests.
|
|
42
|
+
*/
|
|
43
|
+
describe("/health is gated on page-serving readiness", () => {
|
|
44
|
+
test("before the phase-2 swap it reports 503, not ready", async () => {
|
|
45
|
+
expect(getPageHandlerState().ready).toBe(false);
|
|
46
|
+
|
|
47
|
+
const res = await app.request("/health");
|
|
48
|
+
|
|
49
|
+
// The activator treats any non-ok as "not awake yet" and keeps holding
|
|
50
|
+
// the client connection instead of piping it into a 503.
|
|
51
|
+
expect(res.status).toBe(503);
|
|
52
|
+
expect(res.ok).toBe(false);
|
|
53
|
+
expect(await res.json()).toEqual({ status: "starting" });
|
|
54
|
+
// Retry-After is the contract readiness-gated-boot specifies alongside
|
|
55
|
+
// the 503, so a caller backs off rather than hot-looping the wake.
|
|
56
|
+
expect(res.headers.get("Retry-After")).toBe("1");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("after the phase-2 swap it reports 200 ok", async () => {
|
|
60
|
+
// Simulate the phase-2 swap: this is the real call ui-service.ts makes
|
|
61
|
+
// via buildFullApp, and it is what flips fullAppReady.
|
|
62
|
+
setPageHandler(() => async () => new Response("page"));
|
|
63
|
+
expect(getPageHandlerState().ready).toBe(true);
|
|
64
|
+
|
|
65
|
+
const res = await app.request("/health");
|
|
66
|
+
|
|
67
|
+
expect(res.status).toBe(200);
|
|
68
|
+
expect(res.ok).toBe(true);
|
|
69
|
+
expect(await res.json()).toEqual({ status: "ok" });
|
|
70
|
+
});
|
|
71
|
+
});
|