@cosmicdrift/kumiko-dev-server 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/bin/kumiko-build.ts +85 -0
- package/bin/kumiko-dev.ts +90 -0
- package/package.json +45 -0
- package/src/__tests__/build-prod-bundle.integration.ts +265 -0
- package/src/__tests__/build-prod-bundle.test.ts +262 -0
- package/src/__tests__/cache-headers.test.ts +70 -0
- package/src/__tests__/classify-change.test.ts +87 -0
- package/src/__tests__/compose-features-wiring.integration.ts +352 -0
- package/src/__tests__/compose-features.test.ts +81 -0
- package/src/__tests__/crash-tracker.test.ts +89 -0
- package/src/__tests__/create-kumiko-server.integration.ts +286 -0
- package/src/__tests__/few-shot-corpus.test.ts +311 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/resolve-stylesheet.test.ts +90 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.ts +535 -0
- package/src/__tests__/scaffold-feature.test.ts +143 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/build-prod-bundle.ts +587 -0
- package/src/build-server-bundle.ts +308 -0
- package/src/build.ts +28 -0
- package/src/codegen/__tests__/run-codegen.test.ts +494 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
- package/src/codegen/__tests__/watch.test.ts +186 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/render.ts +225 -0
- package/src/codegen/run-codegen.ts +157 -0
- package/src/codegen/scan-events.ts +574 -0
- package/src/codegen/watch.ts +127 -0
- package/src/compose-features.ts +128 -0
- package/src/crash-tracker.ts +56 -0
- package/src/create-kumiko-server.ts +1010 -0
- package/src/drizzle-config.ts +44 -0
- package/src/drizzle-tables-auth-mode.ts +32 -0
- package/src/drizzle-tables-minimal.ts +22 -0
- package/src/few-shot-corpus.ts +369 -0
- package/src/index.ts +57 -0
- package/src/inject-schema.ts +24 -0
- package/src/resolve-tailwind-cli.ts +28 -0
- package/src/run-dev-app.ts +290 -0
- package/src/run-prod-app.ts +892 -0
- package/src/scaffold-feature.ts +226 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
// runProdApp — production-grade Bootstrap-Wrapper für Kumiko-Apps.
|
|
2
|
+
//
|
|
3
|
+
// Symmetrisch zu runDevApp, aber:
|
|
4
|
+
// - DATABASE_URL / REDIS_URL / JWT_SECRET aus env (fail-fast bei Boot,
|
|
5
|
+
// keine ephemeralen Test-DBs)
|
|
6
|
+
// - Hard Schema-Drift-Gate: prüft drizzle/migrations/_journal vs.
|
|
7
|
+
// __drizzle_migrations + tableExists für jede erwartete Tabelle.
|
|
8
|
+
// KEIN Auto-CREATE TABLE im Boot — Migration ist ein CI-Step
|
|
9
|
+
// (`yarn kumiko migrate apply`), Boot validiert nur. Verhindert
|
|
10
|
+
// Race-Conditions bei Multi-Replica-Deploys + macht Schema-Stand
|
|
11
|
+
// reviewbar in der Pull-Request.
|
|
12
|
+
// - Idempotente Seeds: laufen nur wenn DB leer (über `isDbEmpty`-Probe
|
|
13
|
+
// pro Seed). Re-Boots nach erstem Seed sind no-op.
|
|
14
|
+
// - HTTP-Server via Bun.serve mit graceful SIGTERM/SIGINT → drain().
|
|
15
|
+
// - Auth-Routes + bundled-features auto-mix wenn `auth:` gesetzt
|
|
16
|
+
// (gleiche Logik wie runDevApp).
|
|
17
|
+
//
|
|
18
|
+
// App-Author schreibt:
|
|
19
|
+
// await runProdApp({ features, auth, anonymousAccess, seeds });
|
|
20
|
+
//
|
|
21
|
+
// Container/Coolify setzt:
|
|
22
|
+
// DATABASE_URL=postgresql://...
|
|
23
|
+
// REDIS_URL=redis://...
|
|
24
|
+
// JWT_SECRET=<random-32+>
|
|
25
|
+
// PORT=3000
|
|
26
|
+
// KUMIKO_INSTANCE_ID=<stable per replica>
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
AuthErrors,
|
|
30
|
+
AuthHandlers,
|
|
31
|
+
type EmailVerificationOptions,
|
|
32
|
+
type InviteOptions,
|
|
33
|
+
type PasswordResetOptions,
|
|
34
|
+
type SignupOptions,
|
|
35
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
36
|
+
import {
|
|
37
|
+
type SeedAdminOptions,
|
|
38
|
+
seedAdmin,
|
|
39
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
40
|
+
import { createConfigResolver } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
41
|
+
import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
42
|
+
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
43
|
+
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
44
|
+
import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
45
|
+
import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
46
|
+
import {
|
|
47
|
+
buildAppSchema,
|
|
48
|
+
createRegistry,
|
|
49
|
+
type FeatureDefinition,
|
|
50
|
+
validateBoot,
|
|
51
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
52
|
+
import {
|
|
53
|
+
type ApiEntrypoint,
|
|
54
|
+
type ApiEntrypointOptions,
|
|
55
|
+
createApiEntrypoint,
|
|
56
|
+
} from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
57
|
+
import { assertSchemaCurrent, SchemaDriftError } from "@cosmicdrift/kumiko-framework/migrations";
|
|
58
|
+
import {
|
|
59
|
+
createEntityCache,
|
|
60
|
+
createEventDedup,
|
|
61
|
+
createIdempotencyGuard,
|
|
62
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
63
|
+
import Redis from "ioredis";
|
|
64
|
+
import { ASSETS_DIR } from "./build-prod-bundle";
|
|
65
|
+
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
66
|
+
import { injectSchema } from "./inject-schema";
|
|
67
|
+
import { tryHonoFirst } from "./try-hono-first";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Bun.serve-Options für Production.
|
|
71
|
+
*
|
|
72
|
+
* Spec: idleTimeout: 0 (= disabled). SSE-Streams werden via Heartbeat
|
|
73
|
+
* lebend gehalten (siehe SSE_HEARTBEAT_INTERVAL_MS in framework/api/
|
|
74
|
+
* sse-route.ts), kein Bun-side Idle-Cleanup nötig. Mit dem Default
|
|
75
|
+
* von 10 s killt Bun nach jedem Heartbeat-Gap die Connection mit
|
|
76
|
+
* halbem HTTP/2-RST_STREAM → Browser ERR_HTTP2_PROTOCOL_ERROR.
|
|
77
|
+
*
|
|
78
|
+
* Spec-Test in __tests__/run-prod-app-spec.test.ts pinst die 0 gegen
|
|
79
|
+
* "looks like a leak"-Reverts.
|
|
80
|
+
*/
|
|
81
|
+
export function buildBunServeOptions(
|
|
82
|
+
port: number,
|
|
83
|
+
fetchHandler: (req: Request) => Response | Promise<Response>,
|
|
84
|
+
): {
|
|
85
|
+
readonly port: number;
|
|
86
|
+
readonly fetch: (req: Request) => Response | Promise<Response>;
|
|
87
|
+
readonly idleTimeout: number;
|
|
88
|
+
} {
|
|
89
|
+
return { port, fetch: fetchHandler, idleTimeout: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Strict env-var read. Throws with a clear hint when missing — better
|
|
93
|
+
// than discovering a Postgres-connection-refused 30s into the boot.
|
|
94
|
+
function requireEnv(name: string): string {
|
|
95
|
+
const value = process.env[name];
|
|
96
|
+
if (value === undefined || value === "") {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`runProdApp: required env var "${name}" is missing or empty. ` +
|
|
99
|
+
`Set it in your container env / .env.production / Coolify secrets.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Optional env helper — returns undefined for missing, string for set.
|
|
106
|
+
// Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
|
|
107
|
+
function readEnv(name: string): string | undefined {
|
|
108
|
+
const value = process.env[name];
|
|
109
|
+
return value === undefined || value === "" ? undefined : value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Wrapper-API für den Password-Reset-Flow.
|
|
113
|
+
*
|
|
114
|
+
* Setup = Feature-Options (PasswordResetOptions = hmacSecret +
|
|
115
|
+
* tokenTtlMinutes) PLUS die Mail-Side die der Wrapper an die
|
|
116
|
+
* auth-routes-config durchreicht (sendResetEmail-callback +
|
|
117
|
+
* appResetUrl). Apps geben EINEN Block; run{Prod,Dev}App splittet
|
|
118
|
+
* intern auf composeFeatures(authOptions) für die Feature-Options
|
|
119
|
+
* und auth-routes-config für die Mail-Side. extends-Beziehung
|
|
120
|
+
* pinst die Synchronität: jede Feature-Option ist auch Wrapper-Option. */
|
|
121
|
+
export type PasswordResetSetup = PasswordResetOptions & {
|
|
122
|
+
readonly sendResetEmail: (args: {
|
|
123
|
+
email: string;
|
|
124
|
+
resetUrl: string;
|
|
125
|
+
expiresAt: string;
|
|
126
|
+
}) => Promise<void>;
|
|
127
|
+
/** App-URL des ResetPasswordScreen. Framework appended `?token=…`;
|
|
128
|
+
* KEIN trailing `?` oder `#`. Beispiel: "https://admin.example.com/reset-password" */
|
|
129
|
+
readonly appResetUrl: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
|
|
133
|
+
* PasswordResetSetup — extends EmailVerificationOptions + Mail-Side. */
|
|
134
|
+
export type EmailVerificationSetup = EmailVerificationOptions & {
|
|
135
|
+
readonly sendVerificationEmail: (args: {
|
|
136
|
+
email: string;
|
|
137
|
+
verificationUrl: string;
|
|
138
|
+
expiresAt: string;
|
|
139
|
+
}) => Promise<void>;
|
|
140
|
+
readonly appVerifyUrl: string;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/** Wrapper-API für Magic-Link Self-Signup. Mirror der existing
|
|
144
|
+
* PasswordResetSetup-Struktur — Feature-Options (tokenTtlMinutes,
|
|
145
|
+
* tokenLength) plus die Mail-Side die der Wrapper an die auth-routes-
|
|
146
|
+
* config durchreicht. Anders als reset/verify gibt's KEIN hmacSecret —
|
|
147
|
+
* Signup-Tokens sind opaque random in Redis, nicht HMAC-signed. */
|
|
148
|
+
export type SignupSetup = SignupOptions & {
|
|
149
|
+
readonly sendActivationEmail: (args: {
|
|
150
|
+
email: string;
|
|
151
|
+
activationUrl: string;
|
|
152
|
+
expiresAt: string;
|
|
153
|
+
}) => Promise<void>;
|
|
154
|
+
readonly appActivationUrl: string;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Wrapper-API für Tenant-Invite Magic-Link. Drei accept-Branches im
|
|
158
|
+
* framework, der Wrapper reicht NUR die Mail-Side + appAcceptUrl
|
|
159
|
+
* durch — handler-names sind hardcoded in run-prod-app aus
|
|
160
|
+
* AuthHandlers (analog signup). */
|
|
161
|
+
export type InviteSetup = InviteOptions & {
|
|
162
|
+
readonly sendInviteEmail: (args: {
|
|
163
|
+
email: string;
|
|
164
|
+
inviteUrl: string;
|
|
165
|
+
expiresAt: string;
|
|
166
|
+
role: string;
|
|
167
|
+
}) => Promise<void>;
|
|
168
|
+
readonly appAcceptUrl: string;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export type RunProdAppAuthOptions = {
|
|
172
|
+
/** Initial admin user. Seeded once (idempotent — re-boots check first
|
|
173
|
+
* whether the email is already in the users table). */
|
|
174
|
+
readonly admin: SeedAdminOptions;
|
|
175
|
+
/** Optional override of the login error → HTTP status map. */
|
|
176
|
+
readonly loginErrorStatusMap?: Readonly<Record<string, number>>;
|
|
177
|
+
/** Opt-in: revocable server-side sessions. Caller MUSS
|
|
178
|
+
* `createSessionsFeature()` zu `features` adden — runProdApp wired
|
|
179
|
+
* hier nur die Auth-Callbacks (creator/revoker/checker) gegen die
|
|
180
|
+
* echte db-connection, plus sessionStrictMode=true.
|
|
181
|
+
*
|
|
182
|
+
* Standardverhalten ohne diese Option: stateless JWTs ohne sid
|
|
183
|
+
* (legacy-Verhalten, Kartenhaus existing-Apps unangefasst). */
|
|
184
|
+
readonly sessions?: {
|
|
185
|
+
readonly expiresInMs?: number;
|
|
186
|
+
};
|
|
187
|
+
/** Password-reset flow. When set, /api/auth/request-password-reset +
|
|
188
|
+
* /api/auth/reset-password are mounted as public routes UND der
|
|
189
|
+
* request/confirm-Handler im auth-email-password-Feature wird
|
|
190
|
+
* registriert (sonst dispatchen die Routes ins Leere → 500). */
|
|
191
|
+
readonly passwordReset?: PasswordResetSetup;
|
|
192
|
+
/** Email-verification flow. Symmetric to passwordReset. */
|
|
193
|
+
readonly emailVerification?: EmailVerificationSetup;
|
|
194
|
+
/** Self-Signup flow (Magic-Link). When set, /api/auth/signup-request +
|
|
195
|
+
* /api/auth/signup-confirm are mounted; signup-confirm mintet JWT +
|
|
196
|
+
* Cookies wie ein erfolgreicher login (Auto-Login direkt nach
|
|
197
|
+
* Activation). */
|
|
198
|
+
readonly signup?: SignupSetup;
|
|
199
|
+
/** Tenant-Invite flow (Magic-Link). When set, /api/auth/invite-accept,
|
|
200
|
+
* /api/auth/invite-accept-with-login, /api/auth/invite-signup-complete
|
|
201
|
+
* are mounted. */
|
|
202
|
+
readonly invite?: InviteSetup;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Hook for app-specific seeding — runs after the admin (when auth is
|
|
206
|
+
* active). Each seed is responsible for its own idempotence (seeds are
|
|
207
|
+
* expected to check "is my row already there?" before inserting). */
|
|
208
|
+
export type ProdSeedFn = (deps: {
|
|
209
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
210
|
+
}) => Promise<void>;
|
|
211
|
+
|
|
212
|
+
/** Boot-Time-Deps die `extraContext` + `anonymousAccess` Factories als
|
|
213
|
+
* Argument bekommen. Closure dann in der returned Config (z.B. ein
|
|
214
|
+
* TenantResolver der gegen `db` queriet, oder ein extraContext-Provider
|
|
215
|
+
* der direkt SSE-Events publishen will). Single-source: identisch zu
|
|
216
|
+
* setupTestStack's extraContext-Factory-Shape damit Test/Prod gleich
|
|
217
|
+
* aussehen. */
|
|
218
|
+
export type RunProdAppDeps = {
|
|
219
|
+
readonly db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
220
|
+
readonly redis: import("ioredis").default;
|
|
221
|
+
readonly registry: import("@cosmicdrift/kumiko-framework/engine").Registry;
|
|
222
|
+
readonly sseBroker: SseBroker;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export type AnonymousAccessOption =
|
|
226
|
+
| import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]
|
|
227
|
+
| ((
|
|
228
|
+
deps: RunProdAppDeps,
|
|
229
|
+
) => import("@cosmicdrift/kumiko-framework/api").ServerOptions["anonymousAccess"]);
|
|
230
|
+
|
|
231
|
+
export type ExtraContextOption =
|
|
232
|
+
| Record<string, unknown>
|
|
233
|
+
| ((deps: RunProdAppDeps) => Record<string, unknown>);
|
|
234
|
+
|
|
235
|
+
/** Per-Host Routing-Entscheidung für den staticDir-Fallback. Wird aus
|
|
236
|
+
* hostDispatch returned. Drei Modi:
|
|
237
|
+
* - "html": eine bestimmte HTML-Datei (relativ zu staticDir) servieren,
|
|
238
|
+
* mit optionaler Schema-Injection und CSP. Schema-Injection MUSS
|
|
239
|
+
* explizit eingeschaltet werden (default false) — Public-Domain-
|
|
240
|
+
* Antworten leaken sonst die volle Admin-UI-Schema-Topologie.
|
|
241
|
+
* - "redirect": 301/302 an die angegebene Location.
|
|
242
|
+
* - "not-found": klar abweisen (z.B. unbekannte Subdomain).
|
|
243
|
+
*
|
|
244
|
+
* Wird NUR konsultiert wenn der Pfad sonst auf den HTML-Fallback gehen
|
|
245
|
+
* würde — also für "/", "/index.html", oder SPA-Routen die weder Hono
|
|
246
|
+
* matched noch eine konkrete Disk-Datei treffen. Asset-Pfade (/assets/*)
|
|
247
|
+
* und API-Pfade laufen unabhängig vom Host. */
|
|
248
|
+
export type HostDispatchResult =
|
|
249
|
+
| {
|
|
250
|
+
readonly kind: "html";
|
|
251
|
+
readonly file: string;
|
|
252
|
+
readonly injectSchema?: boolean;
|
|
253
|
+
readonly csp?: string;
|
|
254
|
+
}
|
|
255
|
+
| { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
|
|
256
|
+
| { readonly kind: "not-found" };
|
|
257
|
+
|
|
258
|
+
export type HostDispatchFn = (req: {
|
|
259
|
+
readonly host: string;
|
|
260
|
+
readonly path: string;
|
|
261
|
+
}) => HostDispatchResult;
|
|
262
|
+
|
|
263
|
+
export type RunProdAppOptions = {
|
|
264
|
+
/** App-specific features. config/user/tenant/auth-email-password are
|
|
265
|
+
* auto-mixed when `auth:` is set — don't add them yourself. */
|
|
266
|
+
readonly features: readonly FeatureDefinition[];
|
|
267
|
+
/** Listen-Port. Default 3000 (or $PORT). */
|
|
268
|
+
readonly port?: number;
|
|
269
|
+
/** Auth-mode: standard features + routes wired, admin seeded. */
|
|
270
|
+
readonly auth?: RunProdAppAuthOptions;
|
|
271
|
+
/** Custom seed functions, run after the admin seed (when auth-mode). */
|
|
272
|
+
readonly seeds?: readonly ProdSeedFn[];
|
|
273
|
+
/** Anonymous-access for public endpoints (same shape as runDevApp).
|
|
274
|
+
* Akzeptiert entweder einen statischen Config-Object ODER eine
|
|
275
|
+
* Factory `({db, redis, registry}) => Config` — die Factory wird
|
|
276
|
+
* einmal zur Boot-Zeit aufgerufen, NACHDEM db/redis/registry konstruiert
|
|
277
|
+
* sind. Der Caller closure'd typischerweise db/redis/registry in den
|
|
278
|
+
* TenantResolver damit z.B. ein Subdomain → Tenant-Lookup gegen die
|
|
279
|
+
* DB möglich ist (siehe samples/showcases/publicstatus für das
|
|
280
|
+
* Multi-Tenant-Pattern). */
|
|
281
|
+
readonly anonymousAccess?: AnonymousAccessOption;
|
|
282
|
+
/** Static-file root for HTML / assets. Served on the catch-all route
|
|
283
|
+
* for any path that doesn't match an /api/ handler. Use this for the
|
|
284
|
+
* public status page HTML, embed widget JS, etc. */
|
|
285
|
+
readonly staticDir?: string;
|
|
286
|
+
/** Host-aware Routing-Hook für Multi-Tenant + Multi-App-Deployments
|
|
287
|
+
* (z.B. publicstatus's `<sub>.publicstatus.eu` (Public-Page) +
|
|
288
|
+
* `admin.publicstatus.eu` (Admin-UI) + `publicstatus.eu` (Apex/
|
|
289
|
+
* Marketing) im SELBEN Container).
|
|
290
|
+
*
|
|
291
|
+
* Wird aufgerufen wenn der staticDir-Fallback einen HTML-Response
|
|
292
|
+
* generieren würde (Root oder SPA-Route). Default-Verhalten ohne
|
|
293
|
+
* hostDispatch: index.html mit Schema-Injection (Single-App).
|
|
294
|
+
*
|
|
295
|
+
* Sicherheitshinweis: Schema-Injection (`__KUMIKO_SCHEMA__`) leakt
|
|
296
|
+
* die Admin-UI-Topologie (alle Screens, Felder, Layouts) ans HTML.
|
|
297
|
+
* Public-Domain-Antworten sollen das NIEMALS — `injectSchema` ist
|
|
298
|
+
* daher default false und MUSS pro Host explizit eingeschaltet
|
|
299
|
+
* werden. CSP-Header pro Host können zusätzlich Asset-Pfade
|
|
300
|
+
* einschränken. */
|
|
301
|
+
readonly hostDispatch?: HostDispatchFn;
|
|
302
|
+
/** Pfad zu drizzle/migrations für den Boot-Gate. Default "./drizzle/
|
|
303
|
+
* migrations" relativ zum process-cwd (wo die App gestartet wird —
|
|
304
|
+
* bei Container-Deploys typischerweise der App-Workspace-Root, weil
|
|
305
|
+
* WORKDIR im Dockerfile dorthin zeigt). Boot wirft SchemaDriftError
|
|
306
|
+
* wenn Migrations pending sind oder erwartete Tabellen fehlen.
|
|
307
|
+
* Setze auf `false` um den Gate komplett zu deaktivieren — nur für
|
|
308
|
+
* Setups die ihren eigenen Schema-Check fahren (z.B. bring-your-own-
|
|
309
|
+
* ORM). Standard-Apps lassen das default. */
|
|
310
|
+
readonly migrations?: { readonly dir: string } | false;
|
|
311
|
+
/** Extra AppContext keys. configResolver is auto-set in auth-mode.
|
|
312
|
+
* Akzeptiert entweder einen statischen Object ODER eine Factory
|
|
313
|
+
* `({db, redis, registry}) => Record<string, unknown>` — gleiches
|
|
314
|
+
* Pattern wie `anonymousAccess`. Im Auth-Mode wird `configResolver`
|
|
315
|
+
* weiterhin automatisch ergänzt; Factory-Result + auto-resolver
|
|
316
|
+
* werden gemerged (Factory-Werte überschreiben). */
|
|
317
|
+
readonly extraContext?: ExtraContextOption;
|
|
318
|
+
/** Job-Block. Wenn das Feature `r.job(...)` registriert, MUSS dieser
|
|
319
|
+
* Block gesetzt sein — sonst wirft createApiEntrypoint mit dem
|
|
320
|
+
* expliziten "registry declares N job(s)..."-Fehler. Default-Pattern
|
|
321
|
+
* für Single-Container-Deployments (publicstatus, kleine SaaS):
|
|
322
|
+
* `{ runLocalJobs: true }` — der API-Process consumiert auch die
|
|
323
|
+
* Worker-Lane, kein separates worker-Image nötig. Für skalierende
|
|
324
|
+
* Setups (mehrere API-Replicas + dezidierte Worker): runLocalJobs
|
|
325
|
+
* weglassen + workers via separatem `runWorkerApp` (kommt Phase 4). */
|
|
326
|
+
readonly jobs?: {
|
|
327
|
+
/** Default true (Single-Container). */
|
|
328
|
+
readonly runLocalJobs?: boolean;
|
|
329
|
+
/** BullMQ-Queue-Prefix (default "kumiko"). */
|
|
330
|
+
readonly queueNamePrefix?: string;
|
|
331
|
+
};
|
|
332
|
+
/** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
|
|
333
|
+
* Systems. Aufgerufen NACH /api/* + /health, VOR der static-fallback —
|
|
334
|
+
* perfekt für GET-Endpoints die kein JSON liefern: /feed.xml,
|
|
335
|
+
* /og-image, /sitemap.xml, /robots.txt-mit-Logik. Bekommt das raw
|
|
336
|
+
* Hono-app + die Connection-Deps (db/redis) zum Querying.
|
|
337
|
+
*
|
|
338
|
+
* Naming: `deps` statt `ctx` weil im Framework `ctx` der HandlerContext
|
|
339
|
+
* mit user/tenant/registry ist — hier ist der Scope absichtlich kleiner
|
|
340
|
+
* (Routes laufen außerhalb der Auth/Tenant-Pipeline). */
|
|
341
|
+
readonly extraRoutes?: (
|
|
342
|
+
app: import("hono").Hono,
|
|
343
|
+
deps: {
|
|
344
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection;
|
|
345
|
+
redis: import("ioredis").default;
|
|
346
|
+
},
|
|
347
|
+
) => void;
|
|
348
|
+
/** When true (default), Bun.serve is started before runProdApp resolves —
|
|
349
|
+
* the common case: `await runProdApp({...})` boots the server and the
|
|
350
|
+
* process stays up listening on PORT. Set to false in tests that drive
|
|
351
|
+
* the fetch-handler directly (Bun.serve isn't available under vitest +
|
|
352
|
+
* node), then call handle.listen() manually if needed. */
|
|
353
|
+
readonly autoListen?: boolean;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
export type ProdAppHandle = {
|
|
357
|
+
readonly entrypoint: ApiEntrypoint;
|
|
358
|
+
/** The fetch-handler — wired into Bun.serve in production, called
|
|
359
|
+
* directly in tests. Composes Hono + static-fallback. */
|
|
360
|
+
readonly fetch: (req: Request) => Promise<Response> | Response;
|
|
361
|
+
/** Active Bun-server (only set when listen() was called — tests skip
|
|
362
|
+
* listen() because Bun.serve isn't available under vitest/node). */
|
|
363
|
+
server?: ReturnType<typeof Bun.serve>;
|
|
364
|
+
/** Bind to PORT and start serving. Production calls this; tests don't. */
|
|
365
|
+
readonly listen: (port?: number) => Promise<void>;
|
|
366
|
+
readonly stop: () => Promise<void>;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHandle> {
|
|
370
|
+
// 1. Polyfill before anything else — feature code references Temporal.
|
|
371
|
+
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
372
|
+
await ensureTemporalPolyfill();
|
|
373
|
+
|
|
374
|
+
// 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
|
|
375
|
+
// than a 30s timeout chasing a Postgres connection that was never
|
|
376
|
+
// configured.
|
|
377
|
+
const databaseUrl = requireEnv("DATABASE_URL");
|
|
378
|
+
const redisUrl = requireEnv("REDIS_URL");
|
|
379
|
+
const jwtSecret = requireEnv("JWT_SECRET");
|
|
380
|
+
const jwtIssuer = readEnv("JWT_ISSUER");
|
|
381
|
+
const instanceId = readEnv("KUMIKO_INSTANCE_ID");
|
|
382
|
+
const port = options.port ?? Number.parseInt(process.env["PORT"] ?? "3000", 10);
|
|
383
|
+
|
|
384
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
385
|
+
console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
|
|
386
|
+
|
|
387
|
+
// 3. Connections — Postgres + Redis. The Redis client is shared by
|
|
388
|
+
// idempotency, event-dedup, entity-cache, rate-limit; failing to
|
|
389
|
+
// construct here surfaces the misconfig immediately.
|
|
390
|
+
const { db, close: closeDb } = createDbConnection(databaseUrl);
|
|
391
|
+
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
392
|
+
|
|
393
|
+
// 4. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
|
|
394
|
+
// password via composeFeatures — same source-of-truth as runDevApp
|
|
395
|
+
// AND the per-app drizzle-Schema-Generator, so Migration und Runtime
|
|
396
|
+
// sehen exakt dieselbe Liste.
|
|
397
|
+
const composeAuthOptions = buildComposeAuthOptions(options.auth);
|
|
398
|
+
const features = composeFeatures(options.features, {
|
|
399
|
+
includeBundled: !!options.auth,
|
|
400
|
+
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
validateBoot(features);
|
|
404
|
+
const registry = createRegistry(features);
|
|
405
|
+
|
|
406
|
+
// 5. Schema-Drift-Gate. Drizzle-kit migrate (yarn kumiko migrate apply)
|
|
407
|
+
// läuft als CI-Step VOR dem Container-Rollout. Boot prüft hier nur:
|
|
408
|
+
// (a) Alle Migrations aus drizzle/migrations/meta/_journal.json
|
|
409
|
+
// sind in __drizzle_migrations applied
|
|
410
|
+
// (b) Alle erwarteten Tabellen existieren physisch
|
|
411
|
+
// Drift = Boot-Error mit klarer Meldung (kein Auto-Heal — mehrere
|
|
412
|
+
// Container-Replicas würden sonst race-conditionen beim ALTER TABLE
|
|
413
|
+
// fahren). Opt-out via `migrations: false` für custom Schema-Setups.
|
|
414
|
+
if (options.migrations !== false) {
|
|
415
|
+
const migrationsDir = options.migrations?.dir ?? "./drizzle/migrations";
|
|
416
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint
|
|
417
|
+
console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
|
|
418
|
+
try {
|
|
419
|
+
await assertSchemaCurrent(db, migrationsDir);
|
|
420
|
+
} catch (err) {
|
|
421
|
+
if (err instanceof SchemaDriftError) {
|
|
422
|
+
// biome-ignore lint/suspicious/noConsole: terminal error message
|
|
423
|
+
console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
|
|
424
|
+
}
|
|
425
|
+
throw err;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
|
|
430
|
+
const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
|
|
431
|
+
const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
|
|
432
|
+
const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
|
|
433
|
+
|
|
434
|
+
// 7. Lifecycle is built by createApiEntrypoint when not supplied —
|
|
435
|
+
// we let the entrypoint own it and read it back through the handle
|
|
436
|
+
// for SIGTERM.
|
|
437
|
+
//
|
|
438
|
+
// extraContext + anonymousAccess sind factory-union: entweder direktes
|
|
439
|
+
// Object oder Function die {db, redis, registry} bekommt und das Object
|
|
440
|
+
// returned. Factory-Form gilt als bevorzugt für Cases die zur Boot-Zeit
|
|
441
|
+
// gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
|
|
442
|
+
// tenantResolver) — die Factory closure'd `db` und der Resolver kann
|
|
443
|
+
// sie zur Request-Zeit aufrufen.
|
|
444
|
+
// sseBroker hier bauen (statt's createApiEntrypoint intern machen zu
|
|
445
|
+
// lassen) damit extraContext-Factories ihn schon zur Boot-Zeit closure'n
|
|
446
|
+
// können — z.B. ein extraContext-Provider der direkt SSE-Events
|
|
447
|
+
// publisht. Wir reichen denselben Broker dann an createApiEntrypoint
|
|
448
|
+
// durch (sseBroker?-option), damit der Server-internal-Broadcast und
|
|
449
|
+
// App-spezifische Publishes über genau einen Broker laufen.
|
|
450
|
+
const sseBroker = createSseBroker();
|
|
451
|
+
const deps: RunProdAppDeps = { db, redis, registry, sseBroker };
|
|
452
|
+
const resolvedExtraContext =
|
|
453
|
+
typeof options.extraContext === "function"
|
|
454
|
+
? options.extraContext(deps)
|
|
455
|
+
: (options.extraContext ?? {});
|
|
456
|
+
const extraContext = options.auth
|
|
457
|
+
? { configResolver: createConfigResolver(), ...resolvedExtraContext }
|
|
458
|
+
: resolvedExtraContext;
|
|
459
|
+
const resolvedAnonymousAccess =
|
|
460
|
+
typeof options.anonymousAccess === "function"
|
|
461
|
+
? options.anonymousAccess(deps)
|
|
462
|
+
: options.anonymousAccess;
|
|
463
|
+
|
|
464
|
+
// Sessions opt-in: db ist hier schon konkret (createDbConnection oben),
|
|
465
|
+
// also direkt verdrahten — kein late-bound nötig wie bei runDevApp.
|
|
466
|
+
// sessionStrictMode=true: Prod-Sessions sollen nicht stillschweigend
|
|
467
|
+
// von einem JWT-ohne-sid umgangen werden können. sessionMassRevoker
|
|
468
|
+
// (4. callback aus createSessionCallbacks) ist nicht Teil der
|
|
469
|
+
// AuthRoutesConfig-Surface — der wird vom sessions-Feature selbst über
|
|
470
|
+
// die `autoRevokeOnPasswordChange`-Option konsumiert, nicht über die
|
|
471
|
+
// auth-routes.
|
|
472
|
+
const sessionAuthFragment = options.auth?.sessions
|
|
473
|
+
? buildProdSessionAuth(db, options.auth.sessions)
|
|
474
|
+
: undefined;
|
|
475
|
+
|
|
476
|
+
const entrypoint = createApiEntrypoint({
|
|
477
|
+
registry,
|
|
478
|
+
context: {
|
|
479
|
+
db,
|
|
480
|
+
redis,
|
|
481
|
+
entityCache,
|
|
482
|
+
registry,
|
|
483
|
+
...extraContext,
|
|
484
|
+
},
|
|
485
|
+
sseBroker,
|
|
486
|
+
jwtSecret,
|
|
487
|
+
...(jwtIssuer && { jwtIssuer }),
|
|
488
|
+
...(instanceId && { instanceId }),
|
|
489
|
+
dispatcherOptions: { idempotency },
|
|
490
|
+
eventDedup,
|
|
491
|
+
...(options.auth && {
|
|
492
|
+
auth: {
|
|
493
|
+
membershipQuery: TenantQueries.memberships,
|
|
494
|
+
userQuery: UserQueries.findForAuth,
|
|
495
|
+
loginHandler: AuthHandlers.login,
|
|
496
|
+
loginErrorStatusMap: options.auth.loginErrorStatusMap ?? {
|
|
497
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
498
|
+
[AuthErrors.noMembership]: 403,
|
|
499
|
+
},
|
|
500
|
+
...sessionAuthFragment,
|
|
501
|
+
...(options.auth.passwordReset && {
|
|
502
|
+
passwordReset: {
|
|
503
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
504
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
505
|
+
sendResetEmail: options.auth.passwordReset.sendResetEmail,
|
|
506
|
+
appResetUrl: options.auth.passwordReset.appResetUrl,
|
|
507
|
+
},
|
|
508
|
+
}),
|
|
509
|
+
...(options.auth.emailVerification && {
|
|
510
|
+
emailVerification: {
|
|
511
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
512
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
513
|
+
sendVerificationEmail: options.auth.emailVerification.sendVerificationEmail,
|
|
514
|
+
appVerifyUrl: options.auth.emailVerification.appVerifyUrl,
|
|
515
|
+
},
|
|
516
|
+
}),
|
|
517
|
+
...(options.auth.signup && {
|
|
518
|
+
signup: {
|
|
519
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
520
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
521
|
+
sendActivationEmail: options.auth.signup.sendActivationEmail,
|
|
522
|
+
appActivationUrl: options.auth.signup.appActivationUrl,
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
...(options.auth.invite && {
|
|
526
|
+
invite: {
|
|
527
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
528
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
529
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
530
|
+
sendInviteEmail: options.auth.invite.sendInviteEmail,
|
|
531
|
+
appAcceptUrl: options.auth.invite.appAcceptUrl,
|
|
532
|
+
},
|
|
533
|
+
}),
|
|
534
|
+
},
|
|
535
|
+
}),
|
|
536
|
+
...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
|
|
537
|
+
// Auto-Pass-Through für r.job-Wiring: wenn das Registry Jobs
|
|
538
|
+
// deklariert, MUSS der jobs-Block gesetzt sein — sonst stoppt
|
|
539
|
+
// createApiEntrypoint mit explizitem Fehler. Default für Single-
|
|
540
|
+
// Container-Deployments: runLocalJobs=true (API-Process consumiert
|
|
541
|
+
// auch worker-Lane). Caller kann override'n via options.jobs.
|
|
542
|
+
...(registry.getAllJobs().size > 0 && {
|
|
543
|
+
jobs: {
|
|
544
|
+
redisUrl,
|
|
545
|
+
runLocalJobs: options.jobs?.runLocalJobs ?? true,
|
|
546
|
+
...(options.jobs?.queueNamePrefix !== undefined && {
|
|
547
|
+
queueNamePrefix: options.jobs.queueNamePrefix,
|
|
548
|
+
}),
|
|
549
|
+
},
|
|
550
|
+
}),
|
|
551
|
+
} satisfies ApiEntrypointOptions);
|
|
552
|
+
|
|
553
|
+
// 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
|
|
554
|
+
// in die index.html injiziert damit createKumikoApp() im Browser
|
|
555
|
+
// `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
|
|
556
|
+
// wie im dev-server, damit der Client-Code keine Sonderfall-
|
|
557
|
+
// Branch zwischen dev/prod braucht. Boot-once weil Features
|
|
558
|
+
// nach dem Start nicht mehr ändern.
|
|
559
|
+
// TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
|
|
560
|
+
// pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
|
|
561
|
+
// Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
|
|
562
|
+
// statt eines fixed JSON-Strings. Heute: registry-static, also OK.
|
|
563
|
+
const appSchemaJson = JSON.stringify(buildAppSchema(registry));
|
|
564
|
+
|
|
565
|
+
// 9. Seeds: admin first, then app-specific. Both expected to be
|
|
566
|
+
// idempotent — runProdApp doesn't gate "first boot" via flag,
|
|
567
|
+
// seeds check their own preconditions. seedAdmin checks email,
|
|
568
|
+
// app seeds typically check "is my fixture row there?".
|
|
569
|
+
if (options.auth) {
|
|
570
|
+
await seedAdmin(db, options.auth.admin);
|
|
571
|
+
}
|
|
572
|
+
for (const seed of options.seeds ?? []) {
|
|
573
|
+
await seed({ db });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
await entrypoint.start();
|
|
577
|
+
|
|
578
|
+
// 10. App-eigene HTTP-Routes mounten — vor dem static-fallback. Hono
|
|
579
|
+
// matcht in Eintrags-Reihenfolge, also greifen explizite Routen
|
|
580
|
+
// der App (z.B. /feed.xml) bevor der Static-Fallback nach Disk-
|
|
581
|
+
// Files sucht. Eingehende /api/*-Pfade sind schon vom dispatcher
|
|
582
|
+
// belegt; extraRoutes sollte die nicht überschreiben (kein
|
|
583
|
+
// enforce, das ist Author-Verantwortung).
|
|
584
|
+
if (options.extraRoutes) {
|
|
585
|
+
options.extraRoutes(entrypoint.app, { db, redis });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 11. Build the fetch-handler. Static-fallback for non-/api/ paths
|
|
589
|
+
// wired via a wrapper so Hono owns /api/* + extraRoutes and disk
|
|
590
|
+
// owns the rest. Tests use this directly; listen() wraps it in
|
|
591
|
+
// Bun.serve.
|
|
592
|
+
const fetchHandler = options.staticDir
|
|
593
|
+
? buildStaticFallback(
|
|
594
|
+
entrypoint.app.fetch.bind(entrypoint.app),
|
|
595
|
+
options.staticDir,
|
|
596
|
+
appSchemaJson,
|
|
597
|
+
options.hostDispatch,
|
|
598
|
+
)
|
|
599
|
+
: entrypoint.app.fetch.bind(entrypoint.app);
|
|
600
|
+
|
|
601
|
+
// 11. Mark lifecycle ready — health/ready flips to 200 after this.
|
|
602
|
+
entrypoint.lifecycle.markReady();
|
|
603
|
+
|
|
604
|
+
const handle: ProdAppHandle = {
|
|
605
|
+
entrypoint,
|
|
606
|
+
fetch: fetchHandler,
|
|
607
|
+
listen: async (listenPort = port) => {
|
|
608
|
+
// Bun.serve is the production HTTP. Tests don't call listen()
|
|
609
|
+
// because vitest runs under Node where Bun.serve doesn't exist.
|
|
610
|
+
// Options-Shape (inkl. idleTimeout: 0 für SSE) liegt in der
|
|
611
|
+
// exportierten buildBunServeOptions-Funktion — siehe ihren
|
|
612
|
+
// Header für die Begründung.
|
|
613
|
+
if (typeof (globalThis as { Bun?: unknown }).Bun === "undefined") {
|
|
614
|
+
// Klare Fehlermeldung statt nackter ReferenceError. Trifft wenn
|
|
615
|
+
// jemand listen() unter Node/vitest aufruft ohne autoListen:false
|
|
616
|
+
// — hilft beim Debug, statt sich an "Bun is not defined" abzumühen.
|
|
617
|
+
throw new Error(
|
|
618
|
+
"[runProdApp] listen() requires Bun runtime (Bun.serve). " +
|
|
619
|
+
"Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler));
|
|
623
|
+
|
|
624
|
+
// SIGTERM/SIGINT — graceful shutdown. Only registered when we
|
|
625
|
+
// actually own a Bun-server, otherwise the test process picks up
|
|
626
|
+
// signals it shouldn't respond to.
|
|
627
|
+
let shuttingDown = false;
|
|
628
|
+
const shutdown = async (signal: string) => {
|
|
629
|
+
if (shuttingDown) return;
|
|
630
|
+
shuttingDown = true;
|
|
631
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
632
|
+
console.log(`[runProdApp] ${signal} received — draining…`);
|
|
633
|
+
try {
|
|
634
|
+
await handle.stop();
|
|
635
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
636
|
+
console.log("[runProdApp] graceful shutdown complete.");
|
|
637
|
+
} catch (e) {
|
|
638
|
+
// biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
|
|
639
|
+
console.error("[runProdApp] error during shutdown:", e);
|
|
640
|
+
} finally {
|
|
641
|
+
process.exit(0);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
645
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
646
|
+
|
|
647
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
648
|
+
console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
|
|
649
|
+
},
|
|
650
|
+
stop: async () => {
|
|
651
|
+
await entrypoint.stop();
|
|
652
|
+
handle.server?.stop();
|
|
653
|
+
await closeDb();
|
|
654
|
+
redis.disconnect();
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
|
|
659
|
+
// false because Bun.serve isn't available under vitest/node).
|
|
660
|
+
// Production path: `await runProdApp({...})` and the server is up.
|
|
661
|
+
if (options.autoListen !== false) {
|
|
662
|
+
await handle.listen();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return handle;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Static-fallback: try the Hono app first, fall back to a file in
|
|
669
|
+
// staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
|
|
670
|
+
// everything else (HTML, JS, CSS, images) on the disk.
|
|
671
|
+
//
|
|
672
|
+
// Cache-Header-Strategie:
|
|
673
|
+
// /assets/* → public, max-age=31536000, immutable
|
|
674
|
+
// (gehashte Filenames vom Build, sicher cachebar)
|
|
675
|
+
// /index.html → no-cache, must-revalidate
|
|
676
|
+
// (HTML-Shell, must reload on deploy)
|
|
677
|
+
// /manifest.json, /sw.js → no-cache
|
|
678
|
+
// (Update-Detection-Mechanismen, müssen frisch sein)
|
|
679
|
+
// alles andere → kein expliziter Header
|
|
680
|
+
// (Browser-Default, public/-Files wie favicon)
|
|
681
|
+
// File-reader für den static-fallback. Nutzt node:fs/promises statt
|
|
682
|
+
// Bun.file damit der Pfad in vitest+node integration-tests laufen kann
|
|
683
|
+
// (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
|
|
684
|
+
// Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
|
|
685
|
+
// ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
|
|
686
|
+
// ~1 MB.
|
|
687
|
+
async function readStaticFile(
|
|
688
|
+
filePath: string,
|
|
689
|
+
): Promise<{ readonly bytes: Uint8Array; readonly mime: string } | undefined> {
|
|
690
|
+
try {
|
|
691
|
+
const { readFile } = await import("node:fs/promises");
|
|
692
|
+
const bytes = await readFile(filePath);
|
|
693
|
+
return { bytes, mime: mimeTypeFor(filePath) };
|
|
694
|
+
} catch (err) {
|
|
695
|
+
if ((err as { code?: string }).code === "ENOENT") return undefined;
|
|
696
|
+
throw err;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
|
|
701
|
+
// public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
|
|
702
|
+
// im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
|
|
703
|
+
// fragt bei unbekanntem MIME nach).
|
|
704
|
+
function mimeTypeFor(filePath: string): string {
|
|
705
|
+
const ext = filePath.toLowerCase().split(".").pop() ?? "";
|
|
706
|
+
switch (ext) {
|
|
707
|
+
case "html":
|
|
708
|
+
return "text/html; charset=utf-8";
|
|
709
|
+
case "js":
|
|
710
|
+
case "mjs":
|
|
711
|
+
return "text/javascript; charset=utf-8";
|
|
712
|
+
case "css":
|
|
713
|
+
return "text/css; charset=utf-8";
|
|
714
|
+
case "json":
|
|
715
|
+
return "application/json; charset=utf-8";
|
|
716
|
+
case "svg":
|
|
717
|
+
return "image/svg+xml";
|
|
718
|
+
case "png":
|
|
719
|
+
return "image/png";
|
|
720
|
+
case "jpg":
|
|
721
|
+
case "jpeg":
|
|
722
|
+
return "image/jpeg";
|
|
723
|
+
case "ico":
|
|
724
|
+
return "image/x-icon";
|
|
725
|
+
case "txt":
|
|
726
|
+
return "text/plain; charset=utf-8";
|
|
727
|
+
case "xml":
|
|
728
|
+
return "application/xml; charset=utf-8";
|
|
729
|
+
case "webmanifest":
|
|
730
|
+
return "application/manifest+json";
|
|
731
|
+
default:
|
|
732
|
+
return "application/octet-stream";
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function buildStaticFallback(
|
|
737
|
+
apiHandler: (req: Request) => Response | Promise<Response>,
|
|
738
|
+
staticDir: string,
|
|
739
|
+
appSchemaJson: string,
|
|
740
|
+
hostDispatch?: HostDispatchFn,
|
|
741
|
+
): (req: Request) => Promise<Response> {
|
|
742
|
+
const indexHtml = `${staticDir}/index.html`;
|
|
743
|
+
|
|
744
|
+
// Helper: liest eine HTML-Datei von der Disk + (optional) injiziert
|
|
745
|
+
// das pre-serialized AppSchema vor dem client.js-Tag. Schema-Injection
|
|
746
|
+
// ist explicit-opt-in damit Public-Domain-Antworten die Admin-UI-
|
|
747
|
+
// Topologie nicht leaken. injectSchema ist idempotent, doppelte Calls
|
|
748
|
+
// produzieren keinen doppelten Tag.
|
|
749
|
+
async function readHtmlFile(
|
|
750
|
+
path: string,
|
|
751
|
+
injectSchemaInto: boolean,
|
|
752
|
+
): Promise<{ bytes: ArrayBuffer; mime: string } | null> {
|
|
753
|
+
const file = await readStaticFile(path);
|
|
754
|
+
if (!file) return null;
|
|
755
|
+
if (!injectSchemaInto) {
|
|
756
|
+
return {
|
|
757
|
+
bytes: file.bytes.buffer.slice(
|
|
758
|
+
file.bytes.byteOffset,
|
|
759
|
+
file.bytes.byteOffset + file.bytes.byteLength,
|
|
760
|
+
) as ArrayBuffer,
|
|
761
|
+
mime: file.mime,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
const text = new TextDecoder().decode(file.bytes);
|
|
765
|
+
const injected = injectSchema(text, appSchemaJson);
|
|
766
|
+
return { bytes: new TextEncoder().encode(injected).buffer as ArrayBuffer, mime: file.mime };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// hostDispatch konsultieren wenn gesetzt UND der Request auf den
|
|
770
|
+
// HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
|
|
771
|
+
// resolved Response (redirect/404/html) oder null wenn der Default-
|
|
772
|
+
// Pfad weiterlaufen soll.
|
|
773
|
+
async function tryHostDispatch(req: Request): Promise<Response | null> {
|
|
774
|
+
if (!hostDispatch) return null;
|
|
775
|
+
const url = new URL(req.url);
|
|
776
|
+
const host = req.headers.get("host") ?? url.host;
|
|
777
|
+
const result = hostDispatch({ host, path: url.pathname });
|
|
778
|
+
if (result.kind === "not-found") {
|
|
779
|
+
return new Response("Not Found", { status: 404 });
|
|
780
|
+
}
|
|
781
|
+
if (result.kind === "redirect") {
|
|
782
|
+
return new Response(null, {
|
|
783
|
+
status: result.status ?? 302,
|
|
784
|
+
headers: { Location: result.to },
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
// result.kind === "html"
|
|
788
|
+
const filePath = `${staticDir}/${result.file}`;
|
|
789
|
+
const html = await readHtmlFile(filePath, result.injectSchema === true);
|
|
790
|
+
if (!html) {
|
|
791
|
+
// Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
|
|
792
|
+
// Liefer 500 statt silent-404 damit der Bug schnell auffällt.
|
|
793
|
+
return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
|
|
794
|
+
}
|
|
795
|
+
const headers: Record<string, string> = {
|
|
796
|
+
...cacheHeadersFor("/index.html"),
|
|
797
|
+
"content-type": html.mime,
|
|
798
|
+
};
|
|
799
|
+
if (result.csp) headers["content-security-policy"] = result.csp;
|
|
800
|
+
return new Response(html.bytes, { headers });
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return async (req: Request): Promise<Response> => {
|
|
804
|
+
const url = new URL(req.url);
|
|
805
|
+
// /api/* and /health → always Hono (Dispatcher + Health-Probe).
|
|
806
|
+
if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
|
|
807
|
+
return apiHandler(req);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
|
|
811
|
+
// /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
|
|
812
|
+
// dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
|
|
813
|
+
// unbekannte Pfade als index.html. Shared mit dev-server's
|
|
814
|
+
// createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
|
|
815
|
+
const honoTry = await tryHonoFirst({ fetch: apiHandler }, req);
|
|
816
|
+
if (honoTry.matched) {
|
|
817
|
+
return honoTry.response;
|
|
818
|
+
}
|
|
819
|
+
const honoRes = honoTry.response;
|
|
820
|
+
|
|
821
|
+
// Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
|
|
822
|
+
// host-unabhängig — die Bundles in /assets/* werden vom client
|
|
823
|
+
// aktiv geladen, kein Server-side Routing nötig.
|
|
824
|
+
const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
|
|
825
|
+
if (!isIndexRequest) {
|
|
826
|
+
const relPath = url.pathname.slice(1);
|
|
827
|
+
const filePath = `${staticDir}/${relPath}`;
|
|
828
|
+
const file = await readStaticFile(filePath);
|
|
829
|
+
if (file) {
|
|
830
|
+
// @cast-boundary bun-types — Response BodyInit narrowing
|
|
831
|
+
return new Response(file.bytes as unknown as BodyInit, {
|
|
832
|
+
headers: { ...cacheHeadersFor(url.pathname), "content-type": file.mime },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Root oder SPA-Route — hier greift hostDispatch wenn gesetzt.
|
|
838
|
+
// Ohne hostDispatch: alter Single-App-Pfad (index.html mit Schema).
|
|
839
|
+
const dispatched = await tryHostDispatch(req);
|
|
840
|
+
if (dispatched) return dispatched;
|
|
841
|
+
|
|
842
|
+
// Default Single-App-Pfad: index.html, schema injected.
|
|
843
|
+
const index = await readHtmlFile(indexHtml, true);
|
|
844
|
+
if (index) {
|
|
845
|
+
return new Response(index.bytes, {
|
|
846
|
+
headers: { ...cacheHeadersFor("/index.html"), "content-type": index.mime },
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
|
|
851
|
+
// ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
|
|
852
|
+
return honoRes;
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Map URL-Pfad → Cache-Control. Hashed-Asset-Pfade (/assets/*) sind
|
|
857
|
+
// unveränderlich, der Rest bleibt no-cache damit Updates ohne Hard-
|
|
858
|
+
// Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
|
|
859
|
+
// runProdApp.
|
|
860
|
+
export function cacheHeadersFor(pathname: string): Record<string, string> {
|
|
861
|
+
if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
|
|
862
|
+
return { "cache-control": "public, max-age=31536000, immutable" };
|
|
863
|
+
}
|
|
864
|
+
if (pathname === "/" || pathname === "/index.html") {
|
|
865
|
+
return { "cache-control": "no-cache, must-revalidate" };
|
|
866
|
+
}
|
|
867
|
+
if (pathname === "/manifest.json" || pathname === "/sw.js") {
|
|
868
|
+
return { "cache-control": "no-cache" };
|
|
869
|
+
}
|
|
870
|
+
return {};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function buildProdSessionAuth(
|
|
874
|
+
db: import("@cosmicdrift/kumiko-framework/db").DbConnection,
|
|
875
|
+
opts: NonNullable<RunProdAppAuthOptions["sessions"]>,
|
|
876
|
+
): {
|
|
877
|
+
readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
|
|
878
|
+
readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
|
|
879
|
+
readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
|
|
880
|
+
readonly sessionStrictMode: true;
|
|
881
|
+
} {
|
|
882
|
+
const cbs = createSessionCallbacks({
|
|
883
|
+
db,
|
|
884
|
+
...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
|
|
885
|
+
});
|
|
886
|
+
return {
|
|
887
|
+
sessionCreator: cbs.sessionCreator,
|
|
888
|
+
sessionRevoker: cbs.sessionRevoker,
|
|
889
|
+
sessionChecker: cbs.sessionChecker,
|
|
890
|
+
sessionStrictMode: true,
|
|
891
|
+
};
|
|
892
|
+
}
|