@teamscala/service-boot 0.23.442 → 0.23.444
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 +1 -1
- package/src/build-client-assets/manifest.test.ts +93 -0
- package/src/build-client-assets/manifest.ts +11 -16
- package/src/build-client-assets.ts +16 -6
- package/src/read-html-template.test.ts +23 -17
- package/src/read-html-template.ts +41 -43
- package/src/two-phase-full-app.test.ts +35 -0
- package/src/two-phase-full-app.ts +28 -0
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-handlers
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* verifyClientBuildAssets is the capability probe the readiness flip gates on
|
|
7
|
+
* (`a-component-may-not-report-a-state-it-has-not-verified`): a manifest is
|
|
8
|
+
* servable only when BOTH assets it names exist on disk. Measured 2026-09-01
|
|
9
|
+
* (the unstyled-proposal incident): a manifest that never ran — or a first-boot
|
|
10
|
+
* tombstone carrying the unhashed FALLBACK_MANIFEST paths — read as a
|
|
11
|
+
* legitimate bundle and the service served pages whose stylesheet 404'd while
|
|
12
|
+
* every health surface reported ok. These tests pin the refusal for all three
|
|
13
|
+
* unbuilt shapes and the pass for a real build.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { writeManifest, verifyClientBuildAssets } from "./manifest.ts";
|
|
21
|
+
|
|
22
|
+
let dir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
dir = await mkdtemp(join(tmpdir(), "verify-client-build-"));
|
|
26
|
+
await mkdir(join(dir, "dist", "client"), { recursive: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await rm(dir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function writeAssetFiles(styles: string, hydrate: string): Promise<void> {
|
|
34
|
+
await Bun.file(join(dir, "dist", "client", styles.replace(/^\/assets\//, ""))).write("body{}");
|
|
35
|
+
await Bun.file(join(dir, "dist", "client", hydrate.replace(/^\/assets\//, ""))).write("export{}");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("verifyClientBuildAssets", () => {
|
|
39
|
+
test("a manifest whose files both exist verifies", async () => {
|
|
40
|
+
await writeManifest(join(dir, "dist", "client"), {
|
|
41
|
+
stylesPath: "/assets/styles-abc.css",
|
|
42
|
+
hydratePath: "/assets/hydrate-abc.js",
|
|
43
|
+
});
|
|
44
|
+
await writeAssetFiles("/assets/styles-abc.css", "/assets/hydrate-abc.js");
|
|
45
|
+
expect(await verifyClientBuildAssets(dir)).toEqual({ ok: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an absent manifest refuses — the client build never ran", async () => {
|
|
49
|
+
const result = await verifyClientBuildAssets(dir);
|
|
50
|
+
expect(result.ok).toBe(false);
|
|
51
|
+
if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the first-boot tombstone shape (unhashed paths, no files) refuses", async () => {
|
|
55
|
+
await writeManifest(join(dir, "dist", "client"), {
|
|
56
|
+
stylesPath: "/assets/styles.css",
|
|
57
|
+
hydratePath: "/assets/hydrate.js",
|
|
58
|
+
});
|
|
59
|
+
const result = await verifyClientBuildAssets(dir);
|
|
60
|
+
expect(result.ok).toBe(false);
|
|
61
|
+
if (!result.ok) expect(result.reason).toContain("unbuilt assets");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a path-less tombstone (the shape build-client-assets now writes) refuses as unbuilt", async () => {
|
|
65
|
+
await writeManifest(join(dir, "dist", "client"), {
|
|
66
|
+
inputFingerprint: "failed:2026-09-01T00:00:00.000Z",
|
|
67
|
+
buildFailed: true,
|
|
68
|
+
buildFailedReason: "hydrate build failed: something",
|
|
69
|
+
buildFailedAt: "2026-09-01T00:00:00.000Z",
|
|
70
|
+
} as never);
|
|
71
|
+
const result = await verifyClientBuildAssets(dir);
|
|
72
|
+
expect(result.ok).toBe(false);
|
|
73
|
+
if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("a manifest naming a file that was deleted refuses (stale-bundle shape)", async () => {
|
|
77
|
+
await writeManifest(join(dir, "dist", "client"), {
|
|
78
|
+
stylesPath: "/assets/styles-abc.css",
|
|
79
|
+
hydratePath: "/assets/hydrate-abc.js",
|
|
80
|
+
});
|
|
81
|
+
await writeAssetFiles("/assets/styles-abc.css", "/assets/hydrate-abc.js");
|
|
82
|
+
await rm(join(dir, "dist", "client", "hydrate-abc.js"));
|
|
83
|
+
const result = await verifyClientBuildAssets(dir);
|
|
84
|
+
expect(result.ok).toBe(false);
|
|
85
|
+
if (!result.ok) expect(result.reason).toContain("hydrate bundle");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("an unreadable manifest refuses", async () => {
|
|
89
|
+
await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write("{broken");
|
|
90
|
+
const result = await verifyClientBuildAssets(dir);
|
|
91
|
+
expect(result.ok).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -21,8 +21,13 @@ import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
|
|
|
21
21
|
export const MANIFEST_FILE = "asset-manifest.json";
|
|
22
22
|
|
|
23
23
|
export interface AssetManifest {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Optional because a TOMBSTONE from a build that failed with no prior
|
|
25
|
+
// success carries NO paths — there are no real assets to name, and inventing
|
|
26
|
+
// URLs nothing built is the deleted fallback class (no-shims).
|
|
27
|
+
// `readManifest` returns null unless both are present, so a path-less
|
|
28
|
+
// tombstone reads as unbuilt everywhere (verification, readiness, render).
|
|
29
|
+
stylesPath?: string;
|
|
30
|
+
hydratePath?: string;
|
|
26
31
|
// Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
|
|
27
32
|
// versions), hashed by CONTENT — never mtime (cache-key-hashes-source- inputs-only). The boot/build freshness check rebuilds iff this differs
|
|
28
33
|
// from the current closure. mtime comparison is fundamentally broken on Railway: deploy-build bakes the bundle at image-build time, and under the
|
|
@@ -53,15 +58,6 @@ export interface AssetManifest {
|
|
|
53
58
|
sourceMaps?: SourceMapUploadResult;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
// Last-resort paths for a tombstone when no prior successful manifest exists
|
|
57
|
-
// (first-ever boot fails). Points at the unhashed build names so an operator
|
|
58
|
-
// inspecting the manifest sees a real (if 404-ing) target rather than a
|
|
59
|
-
// mystery sentinel.
|
|
60
|
-
export const FALLBACK_MANIFEST: { stylesPath: string; hydratePath: string } = {
|
|
61
|
-
stylesPath: "/assets/styles.css",
|
|
62
|
-
hydratePath: "/assets/hydrate.js",
|
|
63
|
-
};
|
|
64
|
-
|
|
65
61
|
/**
|
|
66
62
|
* Read ONLY the tombstone state of the current manifest — the runtime
|
|
67
63
|
* (read-html-template, ui-service) calls this to decide whether to surface a
|
|
@@ -125,10 +121,9 @@ export async function writeManifest(
|
|
|
125
121
|
* Verify the CURRENT manifest against the files it names — the capability
|
|
126
122
|
* probe the readiness flip gates on (`a-component-may-not-report-a-state-it-
|
|
127
123
|
* has-not-verified`). A manifest is servable only when BOTH referenced assets
|
|
128
|
-
* exist on disk.
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* must read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
|
|
124
|
+
* exist on disk. An absent or unreadable manifest, a first-boot tombstone
|
|
125
|
+
* carrying no paths, and any manifest naming files that do not exist must all
|
|
126
|
+
* read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
|
|
132
127
|
* proposals.scala.business served a client-facing proposal unstyled while
|
|
133
128
|
* Railway reported the deploy SUCCESS, because "client build never ran" was
|
|
134
129
|
* indistinguishable from "client build fine" at every layer that reports.
|
|
@@ -138,7 +133,7 @@ export async function verifyClientBuildAssets(
|
|
|
138
133
|
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
139
134
|
const outDir = join(cwd, "dist", "client");
|
|
140
135
|
const manifest = await readManifest(outDir);
|
|
141
|
-
if (!manifest) {
|
|
136
|
+
if (!manifest || !manifest.stylesPath || !manifest.hydratePath) {
|
|
142
137
|
return {
|
|
143
138
|
ok: false,
|
|
144
139
|
reason: `no readable asset manifest at ${join(outDir, MANIFEST_FILE)} — the client build never ran or its output is gone`,
|
|
@@ -60,7 +60,6 @@ import { CLIENT_BUILD_LOCK, getClientBuildLockTimeoutMs } from "./build-client-a
|
|
|
60
60
|
import { buildHydrateBundle } from "./build-client-assets/hydrate-bundle.ts";
|
|
61
61
|
import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
|
|
62
62
|
import {
|
|
63
|
-
FALLBACK_MANIFEST,
|
|
64
63
|
readManifest,
|
|
65
64
|
writeManifest,
|
|
66
65
|
} from "./build-client-assets/manifest.ts";
|
|
@@ -85,7 +84,9 @@ export async function buildClientAssets(serviceSlug: string): Promise<void> {
|
|
|
85
84
|
// permanently lost the release's maps — client stacks stayed minified
|
|
86
85
|
// until the next real input change forced a rebuild.
|
|
87
86
|
const recorded = existingManifest.sourceMaps?.status;
|
|
88
|
-
const release =
|
|
87
|
+
const release = existingManifest.hydratePath
|
|
88
|
+
? releaseFromHydratePath(existingManifest.hydratePath)
|
|
89
|
+
: null;
|
|
89
90
|
if (recorded !== "uploaded" && release) {
|
|
90
91
|
log.info(
|
|
91
92
|
`[build-client-assets:${serviceSlug}] manifest up-to-date (skip); source maps previously ${recorded ?? "unrecorded"} — retrying upload for release ${release}`,
|
|
@@ -143,10 +144,19 @@ async function performBuild(
|
|
|
143
144
|
try {
|
|
144
145
|
await mkdir(outDir, { recursive: true });
|
|
145
146
|
await writeManifest(outDir, {
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// NO invented paths: when a prior successful build exists its real
|
|
148
|
+
// paths are preserved (SSR keeps serving the prior bundle — degraded
|
|
149
|
+
// but coherent); when none exists the tombstone carries NO asset
|
|
150
|
+
// paths at all. Writing the unhashed build names here was the last
|
|
151
|
+
// remnant of the deleted fallback class — paths that name files
|
|
152
|
+
// nothing built must not exist on disk any more than in served HTML.
|
|
153
|
+
// A path-less tombstone reads as unbuilt everywhere (readManifest →
|
|
154
|
+
// null → verification refuses → readiness never flips), and its
|
|
155
|
+
// buildFailedReason below is the operator's diagnostic — the failure
|
|
156
|
+
// is read via readClientBuildStatus, which never needed the paths.
|
|
157
|
+
...(existingManifest?.stylesPath && existingManifest?.hydratePath
|
|
158
|
+
? { stylesPath: existingManifest.stylesPath, hydratePath: existingManifest.hydratePath }
|
|
159
|
+
: {}),
|
|
150
160
|
// Failure sentinel: the next boot's fingerprint check reads
|
|
151
161
|
// this and cannot match any real fingerprint → forces a retry.
|
|
152
162
|
inputFingerprint: `failed:${new Date().toISOString()}`,
|
|
@@ -68,29 +68,35 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
|
68
68
|
expect(second).not.toContain("/assets/hydrate-old.js");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
// The fallback is DELETED: the unhashed names it emitted were never built,
|
|
72
|
+
// so the page 200'd while its stylesheet 404'd. Measured 2026-09-01 — a
|
|
73
|
+
// client-facing proposal served unstyled while the deploy reported SUCCESS.
|
|
74
|
+
// A manifest-less render must THROW into the branded error-page path, never
|
|
75
|
+
// emit HTML; the readiness gate (verifyClientBuildAssets) holds the service
|
|
76
|
+
// unready, and this throw covers a manifest that vanishes after boot.
|
|
77
|
+
test("refuses to render when the manifest is absent — never emits fallback URLs", async () => {
|
|
78
|
+
expect(readHtmlTemplate()).rejects.toThrow("no servable asset manifest");
|
|
75
79
|
});
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
test("refuses to render when the manifest is unreadable garbage", async () => {
|
|
82
|
+
await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write("{not json");
|
|
83
|
+
expect(readHtmlTemplate()).rejects.toThrow("no servable asset manifest");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the refusal names the manifest path, so the fix is one read away", async () => {
|
|
87
|
+
const path = join(dir, "dist", "client", "asset-manifest.json");
|
|
88
|
+
try {
|
|
89
|
+
await readHtmlTemplate();
|
|
90
|
+
throw new Error("expected readHtmlTemplate to throw");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
expect((error as Error).message).toContain(path);
|
|
93
|
+
}
|
|
88
94
|
});
|
|
89
95
|
|
|
90
|
-
test("does not stamp
|
|
96
|
+
test("does not stamp anything once a real manifest exists", async () => {
|
|
91
97
|
await writeManifest("/assets/hydrate-abc.js", "/assets/styles-abc.css");
|
|
92
98
|
const html = await readHtmlTemplate();
|
|
93
99
|
expect(html).toContain("/assets/hydrate-abc.js");
|
|
94
|
-
expect(html).not.toContain(
|
|
100
|
+
expect(html).not.toContain("x-client-build-status");
|
|
95
101
|
});
|
|
96
102
|
});
|
|
@@ -29,6 +29,18 @@
|
|
|
29
29
|
* Re-stat'ing the tiny manifest per request is negligible (OS-cached, no
|
|
30
30
|
* network); the substituted HTML is reused while the mtime is unchanged.
|
|
31
31
|
*
|
|
32
|
+
* NO SERVED HTML WITHOUT A VERIFIED MANIFEST: when the manifest is absent,
|
|
33
|
+
* unreadable, or names files that do not exist, this module THROWS rather
|
|
34
|
+
* than emitting HTML — the fixed-name fallback (`/assets/styles.css`,
|
|
35
|
+
* `/assets/hydrate.js`) it once served names files NOTHING builds, so a
|
|
36
|
+
* 200 page with those URLs was a stylesheet 404 wearing a working page's
|
|
37
|
+
* clothes. Measured 2026-09-01: proposals.scala.business served a
|
|
38
|
+
* client-facing proposal unstyled while Railway reported the deploy
|
|
39
|
+
* SUCCESS. The readiness flip now gates on `verifyClientBuildAssets`
|
|
40
|
+
* (build-client-assets/manifest.ts), so this throw is defence in depth for
|
|
41
|
+
* a manifest that vanishes mid-process; the page-server render catch turns
|
|
42
|
+
* it into the branded error page, never a 200 with dead URLs.
|
|
43
|
+
*
|
|
32
44
|
* NO-SILENT-REVERTS: when the manifest is a TOMBSTONE (the last client build
|
|
33
45
|
* failed), the served HTML is stamped with a `<meta name="x-client-build-status"
|
|
34
46
|
* content="failed">` tag in <head> so any operator / chrome-devtools / theme
|
|
@@ -40,7 +52,6 @@
|
|
|
40
52
|
*/
|
|
41
53
|
|
|
42
54
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
43
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
44
55
|
import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
|
|
45
56
|
import { join } from "node:path";
|
|
46
57
|
|
|
@@ -49,11 +60,6 @@ interface AssetManifest {
|
|
|
49
60
|
hydratePath: string;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
const FALLBACK: AssetManifest = {
|
|
53
|
-
stylesPath: "/assets/styles.css",
|
|
54
|
-
hydratePath: "/assets/hydrate.js",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
63
|
const PAGE_DOCUMENT = `<!doctype html>
|
|
58
64
|
<html lang="en">
|
|
59
65
|
<head>
|
|
@@ -95,54 +101,46 @@ async function manifestMtimeMs(): Promise<number> {
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
async function loadManifest(): Promise<AssetManifest | null> {
|
|
104
|
+
// Pure read: this function must NOT throw on a missing or unreadable
|
|
105
|
+
// manifest, and must NOT depend on the app logger (other suites mock it
|
|
106
|
+
// with a partial shape). The gate's `boot.client-build-failed` event is
|
|
107
|
+
// the single home for the diagnostic — adding a warn here duplicates it
|
|
108
|
+
// and risks a missing method on a mocked logger.
|
|
98
109
|
const path = manifestPath();
|
|
99
|
-
if (await Bun.file(path).exists())
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
getAppLogger().warn(
|
|
106
|
-
`[read-html-template] asset manifest at ${path} is missing stylesPath/hydratePath — serving unbuilt fallback URLs`,
|
|
107
|
-
);
|
|
108
|
-
} catch (error) {
|
|
109
|
-
getAppLogger().warn(
|
|
110
|
-
`[read-html-template] asset manifest at ${path} is unreadable — serving unbuilt fallback URLs`,
|
|
111
|
-
error,
|
|
112
|
-
);
|
|
110
|
+
if (!(await Bun.file(path).exists())) return null;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
|
|
113
|
+
if (parsed.stylesPath && parsed.hydratePath) {
|
|
114
|
+
return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
|
|
113
115
|
}
|
|
114
|
-
|
|
116
|
+
} catch {
|
|
117
|
+
// fall through to null — gate + boot log will name the reason.
|
|
115
118
|
}
|
|
116
|
-
getAppLogger().warn(
|
|
117
|
-
`[read-html-template] no asset manifest at ${path} — the client build did not run for this deployment; serving unbuilt fallback URLs that will 404`,
|
|
118
|
-
);
|
|
119
119
|
return null;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
function manifestCacheKey(): string {
|
|
123
|
+
// Keying by the manifest path means two testdirs (or two concurrent
|
|
124
|
+
// services in one process, e.g. a fleet supervisor probe) cannot poison
|
|
125
|
+
// each other's cache — absent and present manifests carry different paths.
|
|
126
|
+
return manifestPath();
|
|
127
|
+
}
|
|
128
|
+
|
|
122
129
|
export async function readHtmlTemplate(): Promise<string> {
|
|
123
130
|
const manifestMtime = await manifestMtimeMs();
|
|
124
|
-
const cached = htmlCache.get(
|
|
131
|
+
const cached = htmlCache.get(manifestCacheKey());
|
|
125
132
|
if (cached && cached.manifestMtime === manifestMtime) return cached.html;
|
|
126
133
|
const manifest = await loadManifest();
|
|
127
|
-
let html = PAGE_DOCUMENT
|
|
128
|
-
.replace(/\{\{stylesPath\}\}/g, (manifest ?? FALLBACK).stylesPath)
|
|
129
|
-
.replace(/\{\{hydratePath\}\}/g, (manifest ?? FALLBACK).hydratePath);
|
|
130
|
-
// An ABSENT manifest is a failed client build wearing a working service's
|
|
131
|
-
// clothes: boot succeeds, SSR renders, every response is 200, and the only
|
|
132
|
-
// symptom is a stylesheet URL that was never built. Measured 2026-09-01 —
|
|
133
|
-
// proposals.scala.business served a client-facing proposal unstyled while
|
|
134
|
-
// Railway reported the deploy SUCCESS, because the fallback silently
|
|
135
|
-
// substitutes unhashed names for assets no build produced. The tombstone
|
|
136
|
-
// below already exists for a build that FAILED loudly; a build that never
|
|
137
|
-
// ran must be equally visible, or a broken deployment is indistinguishable
|
|
138
|
-
// from a working one at every layer that reports on it
|
|
139
|
-
// (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
140
134
|
if (!manifest) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
135
|
+
throw new Error(
|
|
136
|
+
`[read-html-template] no servable asset manifest (${manifestPath()}) — refusing to emit HTML referencing unbuilt asset URLs. ` +
|
|
137
|
+
`The readiness gate (verifyClientBuildAssets) should have held this service unready; a manifest disappearing after boot is the remaining path. ` +
|
|
138
|
+
`Fix the client build and restart.`,
|
|
139
|
+
);
|
|
145
140
|
}
|
|
141
|
+
let html = PAGE_DOCUMENT
|
|
142
|
+
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
143
|
+
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
|
|
146
144
|
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
147
145
|
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
148
146
|
// JS dependency, visible via view-source / devtools / a future status
|
|
@@ -155,6 +153,6 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
155
153
|
? html.replace("</head>", `${meta}</head>`)
|
|
156
154
|
: `${meta}${html}`;
|
|
157
155
|
}
|
|
158
|
-
htmlCache.set(
|
|
156
|
+
htmlCache.set(manifestCacheKey(), { html, manifestMtime });
|
|
159
157
|
return html;
|
|
160
158
|
}
|
|
@@ -79,4 +79,39 @@ describe("phase-2 step isolation", () => {
|
|
|
79
79
|
// original invisibility from the other direction.
|
|
80
80
|
expect(body.slice(guardCatch, guardCatch + 400)).toContain("log.error");
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* THE 2026-09-01 REGRESSION (the unstyled-proposal incident). The phase-1
|
|
85
|
+
* catch made the build failure non-fatal, and the readiness flip
|
|
86
|
+
* (setPageHandler / markFullAppReady) ran unconditionally afterwards — so a
|
|
87
|
+
* deployment whose client build never ran flipped ready, served pages, and
|
|
88
|
+
* answered /health 200 while every asset URL 404'd. Railway reported the
|
|
89
|
+
* deploy SUCCESS. The flip must be gated on a VERIFIED manifest, and the
|
|
90
|
+
* gate must sit AFTER mountPgBoss so background work still mounts (the
|
|
91
|
+
* isolation above), and BEFORE both flip call sites.
|
|
92
|
+
*/
|
|
93
|
+
test("readiness flips only after the client build verifies, and the gate sits after the jobs", () => {
|
|
94
|
+
const body = buildFullAppBody();
|
|
95
|
+
const boss = body.indexOf("mountPgBoss(");
|
|
96
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
97
|
+
const flipBlock = body.indexOf("if (deps.createPageHandler)");
|
|
98
|
+
expect(boss).toBeGreaterThan(-1);
|
|
99
|
+
expect(gate).toBeGreaterThan(boss);
|
|
100
|
+
expect(flipBlock).toBeGreaterThan(gate);
|
|
101
|
+
|
|
102
|
+
// BOTH flip call sites are after the gate: the SSR path
|
|
103
|
+
// (setPageHandler) and the client-only path (markFullAppReady).
|
|
104
|
+
const setPageHandlerCall = body.indexOf("setPageHandler(deps.createPageHandler)");
|
|
105
|
+
const markFullAppReadyCall = body.indexOf("markFullAppReady()");
|
|
106
|
+
expect(setPageHandlerCall).toBeGreaterThan(gate);
|
|
107
|
+
expect(markFullAppReadyCall).toBeGreaterThan(gate);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a failed verification throws — the boot never continues past an unverified manifest", () => {
|
|
111
|
+
const body = buildFullAppBody();
|
|
112
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
113
|
+
const slice = body.slice(gate, gate + 1200);
|
|
114
|
+
expect(slice).toContain("!clientBuild.ok");
|
|
115
|
+
expect(slice).toContain("throw new Error");
|
|
116
|
+
});
|
|
82
117
|
});
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
60
60
|
import type { Hono } from "hono";
|
|
61
61
|
import { buildClientAssets } from "./build-client-assets.ts";
|
|
62
|
+
import { verifyClientBuildAssets } from "./build-client-assets/manifest.ts";
|
|
62
63
|
import { mountPgBoss } from "./pg-boss-mount.ts";
|
|
63
64
|
import { swapApp } from "./mutable-app-ref.ts";
|
|
64
65
|
import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
|
|
@@ -112,6 +113,33 @@ export async function buildFullApp(
|
|
|
112
113
|
);
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
// The readiness flip is gated on a VERIFIED client build, never on "phase 2
|
|
117
|
+
// got this far". buildClientAssets's failure above is deliberately non-fatal
|
|
118
|
+
// for background work — but a deployment whose client build never ran (or
|
|
119
|
+
// whose tombstone names unbuilt files) must not flip ready and serve pages:
|
|
120
|
+
// every served asset URL would 404 while /health and the deploy report ok.
|
|
121
|
+
// Measured 2026-09-01: proposals.scala.business served a client-facing
|
|
122
|
+
// proposal unstyled while Railway reported SUCCESS, because the phase-1
|
|
123
|
+
// catch let boot continue past the build and the flip never checked the
|
|
124
|
+
// build's outcome (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
125
|
+
// The throw lands in ui-service's boot.client-build-failed catch, which
|
|
126
|
+
// keeps the minimal app: pages and /health stay 503 (cleanly unavailable)
|
|
127
|
+
// while the pg-boss jobs mounted above KEEP RUNNING. On Railway the
|
|
128
|
+
// healthcheck (healthcheckPath: /health) then fails and the previous good
|
|
129
|
+
// deployment keeps traffic — the deploy reports FAILED, never SUCCESS.
|
|
130
|
+
// readiness-gated-boot: a service is "fully-ready or cleanly-unavailable";
|
|
131
|
+
// a UI service that cannot serve its bundle is not ready
|
|
132
|
+
// (`liveness-by-capability-not-process-existence`).
|
|
133
|
+
const clientBuild = await verifyClientBuildAssets();
|
|
134
|
+
if (!clientBuild.ok) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`[two-phase-boot:${serviceSlug}] refusing readiness — ${clientBuild.reason}. ` +
|
|
137
|
+
`Serving pages here would emit asset URLs that 404 while every health surface reports ok. ` +
|
|
138
|
+
`Background work is mounted and unaffected; pages and /health stay unavailable until a real client build exists. ` +
|
|
139
|
+
`Fix the client build (see build-client-assets logs above) and restart.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
115
143
|
log.info(
|
|
116
144
|
`[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
|
|
117
145
|
`${Object.keys(components).length} component(s)`,
|