@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.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 (90) hide show
  1. package/README.md +15 -0
  2. package/dist/boot/apply-boot-seeds.d.ts +8 -0
  3. package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
  4. package/dist/boot/boot-crypto.d.ts +9 -0
  5. package/dist/boot/boot-crypto.js +39 -0
  6. package/dist/boot/job-run-logger.d.ts +19 -0
  7. package/dist/boot/job-run-logger.js +44 -0
  8. package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
  9. package/dist/boot/pii-event-backfill-on-boot.js +25 -0
  10. package/dist/build-prod-bundle.d.ts +103 -0
  11. package/dist/build-prod-bundle.js +751 -0
  12. package/dist/bun-serve-options.d.ts +32 -0
  13. package/dist/bun-serve-options.js +49 -0
  14. package/dist/compose-features.d.ts +31 -0
  15. package/dist/compose-features.js +124 -0
  16. package/dist/extra-routes-deps.d.ts +30 -0
  17. package/dist/extra-routes-deps.js +4 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.js +7 -0
  20. package/dist/pii-boot-gate.d.ts +13 -0
  21. package/dist/pii-boot-gate.js +46 -0
  22. package/dist/renderer-web-fonts.d.ts +9 -0
  23. package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
  24. package/dist/resolve-tailwind-cli.d.ts +14 -0
  25. package/dist/resolve-tailwind-cli.js +36 -0
  26. package/dist/run-prod-app-boot-context.d.ts +43 -0
  27. package/dist/run-prod-app-boot-context.js +198 -0
  28. package/dist/run-prod-app-static-files.d.ts +32 -0
  29. package/dist/run-prod-app-static-files.js +280 -0
  30. package/dist/run-prod-app.d.ts +471 -0
  31. package/dist/run-prod-app.js +741 -0
  32. package/dist/run-worker-app.d.ts +92 -0
  33. package/dist/run-worker-app.js +220 -0
  34. package/dist/security-headers.d.ts +20 -0
  35. package/dist/security-headers.js +49 -0
  36. package/dist/session-boot-gate.d.ts +11 -0
  37. package/dist/session-boot-gate.js +22 -0
  38. package/dist/session-wiring.d.ts +11 -0
  39. package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
  40. package/dist/try-hono-first.d.ts +24 -0
  41. package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
  42. package/package.json +102 -36
  43. package/src/changes.json +6 -0
  44. package/src/__tests__/boot-extra-context.test.ts +0 -163
  45. package/src/__tests__/boot-probe-fixture.ts +0 -64
  46. package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
  47. package/src/__tests__/build-prod-bundle.test.ts +0 -724
  48. package/src/__tests__/cache-headers.test.ts +0 -83
  49. package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
  50. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
  51. package/src/__tests__/compose-features.test.ts +0 -206
  52. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  53. package/src/__tests__/pii-boot-gate.test.ts +0 -112
  54. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  55. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  56. package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
  57. package/src/__tests__/require-env.test.ts +0 -34
  58. package/src/__tests__/resolve-auth-mail.test.ts +0 -144
  59. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  60. package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
  61. package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
  62. package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
  63. package/src/__tests__/run-prod-app-spec.test.ts +0 -167
  64. package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
  65. package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
  66. package/src/__tests__/run-worker-app-boot.test.ts +0 -183
  67. package/src/__tests__/run-worker-app.integration.test.ts +0 -218
  68. package/src/__tests__/security-headers.test.ts +0 -88
  69. package/src/__tests__/session-boot-gate.test.ts +0 -70
  70. package/src/__tests__/session-wiring.test.ts +0 -16
  71. package/src/__tests__/try-hono-first.test.ts +0 -108
  72. package/src/__tests__/websocket-route.integration.test.ts +0 -695
  73. package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
  74. package/src/boot/__tests__/job-run-logger.test.ts +0 -26
  75. package/src/boot/boot-crypto.ts +0 -67
  76. package/src/boot/job-run-logger.ts +0 -63
  77. package/src/boot/pii-event-backfill-on-boot.ts +0 -43
  78. package/src/build-prod-bundle.ts +0 -996
  79. package/src/bun-serve-options.ts +0 -110
  80. package/src/compose-features.ts +0 -197
  81. package/src/extra-routes-deps.ts +0 -38
  82. package/src/index.ts +0 -27
  83. package/src/pii-boot-gate.ts +0 -80
  84. package/src/resolve-tailwind-cli.ts +0 -45
  85. package/src/run-prod-app-boot-context.ts +0 -319
  86. package/src/run-prod-app-static-files.ts +0 -359
  87. package/src/run-prod-app.ts +0 -1420
  88. package/src/run-worker-app.ts +0 -366
  89. package/src/security-headers.ts +0 -67
  90. package/src/session-boot-gate.ts +0 -34
