@cosmicdrift/kumiko-server-runtime 0.1.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.
Files changed (38) hide show
  1. package/LICENSE +57 -0
  2. package/package.json +87 -0
  3. package/src/__tests__/boot-extra-context.test.ts +140 -0
  4. package/src/__tests__/build-prod-bundle.test.ts +278 -0
  5. package/src/__tests__/cache-headers.test.ts +83 -0
  6. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
  7. package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
  8. package/src/__tests__/compose-features.test.ts +128 -0
  9. package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
  10. package/src/__tests__/inject-schema.test.ts +62 -0
  11. package/src/__tests__/pii-boot-gate.test.ts +68 -0
  12. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
  13. package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
  14. package/src/__tests__/require-env.test.ts +29 -0
  15. package/src/__tests__/resolve-auth-mail.test.ts +69 -0
  16. package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
  17. package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
  18. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  19. package/src/__tests__/run-prod-app.integration.test.ts +915 -0
  20. package/src/__tests__/session-wiring.test.ts +51 -0
  21. package/src/__tests__/try-hono-first.test.ts +63 -0
  22. package/src/boot/__tests__/job-run-logger.test.ts +26 -0
  23. package/src/boot/apply-boot-seeds.ts +19 -0
  24. package/src/boot/boot-crypto.ts +82 -0
  25. package/src/boot/job-run-logger.ts +51 -0
  26. package/src/build-prod-bundle.ts +692 -0
  27. package/src/bun-serve-options.ts +22 -0
  28. package/src/compose-features.ts +164 -0
  29. package/src/extra-routes-deps.ts +47 -0
  30. package/src/index.ts +16 -0
  31. package/src/inject-schema.ts +30 -0
  32. package/src/pii-boot-gate.ts +62 -0
  33. package/src/resolve-tailwind-cli.ts +45 -0
  34. package/src/run-prod-app-boot-context.ts +241 -0
  35. package/src/run-prod-app-static-files.ts +273 -0
  36. package/src/run-prod-app.ts +1137 -0
  37. package/src/session-wiring.ts +29 -0
  38. package/src/try-hono-first.ts +46 -0
