@teamscala/service-boot 0.23.440 → 0.23.441

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.440",
9
+ "version": "0.23.441",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -73,4 +73,24 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
73
73
  expect(html).toContain("/assets/hydrate.js");
74
74
  expect(html).toContain("/assets/styles.css");
75
75
  });
76
+
77
+ // The fallback is correct as a LAST RESORT and dangerous as a SILENT one:
78
+ // the unhashed names it emits were never built, so the page 200s while its
79
+ // stylesheet 404s. Measured 2026-09-01 — a client-facing proposal served
80
+ // unstyled while the deploy reported SUCCESS, because nothing anywhere in
81
+ // the stack distinguished "client build never ran" from "client build fine".
82
+ // The stamp is what makes the two distinguishable to an operator, a probe,
83
+ // or a deploy gate, so it is asserted rather than left to inspection.
84
+ test("stamps the page when no manifest was built, so a dead deploy is visible", async () => {
85
+ const html = await readHtmlTemplate();
86
+ expect(html).toContain('name="x-client-build-status"');
87
+ expect(html).toContain('content="missing-manifest"');
88
+ });
89
+
90
+ test("does not stamp missing-manifest once a real manifest exists", async () => {
91
+ await writeManifest("/assets/hydrate-abc.js", "/assets/styles-abc.css");
92
+ const html = await readHtmlTemplate();
93
+ expect(html).toContain("/assets/hydrate-abc.js");
94
+ expect(html).not.toContain('content="missing-manifest"');
95
+ });
76
96
  });
@@ -40,6 +40,7 @@
40
40
  */
41
41
 
42
42
  import { createCache } from "@teamscala/cache/create-cache";
43
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
43
44
  import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
44
45
  import { join } from "node:path";
45
46
 
@@ -93,7 +94,7 @@ async function manifestMtimeMs(): Promise<number> {
93
94
  }
94
95
  }
95
96
 
96
- async function loadManifest(): Promise<AssetManifest> {
97
+ async function loadManifest(): Promise<AssetManifest | null> {
97
98
  const path = manifestPath();
98
99
  if (await Bun.file(path).exists()) {
99
100
  try {
@@ -101,10 +102,21 @@ async function loadManifest(): Promise<AssetManifest> {
101
102
  if (parsed.stylesPath && parsed.hydratePath) {
102
103
  return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
103
104
  }
104
- } catch (error) {}
105
- getAppLogger().warn("asset manifest unreadable — falling back to default template wiring", error);
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
+ );
113
+ }
114
+ return null;
106
115
  }
107
- return FALLBACK;
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
+ return null;
108
120
  }
109
121
 
110
122
  export async function readHtmlTemplate(): Promise<string> {
@@ -113,8 +125,24 @@ export async function readHtmlTemplate(): Promise<string> {
113
125
  if (cached && cached.manifestMtime === manifestMtime) return cached.html;
114
126
  const manifest = await loadManifest();
115
127
  let html = PAGE_DOCUMENT
116
- .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
117
- .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
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
+ if (!manifest) {
141
+ const meta = `<meta name="x-client-build-status" content="missing-manifest" data-reason="no dist/client/asset-manifest.json; asset URLs below are unbuilt and will 404">`;
142
+ html = html.includes("</head>")
143
+ ? html.replace("</head>", `${meta}</head>`)
144
+ : `${meta}${html}`;
145
+ }
118
146
  // Tombstone stamp — surfaces a failed client build at the served layer.
119
147
  // `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
120
148
  // JS dependency, visible via view-source / devtools / a future status