@@ -0,0 +1,198 @@
1
+ import { AUDIT_FEATURE, createEscapeHatchAuditSink, } from "@cosmicdrift/kumiko-bundled-features/audit";
2
+ import { makeAuthPaths, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
3
+ import { resolveSessionStore } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
4
+ import { bindMfaRevokeAllOtherSessionsFromFeature, bindRevokeAllPatTokensFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
5
+ import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
6
+ import { buildEnvConfigOverrides, createConfigAccessorFactory, createConfigResolver, } from "@cosmicdrift/kumiko-bundled-features/config";
7
+ import { collectChannels, createDeliveryService, DELIVERY_FEATURE, } from "@cosmicdrift/kumiko-bundled-features/delivery";
8
+ import { bindPatAutoRevokeOnPasswordChangeFromFeature, revokeAllPatTokensForUser, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
9
+ import { createSecretsContext, SECRETS_FEATURE_NAME, } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
+ import { bindAutoRevokeFromFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
11
+ import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
12
+ import { resolveBootCrypto } from "./boot/boot-crypto.js";
13
+ // Boot-time context helpers for runProdApp: ctx-extra-context wiring
14
+ // (templateResolver/delivery/secrets/config-resolver), auth-mail convenience
15
+ // normalization, and prod session-auth wiring. Split out of run-prod-app.ts
16
+ // (#1005, Welle 2) — mechanical relocation, these are self-contained pure
17
+ // functions, no closure over runProdApp's local boot state.
18
+ // Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
19
+ export function addConfigAccessorFactory(resolved, registry) {
20
+ if (!resolved.configResolver)
21
+ return resolved;
22
+ return {
23
+ ...resolved,
24
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
25
+ };
26
+ }
27
+ // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
28
+ // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
29
+ // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
30
+ // Ausnahme. templateResolver ist unbedingt (createTemplateResolverApi wirft nie, baut
31
+ // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
32
+ // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
33
+ // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
34
+ // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
35
+ // (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
36
+ // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
37
+ // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
38
+ // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
39
+ // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
40
+ // passiert beim Caller (App gewinnt).
41
+ // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
42
+ // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
43
+ // and every notification silently skips. sseBroker optional (email/push don't
44
+ // need it, in-app SSE does); no jobRunner → queued channels send inline.
45
+ function buildDeliveryNotifyFactory(opts) {
46
+ const deliveryService = createDeliveryService({
47
+ db: opts.db,
48
+ registry: opts.registry,
49
+ channels: collectChannels(opts.registry),
50
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
51
+ ...(opts.escapeHatchAuditSink && { escapeHatchAuditSink: opts.escapeHatchAuditSink }),
52
+ });
53
+ return (user, tenantId) => (notificationType, options) => deliveryService.notify(notificationType, options, user, tenantId);
54
+ }
55
+ function resolveEscapeHatchAuditSink(features, db) {
56
+ const hasAuditFeature = features.some((f) => f.name === AUDIT_FEATURE);
57
+ return hasAuditFeature ? createEscapeHatchAuditSink({ db }) : undefined;
58
+ }
59
+ export function buildBootExtraContext(opts) {
60
+ const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
61
+ const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
62
+ const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
63
+ const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
64
+ const escapeHatchAuditSink = resolveEscapeHatchAuditSink(opts.features, opts.db);
65
+ return {
66
+ templateResolver: createTemplateResolverApi(opts.db),
67
+ ...(opts.kms && { kms: opts.kms }),
68
+ ...(escapeHatchAuditSink && { _escapeHatchAuditSink: escapeHatchAuditSink }),
69
+ ...(hasDeliveryFeature && {
70
+ _notifyFactory: buildDeliveryNotifyFactory({
71
+ db: opts.db,
72
+ registry: opts.registry,
73
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
74
+ ...(escapeHatchAuditSink && { escapeHatchAuditSink }),
75
+ }),
76
+ }),
77
+ // Top-level provider so feature jobs (secrets rotate, config reencrypt)
78
+ // reach it via ctx — previously only test-stack wired it.
79
+ ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
80
+ // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
81
+ // whenever a master key exists — NOT gated on the secrets feature,
82
+ // config encryption must work without mounting ctx.secrets.
83
+ ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
84
+ ...(wireSecrets &&
85
+ crypto.masterKeyProvider && {
86
+ secrets: createSecretsContext({
87
+ db: opts.db,
88
+ masterKeyProvider: crypto.masterKeyProvider,
89
+ dekCache: crypto.dekCache,
90
+ }),
91
+ }),
92
+ ...(opts.hasAuth && {
93
+ configResolver: createConfigResolver({
94
+ appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
95
+ ...(crypto.configCipher && { cipher: crypto.configCipher }),
96
+ }),
97
+ }),
98
+ };
99
+ }
100
+ function buildAuthPathAppUrl(baseUrl, path) {
101
+ return typeof path === "function"
102
+ ? (locale) => `${baseUrl}${path(locale)}`
103
+ : `${baseUrl}${path}`;
104
+ }
105
+ // accountUnlock (#1266) deliberately does NOT join this convenience block —
106
+ // unlike reset/verify/signup/invite it's only meaningful paired with
107
+ // `accountLockout` (kumiko-framework#1627), and apps set both explicitly
108
+ // alongside each other (same shape as `passwordReset`), so `mail` alone
109
+ // can't silently expose a new public endpoint an app didn't ask for.
110
+ function backfillTokenSecrets(auth, tokenSecret) {
111
+ return {
112
+ ...auth,
113
+ ...(auth.passwordReset && {
114
+ passwordReset: {
115
+ ...auth.passwordReset,
116
+ hmacSecret: auth.passwordReset.hmacSecret ?? tokenSecret,
117
+ },
118
+ }),
119
+ ...(auth.emailVerification && {
120
+ emailVerification: {
121
+ ...auth.emailVerification,
122
+ hmacSecret: auth.emailVerification.hmacSecret ?? tokenSecret,
123
+ },
124
+ }),
125
+ };
126
+ }
127
+ export function resolveAuthMail(auth, hmacSecret, envSource) {
128
+ // Runs before the mail/SMTP guards below so an app-supplied block without
129
+ // hmacSecret is backfilled even without a `mail` block; an explicit secret still wins.
130
+ const tokenSecret = auth.mail?.hmacSecret ?? hmacSecret;
131
+ const withResolvedSecrets = backfillTokenSecrets(auth, tokenSecret);
132
+ if (!auth.mail)
133
+ return withResolvedSecrets;
134
+ // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
135
+ // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
136
+ // (channel-email), nicht über diesen Transport — er ist nur der Detektor
137
+ // "ist Mail konfiguriert?".
138
+ if (!createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })) {
139
+ return withResolvedSecrets;
140
+ }
141
+ const paths = makeAuthPaths(auth.mail.paths);
142
+ // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
143
+ const mailPresentation = {
144
+ ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
145
+ ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
146
+ };
147
+ return {
148
+ ...withResolvedSecrets,
149
+ passwordReset: withResolvedSecrets.passwordReset ?? {
150
+ hmacSecret: tokenSecret,
151
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.resetPassword),
152
+ ...mailPresentation,
153
+ },
154
+ emailVerification: withResolvedSecrets.emailVerification ?? {
155
+ hmacSecret: tokenSecret,
156
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.verifyEmail),
157
+ ...(auth.mail.emailVerificationMode !== undefined && {
158
+ mode: auth.mail.emailVerificationMode,
159
+ }),
160
+ ...mailPresentation,
161
+ },
162
+ signup: withResolvedSecrets.signup ?? {
163
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.signupComplete),
164
+ ...mailPresentation,
165
+ },
166
+ invite: withResolvedSecrets.invite ?? {
167
+ appUrl: buildAuthPathAppUrl(auth.mail.baseUrl, paths.inviteAccept),
168
+ ...mailPresentation,
169
+ },
170
+ };
171
+ }
172
+ export async function buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature) {
173
+ // Resolve the sessions feature sessionStore provider (#1372).
174
+ const store = await resolveSessionStore({ db, registry });
175
+ if (sessionsFeature) {
176
+ bindAutoRevokeFromFeature(sessionsFeature)?.(store.massRevoker);
177
+ }
178
+ if (mfaFeature) {
179
+ bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(store.revokeAllOthers);
180
+ }
181
+ if (mfaFeature && patFeature) {
182
+ bindRevokeAllPatTokensFromFeature(mfaFeature)?.((userId) => revokeAllPatTokensForUser(db, userId));
183
+ }
184
+ return {
185
+ sessionCreator: store.creator,
186
+ sessionRevoker: store.revoker,
187
+ sessionChecker: store.checker,
188
+ };
189
+ }
190
+ // Password-change → PAT-revoke, independent of sessions/MFA being mounted
191
+ // (unlike buildProdSessionAuth, which only runs when a sessionStore provider
192
+ // is registered). Call unconditionally whenever personal-access-tokens is
193
+ // mounted — a no-op if it isn't (bindPatAutoRevokeOnPasswordChangeFromFeature
194
+ // returns undefined for an unmounted feature.exports shape, but callers
195
+ // should still gate on patFeature being defined at all).
196
+ export function wireProdPatAutoRevoke(db, patFeature) {
197
+ bindPatAutoRevokeOnPasswordChangeFromFeature(patFeature)?.((userId) => revokeAllPatTokensForUser(db, userId));
198
+ }
@@ -0,0 +1,32 @@
1
+ import { type CachePolicy } from "@cosmicdrift/kumiko-framework/api";
2
+ import { type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { HostDispatchFn, PageHeadResolver } from "./run-prod-app.js";
4
+ export declare function readStaticFile(filePath: string): Promise<{
5
+ readonly bytes: Uint8Array;
6
+ readonly mime: string;
7
+ readonly mtimeMs: number;
8
+ } | undefined>;
9
+ export declare function serveDiskFile(req: Request, pathname: string, file: {
10
+ readonly bytes: Uint8Array;
11
+ readonly mime: string;
12
+ readonly mtimeMs: number;
13
+ }): Response;
14
+ export declare function mimeTypeFor(filePath: string): string;
15
+ type QueryDispatcher = {
16
+ readonly query: (type: string, payload: unknown, user: SessionUser) => Promise<unknown>;
17
+ };
18
+ export type PageHeadOptions = {
19
+ readonly resolvePageHead: PageHeadResolver;
20
+ readonly dispatcher: QueryDispatcher;
21
+ };
22
+ export declare function buildStaticFallback(apiHandler: (req: Request, socketAddress?: string) => Response | Promise<Response>, staticDir: string, hostDispatch?: HostDispatchFn, pageHead?: PageHeadOptions,
23
+ /** Backs hostDispatch's `systemQuery` — required whenever `hostDispatch`
24
+ * is set (a hostDispatch that reads `deps.systemQuery` without it would
25
+ * throw). Separate from `pageHead.dispatcher` since resolvePageHead is
26
+ * optional independently of hostDispatch. */
27
+ hostDispatchDispatcher?: QueryDispatcher,
28
+ /** trustedProxyHops for the systemQuery request-context builds below —
29
+ * 1:1 with runProdApp's effective value. */
30
+ trustedProxyHops?: number): (req: Request, socketAddress?: string) => Promise<Response>;
31
+ export declare function staticCachePolicy(pathname: string): CachePolicy;
32
+ export {};
@@ -0,0 +1,280 @@
1
+ import { buildRequestContextDataFromRequest, cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, requestContext, } from "@cosmicdrift/kumiko-framework/api";
2
+ import { createAnonymousUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-headless/apex";
4
+ import { ASSETS_DIR } from "./build-prod-bundle.js";
5
+ import { stripNoRouteMatchHeader, tryHonoFirst } from "./try-hono-first.js";
6
+ // Static-asset + SPA-fallback serving for runProdApp's HTTP handler. Split
7
+ // out of run-prod-app.ts (#1005, Welle 2) — mechanical relocation, these
8
+ // functions are self-contained (no closure over runProdApp's local boot
9
+ // state), only params.
10
+ // Static-fallback: try the Hono app first, fall back to a file in
11
+ // staticDir if Hono returns 404. Keeps /api/* on the dispatcher and
12
+ // everything else (HTML, JS, CSS, images) on the disk.
13
+ //
14
+ // Cache-Header-Strategie:
15
+ // /assets/* → public, max-age=31536000, immutable
16
+ // (gehashte Filenames vom Build, sicher cachebar)
17
+ // /index.html → no-cache, must-revalidate
18
+ // (HTML-Shell, must reload on deploy)
19
+ // /manifest.json, /sw.js → no-cache
20
+ // (Update-Detection-Mechanismen, müssen frisch sein)
21
+ // alles andere → kein expliziter Header
22
+ // (Browser-Default, public/-Files wie favicon)
23
+ // File-reader für den static-fallback. Nutzt node:fs/promises statt
24
+ // Bun.file damit der Pfad in vitest+node integration-tests laufen kann
25
+ // (Bun.file ist Bun-only). Performance-cost ist marginal: die Disk-
26
+ // Files in einem prod-staticDir sind 1-200 KB, full-buffer-Read ist
27
+ // ein paar Mikrosekunden. Streaming via Bun.file wäre nur relevant ab
28
+ // ~1 MB.
29
+ export async function readStaticFile(filePath) {
30
+ try {
31
+ const { readFile, stat } = await import("node:fs/promises");
32
+ const [bytes, fileStat] = await Promise.all([readFile(filePath), stat(filePath)]);
33
+ return { bytes, mime: mimeTypeFor(filePath), mtimeMs: fileStat.mtimeMs };
34
+ }
35
+ catch (err) {
36
+ const code = err.code;
37
+ // ENOENT: no such path. EISDIR: readFile() on a directory (e.g. GET
38
+ // /assets where "assets" is a subfolder copied verbatim from public/).
39
+ // ENOTDIR: a path segment that isn't a directory is used as one (e.g.
40
+ // GET /index.html/x — "index.html" is a file, not a directory) — all
41
+ // three mean "not a servable file", so fall through to the SPA
42
+ // fallback instead of a 500.
43
+ if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR")
44
+ return undefined;
45
+ throw err;
46
+ }
47
+ }
48
+ export function serveDiskFile(req, pathname, file) {
49
+ return cachedResponse(req, {
50
+ // @cast-boundary bun-types — Response BodyInit narrowing
51
+ body: file.bytes,
52
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
53
+ cache: staticCachePolicy(pathname),
54
+ headers: { "content-type": file.mime },
55
+ lastModified: new Date(file.mtimeMs),
56
+ });
57
+ }
58
+ // Minimal-Mime-Map — deckt die Files ab die kumiko-build und typische
59
+ // public/-Inhalte produzieren. Bun.file leitet das aus dem Suffix ab,
60
+ // im node-Pfad müssen wir es selbst tun. Default: octet-stream (Browser
61
+ // fragt bei unbekanntem MIME nach).
62
+ export function mimeTypeFor(filePath) {
63
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
64
+ switch (ext) {
65
+ case "html":
66
+ return "text/html; charset=utf-8";
67
+ case "js":
68
+ case "mjs":
69
+ return "text/javascript; charset=utf-8";
70
+ case "css":
71
+ return "text/css; charset=utf-8";
72
+ case "json":
73
+ return "application/json; charset=utf-8";
74
+ case "svg":
75
+ return "image/svg+xml";
76
+ case "png":
77
+ return "image/png";
78
+ case "jpg":
79
+ case "jpeg":
80
+ return "image/jpeg";
81
+ case "ico":
82
+ return "image/x-icon";
83
+ case "txt":
84
+ return "text/plain; charset=utf-8";
85
+ case "xml":
86
+ return "application/xml; charset=utf-8";
87
+ case "webmanifest":
88
+ return "application/manifest+json";
89
+ case "woff2":
90
+ return "font/woff2";
91
+ default:
92
+ return "application/octet-stream";
93
+ }
94
+ }
95
+ export function buildStaticFallback(apiHandler, staticDir, hostDispatch, pageHead,
96
+ /** Backs hostDispatch's `systemQuery` — required whenever `hostDispatch`
97
+ * is set (a hostDispatch that reads `deps.systemQuery` without it would
98
+ * throw). Separate from `pageHead.dispatcher` since resolvePageHead is
99
+ * optional independently of hostDispatch. */
100
+ hostDispatchDispatcher,
101
+ /** trustedProxyHops for the systemQuery request-context builds below —
102
+ * 1:1 with runProdApp's effective value. */
103
+ trustedProxyHops = 0) {
104
+ const indexHtml = `${staticDir}/index.html`;
105
+ const clientIpResolver = createClientIpResolver(trustedProxyHops, "buildStaticFallback");
106
+ // Reads an HTML file from disk. No schema injection — createKumikoApp
107
+ // fetches it itself from the authenticated GET /api/schema, so the HTML
108
+ // is identical for every host.
109
+ async function readHtmlFile(path) {
110
+ const file = await readStaticFile(path);
111
+ if (!file)
112
+ return null;
113
+ return {
114
+ bytes: file.bytes.buffer.slice(file.bytes.byteOffset, file.bytes.byteOffset + file.bytes.byteLength),
115
+ mime: file.mime,
116
+ etag: computeWeakEtag(file.mtimeMs, file.bytes.byteLength),
117
+ mtimeMs: file.mtimeMs,
118
+ };
119
+ }
120
+ function serveHtmlFile(req, pathname, html, extraHeaders) {
121
+ return cachedResponse(req, {
122
+ body: html.bytes,
123
+ etag: html.etag,
124
+ cache: staticCachePolicy(pathname),
125
+ headers: { "content-type": html.mime, ...extraHeaders },
126
+ lastModified: new Date(html.mtimeMs),
127
+ });
128
+ }
129
+ // Injects resolved head metadata into an already-read HTML payload, right
130
+ // before it's served. Recomputes a strong etag over the final bytes so
131
+ // two requests with different head metadata never collide on one etag
132
+ // (see computeStrongEtag usage in readHtmlFile above for the same
133
+ // requirement on schema-injection). No resolvePageHead configured, no
134
+ // meta resolved, or nothing actually changed (e.g. no `</head>` to
135
+ // inject into) → the original html object is returned untouched.
136
+ //
137
+ // The resolve+timeout+inject itself runs through headless's
138
+ // resolveAndInjectPageHead — the single call site runDevApp also uses
139
+ // (kumiko-framework#3026), so only the systemQuery/dispatcher wiring
140
+ // below (Request-bound, prod-specific) and the etag recompute are local.
141
+ async function applyPageHead(req, html, socketAddress) {
142
+ if (!pageHead)
143
+ return html;
144
+ const url = new URL(req.url);
145
+ const host = req.headers.get("host") ?? url.host;
146
+ const systemQuery = (type, payload, tenantId) => requestContext.run(requestContext.get() ??
147
+ buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }), () => pageHead.dispatcher.query(type, payload, createAnonymousUser(tenantId)));
148
+ const text = new TextDecoder().decode(html.bytes);
149
+ const injected = await resolveAndInjectPageHead(text, pageHead.resolvePageHead, {
150
+ path: url.pathname,
151
+ host,
152
+ systemQuery,
153
+ });
154
+ if (injected === text)
155
+ return html;
156
+ const encoded = new TextEncoder().encode(injected);
157
+ return {
158
+ bytes: encoded.buffer,
159
+ mime: html.mime,
160
+ etag: computeStrongEtag(encoded),
161
+ mtimeMs: html.mtimeMs,
162
+ };
163
+ }
164
+ // hostDispatch konsultieren wenn gesetzt UND der Request auf den
165
+ // HTML-Fallback fällt (Root oder SPA-Route). Returnt entweder die
166
+ // resolved Response (redirect/404/html) oder null wenn der Default-
167
+ // Pfad weiterlaufen soll.
168
+ async function tryHostDispatch(req, socketAddress) {
169
+ if (!hostDispatch)
170
+ return null;
171
+ const url = new URL(req.url);
172
+ const host = req.headers.get("host") ?? url.host;
173
+ const systemQuery = (type, payload, tenantId) => {
174
+ if (!hostDispatchDispatcher) {
175
+ throw new Error("hostDispatch called deps.systemQuery but buildStaticFallback got no hostDispatchDispatcher");
176
+ }
177
+ return requestContext.run(requestContext.get() ??
178
+ buildRequestContextDataFromRequest(req, { resolver: clientIpResolver, socketAddress }), () => hostDispatchDispatcher.query(type, payload, createAnonymousUser(tenantId)));
179
+ };
180
+ const result = await hostDispatch({ host, path: url.pathname, search: url.search }, { systemQuery });
181
+ if (result.kind === "not-found") {
182
+ return new Response("Not Found", { status: 404 });
183
+ }
184
+ if (result.kind === "redirect") {
185
+ return new Response(null, {
186
+ status: result.status ?? 302,
187
+ headers: { Location: result.to },
188
+ });
189
+ }
190
+ // result.kind === "html"
191
+ const filePath = `${staticDir}/${result.file}`;
192
+ const html = await readHtmlFile(filePath);
193
+ if (!html) {
194
+ // Author-Fehler: hostDispatch verweist auf nicht-existente Datei.
195
+ // Liefer 500 statt silent-404 damit der Bug schnell auffällt.
196
+ return new Response(`hostDispatch: file not found: ${result.file}`, { status: 500 });
197
+ }
198
+ // Per-host body (hostDispatch picks the file by host) → Vary: Host,
199
+ // otherwise a shared cache could serve Host A's HTML to Host B.
200
+ const extraHeaders = { vary: "Host" };
201
+ if (result.csp)
202
+ extraHeaders["content-security-policy"] = result.csp;
203
+ const withHead = await applyPageHead(req, html, socketAddress);
204
+ return serveHtmlFile(req, "/index.html", withHead, extraHeaders);
205
+ }
206
+ return async (req, socketAddress) => {
207
+ const url = new URL(req.url);
208
+ // /api/* and /health → always Hono (Dispatcher + Health-Probe). Bypasses
209
+ // tryHonoFirst entirely, so the router-miss marker must be stripped
210
+ // here too — otherwise an unmatched /api/* path would leak it straight
211
+ // to the client (see try-hono-first.ts's header-hygiene note).
212
+ if (url.pathname.startsWith("/api/") || url.pathname === "/health") {
213
+ return stripNoRouteMatchHeader(await apiHandler(req, socketAddress));
214
+ }
215
+ // Hono-First für andere Pfade: extraRoutes (z.B. /feed.xml,
216
+ // /sitemap.xml) UND r.httpRoute-Features (z.B. /legal/*) müssen vor
217
+ // dem Disk-Lookup greifen, sonst schluckt der SPA-Fallback unten
218
+ // unbekannte Pfade als index.html. Shared mit dev-server's
219
+ // createKumikoServer.handleFetch damit beide IDENTISCHE Semantik haben.
220
+ const honoTry = await tryHonoFirst({ fetch: apiHandler }, req, socketAddress);
221
+ if (honoTry.matched) {
222
+ return honoTry.response;
223
+ }
224
+ const honoRes = honoTry.response;
225
+ // Disk-/SPA-Fallback ist GET/HEAD-only. Ein non-GET ohne Hono-Match
226
+ // (z.B. POST auf einen falsch konfigurierten Webhook-Pfad) muss den
227
+ // Hono-404 durchreichen — 200 index.html würde dem Provider
228
+ // "delivered" signalisieren und Events gingen still verloren (#259).
229
+ if (req.method !== "GET" && req.method !== "HEAD") {
230
+ return honoRes;
231
+ }
232
+ // Disk-Datei (Asset oder konkrete File). Asset-Pfade laufen
233
+ // host-unabhängig — die Bundles in /assets/* werden vom client
234
+ // aktiv geladen, kein Server-side Routing nötig.
235
+ const isIndexRequest = url.pathname === "/" || url.pathname === "/index.html";
236
+ if (!isIndexRequest) {
237
+ const relPath = url.pathname.slice(1);
238
+ const filePath = `${staticDir}/${relPath}`;
239
+ const file = await readStaticFile(filePath);
240
+ if (file) {
241
+ return serveDiskFile(req, url.pathname, file);
242
+ }
243
+ }
244
+ // Root or SPA route — hostDispatch applies here when set.
245
+ // Without hostDispatch: the old single-app path (index.html).
246
+ const dispatched = await tryHostDispatch(req, socketAddress);
247
+ if (dispatched)
248
+ return dispatched;
249
+ // Default single-app path: index.html, no schema injection.
250
+ const index = await readHtmlFile(indexHtml);
251
+ if (index) {
252
+ const withHead = await applyPageHead(req, index, socketAddress);
253
+ return serveHtmlFile(req, "/index.html", withHead);
254
+ }
255
+ // Kein Hono-Match, keine Disk-Datei, kein index.html → liefer den
256
+ // ursprünglichen 404 von Hono durch (statt einen neuen Roundtrip).
257
+ return honoRes;
258
+ };
259
+ }
260
+ // Map URL-Pfad → Cache-Policy. Hashed-Asset-Pfade (/assets/*) sind
261
+ // unveränderlich, der Rest bleibt revalidate/no-cache damit Updates ohne
262
+ // Hard-Reload greifen. Exported für Unit-Tests; Konsumenten gehen via
263
+ // runProdApp.
264
+ export function staticCachePolicy(pathname) {
265
+ if (pathname.startsWith(`/${ASSETS_DIR}/`)) {
266
+ return { kind: "immutable" };
267
+ }
268
+ if (pathname === "/" || pathname === "/index.html") {
269
+ return { kind: "revalidate" };
270
+ }
271
+ if (pathname === "/manifest.json" ||
272
+ pathname === "/sw.js" ||
273
+ // ponytail: build-info.json ist statisch — kein /api/version-Endpoint
274
+ // nötig, der Disk-Fallback serviert sie. no-cache, sonst pollt der
275
+ // UpdateChecker eine veraltete id.
276
+ pathname === "/build-info.json") {
277
+ return { kind: "no-cache" };
278
+ }
279
+ return { kind: "none" };
280
+ }