@cosmicdrift/kumiko-dev-server 0.57.2 → 0.59.1

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.57.2",
3
+ "version": "0.59.1",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -46,8 +46,8 @@
46
46
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
47
47
  },
48
48
  "dependencies": {
49
- "@cosmicdrift/kumiko-bundled-features": "0.55.1",
50
- "@cosmicdrift/kumiko-framework": "0.55.1",
49
+ "@cosmicdrift/kumiko-bundled-features": "0.57.2",
50
+ "@cosmicdrift/kumiko-framework": "0.57.2",
51
51
  "ts-morph": "^28.0.0"
52
52
  },
53
53
  "publishConfig": {
@@ -13,6 +13,7 @@ import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import {
15
15
  type ClientEntry,
16
+ computeBuildId,
16
17
  discoverClientEntries,
17
18
  discoverClientEntry,
18
19
  discoverHtmlTemplate,
@@ -260,3 +261,51 @@ describe("build-prod-bundle/injectAssetTags", () => {
260
261
  );
261
262
  });
262
263
  });
264
+
265
+ describe("build-prod-bundle/computeBuildId", () => {
266
+ test("identisches Manifest → gleiche id", () => {
267
+ const manifest = {
268
+ "client.js": "/assets/client-abc.js",
269
+ "styles.css": "/assets/styles-xyz.css",
270
+ };
271
+ expect(computeBuildId(manifest)).toBe(computeBuildId({ ...manifest }));
272
+ });
273
+
274
+ test("Key-Reihenfolge egal — nur die URL-Werte zählen", () => {
275
+ const a = { "client.js": "/assets/client-abc.js", "styles.css": "/assets/styles-xyz.css" };
276
+ const b = { "styles.css": "/assets/styles-xyz.css", "client.js": "/assets/client-abc.js" };
277
+ expect(computeBuildId(a)).toBe(computeBuildId(b));
278
+ });
279
+
280
+ test("geändertes Asset (neuer Hash) → andere id", () => {
281
+ const before = { "client.js": "/assets/client-abc.js" };
282
+ const after = { "client.js": "/assets/client-def.js" };
283
+ expect(computeBuildId(before)).not.toBe(computeBuildId(after));
284
+ });
285
+
286
+ test("12 Hex-Zeichen", () => {
287
+ expect(computeBuildId({ "client.js": "/assets/client-abc.js" })).toMatch(/^[0-9a-f]{12}$/);
288
+ });
289
+ });
290
+
291
+ describe("build-prod-bundle/injectAssetTags build-info", () => {
292
+ test("bäckt window.__KUMIKO_BUILD__ vor </head> wenn buildInfo gesetzt", () => {
293
+ const html = `<html><head><title>x</title></head><body><script type="module" src="/client.js"></script></body></html>`;
294
+ const result = injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry(), {
295
+ id: "deadbeef0000",
296
+ builtAt: "2026-06-18T12:00:00.000Z",
297
+ });
298
+
299
+ expect(result).toContain(
300
+ 'window.__KUMIKO_BUILD__={"id":"deadbeef0000","builtAt":"2026-06-18T12:00:00.000Z"}',
301
+ );
302
+ expect(result.indexOf("__KUMIKO_BUILD__")).toBeLessThan(result.indexOf("</head>"));
303
+ });
304
+
305
+ test("ohne buildInfo kein __KUMIKO_BUILD__-Script", () => {
306
+ const html = `<html><head></head><body><script type="module" src="/client.js"></script></body></html>`;
307
+ const result = injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry());
308
+
309
+ expect(result).not.toContain("__KUMIKO_BUILD__");
310
+ });
311
+ });
@@ -4,7 +4,8 @@
4
4
  // Strategie (siehe run-prod-app.ts):
5
5
  // /assets/* → max-age=31536000, immutable
6
6
  // /, /index.html → no-cache, must-revalidate
7
- // /manifest.json, /sw.js → no-cache
7
+ // /manifest.json, /sw.js,
8
+ // /build-info.json → no-cache
8
9
  // alles andere → kein expliziter Header
9
10
 
10
11
  import { describe, expect, test } from "bun:test";
@@ -53,6 +54,12 @@ describe("cacheHeadersFor", () => {
53
54
  });
