@cosmicdrift/kumiko-framework 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 +2 -2
- package/src/api/__tests__/http-cache.test.ts +150 -0
- package/src/api/http-cache.ts +120 -0
- package/src/api/index.ts +10 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +4 -11
- package/src/engine/build-app-schema.ts +11 -10
- package/src/engine/index.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.87.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
cacheControlHeader,
|
|
4
|
+
cachedResponse,
|
|
5
|
+
computeRevisionEtag,
|
|
6
|
+
computeStrongEtag,
|
|
7
|
+
computeWeakEtag,
|
|
8
|
+
etagMatches,
|
|
9
|
+
parseIfNoneMatch,
|
|
10
|
+
} from "../http-cache";
|
|
11
|
+
|
|
12
|
+
describe("computeWeakEtag", () => {
|
|
13
|
+
test("formats mtime-size weak tag", () => {
|
|
14
|
+
expect(computeWeakEtag(1_700_000_000_000, 4096)).toBe('W/"1700000000000-4096"');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("computeStrongEtag", () => {
|
|
19
|
+
test("same seed → same tag", () => {
|
|
20
|
+
expect(computeStrongEtag("hello")).toBe(computeStrongEtag("hello"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("different seed → different tag", () => {
|
|
24
|
+
expect(computeStrongEtag("hello")).not.toBe(computeStrongEtag("world"));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("computeRevisionEtag", () => {
|
|
29
|
+
test("stable for same parts", () => {
|
|
30
|
+
const parts = ["tenant-a", "about", "de", "3", "2026-01-01T00:00:00.000Z"];
|
|
31
|
+
expect(computeRevisionEtag(parts)).toBe(computeRevisionEtag(parts));
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("cacheControlHeader", () => {
|
|
36
|
+
test("immutable default", () => {
|
|
37
|
+
expect(cacheControlHeader({ kind: "immutable" })).toBe("public, max-age=31536000, immutable");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("revalidate default", () => {
|
|
41
|
+
expect(cacheControlHeader({ kind: "revalidate" })).toBe("public, max-age=0, must-revalidate");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("no-cache", () => {
|
|
45
|
+
expect(cacheControlHeader({ kind: "no-cache" })).toBe("no-cache");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("none → undefined", () => {
|
|
49
|
+
expect(cacheControlHeader({ kind: "none" })).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("parseIfNoneMatch", () => {
|
|
54
|
+
test("empty / null", () => {
|
|
55
|
+
expect(parseIfNoneMatch(null)).toEqual([]);
|
|
56
|
+
expect(parseIfNoneMatch("")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("single tag", () => {
|
|
60
|
+
expect(parseIfNoneMatch('"abc"')).toEqual(['"abc"']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("multiple tags", () => {
|
|
64
|
+
expect(parseIfNoneMatch(' "a" , W/"b" ')).toEqual(['"a"', 'W/"b"']);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("wildcard", () => {
|
|
68
|
+
expect(parseIfNoneMatch("*")).toEqual(["*"]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("etagMatches", () => {
|
|
73
|
+
const etag = '"deadbeef"';
|
|
74
|
+
|
|
75
|
+
test("no header → false", () => {
|
|
76
|
+
expect(etagMatches(null, etag)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("exact match", () => {
|
|
80
|
+
expect(etagMatches('"deadbeef"', etag)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("weak matches strong value", () => {
|
|
84
|
+
expect(etagMatches('W/"deadbeef"', etag)).toBe(true);
|
|
85
|
+
expect(etagMatches('"deadbeef"', 'W/"deadbeef"')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("wildcard", () => {
|
|
89
|
+
expect(etagMatches("*", etag)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("mismatch", () => {
|
|
93
|
+
expect(etagMatches('"other"', etag)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("cachedResponse", () => {
|
|
98
|
+
const etag = computeStrongEtag("body");
|
|
99
|
+
|
|
100
|
+
test("200 with body and headers", () => {
|
|
101
|
+
const res = cachedResponse(new Request("https://example.test/"), {
|
|
102
|
+
body: "hello",
|
|
103
|
+
etag,
|
|
104
|
+
cache: { kind: "revalidate" },
|
|
105
|
+
headers: { "content-type": "text/plain" },
|
|
106
|
+
});
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
expect(res.headers.get("etag")).toBe(etag);
|
|
109
|
+
expect(res.headers.get("cache-control")).toBe("public, max-age=0, must-revalidate");
|
|
110
|
+
expect(res.headers.get("content-type")).toBe("text/plain");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("304 when If-None-Match matches", async () => {
|
|
114
|
+
const req = new Request("https://example.test/", {
|
|
115
|
+
headers: { "if-none-match": etag },
|
|
116
|
+
});
|
|
117
|
+
const res = cachedResponse(req, {
|
|
118
|
+
body: "hello",
|
|
119
|
+
etag,
|
|
120
|
+
cache: { kind: "revalidate" },
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(304);
|
|
123
|
+
expect(res.headers.get("etag")).toBe(etag);
|
|
124
|
+
expect(await res.text()).toBe("");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("HEAD returns no body", async () => {
|
|
128
|
+
const res = cachedResponse(new Request("https://example.test/", { method: "HEAD" }), {
|
|
129
|
+
body: "hello",
|
|
130
|
+
etag,
|
|
131
|
+
cache: { kind: "revalidate" },
|
|
132
|
+
});
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
expect(await res.text()).toBe("");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("304 via If-Modified-Since", () => {
|
|
138
|
+
const lastModified = new Date("2026-06-01T12:00:00.000Z");
|
|
139
|
+
const req = new Request("https://example.test/", {
|
|
140
|
+
headers: { "if-modified-since": lastModified.toUTCString() },
|
|
141
|
+
});
|
|
142
|
+
const res = cachedResponse(req, {
|
|
143
|
+
body: "hello",
|
|
144
|
+
etag,
|
|
145
|
+
cache: { kind: "revalidate" },
|
|
146
|
+
lastModified,
|
|
147
|
+
});
|
|
148
|
+
expect(res.status).toBe(304);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type CachePolicy =
|
|
4
|
+
| { readonly kind: "immutable"; readonly maxAgeSeconds?: number }
|
|
5
|
+
| { readonly kind: "revalidate"; readonly maxAgeSeconds?: number }
|
|
6
|
+
| { readonly kind: "no-cache" }
|
|
7
|
+
| { readonly kind: "none" };
|
|
8
|
+
|
|
9
|
+
export type CachedResponseInit = {
|
|
10
|
+
readonly body: BodyInit | null;
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
readonly headers?: Record<string, string>;
|
|
13
|
+
readonly etag: string;
|
|
14
|
+
readonly cache: CachePolicy;
|
|
15
|
+
readonly lastModified?: Date;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_IMMUTABLE_MAX_AGE = 31_536_000;
|
|
19
|
+
const DEFAULT_REVALIDATE_MAX_AGE = 0;
|
|
20
|
+
|
|
21
|
+
function digestEtag(seed: string | Uint8Array): string {
|
|
22
|
+
const hash = createHash("sha256").update(seed).digest("base64url").slice(0, 22);
|
|
23
|
+
return `"${hash}"`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Weak ETag for disk files — cheap, good enough for static assets. */
|
|
27
|
+
export function computeWeakEtag(mtimeMs: number, size: number): string {
|
|
28
|
+
return `W/"${mtimeMs}-${size}"`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Strong ETag from final response bytes (e.g. index.html after schema inject). */
|
|
32
|
+
export function computeStrongEtag(seed: string | Uint8Array): string {
|
|
33
|
+
return digestEtag(seed);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strong ETag from revision parts — avoids rendering before a 304 check. */
|
|
37
|
+
export function computeRevisionEtag(parts: readonly string[]): string {
|
|
38
|
+
return digestEtag(parts.join("\0"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function cacheControlHeader(policy: CachePolicy): string | undefined {
|
|
42
|
+
switch (policy.kind) {
|
|
43
|
+
case "immutable":
|
|
44
|
+
return `public, max-age=${policy.maxAgeSeconds ?? DEFAULT_IMMUTABLE_MAX_AGE}, immutable`;
|
|
45
|
+
case "revalidate": {
|
|
46
|
+
const maxAge = policy.maxAgeSeconds ?? DEFAULT_REVALIDATE_MAX_AGE;
|
|
47
|
+
return `public, max-age=${maxAge}, must-revalidate`;
|
|
48
|
+
}
|
|
49
|
+
case "no-cache":
|
|
50
|
+
return "no-cache";
|
|
51
|
+
case "none":
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeEtag(value: string): string {
|
|
57
|
+
return value.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function weakEtagMatches(stored: string, candidate: string): boolean {
|
|
61
|
+
const storedNorm = normalizeEtag(stored);
|
|
62
|
+
const candidateNorm = normalizeEtag(candidate);
|
|
63
|
+
if (storedNorm === candidateNorm) return true;
|
|
64
|
+
const storedWeak = storedNorm.startsWith("W/") ? storedNorm.slice(2) : storedNorm;
|
|
65
|
+
const candidateWeak = candidateNorm.startsWith("W/") ? candidateNorm.slice(2) : candidateNorm;
|
|
66
|
+
return storedWeak === candidateWeak;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse `If-None-Match` into individual tag values (order preserved). */
|
|
70
|
+
export function parseIfNoneMatch(header: string | null): readonly string[] {
|
|
71
|
+
if (header === null || header.trim() === "") return [];
|
|
72
|
+
if (header.trim() === "*") return ["*"];
|
|
73
|
+
return header
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((part) => part.trim())
|
|
76
|
+
.filter((part) => part.length > 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function etagMatches(ifNoneMatch: string | null, etag: string): boolean {
|
|
80
|
+
const tags = parseIfNoneMatch(ifNoneMatch);
|
|
81
|
+
if (tags.length === 0) return false;
|
|
82
|
+
if (tags.includes("*")) return true;
|
|
83
|
+
return tags.some((tag) => weakEtagMatches(etag, tag));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isNotModifiedSince(ifModifiedSince: string, lastModified: Date): boolean {
|
|
87
|
+
const parsed = Date.parse(ifModifiedSince);
|
|
88
|
+
if (Number.isNaN(parsed)) return false;
|
|
89
|
+
// HTTP dates have second precision — floor the stored mtime.
|
|
90
|
+
return Math.floor(lastModified.getTime() / 1000) * 1000 <= parsed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildResponseHeaders(init: CachedResponseInit): Record<string, string> {
|
|
94
|
+
const headers: Record<string, string> = { ...(init.headers ?? {}), etag: init.etag };
|
|
95
|
+
const cacheControl = cacheControlHeader(init.cache);
|
|
96
|
+
if (cacheControl !== undefined) headers["cache-control"] = cacheControl;
|
|
97
|
+
if (init.lastModified !== undefined) {
|
|
98
|
+
headers["last-modified"] = init.lastModified.toUTCString();
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Conditional GET/HEAD helper — returns 304 when the client already has the revision. */
|
|
104
|
+
export function cachedResponse(req: Request, init: CachedResponseInit): Response {
|
|
105
|
+
const headers = buildResponseHeaders(init);
|
|
106
|
+
const ifNoneMatch = req.headers.get("if-none-match");
|
|
107
|
+
if (etagMatches(ifNoneMatch, init.etag)) {
|
|
108
|
+
return new Response(null, { status: 304, headers });
|
|
109
|
+
}
|
|
110
|
+
if (init.lastModified !== undefined) {
|
|
111
|
+
const ifModifiedSince = req.headers.get("if-modified-since");
|
|
112
|
+
if (ifModifiedSince !== null && isNotModifiedSince(ifModifiedSince, init.lastModified)) {
|
|
113
|
+
return new Response(null, { status: 304, headers });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (req.method === "HEAD") {
|
|
117
|
+
return new Response(null, { status: init.status ?? 200, headers });
|
|
118
|
+
}
|
|
119
|
+
return new Response(init.body, { status: init.status ?? 200, headers });
|
|
120
|
+
}
|
package/src/api/index.ts
CHANGED
|
@@ -18,6 +18,16 @@ export type {
|
|
|
18
18
|
SessionRevoker,
|
|
19
19
|
} from "./auth-routes";
|
|
20
20
|
export { createAuthRoutes, createInMemoryLoginRateLimiter } from "./auth-routes";
|
|
21
|
+
export type { CachedResponseInit, CachePolicy } from "./http-cache";
|
|
22
|
+
export {
|
|
23
|
+
cacheControlHeader,
|
|
24
|
+
cachedResponse,
|
|
25
|
+
computeRevisionEtag,
|
|
26
|
+
computeStrongEtag,
|
|
27
|
+
computeWeakEtag,
|
|
28
|
+
etagMatches,
|
|
29
|
+
parseIfNoneMatch,
|
|
30
|
+
} from "./http-cache";
|
|
21
31
|
export type { JwtHelper, JwtPayload } from "./jwt";
|
|
22
32
|
export { createJwtHelper } from "./jwt";
|
|
23
33
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
@@ -206,19 +206,14 @@ describe("buildAppSchema — dangling audience-ref dev-warning (#408/3)", () =>
|
|
|
206
206
|
// (the boot-validator exempts the audience QNs), but the entry renders
|
|
207
207
|
// invisibly — the dev must see a warning, not silently nothing.
|
|
208
208
|
function warnsFor(scopes: string[]): string[] {
|
|
209
|
-
const prevEnv = process.env.NODE_ENV;
|
|
210
|
-
process.env.NODE_ENV = "development";
|
|
211
209
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
212
210
|
try {
|
|
213
|
-
buildAppSchema(createRegistry([placingShell(...scopes), billing])
|
|
211
|
+
buildAppSchema(createRegistry([placingShell(...scopes), billing]), {
|
|
212
|
+
authoringWarnings: true,
|
|
213
|
+
});
|
|
214
214
|
return warn.mock.calls.map((c) => String(c[0]));
|
|
215
215
|
} finally {
|
|
216
216
|
warn.mockRestore();
|
|
217
|
-
if (prevEnv === undefined) {
|
|
218
|
-
delete process.env.NODE_ENV;
|
|
219
|
-
} else {
|
|
220
|
-
process.env.NODE_ENV = prevEnv;
|
|
221
|
-
}
|
|
222
217
|
}
|
|
223
218
|
}
|
|
224
219
|
|
|
@@ -235,9 +230,7 @@ describe("buildAppSchema — dangling audience-ref dev-warning (#408/3)", () =>
|
|
|
235
230
|
expect(messages.some((m) => m.includes("nie generiert"))).toBe(false);
|
|
236
231
|
});
|
|
237
232
|
|
|
238
|
-
test("authoring warnings are
|
|
239
|
-
// bun:test sets NODE_ENV=test; an unplaced audience (tenant left over by
|
|
240
|
-
// placingShell("system")) must NOT emit a console.warn into CI logs.
|
|
233
|
+
test("authoring warnings are off by default — tests/prod boot stay silent", () => {
|
|
241
234
|
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
242
235
|
try {
|
|
243
236
|
buildAppSchema(createRegistry([placingShell("system"), billing]));
|
|
@@ -34,7 +34,13 @@ import {
|
|
|
34
34
|
import type { Registry } from "./types/feature";
|
|
35
35
|
import type { FieldDefinition } from "./types/fields";
|
|
36
36
|
|
|
37
|
-
export
|
|
37
|
+
export type BuildAppSchemaOptions = {
|
|
38
|
+
/** Dev-server authoring hints (Settings-Hub placement). Default off — only
|
|
39
|
+
* `createKumikoServer` opts in; prod boot + unit tests stay silent. */
|
|
40
|
+
readonly authoringWarnings?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function buildAppSchema(registry: Registry, options: BuildAppSchemaOptions = {}): AppSchema {
|
|
38
44
|
const features: FeatureSchema[] = [];
|
|
39
45
|
for (const [featureName, feature] of registry.features) {
|
|
40
46
|
const navs = Object.values(feature.navs);
|
|
@@ -78,8 +84,10 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
78
84
|
const placed = placeSettingsHub(workspaces, generated);
|
|
79
85
|
workspaces = placed.workspaces;
|
|
80
86
|
if (placed.standalone !== undefined) workspaces.push(placed.standalone);
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
if (options.authoringWarnings === true) {
|
|
88
|
+
warnUnplacedAudiences(placed.unplaced);
|
|
89
|
+
warnDanglingAudienceRefs(placed.danglingRefs);
|
|
90
|
+
}
|
|
83
91
|
}
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -200,10 +208,6 @@ function placeSettingsHub(
|
|
|
200
208
|
function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
201
209
|
// skip: every audience placed — nothing to warn about
|
|
202
210
|
if (unplaced.length === 0) return;
|
|
203
|
-
const env = typeof process !== "undefined" ? process.env.NODE_ENV : undefined;
|
|
204
|
-
// skip: dev-only authoring hint — silent in production and in tests
|
|
205
|
-
// (bun:test sets NODE_ENV=test) where it would only noise up CI logs.
|
|
206
|
-
if (env === "production" || env === "test") return;
|
|
207
211
|
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
208
212
|
console.warn(
|
|
209
213
|
`[kumiko] Settings-Hub: ${unplaced.join(", ")} nicht in einer App-Workspace platziert — ` +
|
|
@@ -216,9 +220,6 @@ function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
|
216
220
|
function warnDanglingAudienceRefs(dangling: readonly string[]): void {
|
|
217
221
|
// skip: no dangling refs — nothing to warn about
|
|
218
222
|
if (dangling.length === 0) return;
|
|
219
|
-
const env = typeof process !== "undefined" ? process.env.NODE_ENV : undefined;
|
|
220
|
-
// skip: dev-only authoring hint — silent in production and in tests
|
|
221
|
-
if (env === "production" || env === "test") return;
|
|
222
223
|
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
223
224
|
console.warn(
|
|
224
225
|
`[kumiko] Settings-Hub: ${dangling
|
package/src/engine/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ export {
|
|
|
7
7
|
validateBoot,
|
|
8
8
|
} from "./boot-validator";
|
|
9
9
|
export { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
|
|
10
|
-
export { buildAppSchema } from "./build-app-schema";
|
|
10
|
+
export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema";
|
|
11
11
|
export type { ConfigFeatureSchema } from "./build-config-feature-schema";
|
|
12
12
|
export {
|
|
13
13
|
buildConfigFeatureSchema,
|