@cosmicdrift/kumiko-framework 0.86.0 → 0.87.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-framework",
3
- "version": "0.86.0",
3
+ "version": "0.87.1",
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.86.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.87.1",
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 suppressed under NODE_ENV=testno CI-log noise (#408/1)", () => {
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 defaulttests/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 function buildAppSchema(registry: Registry): AppSchema {
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
- warnUnplacedAudiences(placed.unplaced);
82
- warnDanglingAudienceRefs(placed.danglingRefs);
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
@@ -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,