54
55
  });
55
56
 
57
+ test("/build-info.json → no-cache (sonst pollt UpdateChecker eine veraltete id)", () => {
58
+ expect(cacheHeadersFor("/build-info.json")).toEqual({
59
+ "cache-control": "no-cache",
60
+ });
61
+ });
62
+
56
63
  test("public-folder file (favicon) → kein expliziter Header", () => {
57
64
  expect(cacheHeadersFor("/favicon.ico")).toEqual({});
58
65
  });
@@ -71,6 +71,20 @@ export type BuildResult = {
71
71
  * { "client.js": "/assets/client-a3f2.js",
72
72
  * "styles.css": "/assets/styles-9b4c.css" } */
73
73
  readonly manifest: BuildManifest;
74
+ /** Build-Identität (Hash über die Asset-URLs) + lesbarer Zeitstempel.
75
+ * undefined bei vanilla public-only-Builds (kein Bundle). */
76
+ readonly buildInfo?: BuildInfo;
77
+ };
78
+
79
+ /** In index.html gebacken (`window.__KUMIKO_BUILD__`) UND als
80
+ * dist/build-info.json geschrieben. Der UpdateChecker pollt build-info.json
81
+ * und vergleicht `id` gegen den geladenen Stand → Reload-Banner bei Drift. */
82
+ export type BuildInfo = {
83
+ /** Hash über die sortierten (content-gehashten) Asset-URLs. Ändert sich
84
+ * gdw. sich ein Asset ändert — selbsttragend, kein Env-Var/Dockerfile. */
85
+ readonly id: string;
86
+ /** ISO-Zeitstempel des Builds, lesbare Anzeige-Version (ersetzt den rohen sha). */
87
+ readonly builtAt: string;
74
88
  };
75
89
 
76
90
  // Default-HTML wird nur genutzt wenn der App-Author KEIN index.html liefert.
@@ -166,6 +180,17 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
166
180
  await copyPublicFolder(publicDir, outDir, templateBasenames);
167
181
  }
168
182
 
183
+ // 5b. Build-Identität: Hash über die content-gehashten Asset-URLs +
184
+ // lesbarer Zeitstempel. Wird in index.html gebacken und als
185
+ // build-info.json geschrieben (Update-Awareness, default an).
186
+ // ponytail: id hängt nur an Assets — ein reiner Server-Deploy ohne
187
+ // Client-Bundle-Change behält die id → kein Banner (die UI ist dann
188
+ // nicht stale). Bei Bedarf an Server-Versionierung koppeln.
189
+ const buildInfo: BuildInfo | undefined =
190
+ Object.keys(manifest).length > 0
191
+ ? { id: computeBuildId(manifest), builtAt: new Date().toISOString() }
192
+ : undefined;
193
+
169
194
  // 6. HTML pro Entry rendern. Convention: ein HTML-File pro Client-Entry,
170
195
  // jede mit ihrem eigenen Script-Tag. Server (runProdApp.hostDispatch)
171
196
  // serviert je nach Host das passende File.
@@ -176,16 +201,24 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
176
201
  for (const entry of clientEntries) {
177
202
  const templatePath = resolve(cwd, entry.htmlPath);
178
203
  const templateExists = existsSync(templatePath);
179
- const html = await renderHtml(templateExists ? templatePath : undefined, manifest, entry);
204
+ const html = await renderHtml(
205
+ templateExists ? templatePath : undefined,
206
+ manifest,
207
+ entry,
208
+ buildInfo,
209
+ );
180
210
  const outFile = basenameOf(entry.htmlPath);
181
211
  await writeFile(join(outDir, outFile), html);
182
212
  }
183
213
  }
184
214
 
185
- // 7. Manifest.
215
+ // 7. Manifest + Build-Info.
186
216
  await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
217
+ if (buildInfo) {
218
+ await writeFile(join(outDir, "build-info.json"), `${JSON.stringify(buildInfo)}\n`);
219
+ }
187
220
 
188
- return { outDir, manifest };
221
+ return { outDir, manifest, ...(buildInfo && { buildInfo }) };
189
222
  }
190
223
 
191
224
  // ---------------------------------------------------------------------------
