@cosmicdrift/kumiko-dev-server 0.85.0 → 0.87.0

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.85.0",
3
+ "version": "0.87.0",
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>",
@@ -50,8 +50,8 @@
50
50
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "0.85.0",
54
- "@cosmicdrift/kumiko-framework": "0.85.0",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.87.0",
54
+ "@cosmicdrift/kumiko-framework": "0.87.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -1,77 +1,83 @@
1
- // Unit-Tests für cacheHeadersFor — die Cache-Strategie hinter
1
+ // Unit-Tests für staticCachePolicy — die Cache-Strategie hinter
2
2
  // runProdApp's static-fallback. Pure function, leicht zu testen.
3
3
  //
4
4
  // Strategie (siehe run-prod-app.ts):
5
- // /assets/* → max-age=31536000, immutable
6
- // /, /index.html → no-cache, must-revalidate
5
+ // /assets/* → immutable
6
+ // /, /index.html → revalidate (max-age=0, must-revalidate)
7
7
  // /manifest.json, /sw.js,
8
8
  // /build-info.json → no-cache
9
- // alles andere → kein expliziter Header
9
+ // alles andere → none
10
10
 
11
11
  import { describe, expect, test } from "bun:test";
12
- import { cacheHeadersFor } from "../run-prod-app";
12
+ import { cacheControlHeader } from "@cosmicdrift/kumiko-framework/api";
13
+ import { staticCachePolicy } from "../run-prod-app";
13
14
 
