@cosmicdrift/kumiko-server-runtime 0.329.0 → 0.330.2

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