@@ -493,6 +526,7 @@ async function renderHtml(
493
526
  templatePath: string | undefined,
494
527
  manifest: BuildManifest,
495
528
  entry: ClientEntry,
529
+ buildInfo: BuildInfo | undefined,
496
530
  ): Promise<string> {
497
531
  // Edge-Case: kein eigenes HTML-Template + Bun.build oder Tailwind hat
498
532
  // Output produziert. DEFAULT_HTML hat keine Placeholder (vanilla
@@ -502,7 +536,7 @@ async function renderHtml(
502
536
  throw new Error(buildMissingTemplateError(manifest, entry));
503
537
  }
504
538
  const template = templatePath ? await readFile(templatePath, "utf8") : DEFAULT_HTML;
505
- return injectAssetTags(template, manifest, entry);
539
+ return injectAssetTags(template, manifest, entry, buildInfo);
506
540
  }
507
541
 
508
542
  function buildMissingTemplateError(manifest: BuildManifest, entry: ClientEntry): string {
@@ -545,9 +579,21 @@ function buildMissingTemplateError(manifest: BuildManifest, entry: ClientEntry):
545
579
  // Fehlt ein erwarteter Tag, wirft der Build einen Fehler mit dem exakten
546
580
  // Snippet zum Reinkopieren — kein silent injection mehr, weil das den
547
581
  // Diff zwischen Dev- und Prod-HTML unsichtbar macht.
548
- export function injectAssetTags(html: string, manifest: BuildManifest, entry: ClientEntry): string {
582
+ export function injectAssetTags(
583
+ html: string,
584
+ manifest: BuildManifest,
585
+ entry: ClientEntry,
586
+ buildInfo?: BuildInfo,
587
+ ): string {
549
588
  let result = html;
550
589
 
590
+ // Build-Stand vor </head> backen. id/builtAt sind Hex bzw. ISO → kein
591
+ // </script>-Escaping nötig. Ohne </head> (z.B. Fragment) still skip.
592
+ if (buildInfo && result.includes("</head>")) {
593
+ const tag = `<script>window.__KUMIKO_BUILD__=${JSON.stringify(buildInfo)};</script>`;
594
+ result = result.replace("</head>", ` ${tag}\n </head>`);
595
+ }
596
+
551
597
  const cssUrl = manifest["styles.css"];
552
598
  if (cssUrl && !result.includes(cssUrl)) {
553
599
  const placeholder = /<link\s+rel="stylesheet"\s+href="\/styles\.css"\s*\/?>/.exec(result);
@@ -617,6 +663,17 @@ function shortHash(content: string | Uint8Array): string {
617
663
  return createHash("sha256").update(content).digest("hex").slice(0, 8);
618
664
  }
619
665
 
666
+ // Build-Identität aus den content-gehashten Asset-URLs. Sortiert →
667
+ // deterministisch (Manifest-Key-Reihenfolge egal). Identischer Source →
668
+ // gleiche id, geändertes Asset → andere id.
669
+ // @internal — exported nur für Unit-Tests.
670
+ export function computeBuildId(manifest: BuildManifest): string {
671
+ return createHash("sha256")
672
+ .update(Object.values(manifest).sort().join("\n"))
673
+ .digest("hex")
674
+ .slice(0, 12);
675
+ }
676
+
620
677
  // ---------------------------------------------------------------------------
621
678
  // CLI output
622
679
  // ---------------------------------------------------------------------------
@@ -1161,7 +1161,14 @@ export function cacheHeadersFor(pathname: string): Record<string, string> {
1161
1161
  if (pathname === "/" || pathname === "/index.html") {
1162
1162
  return { "cache-control": "no-cache, must-revalidate" };
1163
1163
  }
1164
- if (pathname === "/manifest.json" || pathname === "/sw.js") {
1164
+ if (
1165
+ pathname === "/manifest.json" ||
1166
+ pathname === "/sw.js" ||
1167
+ // ponytail: build-info.json ist statisch — kein /api/version-Endpoint
1168
+ // nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
1169
+ // UpdateChecker eine veraltete id.
1170
+ pathname === "/build-info.json"
1171
+ ) {
1165
1172
  return { "cache-control": "no-cache" };
1166
1173
  }
1167
1174
  return {};