@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.
- package/LICENSE +57 -0
- package/package.json +87 -0
- package/src/__tests__/boot-extra-context.test.ts +140 -0
- package/src/__tests__/build-prod-bundle.test.ts +278 -0
- package/src/__tests__/cache-headers.test.ts +83 -0
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +308 -0
- package/src/__tests__/compose-features-wiring.integration.test.ts +382 -0
- package/src/__tests__/compose-features.test.ts +128 -0
- package/src/__tests__/config-seed-boot.integration.test.ts +158 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/pii-boot-gate.test.ts +68 -0
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +85 -0
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +35 -0
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/resolve-auth-mail.test.ts +69 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +81 -0
- package/src/__tests__/run-prod-app-env-source.test.ts +157 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.test.ts +915 -0
- package/src/__tests__/session-wiring.test.ts +51 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/boot/__tests__/job-run-logger.test.ts +26 -0
- package/src/boot/apply-boot-seeds.ts +19 -0
- package/src/boot/boot-crypto.ts +82 -0
- package/src/boot/job-run-logger.ts +51 -0
- package/src/build-prod-bundle.ts +692 -0
- package/src/bun-serve-options.ts +22 -0
- package/src/compose-features.ts +164 -0
- package/src/extra-routes-deps.ts +47 -0
- package/src/index.ts +16 -0
- package/src/inject-schema.ts +30 -0
- package/src/pii-boot-gate.ts +62 -0
- package/src/resolve-tailwind-cli.ts +45 -0
- package/src/run-prod-app-boot-context.ts +241 -0
- package/src/run-prod-app-static-files.ts +273 -0
- package/src/run-prod-app.ts +1137 -0
- package/src/session-wiring.ts +29 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Unit-Tests für resolveTailwindCli — die zwei Failure-Branches sind
|
|
2
|
+
// genau das, was den dev-server-Crash bei flakigem Netz verhindert:
|
|
3
|
+
// kein Bun → undefined, Package nicht installiert → undefined.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { canResolveTailwindStylesheet, resolveTailwindCli } from "../resolve-tailwind-cli";
|
|
7
|
+
|
|
8
|
+
describe("resolveTailwindCli", () => {
|
|
9
|
+
test("ohne Bun-Resolver → undefined (silent skip)", () => {
|
|
10
|
+
const out = resolveTailwindCli({ bun: undefined, cwd: "/somewhere" });
|
|
11
|
+
expect(out).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Bun.resolveSync wirft → undefined", () => {
|
|
15
|
+
const out = resolveTailwindCli({
|
|
16
|
+
bun: {
|
|
17
|
+
resolveSync: () => {
|
|
18
|
+
throw new Error("module not found");
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
cwd: "/somewhere",
|
|
22
|
+
});
|
|
23
|
+
expect(out).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("Bun.resolveSync liefert package.json → absoluter Bin-Pfad", () => {
|
|
27
|
+
const out = resolveTailwindCli({
|
|
28
|
+
bun: {
|
|
29
|
+
resolveSync: () => "/repo/node_modules/@tailwindcss/cli/package.json",
|
|
30
|
+
},
|
|
31
|
+
cwd: "/repo",
|
|
32
|
+
});
|
|
33
|
+
expect(out).toBe("/repo/node_modules/@tailwindcss/cli/dist/index.mjs");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Bun-Resolver wird mit korrektem id und cwd aufgerufen", () => {
|
|
37
|
+
const calls: Array<{ id: string; from: string }> = [];
|
|
38
|
+
resolveTailwindCli({
|
|
39
|
+
bun: {
|
|
40
|
+
resolveSync: (id, from) => {
|
|
41
|
+
calls.push({ id, from });
|
|
42
|
+
return "/x/node_modules/@tailwindcss/cli/package.json";
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
cwd: "/some/working/dir",
|
|
46
|
+
});
|
|
47
|
+
expect(calls).toEqual([{ id: "@tailwindcss/cli/package.json", from: "/some/working/dir" }]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("canResolveTailwindStylesheet", () => {
|
|
52
|
+
test("tailwindcss resolvable from entry dir → true", () => {
|
|
53
|
+
const out = canResolveTailwindStylesheet("/repo/packages/app/src/styles.css", {
|
|
54
|
+
bun: {
|
|
55
|
+
resolveSync: (id, from) => {
|
|
56
|
+
if (id === "tailwindcss" && from === "/repo/packages/app/src") {
|
|
57
|
+
return "/repo/node_modules/tailwindcss/index.css";
|
|
58
|
+
}
|
|
59
|
+
throw new Error("not found");
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
cwd: "/repo/packages/app",
|
|
63
|
+
});
|
|
64
|
+
expect(out).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("tailwindcss not resolvable from entry dir → false", () => {
|
|
68
|
+
const out = canResolveTailwindStylesheet(
|
|
69
|
+
"/cache/@cosmicdrift/kumiko-renderer-web/src/styles.css",
|
|
70
|
+
{
|
|
71
|
+
bun: {
|
|
72
|
+
resolveSync: () => {
|
|
73
|
+
throw new Error("not found");
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
cwd: "/tmp/minimal-fixture",
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
expect(out).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Regression: the boot-path must read the injected `envSource`, not the real
|
|
2
|
+
// process.env. Boot-mode (KUMIKO_DRY_RUN_ENV=boot) validates wiring + builds
|
|
3
|
+
// the registry, then tears down the lazy DB/Redis clients before any socket
|
|
4
|
+
// opens — so this runs without a real Postgres/Redis (same as the CI boot
|
|
5
|
+
// smoke). Before the fix, requireEnv/readEnv read process.env directly, so the
|
|
6
|
+
// required-var test would throw "required env var DATABASE_URL is missing" and
|
|
7
|
+
// the PORT test would bind the default instead of the injected port.
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
createBooleanField,
|
|
12
|
+
createEntity,
|
|
13
|
+
createTextField,
|
|
14
|
+
defineFeature,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { runProdApp } from "../run-prod-app";
|
|
18
|
+
|
|
19
|
+
const probeEntity = createEntity({
|
|
20
|
+
fields: {
|
|
21
|
+
name: createTextField({ required: true }),
|
|
22
|
+
active: createBooleanField({ default: true }),
|
|
23
|
+
},
|
|
24
|
+
table: "env_source_probe",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const probeFeature = defineFeature("env-source-probe", (r) => {
|
|
28
|
+
r.entity("widget", probeEntity);
|
|
29
|
+
r.queryHandler({
|
|
30
|
+
name: "ping",
|
|
31
|
+
schema: z.object({}),
|
|
32
|
+
access: { roles: ["anonymous"] },
|
|
33
|
+
handler: async () => ({ pong: true }),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Cleared from process.env so the test fully controls config via envSource.
|
|
38
|
+
// DATABASE_URL/REDIS_URL/JWT_SECRET are required (their read throws pre-fix);
|
|
39
|
+
// PORT is non-throwing, cleared only so ambient PORT can't mask the second
|
|
40
|
+
// test's "PORT comes from envSource" assertion.
|
|
41
|
+
const CLEARED_VARS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
|
|
42
|
+
|
|
43
|
+
const DUMMY_ENV = {
|
|
44
|
+
KUMIKO_DRY_RUN_ENV: "boot",
|
|
45
|
+
DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:1/smoke",
|
|
46
|
+
REDIS_URL: "redis://127.0.0.1:1",
|
|
47
|
+
JWT_SECRET: "smokesmokesmokesmokesmokesmokesmokesmoke",
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
describe("runProdApp boot-mode env-source", () => {
|
|
51
|
+
const saved: Record<string, string | undefined> = {};
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
for (const k of CLEARED_VARS) {
|
|
55
|
+
saved[k] = process.env[k];
|
|
56
|
+
delete process.env[k];
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
for (const k of CLEARED_VARS) {
|
|
62
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
63
|
+
else process.env[k] = saved[k];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("boots from injected envSource even when process.env lacks the required vars", async () => {
|
|
68
|
+
const logs: string[] = [];
|
|
69
|
+
const originalLog = console.log;
|
|
70
|
+
console.log = (...args: unknown[]) => {
|
|
71
|
+
logs.push(args.map(String).join(" "));
|
|
72
|
+
};
|
|
73
|
+
let handle: Awaited<ReturnType<typeof runProdApp>>;
|
|
74
|
+
try {
|
|
75
|
+
handle = await runProdApp({
|
|
76
|
+
features: [probeFeature],
|
|
77
|
+
autoListen: false,
|
|
78
|
+
migrations: false,
|
|
79
|
+
// REDIS_URL points at an unreachable port — boot-mode must NOT
|
|
80
|
+
// construct the (eager) Redis client, so this never tries to connect.
|
|
81
|
+
envSource: { ...DUMMY_ENV },
|
|
82
|
+
});
|
|
83
|
+
} finally {
|
|
84
|
+
console.log = originalLog;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Boot-mode with an injected envSource returns an inert dry-run handle.
|
|
88
|
+
expect(handle).toBeDefined();
|
|
89
|
+
expect(typeof handle.stop).toBe("function");
|
|
90
|
+
// The registry was built + validated before any connection was opened.
|
|
91
|
+
expect(logs.some((line) => line.includes("boot validation OK"))).toBe(true);
|
|
92
|
+
await handle.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("boot-mode constructs no eager Redis client — kein TCP-Connect auf REDIS_URL", async () => {
|
|
96
|
+
// Kern-Garantie (224/2), als Netzwerk-Beweis statt Konstruktor-Spy:
|
|
97
|
+
// REDIS_URL zeigt auf einen lokalen Listener — `new Redis(...)`
|
|
98
|
+
// connectet eager, der Boot-Exit MUSS also vorher liegen, sonst
|
|
99
|
+
// zählt der Listener eine Connection.
|
|
100
|
+
let connections = 0;
|
|
101
|
+
const listener = Bun.listen({
|
|
102
|
+
hostname: "127.0.0.1",
|
|
103
|
+
port: 0,
|
|
104
|
+
socket: {
|
|
105
|
+
open(socket) {
|
|
106
|
+
connections += 1;
|
|
107
|
+
socket.end();
|
|
108
|
+
},
|
|
109
|
+
data() {},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const originalLog = console.log;
|
|
114
|
+
console.log = () => {};
|
|
115
|
+
try {
|
|
116
|
+
const handle = await runProdApp({
|
|
117
|
+
features: [probeFeature],
|
|
118
|
+
autoListen: false,
|
|
119
|
+
migrations: false,
|
|
120
|
+
envSource: { ...DUMMY_ENV, REDIS_URL: `redis://127.0.0.1:${listener.port}` },
|
|
121
|
+
});
|
|
122
|
+
await handle.stop();
|
|
123
|
+
} finally {
|
|
124
|
+
console.log = originalLog;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Ein eager Connect wäre bereits beim runProdApp-await passiert;
|
|
128
|
+
// kleine Nachfrist für asynchrone Socket-Anläufe.
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
130
|
+
listener.stop(true);
|
|
131
|
+
expect(connections).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("resolves PORT from envSource, not process.env", async () => {
|
|
135
|
+
const logs: string[] = [];
|
|
136
|
+
const originalLog = console.log;
|
|
137
|
+
console.log = (...args: unknown[]) => {
|
|
138
|
+
logs.push(args.map(String).join(" "));
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
const handle = await runProdApp({
|
|
142
|
+
features: [probeFeature],
|
|
143
|
+
autoListen: false,
|
|
144
|
+
migrations: false,
|
|
145
|
+
envSource: { ...DUMMY_ENV, PORT: "8123" },
|
|
146
|
+
});
|
|
147
|
+
await handle.stop();
|
|
148
|
+
} finally {
|
|
149
|
+
console.log = originalLog;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The boot logs "booting Kumiko stack on port <port>" — pre-fix this read
|
|
153
|
+
// process.env["PORT"] (deleted here) and would log the 3000 default.
|
|
154
|
+
expect(logs.some((line) => line.includes("port 8123"))).toBe(true);
|
|
155
|
+
expect(logs.some((line) => line.includes("port 3000"))).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Spec-Tests für die Bun.serve-Options + Heartbeat-Cadence. Diese
|
|
2
|
+
// Konstanten/Defaults haben Live-Bugs verursacht und sind 1-Zeile-
|
|
3
|
+
// revertierbar — die Tests pinsen Intent + akzeptablen Range gegen
|
|
4
|
+
// "looks like a leak"-Reverts oder "bisschen rauf, sollte reichen"-
|
|
5
|
+
// Tweaks.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { SSE_HEARTBEAT_INTERVAL_MS } from "@cosmicdrift/kumiko-framework/api";
|
|
9
|
+
import { buildBunServeOptions } from "../run-prod-app";
|
|
10
|
+
|
|
11
|
+
describe("Bun.serve options for production", () => {
|
|
12
|
+
test("idleTimeout is 0 (disabled) — required for SSE long-lived connections", () => {
|
|
13
|
+
// Bun.serve default ist 10s — ohne Override killt das SSE-Streams
|
|
14
|
+
// mit halbem HTTP/2-RST_STREAM. Spec ist "disabled"; jeder andere
|
|
15
|
+
// Wert (auch ein vermeintlich "großzügiges" 60) bricht SSE sobald
|
|
16
|
+
// ein Client den Tab im Hintergrund hat (kein Heartbeat-Read auf
|
|
17
|
+
// Browser-Seite, Idle-Timer feuert).
|
|
18
|
+
const opts = buildBunServeOptions(0, () => new Response("ok"));
|
|
19
|
+
expect(opts.idleTimeout).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("port + fetch werden 1:1 durchgereicht", () => {
|
|
23
|
+
const fetchHandler = (_req: Request) => new Response("test");
|
|
24
|
+
const opts = buildBunServeOptions(3000, fetchHandler);
|
|
25
|
+
expect(opts.port).toBe(3000);
|
|
26
|
+
expect(opts.fetch).toBe(fetchHandler);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("SSE heartbeat cadence", () => {
|
|
31
|
+
test("liegt unter dem strengsten Edge-Idle-Timeout (Cloudflare 100s)", () => {
|
|
32
|
+
// Bun.serve idleTimeout ist disabled (siehe buildBunServeOptions),
|
|
33
|
+
// damit fällt der Bun-default-10s-Layer raus. Die strengsten
|
|
34
|
+
// verbleibenden Layer sind CDN/LB-Edges:
|
|
35
|
+
// - Cloudflare Edge: 100 s ← strengster realistischer Layer
|
|
36
|
+
// - AWS ALB: 60 s
|
|
37
|
+
// - Nginx default: keep-alive 60 s, aber nicht für Streams
|
|
38
|
+
// Heartbeat muss DEUTLICH darunter liegen damit auch ein verschluckter
|
|
39
|
+
// Frame nicht zur Connection-Death führt — Faktor 5 ist konservativ.
|
|
40
|
+
expect(SSE_HEARTBEAT_INTERVAL_MS).toBeLessThan(60_000);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("liegt nicht zu hoch — DefenseInDepth gegen Bun-Default-Drift", () => {
|
|
44
|
+
// Wenn jemand idleTimeout: 0 wieder rausnimmt (Audit, Linter,
|
|
45
|
+
// Misverständnis), darf der Heartbeat nicht der einzige
|
|
46
|
+
// Schutzwall sein der unter dem 10s-Bun-default liegt. ≤30s
|
|
47
|
+
// gibt Cushion + bleibt deutlich unter Bun-default-10s × 3.
|
|
48
|
+
expect(SSE_HEARTBEAT_INTERVAL_MS).toBeLessThanOrEqual(30_000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("ist >= 5 s — Frequency-Wall gegen versehentliches Network-Spam", () => {
|
|
52
|
+
// 1000 anonyme Viewer × Heartbeat-Frequency darf den Server nicht
|
|
53
|
+
// mit Frames fluten. 5s = 200 frames/s pro 1000 Clients ist OK.
|
|
54
|
+
// Unter 5s wäre eher ein "tippfehler" als bewusste Wahl.
|
|
55
|
+
expect(SSE_HEARTBEAT_INTERVAL_MS).toBeGreaterThanOrEqual(5_000);
|
|
56
|
+
});
|
|
57
|
+
});
|