@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.
|
|
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.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "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
|
|
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/* →
|
|
6
|
-
// /, /index.html →
|
|
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 →
|
|
9
|
+
// alles andere → none
|
|
10
10
|
|
|
11
11
|
import { describe, expect, test } from "bun:test";
|
|
12
|
-
import {
|
|
12
|
+
import { cacheControlHeader } from "@cosmicdrift/kumiko-framework/api";
|
|
13
|
+
import { staticCachePolicy } from "../run-prod-app";
|
|
13
14
|
|
|
14
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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("/ →
|
|
34
|
-
expect(
|
|
35
|
-
"cache-control": "
|
|
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 →
|
|
40
|
-
expect(
|
|
41
|
-
"cache-control": "
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
70
|
+
expect(cacheControlFor("/favicon.ico")).toEqual({});
|
|
65
71
|
});
|
|
66
72
|
|
|
67
73
|
test("public-folder file (og-image) → kein expliziter Header", () => {
|
|
68
|
-
expect(
|
|
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(
|
|
75
|
-
expect(
|
|
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
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
-
|
|
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
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
1201
|
-
// unveränderlich, der Rest bleibt no-cache damit Updates ohne
|
|
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
|
|
1247
|
+
export function staticCachePolicy(pathname: string): CachePolicy {
|
|
1205
1248
|
if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
|
|
1206
|
-
return {
|
|
1249
|
+
return { kind: "immutable" };
|
|
1207
1250
|
}
|
|
1208
1251
|
if (pathname === "/" || pathname === "/index.html") {
|
|
1209
|
-
return {
|
|
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 {
|
|
1262
|
+
return { kind: "no-cache" };
|
|
1220
1263
|
}
|
|
1221
|
-
return {};
|
|
1264
|
+
return { kind: "none" };
|
|
1222
1265
|
}
|
|
1223
1266
|
|
|
1224
1267
|
function buildProdSessionAuth(
|