@teamscala/service-boot 0.23.343 → 0.23.345
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
|
@@ -18,9 +18,49 @@ import { mountServerPackages } from "./mount-server-packages.ts";
|
|
|
18
18
|
import type { PageServerDeps } from "./ui-service.ts";
|
|
19
19
|
import type { CreatePageHandler } from "./types.ts";
|
|
20
20
|
/**
|
|
21
|
-
* Set the real page handler (called when Phase 2 completes).
|
|
21
|
+
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
22
|
+
*
|
|
23
|
+
* Registering the SSR handler implies the full app is built, so this also flips
|
|
24
|
+
* readiness — but the reverse does NOT hold, which is why `markFullAppReady`
|
|
25
|
+
* exists separately. See its note.
|
|
22
26
|
*/
|
|
23
27
|
export declare function setPageHandler(handler: CreatePageHandler): void;
|
|
28
|
+
/**
|
|
29
|
+
* Flip readiness once phase 2 has built the full app.
|
|
30
|
+
*
|
|
31
|
+
* SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
|
|
32
|
+
* means "phase 2 finished"; the page handler means "this service renders pages
|
|
33
|
+
* server-side". Every service was SSR, so one flag honestly carried both — until
|
|
34
|
+
* `render_mode=client` (`granular runtime`) made a service that completes phase 2
|
|
35
|
+
* and never registers an SSR handler.
|
|
36
|
+
*
|
|
37
|
+
* Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
|
|
38
|
+
* un-ready: `/health` answered `{"status":"starting"}` forever while the process
|
|
39
|
+
* was healthy and serving. That is the failure `readiness-gated-boot` is written
|
|
40
|
+
* against — a service must be "binary: fully-ready or cleanly-unavailable", and
|
|
41
|
+
* a never-ready health signal makes the activator's wake gate and every
|
|
42
|
+
* health-gated router treat a working service as down.
|
|
43
|
+
*
|
|
44
|
+
* Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
|
|
45
|
+
* built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
|
|
46
|
+
* still reported starting — while three untouched UI services reported ok.
|
|
47
|
+
*/
|
|
48
|
+
export declare function markFullAppReady(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Reset both phase-2 flags. TEST-ONLY.
|
|
51
|
+
*
|
|
52
|
+
* `fullAppReady` is module state that only ever transitions false -> true in
|
|
53
|
+
* production, and Bun shares a module instance across every test file in a run —
|
|
54
|
+
* so the SSR readiness test and the client-only readiness test contaminate each
|
|
55
|
+
* other purely by file order. Splitting them across files does NOT isolate them;
|
|
56
|
+
* it only hides the coupling until CI runs them together.
|
|
57
|
+
*
|
|
58
|
+
* A test that passes alone and fails in a suite is worse than no test, so the
|
|
59
|
+
* reset is exported rather than left as an ordering convention nobody can see.
|
|
60
|
+
* Never called from production code — `setPageHandler`/`markFullAppReady` are
|
|
61
|
+
* the only writers on the boot path.
|
|
62
|
+
*/
|
|
63
|
+
export declare function __resetPhase2StateForTest(): void;
|
|
24
64
|
/**
|
|
25
65
|
* Get the current page handler state.
|
|
26
66
|
*/
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -64,13 +64,60 @@ let realPageHandler: CreatePageHandler | null = null;
|
|
|
64
64
|
let fullAppReady = false;
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Set the real page handler (called when Phase 2 completes).
|
|
67
|
+
* Set the real page handler (called when Phase 2 completes on an SSR service).
|
|
68
|
+
*
|
|
69
|
+
* Registering the SSR handler implies the full app is built, so this also flips
|
|
70
|
+
* readiness — but the reverse does NOT hold, which is why `markFullAppReady`
|
|
71
|
+
* exists separately. See its note.
|
|
68
72
|
*/
|
|
69
73
|
export function setPageHandler(handler: CreatePageHandler): void {
|
|
70
74
|
realPageHandler = handler;
|
|
75
|
+
markFullAppReady();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Flip readiness once phase 2 has built the full app.
|
|
80
|
+
*
|
|
81
|
+
* SEPARATE FROM `setPageHandler` BECAUSE THE TWO FACTS CAME APART. Readiness
|
|
82
|
+
* means "phase 2 finished"; the page handler means "this service renders pages
|
|
83
|
+
* server-side". Every service was SSR, so one flag honestly carried both — until
|
|
84
|
+
* `render_mode=client` (`granular runtime`) made a service that completes phase 2
|
|
85
|
+
* and never registers an SSR handler.
|
|
86
|
+
*
|
|
87
|
+
* Leaving readiness inside `setPageHandler` made such a service PERMANENTLY
|
|
88
|
+
* un-ready: `/health` answered `{"status":"starting"}` forever while the process
|
|
89
|
+
* was healthy and serving. That is the failure `readiness-gated-boot` is written
|
|
90
|
+
* against — a service must be "binary: fully-ready or cleanly-unavailable", and
|
|
91
|
+
* a never-ready health signal makes the activator's wake gate and every
|
|
92
|
+
* health-gated router treat a working service as down.
|
|
93
|
+
*
|
|
94
|
+
* Measured 2026-08-07 on scala-agents-ui: boot completed in 5023ms, "Full app
|
|
95
|
+
* built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and `/health`
|
|
96
|
+
* still reported starting — while three untouched UI services reported ok.
|
|
97
|
+
*/
|
|
98
|
+
export function markFullAppReady(): void {
|
|
71
99
|
fullAppReady = true;
|
|
72
100
|
}
|
|
73
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Reset both phase-2 flags. TEST-ONLY.
|
|
104
|
+
*
|
|
105
|
+
* `fullAppReady` is module state that only ever transitions false -> true in
|
|
106
|
+
* production, and Bun shares a module instance across every test file in a run —
|
|
107
|
+
* so the SSR readiness test and the client-only readiness test contaminate each
|
|
108
|
+
* other purely by file order. Splitting them across files does NOT isolate them;
|
|
109
|
+
* it only hides the coupling until CI runs them together.
|
|
110
|
+
*
|
|
111
|
+
* A test that passes alone and fails in a suite is worse than no test, so the
|
|
112
|
+
* reset is exported rather than left as an ordering convention nobody can see.
|
|
113
|
+
* Never called from production code — `setPageHandler`/`markFullAppReady` are
|
|
114
|
+
* the only writers on the boot path.
|
|
115
|
+
*/
|
|
116
|
+
export function __resetPhase2StateForTest(): void {
|
|
117
|
+
realPageHandler = null;
|
|
118
|
+
fullAppReady = false;
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
/**
|
|
75
122
|
* Get the current page handler state.
|
|
76
123
|
*/
|
|
@@ -504,11 +551,25 @@ export async function buildFullApp(
|
|
|
504
551
|
`${Object.keys(components).length} component(s)`,
|
|
505
552
|
);
|
|
506
553
|
|
|
507
|
-
// Set the real page handler so the dynamic handler in Phase 1 can delegate to it
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
)
|
|
554
|
+
// Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
|
|
555
|
+
// Client-only services (render_mode=client) have no SSR page handler — skip
|
|
556
|
+
// (they serve via the client bundle, never SSR). Guarded so undefined is a
|
|
557
|
+
// no-op rather than a crash.
|
|
558
|
+
if (deps.createPageHandler) {
|
|
559
|
+
setPageHandler(deps.createPageHandler);
|
|
560
|
+
log.info(
|
|
561
|
+
`[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
|
|
562
|
+
);
|
|
563
|
+
} else {
|
|
564
|
+
// The full app IS built — this service simply never renders server-side,
|
|
565
|
+
// so readiness is flipped directly rather than as a side effect of
|
|
566
|
+
// registering an SSR handler that will never exist. Without this a
|
|
567
|
+
// client-only service is permanently un-ready (see markFullAppReady).
|
|
568
|
+
markFullAppReady();
|
|
569
|
+
log.info(
|
|
570
|
+
`[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
512
573
|
|
|
513
574
|
const resolvedBlocks = loaded.map((slug) => ({
|
|
514
575
|
slug,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* A CLIENT-ONLY service (`render_mode=client`) must reach ready, even though it
|
|
7
|
+
* never registers an SSR page handler.
|
|
8
|
+
*
|
|
9
|
+
* Separate file from `two-phase-health-gate.test.ts` on purpose: `fullAppReady`
|
|
10
|
+
* is module state that only transitions false -> true and has no reset hook, so
|
|
11
|
+
* the two ready-paths cannot both be exercised in one module instance. A reset
|
|
12
|
+
* hook would exist only for tests.
|
|
13
|
+
*
|
|
14
|
+
* THE REGRESSION THIS LOCKS (measured 2026-08-07 on scala-agents-ui). Readiness
|
|
15
|
+
* used to be flipped INSIDE `setPageHandler`, which was honest while every
|
|
16
|
+
* service was SSR. `render_mode=client` made a service that completes phase 2
|
|
17
|
+
* and never calls it, so `/health` answered `{"status":"starting"}` FOREVER
|
|
18
|
+
* while the process was healthy and serving — boot completed in 5023ms, "Full
|
|
19
|
+
* app built: 50 block(s)" logged, `GET /` correctly 302'd to sign-in, and three
|
|
20
|
+
* untouched UI services reported ok beside it.
|
|
21
|
+
*
|
|
22
|
+
* That is precisely what `readiness-gated-boot` forbids ("binary: fully-ready or
|
|
23
|
+
* cleanly-unavailable"): a permanently-un-ready health signal makes the
|
|
24
|
+
* activator's wake gate and every health-gated router treat a working service as
|
|
25
|
+
* down. Exercised through a real Hono request against the REAL exported handler
|
|
26
|
+
* (`proxy-is-not-behaviour`) — a source grep would prove the call exists, never
|
|
27
|
+
* that the endpoint answers 200.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
31
|
+
import { Hono } from "hono";
|
|
32
|
+
import {
|
|
33
|
+
__resetPhase2StateForTest,
|
|
34
|
+
getPageHandlerState,
|
|
35
|
+
healthHandler,
|
|
36
|
+
markFullAppReady,
|
|
37
|
+
} from "./two-phase-boot.ts";
|
|
38
|
+
|
|
39
|
+
/** Mount the real handler exactly as buildMinimalApp does. */
|
|
40
|
+
const app = new Hono().get("/health", healthHandler);
|
|
41
|
+
|
|
42
|
+
describe("/health for a client-only service", () => {
|
|
43
|
+
// Bun shares one module instance across every test file in a run, so the SSR
|
|
44
|
+
// readiness test's setPageHandler leaks into this file when both run together.
|
|
45
|
+
// Without this reset the suite passes file-by-file and fails in CI.
|
|
46
|
+
beforeAll(() => {
|
|
47
|
+
__resetPhase2StateForTest();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("before phase 2 completes it reports 503, not ready", async () => {
|
|
51
|
+
expect(getPageHandlerState().ready).toBe(false);
|
|
52
|
+
|
|
53
|
+
const res = await app.request("/health");
|
|
54
|
+
|
|
55
|
+
expect(res.status).toBe(503);
|
|
56
|
+
expect(await res.json()).toEqual({ status: "starting" });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reaches ready without an SSR page handler", async () => {
|
|
60
|
+
// The real client-only phase-2 path: buildFullApp finishes and marks the
|
|
61
|
+
// app ready WITHOUT setPageHandler, because there is no SSR handler.
|
|
62
|
+
markFullAppReady();
|
|
63
|
+
|
|
64
|
+
const state = getPageHandlerState();
|
|
65
|
+
expect(state.ready).toBe(true);
|
|
66
|
+
// The discriminator: ready is true while hasHandler stays false. Asserting
|
|
67
|
+
// both is what separates this from the SSR path — a fix that flipped ready
|
|
68
|
+
// by registering a dummy handler would pass `ready` and fail here.
|
|
69
|
+
expect(state.hasHandler).toBe(false);
|
|
70
|
+
|
|
71
|
+
const res = await app.request("/health");
|
|
72
|
+
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
expect(res.ok).toBe(true);
|
|
75
|
+
expect(await res.json()).toEqual({ status: "ok" });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -27,9 +27,14 @@
|
|
|
27
27
|
* 137ms warm), which is why one app looked healthy and the others did not.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { describe, expect, test } from "bun:test";
|
|
30
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
31
31
|
import { Hono } from "hono";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
__resetPhase2StateForTest,
|
|
34
|
+
getPageHandlerState,
|
|
35
|
+
healthHandler,
|
|
36
|
+
setPageHandler,
|
|
37
|
+
} from "./two-phase-boot.ts";
|
|
33
38
|
|
|
34
39
|
/** Mount the real handler exactly as buildMinimalApp does. */
|
|
35
40
|
const app = new Hono().get("/health", healthHandler);
|
|
@@ -41,6 +46,12 @@ const app = new Hono().get("/health", healthHandler);
|
|
|
41
46
|
* hook and adding one would exist only for tests.
|
|
42
47
|
*/
|
|
43
48
|
describe("/health is gated on page-serving readiness", () => {
|
|
49
|
+
// Bun shares one module instance across test files, so a sibling file that
|
|
50
|
+
// marks the app ready would otherwise decide this file's first assertion.
|
|
51
|
+
beforeAll(() => {
|
|
52
|
+
__resetPhase2StateForTest();
|
|
53
|
+
});
|
|
54
|
+
|
|
44
55
|
test("before the phase-2 swap it reports 503, not ready", async () => {
|
|
45
56
|
expect(getPageHandlerState().ready).toBe(false);
|
|
46
57
|
|