package/LICENSE ADDED
@@ -0,0 +1,57 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Marc Frost
6
+
7
+ Licensed Work: @cosmicdrift/kumiko-framework
8
+ The Licensed Work is © 2026 Marc Frost.
9
+
10
+ Additional Use Grant:
11
+ You may use the Licensed Work in production for any purpose, including
12
+ commercially, EXCEPT for the Restricted Use.
13
+
14
+ "Restricted Use" is defined as using the Licensed Work to provide a platform
15
+ or service to third parties that allows them to host, deploy, or run their
16
+ own applications built with the Licensed Work. This includes, but is not
17
+ limited to: managed hosting services, software-as-a-service (SaaS) platforms,
18
+ platform-as-a-service (PaaS), developer platforms, or any multi-tenant
19
+ managed offering of the Licensed Work.
20
+
21
+ This restriction does not apply to the Licensor, any entity controlled by,
22
+ controlling, or under common control with the Licensor ("Affiliates"), or
23
+ contractors acting on their behalf. The Licensor remains free to use the
24
+ Licensed Work for any purpose, including for the operation of kumiko.rocks.
25
+
26
+ Change Date: 2030-05-05
27
+ Change License: Apache License, Version 2.0
28
+
29
+
30
+ Terms
31
+
32
+ The Licensor hereby grants you the right to copy, modify, create derivative works,
33
+ redistribute, and make non-production use of the Licensed Work. The Licensor may
34
+ make an Additional Use Grant, above, permitting limited production use.
35
+
36
+ Effective on the Change Date, or the fourth anniversary of the first publicly
37
+ available distribution of the Licensed Work under this License, whichever comes
38
+ first, this License will convert to the Change License.
39
+
40
+ This Business Source License governs use of the Licensed Work in all cases, except
41
+ as to any use that is explicitly granted in the Additional Use Grant above or
42
+ under the Change License after the Change Date.
43
+
44
+ If your use of the Licensed Work does not comply with the requirements of this
45
+ License, you must cease use of the Licensed Work immediately.
46
+
47
+ All copies of the Licensed Work, and all derivative works thereof, must include
48
+ this License.
49
+
50
+ This License does not grant you any right, title, or interest in any trademark,
51
+ logo, or branding of the Licensor, except as required to comply with this License.
52
+
53
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
54
+ “AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
55
+ WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
56
+ TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
57
+ ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
package/package.json ADDED
@@ -0,0 +1,87 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-server-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
10
+ "directory": "packages/server-runtime"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
14
+ },
15
+ "homepage": "https://kumiko.rocks",
16
+ "type": "module",
17
+ "kumiko": {
18
+ "runtime": "prod"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./src/index.ts",
23
+ "default": "./src/index.ts"
24
+ },
25
+ "./compose-features": {
26
+ "types": "./src/compose-features.ts",
27
+ "default": "./src/compose-features.ts"
28
+ },
29
+ "./run-prod-app": {
30
+ "types": "./src/run-prod-app.ts",
31
+ "default": "./src/run-prod-app.ts"
32
+ },
33
+ "./bun-serve-options": {
34
+ "types": "./src/bun-serve-options.ts",
35
+ "default": "./src/bun-serve-options.ts"
36
+ },
37
+ "./pii-boot-gate": {
38
+ "types": "./src/pii-boot-gate.ts",
39
+ "default": "./src/pii-boot-gate.ts"
40
+ },
41
+ "./boot/apply-boot-seeds": {
42
+ "types": "./src/boot/apply-boot-seeds.ts",
43
+ "default": "./src/boot/apply-boot-seeds.ts"
44
+ },
45
+ "./boot/boot-crypto": {
46
+ "types": "./src/boot/boot-crypto.ts",
47
+ "default": "./src/boot/boot-crypto.ts"
48
+ },
49
+ "./boot/job-run-logger": {
50
+ "types": "./src/boot/job-run-logger.ts",
51
+ "default": "./src/boot/job-run-logger.ts"
52
+ },
53
+ "./extra-routes-deps": {
54
+ "types": "./src/extra-routes-deps.ts",
55
+ "default": "./src/extra-routes-deps.ts"
56
+ },
57
+ "./inject-schema": {
58
+ "types": "./src/inject-schema.ts",
59
+ "default": "./src/inject-schema.ts"
60
+ },
61
+ "./resolve-tailwind-cli": {
62
+ "types": "./src/resolve-tailwind-cli.ts",
63
+ "default": "./src/resolve-tailwind-cli.ts"
64
+ },
65
+ "./try-hono-first": {
66
+ "types": "./src/try-hono-first.ts",
67
+ "default": "./src/try-hono-first.ts"
68
+ },
69
+ "./build-prod-bundle": {
70
+ "types": "./src/build-prod-bundle.ts",
71
+ "default": "./src/build-prod-bundle.ts"
72
+ }
73
+ },
74
+ "dependencies": {
75
+ "@cosmicdrift/kumiko-bundled-features": "0.152.0",
76
+ "@cosmicdrift/kumiko-framework": "0.152.0"
77
+ },
78
+ "publishConfig": {
79
+ "registry": "https://registry.npmjs.org",
80
+ "access": "public"
81
+ },
82
+ "files": [
83
+ "src",
84
+ "README.md",
85
+ "LICENSE"
86
+ ]
87
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
3
+ import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
4
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
5
+ import { createRegistry, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
7
+ import { buildBootExtraContext } from "../run-prod-app";
8
+
9
+ // Pins runProdApp's framework-default-provider autowire (buildBootExtraContext):
10
+ // textContent unconditional, secrets feature-gated, KEK-env trap avoided, the
11
+ // money-horse regression (no secrets feature → no forced KEK → no throw), and
12
+ // the delivery _notifyFactory wiring that makes ctx.notify work in prod (only
13
+ // test-wired before — runProdApp/runDevApp call THIS, not createDeliveryTestContext).
14
+
15
+ // db is never touched at construction time — createTextContentApi /
16
+ // createSecretsContext only store the handle and query lazily. A bare stub
17
+ // keeps this a fast unit test (no Postgres) instead of a full boot.
18
+ const fakeDb = {} as unknown as DbConnection;
19
+ const registry = createRegistry([]);
20
+ const KEK = Buffer.alloc(32, 7).toString("base64");
21
+
22
+ const otherFeature = defineFeature("widgets", () => {});
23
+
24
+ describe("buildBootExtraContext — framework-default provider autowire", () => {
25
+ test("textContent is wired unconditionally (no secrets feature, no auth)", () => {
26
+ const ctx = buildBootExtraContext({
27
+ db: fakeDb,
28
+ features: [otherFeature],
29
+ envSource: {},
30
+ registry,
31
+ hasAuth: false,
32
+ });
33
+ expect(typeof (ctx["textContent"] as { getBlock?: unknown }).getBlock).toBe("function");
34
+ expect(ctx["secrets"]).toBeUndefined();
35
+ expect(ctx["configResolver"]).toBeUndefined();
36
+ expect(ctx["_notifyFactory"]).toBeUndefined();
37
+ });
38
+
39
+ test("delivery feature mounted → _notifyFactory wired (ctx.notify works in prod)", () => {
40
+ const ctx = buildBootExtraContext({
41
+ db: fakeDb,
42
+ features: [createDeliveryFeature()],
43
+ envSource: {},
44
+ registry,
45
+ hasAuth: false,
46
+ });
47
+ expect(typeof ctx["_notifyFactory"]).toBe("function");
48
+ });
49
+
50
+ test("no delivery feature → _notifyFactory not wired", () => {
51
+ const ctx = buildBootExtraContext({
52
+ db: fakeDb,
53
+ features: [otherFeature],
54
+ envSource: {},
55
+ registry,
56
+ hasAuth: false,
57
+ });
58
+ expect(ctx["_notifyFactory"]).toBeUndefined();
59
+ });
60
+
61
+ test("secrets feature mounted + valid KEK env → ctx.secrets wired", () => {
62
+ const ctx = buildBootExtraContext({
63
+ db: fakeDb,
64
+ features: [createSecretsFeature()],
65
+ envSource: {
66
+ KUMIKO_SECRETS_MASTER_KEY_V1: KEK,
67
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
68
+ },
69
+ registry,
70
+ hasAuth: false,
71
+ });
72
+ const secrets = ctx["secrets"] as {
73
+ get?: unknown;
74
+ set?: unknown;
75
+ has?: unknown;
76
+ delete?: unknown;
77
+ };
78
+ expect(typeof secrets.get).toBe("function");
79
+ expect(typeof secrets.set).toBe("function");
80
+ });
81
+
82
+ test("secrets feature mounted, only V1 set (no CURRENT_VERSION) → wired (default '1')", () => {
83
+ const ctx = buildBootExtraContext({
84
+ db: fakeDb,
85
+ features: [createSecretsFeature()],
86
+ envSource: { KUMIKO_SECRETS_MASTER_KEY_V1: KEK },
87
+ registry,
88
+ hasAuth: false,
89
+ });
90
+ expect(ctx["secrets"]).toBeDefined();
91
+ });
92
+
93
+ test("secrets feature mounted but NO KEK + no override → skipped, no throw (dev DEV_KEY path)", () => {
94
+ // dev wires its own DEV_KEY secrets explicitly; the auto-wire must not
95
+ // eagerly construct an env-provider and crash the boot when no env KEK
96
+ // exists. ctx.secrets stays unset so the app's explicit wiring wins.
97
+ let ctx: Record<string, unknown> | undefined;
98
+ expect(() => {
99
+ ctx = buildBootExtraContext({
100
+ db: fakeDb,
101
+ features: [createSecretsFeature()],
102
+ envSource: {},
103
+ registry,
104
+ hasAuth: false,
105
+ });
106
+ }).not.toThrow();
107
+ expect(ctx?.["secrets"]).toBeUndefined();
108
+ });
109
+
110
+ test("no secrets feature → no KEK ever read, boots green (money-horse regression)", () => {
111
+ const ctx = buildBootExtraContext({
112
+ db: fakeDb,
113
+ features: [otherFeature],
114
+ envSource: {}, // no KEK — must NOT matter when secrets isn't mounted
115
+ registry,
116
+ hasAuth: false,
117
+ });
118
+ expect(ctx["secrets"]).toBeUndefined();
119
+ });
120
+
121
+ test("masterKey override is used verbatim — no env-provider built", () => {
122
+ const override: MasterKeyProvider = {
123
+ wrapDek: async () => ({ encryptedDek: Buffer.alloc(0), kekVersion: 1 }),
124
+ unwrapDek: async () => Buffer.alloc(0),
125
+ currentVersion: () => 1,
126
+ isAvailable: async () => true,
127
+ };
128
+ // envSource is empty: if the override weren't honoured, createEnvMasterKeyProvider
129
+ // would throw "no KEK". It doesn't → the override path is taken.
130
+ const ctx = buildBootExtraContext({
131
+ db: fakeDb,
132
+ features: [createSecretsFeature()],
133
+ envSource: {},
134
+ registry,
135
+ hasAuth: false,
136
+ masterKey: override,
137
+ });
138
+ expect(ctx["secrets"]).toBeDefined();
139
+ });
140
+ });
@@ -0,0 +1,278 @@
1
+ // Unit-Tests für die Pure-Logic-Helpers von build-prod-bundle.
2
+ //
3
+ // Bun.build und Tailwind-CLI brauchen einen Bun-Runtime, deshalb
4
+ // werden die hier nicht aufgerufen — nur Discovery + HTML-Injection
5
+ // die unter Node funktionieren. End-to-End-Tests (mit echtem Bun.build)
6
+ // laufen im CI als `bun run build` auf der Showcase-App; das ist der
7
+ // ehrlichere Smoke-Test.
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { existsSync } from "node:fs";
11
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import {
15
+ type ClientEntry,
16
+ computeBuildId,
17
+ discoverClientEntries,
18
+ discoverHtmlTemplate,
19
+ injectAssetTags,
20
+ } from "../build-prod-bundle";
21
+
22
+ // Synthetic single-entry helper. Realer Build erzeugt das via
23
+ // discoverClientEntries; hier reicht die Form für injectAssetTags-Tests.
24
+ function clientEntry(): ClientEntry {
25
+ return {
26
+ name: "client",
27
+ sourceFile: "src/client.tsx",
28
+ manifestKey: "client.js",
29
+ htmlPath: "index.html",
30
+ };
31
+ }
32
+
33
+ function namedEntry(name: string): ClientEntry {
34
+ return {
35
+ name,
36
+ sourceFile: `src/client-${name}.tsx`,
37
+ manifestKey: `client-${name}.js`,
38
+ htmlPath: name === "public" ? "index.html" : `${name}.html`,
39
+ };
40
+ }
41
+
42
+ describe("build-prod-bundle/discovery", () => {
43
+ let workDir = "";
44
+
45
+ beforeEach(async () => {
46
+ workDir = await mkdtemp(join(tmpdir(), "kumiko-build-test-"));
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await rm(workDir, { recursive: true, force: true });
51
+ });
52
+
53
+ test("discoverClientEntries findet single-mode src/client.tsx", async () => {
54
+ await mkdir(join(workDir, "src"), { recursive: true });
55
+ await writeFile(join(workDir, "src/client.tsx"), "// single");
56
+
57
+ const entries = discoverClientEntries(workDir);
58
+
59
+ expect(entries).toHaveLength(1);
60
+ expect(entries[0]?.name).toBe("client");
61
+ expect(entries[0]?.manifestKey).toBe("client.js");
62
+ expect(entries[0]?.htmlPath).toBe("index.html");
63
+ });
64
+
65
+ test("discoverClientEntries findet multi-mode client-public + client-admin", async () => {
66
+ await mkdir(join(workDir, "src"), { recursive: true });
67
+ await writeFile(join(workDir, "src/client-public.tsx"), "// public");
68
+ await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
69
+
70
+ const entries = discoverClientEntries(workDir);
71
+
72
+ // Sortiert nach name.
73
+ expect(entries.map((e) => e.name)).toEqual(["admin", "public"]);
74
+
75
+ const admin = entries.find((e) => e.name === "admin");
76
+ expect(admin?.manifestKey).toBe("client-admin.js");
77
+ expect(admin?.htmlPath).toBe("admin.html");
78
+
79
+ const pub = entries.find((e) => e.name === "public");
80
+ expect(pub?.manifestKey).toBe("client-public.js");
81
+ // "public" mappt auf das Default-Template (index.html), nicht
82
+ // public.html — das ist Convention damit das default-served-Template
83
+ // den vom-User-erwarteten Namen behält.
84
+ expect(pub?.htmlPath).toBe("index.html");
85
+ });
86
+
87
+ test("discoverClientEntries multi-mode dominiert über single-mode wenn beide da", async () => {
88
+ await mkdir(join(workDir, "src"), { recursive: true });
89
+ await writeFile(join(workDir, "src/client.tsx"), "// single");
90
+ await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
91
+
92
+ const entries = discoverClientEntries(workDir);
93
+
94
+ // Multi-mode aktiv → "client" wird ignoriert.
95
+ expect(entries.map((e) => e.name)).toEqual(["admin"]);
96
+ });
97
+
98
+ test("discoverClientEntries gibt leeres Array zurück wenn nichts da", () => {
99
+ expect(discoverClientEntries(workDir)).toEqual([]);
100
+ });
101
+
102
+ test("discoverHtmlTemplate findet index.html im cwd", async () => {
103
+ await writeFile(join(workDir, "index.html"), "<html></html>");
104
+
105
+ expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "index.html"));
106
+ });
107
+
108
+ test("discoverHtmlTemplate findet public/index.html als Fallback", async () => {
109
+ await mkdir(join(workDir, "public"), { recursive: true });
110
+ await writeFile(join(workDir, "public/index.html"), "<html></html>");
111
+
112
+ expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "public/index.html"));
113
+ });
114
+
115
+ test("discoverHtmlTemplate bevorzugt cwd-index.html über public/index.html", async () => {
116
+ await mkdir(join(workDir, "public"), { recursive: true });
117
+ await writeFile(join(workDir, "index.html"), "<!-- root -->");
118
+ await writeFile(join(workDir, "public/index.html"), "<!-- public -->");
119
+
120
+ expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "index.html"));
121
+ });
122
+
123
+ test("discoverHtmlTemplate gibt undefined zurück wenn nichts da ist", () => {
124
+ expect(existsSync(workDir)).toBe(true);
125
+ expect(discoverHtmlTemplate(workDir)).toBeUndefined();
126
+ });
127
+ });
128
+
129
+ describe("build-prod-bundle/injectAssetTags", () => {
130
+ test("ersetzt /client.js durch hashed URL im script-tag", () => {
131
+ const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
132
+ const result = injectAssetTags(
133
+ html,
134
+ { "client.js": "/assets/client-abc123.js" },
135
+ clientEntry(),
136
+ );
137
+
138
+ expect(result).toContain('src="/assets/client-abc123.js"');
139
+ expect(result).not.toContain('src="/client.js"');
140
+ });
141
+
142
+ test("ersetzt /styles.css durch hashed URL im link-tag", () => {
143
+ const html = `<html><head><link rel="stylesheet" href="/styles.css" /></head><body></body></html>`;
144
+ const result = injectAssetTags(
145
+ html,
146
+ { "styles.css": "/assets/styles-def456.css" },
147
+ clientEntry(),
148
+ );
149
+
150
+ expect(result).toContain('href="/assets/styles-def456.css"');
151
+ expect(result).not.toContain('href="/styles.css"');
152
+ });
153
+
154
+ test("wirft mit Anweisung wenn /client.js Placeholder fehlt", () => {
155
+ const html = `<html><body><div id="root"></div></body></html>`;
156
+
157
+ expect(() =>
158
+ injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry()),
159
+ ).toThrow(/keinen Entry-Tag für \/client\.js/);
160
+ expect(() =>
161
+ injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry()),
162
+ ).toThrow(/<script type="module" src="\/client\.js"><\/script>/);
163
+ });
164
+
165
+ test("wirft mit Anweisung wenn /styles.css Placeholder fehlt", () => {
166
+ const html = `<html><head><title>App</title></head><body></body></html>`;
167
+
168
+ expect(() =>
169
+ injectAssetTags(html, { "styles.css": "/assets/styles-xyz.css" }, clientEntry()),
170
+ ).toThrow(/keinen Entry-Tag für \/styles\.css/);
171
+ expect(() =>
172
+ injectAssetTags(html, { "styles.css": "/assets/styles-xyz.css" }, clientEntry()),
173
+ ).toThrow(/<link rel="stylesheet" href="\/styles\.css" \/>/);
174
+ });
175
+
176
+ test("ist idempotent — zweite Injection auf bereits ersetztem HTML ändert nichts", () => {
177
+ const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
178
+ const manifest = { "client.js": "/assets/client-abc.js" };
179
+ const first = injectAssetTags(html, manifest, clientEntry());
180
+ const second = injectAssetTags(first, manifest, clientEntry());
181
+
182
+ expect(first).toBe(second);
183
+ expect(first).toContain('src="/assets/client-abc.js"');
184
+ });
185
+
186
+ test("ändert template ohne client/styles im manifest nicht", () => {
187
+ const html = `<html><body>Hello</body></html>`;
188
+ const result = injectAssetTags(html, {}, clientEntry());
189
+
190
+ expect(result).toBe(html);
191
+ });
192
+
193
+ test("verträgt mehrere script-tags und ersetzt nur den /client.js", () => {
194
+ const html = `<html><body>
195
+ <script>console.log("inline");</script>
196
+ <script type="module" src="/client.js"></script>
197
+ </body></html>`;
198
+ const result = injectAssetTags(html, { "client.js": "/assets/client-x.js" }, clientEntry());
199
+
200
+ expect(result).toContain('console.log("inline")');
201
+ expect(result).toContain('src="/assets/client-x.js"');
202
+ expect(result).not.toContain('src="/client.js"');
203
+ });
204
+
205
+ test("multi-mode: admin-entry ersetzt nur /client-admin.js, lässt /client-public.js liegen", () => {
206
+ const html = `<html><body>
207
+ <script type="module" src="/client-admin.js"></script>
208
+ <script type="module" src="/client-public.js"></script>
209
+ </body></html>`;
210
+ const manifest = {
211
+ "client-admin.js": "/assets/client-admin-aaa.js",
212
+ "client-public.js": "/assets/client-public-bbb.js",
213
+ };
214
+ const result = injectAssetTags(html, manifest, namedEntry("admin"));
215
+
216
+ expect(result).toContain('src="/assets/client-admin-aaa.js"');
217
+ // public-bundle bleibt unangetastet — admin.html lädt nur sein eigenes Bundle.
218
+ expect(result).toContain('src="/client-public.js"');
219
+ expect(result).not.toContain('src="/client-admin.js"');
220
+ });
221
+
222
+ test("multi-mode: error-message nennt den richtigen Template-Namen", () => {
223
+ const html = `<html><body>no admin script</body></html>`;
224
+ const manifest = { "client-admin.js": "/assets/client-admin-x.js" };
225
+
226
+ expect(() => injectAssetTags(html, manifest, namedEntry("admin"))).toThrow(
227
+ /admin\.html hat keinen Entry-Tag für \/client-admin\.js/,
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("build-prod-bundle/computeBuildId", () => {
233
+ test("identisches Manifest → gleiche id", () => {
234
+ const manifest = {
235
+ "client.js": "/assets/client-abc.js",
236
+ "styles.css": "/assets/styles-xyz.css",
237
+ };
238
+ expect(computeBuildId(manifest)).toBe(computeBuildId({ ...manifest }));
239
+ });
240
+
241
+ test("Key-Reihenfolge egal — nur die URL-Werte zählen", () => {
242
+ const a = { "client.js": "/assets/client-abc.js", "styles.css": "/assets/styles-xyz.css" };
243
+ const b = { "styles.css": "/assets/styles-xyz.css", "client.js": "/assets/client-abc.js" };
244
+ expect(computeBuildId(a)).toBe(computeBuildId(b));
245
+ });
246
+
247
+ test("geändertes Asset (neuer Hash) → andere id", () => {
248
+ const before = { "client.js": "/assets/client-abc.js" };
249
+ const after = { "client.js": "/assets/client-def.js" };
250
+ expect(computeBuildId(before)).not.toBe(computeBuildId(after));
251
+ });
252
+
253
+ test("12 Hex-Zeichen", () => {
254
+ expect(computeBuildId({ "client.js": "/assets/client-abc.js" })).toMatch(/^[0-9a-f]{12}$/);
255
+ });
256
+ });
257
+
258
+ describe("build-prod-bundle/injectAssetTags build-info", () => {
259
+ test("bäckt window.__KUMIKO_BUILD__ vor </head> wenn buildInfo gesetzt", () => {
260
+ const html = `<html><head><title>x</title></head><body><script type="module" src="/client.js"></script></body></html>`;
261
+ const result = injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry(), {
262
+ id: "deadbeef0000",
263
+ builtAt: "2026-06-18T12:00:00.000Z",
264
+ });
265
+
266
+ expect(result).toContain(
267
+ 'window.__KUMIKO_BUILD__={"id":"deadbeef0000","builtAt":"2026-06-18T12:00:00.000Z"}',
268
+ );
269
+ expect(result.indexOf("__KUMIKO_BUILD__")).toBeLessThan(result.indexOf("</head>"));
270
+ });
271
+
272
+ test("ohne buildInfo kein __KUMIKO_BUILD__-Script", () => {
273
+ const html = `<html><head></head><body><script type="module" src="/client.js"></script></body></html>`;
274
+ const result = injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry());
275
+
276
+ expect(result).not.toContain("__KUMIKO_BUILD__");
277
+ });
278
+ });
@@ -0,0 +1,83 @@
1
+ // Unit-Tests für staticCachePolicy — die Cache-Strategie hinter
2
+ // runProdApp's static-fallback. Pure function, leicht zu testen.
3
+ //
4
+ // Strategie (siehe run-prod-app.ts):
5
+ // /assets/* → immutable
6
+ // /, /index.html → revalidate (max-age=0, must-revalidate)
7
+ // /manifest.json, /sw.js,
8
+ // /build-info.json → no-cache
9
+ // alles andere → none
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { cacheControlHeader } from "@cosmicdrift/kumiko-framework/api";
13
+ import { staticCachePolicy } from "../run-prod-app";
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", () => {
21
+ test("hashed asset → immutable + 1 Jahr", () => {
22
+ expect(cacheControlFor("/assets/client-abc123.js")).toEqual({
23
+ "cache-control": "public, max-age=31536000, immutable",
24
+ });
25
+ });
26
+
27
+ test("hashed CSS asset → immutable + 1 Jahr", () => {
28
+ expect(cacheControlFor("/assets/styles-def456.css")).toEqual({
29
+ "cache-control": "public, max-age=31536000, immutable",
30
+ });
31
+ });
32
+
33
+ test("nested asset path → immutable", () => {
34
+ expect(cacheControlFor("/assets/chunks/foo-789.js")).toEqual({
35
+ "cache-control": "public, max-age=31536000, immutable",
36
+ });
37
+ });
38
+
39
+ test("/ → revalidate", () => {
40
+ expect(cacheControlFor("/")).toEqual({
41
+ "cache-control": "public, max-age=0, must-revalidate",
42
+ });
43
+ });
44
+
45
+ test("/index.html → revalidate", () => {
46
+ expect(cacheControlFor("/index.html")).toEqual({
47
+ "cache-control": "public, max-age=0, must-revalidate",
48
+ });
49
+ });
50
+
51
+ test("/manifest.json → no-cache", () => {
52
+ expect(cacheControlFor("/manifest.json")).toEqual({
53
+ "cache-control": "no-cache",
54
+ });
55
+ });
56
+
57
+ test("/sw.js → no-cache", () => {
58
+ expect(cacheControlFor("/sw.js")).toEqual({
59
+ "cache-control": "no-cache",
60
+ });
61
+ });
62
+
63
+ test("/build-info.json → no-cache (sonst pollt UpdateChecker eine veraltete id)", () => {
64
+ expect(cacheControlFor("/build-info.json")).toEqual({
65
+ "cache-control": "no-cache",
66
+ });
67
+ });
68
+
69
+ test("public-folder file (favicon) → kein expliziter Header", () => {
70
+ expect(cacheControlFor("/favicon.ico")).toEqual({});
71
+ });
72
+
73
+ test("public-folder file (og-image) → kein expliziter Header", () => {
74
+ expect(cacheControlFor("/og-image.png")).toEqual({});
75
+ });
76
+
77
+ test("path mit assets als Substring (kein /assets/ prefix) → kein immutable", () => {
78
+ // Schutz: /myassets/foo.js soll NICHT immutable kriegen — wäre ein Bug
79
+ // weil die nicht gehashed sind.
80
+ expect(cacheControlFor("/myassets/foo.js")).toEqual({});
81
+ expect(cacheControlFor("/foo/assets/bar.js")).toEqual({});
82
+ });
83
+ });