@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.
- package/README.md +15 -0
- package/dist/boot/apply-boot-seeds.d.ts +8 -0
- package/{src/boot/apply-boot-seeds.ts → dist/boot/apply-boot-seeds.js} +2 -10
- package/dist/boot/boot-crypto.d.ts +9 -0
- package/dist/boot/boot-crypto.js +39 -0
- package/dist/boot/job-run-logger.d.ts +19 -0
- package/dist/boot/job-run-logger.js +44 -0
- package/dist/boot/pii-event-backfill-on-boot.d.ts +13 -0
- package/dist/boot/pii-event-backfill-on-boot.js +25 -0
- package/dist/build-prod-bundle.d.ts +103 -0
- package/dist/build-prod-bundle.js +751 -0
- package/dist/bun-serve-options.d.ts +32 -0
- package/dist/bun-serve-options.js +49 -0
- package/dist/compose-features.d.ts +31 -0
- package/dist/compose-features.js +124 -0
- package/dist/extra-routes-deps.d.ts +30 -0
- package/dist/extra-routes-deps.js +4 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/pii-boot-gate.d.ts +13 -0
- package/dist/pii-boot-gate.js +46 -0
- package/dist/renderer-web-fonts.d.ts +9 -0
- package/{src/renderer-web-fonts.ts → dist/renderer-web-fonts.js} +14 -18
- package/dist/resolve-tailwind-cli.d.ts +14 -0
- package/dist/resolve-tailwind-cli.js +36 -0
- package/dist/run-prod-app-boot-context.d.ts +43 -0
- package/dist/run-prod-app-boot-context.js +198 -0
- package/dist/run-prod-app-static-files.d.ts +32 -0
- package/dist/run-prod-app-static-files.js +280 -0
- package/dist/run-prod-app.d.ts +471 -0
- package/dist/run-prod-app.js +741 -0
- package/dist/run-worker-app.d.ts +92 -0
- package/dist/run-worker-app.js +220 -0
- package/dist/security-headers.d.ts +20 -0
- package/dist/security-headers.js +49 -0
- package/dist/session-boot-gate.d.ts +11 -0
- package/dist/session-boot-gate.js +22 -0
- package/dist/session-wiring.d.ts +11 -0
- package/{src/session-wiring.ts → dist/session-wiring.js} +2 -6
- package/dist/try-hono-first.d.ts +24 -0
- package/{src/try-hono-first.ts → dist/try-hono-first.js} +8 -36
- package/package.json +102 -36
- package/src/changes.json +6 -0
- package/src/__tests__/boot-extra-context.test.ts +0 -163
- package/src/__tests__/boot-probe-fixture.ts +0 -64
- package/src/__tests__/build-prod-bundle.inprocess.test.ts +0 -233
- package/src/__tests__/build-prod-bundle.test.ts +0 -724
- package/src/__tests__/cache-headers.test.ts +0 -83
- package/src/__tests__/compose-features-mfa-wiring.integration.test.ts +0 -308
- package/src/__tests__/compose-features-wiring.integration.test.ts +0 -609
- package/src/__tests__/compose-features.test.ts +0 -206
- package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
- package/src/__tests__/pii-boot-gate.test.ts +0 -112
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
- package/src/__tests__/renderer-web-theme-plugin.integration.test.ts +0 -77
- package/src/__tests__/require-env.test.ts +0 -34
- package/src/__tests__/resolve-auth-mail.test.ts +0 -144
- package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
- package/src/__tests__/run-prod-app-dry-run.test.ts +0 -135
- package/src/__tests__/run-prod-app-env-source.test.ts +0 -118
- package/src/__tests__/run-prod-app-kms-slots.test.ts +0 -108
- package/src/__tests__/run-prod-app-spec.test.ts +0 -167
- package/src/__tests__/run-prod-app-static-files.test.ts +0 -298
- package/src/__tests__/run-prod-app.integration.test.ts +0 -1525
- package/src/__tests__/run-worker-app-boot.test.ts +0 -183
- package/src/__tests__/run-worker-app.integration.test.ts +0 -218
- package/src/__tests__/security-headers.test.ts +0 -88
- package/src/__tests__/session-boot-gate.test.ts +0 -70
- package/src/__tests__/session-wiring.test.ts +0 -16
- package/src/__tests__/try-hono-first.test.ts +0 -108
- package/src/__tests__/websocket-route.integration.test.ts +0 -695
- package/src/boot/__tests__/job-run-logger.integration.test.ts +0 -262
- package/src/boot/__tests__/job-run-logger.test.ts +0 -26
- package/src/boot/boot-crypto.ts +0 -67
- package/src/boot/job-run-logger.ts +0 -63
- package/src/boot/pii-event-backfill-on-boot.ts +0 -43
- package/src/build-prod-bundle.ts +0 -996
- package/src/bun-serve-options.ts +0 -110
- package/src/compose-features.ts +0 -197
- package/src/extra-routes-deps.ts +0 -38
- package/src/index.ts +0 -27
- package/src/pii-boot-gate.ts +0 -80
- package/src/resolve-tailwind-cli.ts +0 -45
- package/src/run-prod-app-boot-context.ts +0 -319
- package/src/run-prod-app-static-files.ts +0 -359
- package/src/run-prod-app.ts +0 -1420
- package/src/run-worker-app.ts +0 -366
- package/src/security-headers.ts +0 -67
- 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
|
+
}
|