14
- describe("cacheHeadersFor", () => {
15
+ function cacheControlFor(pathname: string): Record<string, string> {
16
+ const header = cacheControlHeader(staticCachePolicy(pathname));
17
+ return header === undefined ? {} : { "cache-control": header };
18
+ }
19
+
20
+ describe("staticCachePolicy", () => {
15
21
  test("hashed asset → immutable + 1 Jahr", () => {
16
- expect(cacheHeadersFor("/assets/client-abc123.js")).toEqual({
22
+ expect(cacheControlFor("/assets/client-abc123.js")).toEqual({
17
23
  "cache-control": "public, max-age=31536000, immutable",
18
24
  });
19
25
  });
20
26
 
21
27
  test("hashed CSS asset → immutable + 1 Jahr", () => {
22
- expect(cacheHeadersFor("/assets/styles-def456.css")).toEqual({
28
+ expect(cacheControlFor("/assets/styles-def456.css")).toEqual({
23
29
  "cache-control": "public, max-age=31536000, immutable",
24
30
  });
25
31
  });
26
32
 
27
33
  test("nested asset path → immutable", () => {
28
- expect(cacheHeadersFor("/assets/chunks/foo-789.js")).toEqual({
34
+ expect(cacheControlFor("/assets/chunks/foo-789.js")).toEqual({
29
35
  "cache-control": "public, max-age=31536000, immutable",
30
36
  });
31
37
  });
32
38
 
33
- test("/ → no-cache, must-revalidate", () => {
34
- expect(cacheHeadersFor("/")).toEqual({
35
- "cache-control": "no-cache, must-revalidate",
39
+ test("/ → revalidate", () => {
40
+ expect(cacheControlFor("/")).toEqual({
41
+ "cache-control": "public, max-age=0, must-revalidate",
36
42
  });
37
43
  });
38
44
 
39
- test("/index.html → no-cache, must-revalidate", () => {
40
- expect(cacheHeadersFor("/index.html")).toEqual({
41
- "cache-control": "no-cache, must-revalidate",
45
+ test("/index.html → revalidate", () => {
46
+ expect(cacheControlFor("/index.html")).toEqual({
47
+ "cache-control": "public, max-age=0, must-revalidate",
42
48
  });
43
49
  });
44
50
 
45
51
  test("/manifest.json → no-cache", () => {
46
- expect(cacheHeadersFor("/manifest.json")).toEqual({
52
+ expect(cacheControlFor("/manifest.json")).toEqual({
47
53
  "cache-control": "no-cache",
48
54
  });
49
55
  });
50
56
 
51
57
  test("/sw.js → no-cache", () => {
52
- expect(cacheHeadersFor("/sw.js")).toEqual({
58
+ expect(cacheControlFor("/sw.js")).toEqual({
53
59
  "cache-control": "no-cache",
54
60
  });
55
61
  });
56
62
 
57
63
  test("/build-info.json → no-cache (sonst pollt UpdateChecker eine veraltete id)", () => {
58
- expect(cacheHeadersFor("/build-info.json")).toEqual({
64
+ expect(cacheControlFor("/build-info.json")).toEqual({
59
65
  "cache-control": "no-cache",
60
66
  });
61
67
  });
62
68
 
63
69
  test("public-folder file (favicon) → kein expliziter Header", () => {
64
- expect(cacheHeadersFor("/favicon.ico")).toEqual({});
70
+ expect(cacheControlFor("/favicon.ico")).toEqual({});
65
71
  });
66
72
 
67
73
  test("public-folder file (og-image) → kein expliziter Header", () => {
68
- expect(cacheHeadersFor("/og-image.png")).toEqual({});
74
+ expect(cacheControlFor("/og-image.png")).toEqual({});
69
75
  });
70
76
 
71
77
  test("path mit assets als Substring (kein /assets/ prefix) → kein immutable", () => {
72
78
  // Schutz: /myassets/foo.js soll NICHT immutable kriegen — wäre ein Bug
73
79
  // weil die nicht gehashed sind.
74
- expect(cacheHeadersFor("/myassets/foo.js")).toEqual({});
75
- expect(cacheHeadersFor("/foo/assets/bar.js")).toEqual({});
80
+ expect(cacheControlFor("/myassets/foo.js")).toEqual({});
81
+ expect(cacheControlFor("/foo/assets/bar.js")).toEqual({});
76
82
  });
77
83
  });
@@ -361,6 +361,43 @@ describe("runProdApp", () => {
361
361
  const res = await handle.fetch(new Request("http://test/robots.txt"));
362
362
  expect(res.status).toBe(200);
363
363
  expect(await res.text()).toContain("User-agent: *");
364
+ expect(res.headers.get("etag")).toBeTruthy();
365
+ });
366
+
367
+ test("static-fallback: If-None-Match → 304 on disk file", async () => {
368
+ const tmpStaticDir = await createTempStaticDir({
369
+ "robots.txt": "User-agent: *\nAllow: /",
370
+ "index.html": "<html>SPA shell</html>",
371
+ });
372
+
373
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
374
+ const first = await handle.fetch(new Request("http://test/robots.txt"));
375
+ const etag = first.headers.get("etag");
376
+ expect(etag).toBeTruthy();
377
+
378
+ const second = await handle.fetch(
379
+ new Request("http://test/robots.txt", { headers: { "if-none-match": etag ?? "" } }),
380
+ );
381
+ expect(second.status).toBe(304);
382
+ expect(await second.text()).toBe("");
383
+ });
384
+
385
+ test("static-fallback: If-None-Match → 304 on SPA index.html", async () => {
386
+ const tmpStaticDir = await createTempStaticDir({
387
+ "index.html": "<html>SPA shell</html>",
388
+ });
389
+
390
+ const handle = await boot(undefined, { staticDir: tmpStaticDir });
391
+ const first = await handle.fetch(new Request("http://test/some/spa/route"));
392
+ const etag = first.headers.get("etag");
393
+ expect(etag).toBeTruthy();
394
+
395
+ const second = await handle.fetch(
396
+ new Request("http://test/some/spa/route", {
397
+ headers: { "if-none-match": etag ?? "" },
398
+ }),
399
+ );
400
+ expect(second.status).toBe(304);
364
401
  });
365
402
 
366
403
  test("static-fallback: unknown path → SPA-fallback to index.html", async () => {
@@ -52,6 +52,8 @@ const RUNTIME_EXTERNALS = [
52
52
  "ioredis",
53
53
  "postgres",
54
54
  "temporal-polyfill",
55
+ "pino",
56
+ "pino-pretty",
55
57
  ] as const;
56
58
 
57
59
  // Pakete die nur im Build-Stack erscheinen (transitive Imports im Framework),
@@ -61,8 +63,6 @@ const RUNTIME_EXTERNALS = [
61
63
  // runtime-deps.
62
64
  const BUILD_ONLY_EXTERNALS = [
63
65
  "meilisearch",
64
- "pino",
65
- "pino-pretty",
66
66
  "@planetscale/database",
67
67
  "@libsql/client",
68
68
  "better-sqlite3",
@@ -719,7 +719,7 @@ export async function createKumikoServer(
719
719
  // dev-server injiziert das in jede HTML-Response. Re-build NICHT
720
720
  // bei Hot-Reload weil sich Feature-Defs nur über einen restart
721
721
  // ändern.
722
- const appSchemaJson = JSON.stringify(buildAppSchema(stack.registry));
722
+ const appSchemaJson = JSON.stringify(buildAppSchema(stack.registry, { authoringWarnings: true }));
723
723
 
724
724
  // --- SSE reload ---
725
725
  // bootId identifiziert diese spezifische Server-Process-Instanz. Wird
@@ -44,7 +44,14 @@ import {
44
44
  import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/sessions";
45
45
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
46
46
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
47
- import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
47
+ import {
48
+ type CachePolicy,
49
+ cachedResponse,
50
+ computeStrongEtag,
51
+ computeWeakEtag,
52
+ createSseBroker,
53
+ type SseBroker,
54
+ } from "@cosmicdrift/kumiko-framework/api";
48
55
  import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
49
56
  import {
50
57
  buildAppSchema,
@@ -1022,17 +1029,38 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1022
1029
  // ~1 MB.
1023
1030
  async function readStaticFile(
1024
1031
  filePath: string,
1025
- ): Promise<{ readonly bytes: Uint8Array; readonly mime: string } | undefined> {
1032
+ ): Promise<
1033
+ { readonly bytes: Uint8Array; readonly mime: string; readonly mtimeMs: number } | undefined
1034
+ > {
1026
1035
  try {
1027
- const { readFile } = await import("node:fs/promises");
1028
- const bytes = await readFile(filePath);
1029
- return { bytes, mime: mimeTypeFor(filePath) };
1036
+ const { readFile, stat } = await import("node:fs/promises");
1037
+ const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
1038
+ return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
1030
1039
  } catch (err) {
1031
1040
  if ((err as { code?: string }).code === "ENOENT") return undefined;
1032
1041
  throw err;
1033
1042
  }
1034
1043
  }
1035
1044
 
1045
+ function serveDiskFile(
1046
+ req: Request,
1047
+ pathname: string,
1048
+ file: {
1049
+ readonly bytes: Uint8Array;
1050
+ readonly mime: string;
1051
+ readonly mtimeMs: number;
1052
+ },
1053
+ ): Response {
1054
+ return cachedResponse(req, {
1055
+ // @cast-boundary bun-types — Response BodyInit narrowing
1056
+ body: file.bytes as unknown as BodyInit,
1057
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
1058
+ cache: staticCachePolicy(pathname),
1059
+ headers: { "content-type": file.mime },
1060
+ lastModified: new Date(file.mtimeMs),
1061
+ });
1062
+ }
1063
+
1036
1064
  // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
1037
1065
  // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
1038
1066
  // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
@@ -1085,7 +1113,7 @@ function buildStaticFallback(
1085
1113
  async function readHtmlFile(
1086
1114
  path: string,
1087
1115
  injectSchemaInto: boolean,
1088
- ): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
1116
+ ): Promise<{ bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number } | null> {
1089
1117
  const file = await readStaticFile(path);
1090
1118
  if (!file) return null;
1091
1119
  if (!injectSchemaInto) {
@@ -1095,11 +1123,34 @@ function buildStaticFallback(
1095
1123
  file.bytes.byteOffset + file.bytes.byteLength,
1096
1124
  ) as ArrayBuffer,
1097
1125
  mime: file.mime,
1126
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
1127
+ mtimeMs: file.mtimeMs,
1098
1128
  };
1099
1129
  }
1100
1130
  const text = new TextDecoder().decode(file.bytes);
1101
1131
  const injected = injectSchema(text, appSchemaJson);
1102
- return { bytes: new TextEncoder().encode(injected).buffer as ArrayBuffer, mime: file.mime };
1132
+ const bytes = new TextEncoder().encode(injected).buffer as ArrayBuffer;
1133
+ return {
1134
+ bytes,
1135
+ mime: file.mime,
1136
+ etag: computeStrongEtag(new Uint8Array(bytes)),
1137
+ mtimeMs: file.mtimeMs,
1138
+ };
1139
+ }
1140
+
1141
+ function serveHtmlFile(
1142
+ req: Request,
1143
+ pathname: string,
1144
+ html: { bytes: ArrayBuffer; mime: string; etag: string; mtimeMs: number },
1145
+ extraHeaders?: Record<string, string>,
1146
+ ): Response {
1147
+ return cachedResponse(req, {
1148
+ body: html.bytes,
1149
+ etag: html.etag,
1150
+ cache: staticCachePolicy(pathname),
1151
+ headers: { "content-type": html.mime, ...extraHeaders },
1152
+ lastModified: new Date(html.mtimeMs),
1153
+ });
1103
1154
  }
1104
1155
 
1105
1156
  // hostDispatch konsultieren wenn gesetzt UND der Request auf den
@@ -1128,12 +1179,9 @@ function buildStaticFallback(
1128
1179
  // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
1129
1180
  return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
1130
1181
  }
1131
- const headers: Record<string, string> = {
1132
- ...cacheHeadersFor("/index.html"),
1133
- "content-type": html.mime,
1134
- };
1135
- if (result.csp) headers["content-security-policy"] = result.csp;
1136
- return new Response(html.bytes, { headers });
1182
+ const extraHeaders: Record<string, string> = {};
1183
+ if (result.csp) extraHeaders["content-security-policy"] = result.csp;
1184
+ return serveHtmlFile(req, "/index.html", html, extraHeaders);
1137
1185
  }
1138
1186
 
1139
1187
  return async (req: Request): Promise<Response> => {
@@ -1171,10 +1219,7 @@ function buildStaticFallback(
1171
1219
  const filePath = `${staticDir}/${relPath}`;
1172
1220
  const file = await readStaticFile(filePath);
1173
1221
  if (file) {
1174
- // @cast-boundary bun-types — Response BodyInit narrowing
1175
- return new Response(file.bytes as unknown as BodyInit, {
1176
- headers: { ...cacheHeadersFor(url.pathname), "content-type": file.mime },
1177
- });
1222
+ return serveDiskFile(req, url.pathname, file);
1178
1223
  }
1179
1224
  }
1180
1225
 
@@ -1186,9 +1231,7 @@ function buildStaticFallback(
1186
1231
  // Default Single-App-Pfad: index.html, schema injected.
1187
1232
  const index = await readHtmlFile(indexHtml, true);
1188
1233
  if (index) {
1189
- return new Response(index.bytes, {
1190
- headers: { ...cacheHeadersFor("/index.html"), "content-type": index.mime },
1191
- });
1234
+ return serveHtmlFile(req, "/index.html", index);
1192
1235
  }
1193
1236
 
1194
1237
  // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
@@ -1197,16 +1240,16 @@ function buildStaticFallback(
1197
1240
  };
1198
1241
  }
1199
1242
 
1200
- // Map URL-Pfad → Cache-Control. Hashed-Asset-Pfade (/assets/*) sind
1201
- // unveränderlich, der Rest bleibt no-cache damit Updates ohne Hard-
1202
- // Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
1243
+ // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
1244
+ // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
1245
+ // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
1203
1246
  // runProdApp.
1204
- export function cacheHeadersFor(pathname: string): Record<string, string> {
1247
+ export function staticCachePolicy(pathname: string): CachePolicy {
1205
1248
  if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
1206
- return { "cache-control": "public, max-age=31536000, immutable" };
1249
+ return { kind: "immutable" };
1207
1250
  }
1208
1251
  if (pathname === "/" || pathname === "/index.html") {
1209
- return { "cache-control": "no-cache, must-revalidate" };
1252
+ return { kind: "revalidate" };
1210
1253
  }
1211
1254
  if (
1212
1255
  pathname === "/manifest.json" ||
@@ -1216,9 +1259,9 @@ export function cacheHeadersFor(pathname: string): Record<string, string> {
1216
1259
  // UpdateChecker eine veraltete id.
1217
1260
  pathname === "/build-info.json"
1218
1261
  ) {
1219
- return { "cache-control": "no-cache" };
1262
+ return { kind: "no-cache" };
1220
1263
  }
1221
- return {};
1264
+ return { kind: "none" };
1222
1265
  }
1223
1266
 
1224
1267
  function buildProdSessionAuth(