@teamscala/service-boot 0.23.301 → 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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.301",
9
+ "version": "0.23.303",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -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>>;
@@ -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", (c) => c.json({ status: "ok" }));
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
+ });
package/src/ui-service.ts CHANGED
@@ -78,7 +78,14 @@ export async function bootUiService(
78
78
  // is unaffected by construction: it injects PORT and a Railway-only service
79
79
  // has no declared port, so the env still wins there.
80
80
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
81
- const resolution = resolveServicePort(envPort, config.port);
81
+ // The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
82
+ // services `port` is the ACTIVATOR's ingress (what callers resolve), not the
83
+ // app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
84
+ // unchanged. Without this, a migrated row (port=activator) would make the app
85
+ // bind the activator's port → SO_REUSEPORT silent load-balancing (the
86
+ // service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
87
+ const declaredAppPort = config.app_port ?? config.port;
88
+ const resolution = resolveServicePort(envPort, declaredAppPort);
82
89
  if ("error" in resolution) {
83
90
  throw new BootFatalError(
84
91
  "ui-service.port-resolution",