@cosmicdrift/kumiko-server-runtime 0.387.0 → 0.389.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 (61) hide show
  1. package/dist/boot/boot-crypto.js +3 -3
  2. package/dist/boot/shared-boot-steps.d.ts +46 -0
  3. package/dist/boot/shared-boot-steps.js +160 -0
  4. package/dist/boot/wait-for-database.d.ts +5 -0
  5. package/dist/boot/wait-for-database.js +7 -6
  6. package/dist/boot/worker-boot-core.js +24 -38
  7. package/dist/boot-options/billing-option.d.ts +18 -9
  8. package/dist/boot-options/billing-option.js +44 -18
  9. package/dist/boot-options/demo-mode.d.ts +3 -3
  10. package/dist/boot-options/demo-mode.js +4 -1
  11. package/dist/boot-options/file-storage-option.js +6 -6
  12. package/dist/boot-options/option-env-schema.js +11 -2
  13. package/dist/boot-options/resolve-boot-options.d.ts +1 -0
  14. package/dist/boot-options/resolve-boot-options.js +11 -5
  15. package/dist/boot-options/stack-boot-requirements.d.ts +1 -1
  16. package/dist/boot-options/stack-boot-requirements.js +2 -14
  17. package/dist/build-prod-bundle.js +13 -13
  18. package/dist/bundled-lists-table.d.ts +2 -0
  19. package/dist/bundled-lists-table.js +56 -0
  20. package/dist/compose-auth-options.d.ts +16 -0
  21. package/dist/compose-auth-options.js +49 -0
  22. package/dist/compose-features.d.ts +5 -17
  23. package/dist/compose-features.js +6 -53
  24. package/dist/csp-hashes.js +1 -1
  25. package/dist/extra-routes-deps.d.ts +4 -0
  26. package/dist/extra-routes-deps.js +1 -0
  27. package/dist/fatal-error-handlers.js +7 -0
  28. package/dist/files-for-tenant.d.ts +3 -0
  29. package/dist/files-for-tenant.js +10 -0
  30. package/dist/renderer-web-fonts.js +3 -9
  31. package/dist/require-env.d.ts +2 -0
  32. package/dist/require-env.js +21 -0
  33. package/dist/resolve-installed-package.d.ts +10 -0
  34. package/dist/resolve-installed-package.js +32 -0
  35. package/dist/resolve-tailwind-cli.d.ts +3 -8
  36. package/dist/resolve-tailwind-cli.js +8 -25
  37. package/dist/run-bootstrap.d.ts +1 -1
  38. package/dist/run-bootstrap.js +1 -1
  39. package/dist/run-prod-app-auth.d.ts +17 -0
  40. package/dist/run-prod-app-auth.js +83 -0
  41. package/dist/run-prod-app-boot-context.d.ts +2 -0
  42. package/dist/run-prod-app-boot-context.js +15 -7
  43. package/dist/run-prod-app-config.d.ts +36 -0
  44. package/dist/run-prod-app-config.js +169 -0
  45. package/dist/run-prod-app-connections.d.ts +17 -0
  46. package/dist/run-prod-app-connections.js +59 -0
  47. package/dist/run-prod-app-entrypoint.d.ts +14 -0
  48. package/dist/run-prod-app-entrypoint.js +149 -0
  49. package/dist/run-prod-app-handle.d.ts +20 -0
  50. package/dist/run-prod-app-handle.js +105 -0
  51. package/dist/run-prod-app-request-context.d.ts +15 -0
  52. package/dist/run-prod-app-request-context.js +103 -0
  53. package/dist/run-prod-app-seeds.d.ts +13 -0
  54. package/dist/run-prod-app-seeds.js +88 -0
  55. package/dist/run-prod-app-static-files.js +9 -1
  56. package/dist/run-prod-app.d.ts +27 -19
  57. package/dist/run-prod-app.js +47 -727
  58. package/dist/run-worker-app.d.ts +12 -2
  59. package/dist/run-worker-app.js +2 -0
  60. package/package.json +27 -8
  61. package/src/changes.json +86 -0
@@ -39,81 +39,23 @@
39
39
  // same JWT_SECRET.
40
40
  // PORT=3000
41
41
  // KUMIKO_INSTANCE_ID=<stable per replica>
42
- import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
43
- import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
44
- import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
45
- import { AUTH_MFA_FEATURE, AuthMfaHandlers, bindMfaTokenSecretsFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
46
- import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
47
- import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
48
- import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
49
- import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
50
- import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
51
- import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
52
- import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
53
- import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
54
- import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
55
- import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
56
42
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
57
- import { createFallbackLogger, setProcessLogger } from "@cosmicdrift/kumiko-framework/logging";
58
- import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
59
- import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
60
- import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, createUnlimitedRateLimitResolver, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
61
- import { createRedisCacheSyncBus, logRedisConnectionState, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
62
- import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
63
- import { Redis } from "ioredis";
64
- import { applyBootSeeds } from "./boot/apply-boot-seeds.js";
65
- import { resolveBootCrypto } from "./boot/boot-crypto.js";
66
43
  import { runEnvGate } from "./boot/env-gate.js";
67
- import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
68
- import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
69
- import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
70
- import { waitForDatabase, waitUntilDatabaseReachable } from "./boot/wait-for-database.js";
71
- import { installShutdownSignals } from "./boot-options/process-signals.js";
72
- import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
73
- import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
74
- import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
75
- import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
76
- import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
77
- import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
78
- import { assertPiiBootInvariants } from "./pii-boot-gate.js";
44
+ import { buildEffectiveFeaturesResolver } from "./boot/shared-boot-steps.js";
79
45
  import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
80
- import { resolveRateLimit } from "./rate-limit-defaults.js";
81
- import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
82
- import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
83
- import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
84
- import { buildStaticFallback, resolveCspScriptHashes, resolveHostDispatchScriptHashes, } from "./run-prod-app-static-files.js";
85
- import { withSecurityHeaders } from "./security-headers.js";
86
- import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
87
- import { assertSessionBootInvariants } from "./session-boot-gate.js";
88
- import { shouldWireProdSessions } from "./session-wiring.js";
89
- import { resolveTrustedProxyHops } from "./trusted-proxy-hops.js";
90
- import { stripNoRouteMatchHeader } from "./try-hono-first.js";
46
+ import { wireProdAuth } from "./run-prod-app-auth.js";
47
+ import { composeProdFeatures, resolveProdBootConfig, runWithBootAbortCleanups, } from "./run-prod-app-config.js";
48
+ import { assertProdSchemaCurrent, connectProdDependencies } from "./run-prod-app-connections.js";
49
+ import { buildProdEntrypointOptions, createProdEntrypoint, registerProdShutdownHooks, runProdWireHook, startProdBackgroundWork, } from "./run-prod-app-entrypoint.js";
50
+ import { buildProdFetchHandler, createProdAppHandle } from "./run-prod-app-handle.js";
51
+ import { buildProdRequestContext } from "./run-prod-app-request-context.js";
52
+ import { runProdBootSeeds } from "./run-prod-app-seeds.js";
91
53
  export { parseRunMode } from "./boot/env-gate.js";
92
54
  export { buildBunServeOptions } from "./bun-serve-options.js";
55
+ export { requireEnv } from "./require-env.js";
93
56
  export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
94
- export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
57
+ export { addConfigAccessorFactory, buildBootExtraContext, buildConfigOverrides, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
95
58
  export { staticCachePolicy } from "./run-prod-app-static-files.js";
96
- // Strict env-var read. Throws with a clear hint when missing — better
97
- // than discovering a Postgres-connection-refused 30s into the boot.
98
- // `src` defaults to process.env but is threaded from the caller's envSource
99
- // so the boot-path reads the SAME env-quelle that was validated above —
100
- // injected dummies in test-mode must not silently fall back to process.env.
101
- export function requireEnv(name, src = process.env, context = "runProdApp") {
102
- const value = src[name];
103
- if (value === undefined || value === "") {
104
- const advice = context === "runProdApp"
105
- ? "Set it in your container env / .env.production / Coolify secrets."
106
- : "Set it in your .env / shell before running the dev server.";
107
- throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
108
- }
109
- return value;
110
- }
111
- // Optional env helper — returns undefined for missing, string for set.
112
- // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
113
- function readEnv(name, src = process.env) {
114
- const value = src[name];
115
- return value === undefined || value === "" ? undefined : value;
116
- }
117
59
  // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
118
60
  // was passed (= test-mode). The handle is intentionally inert — listen()
119
61
  // and stop() are no-ops; tests inspect the dry-run console output and
@@ -133,35 +75,10 @@ function makeDryRunHandle() {
133
75
  stop: noop,
134
76
  };
135
77
  }
136
- const SERVER_GRACEFUL_STOP_MS = 10_000;
137
- // stop() without force waits for open connections (SSE never ends on its own); force-close after the grace period.
138
- async function stopServerGracefully(server) {
139
- let graceTimer;
140
- const graceElapsed = new Promise((resolve) => {
141
- graceTimer = setTimeout(resolve, SERVER_GRACEFUL_STOP_MS);
142
- });
143
- try {
144
- await Promise.race([server.stop(), graceElapsed]);
145
- }
146
- finally {
147
- clearTimeout(graceTimer);
148
- }
149
- await server.stop(true);
150
- }
151
- let warnedLegacyJwtSecret = false;
152
- function warnLegacyJwtSecretOnce() {
153
- // skip: already warned once this process — avoid log spam on every boot path.
154
- if (warnedLegacyJwtSecret)
155
- return;
156
- warnedLegacyJwtSecret = true;
157
- createFallbackLogger("runProdApp").warn("JWT keyring carries a legacy (pre-rotation) verify-only key — " +
158
- "it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
159
- "fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
160
- "set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
161
- "that is no longer present in any in-flight token (see resolveAuthMail / " +
162
- "loadJwtSecretOrKeyring).");
163
- }
164
78
  export async function runProdApp(options) {
79
+ return runWithBootAbortCleanups((register) => bootProdApp(options, register));
80
+ }
81
+ async function bootProdApp(options, registerAbortCleanup) {
165
82
  assertSeedModeNotInProduction();
166
83
  // 0. Env-Schema validation + dry-run modes. Runs FIRST so:
167
84
  // - operators can introspect env-requirements without a real boot
@@ -185,108 +102,10 @@ export async function runProdApp(options) {
185
102
  return makeDryRunHandle();
186
103
  const { runMode, decryptedEnvSource } = envGate;
187
104
  const log = installProcessLogger(options.extraContext, "runProdApp");
188
- // Stacks whose boot options are missing and the declarative boot options
189
- // (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
190
- // the env so an explicitly set variable wins.
191
- assertStackBootRequirements(options);
192
- const bootOptionWiring = await resolveBootOptionWiring(options, decryptedEnvSource);
193
- const envSource = Object.keys(bootOptionWiring.envDefaults).length > 0
194
- ? { ...bootOptionWiring.envDefaults, ...decryptedEnvSource }
195
- : decryptedEnvSource;
196
- const bootKms = bootOptionWiring.kms;
197
- // 1. Polyfill before anything else — feature code references Temporal.
198
- const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
199
- await ensureTemporalPolyfill();
200
- // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
201
- // than a 30s timeout chasing a Postgres connection that was never
202
- // configured.
203
- const databaseUrl = requireEnv("DATABASE_URL", envSource);
204
- const redisUrl = requireEnv("REDIS_URL", envSource);
205
- // JWT_SECRET stays mandatory (also the resolveAuthMail hmacSecret
206
- // fallback below — a separate, non-rotating HMAC token family, not the
207
- // session JWT). jwtSecretOrKeyring is the OPTIONAL upgrade: set
208
- // JWT_SECRET_V<n> + JWT_SECRET_CURRENT_VERSION for zero-downtime
209
- // rotation of the session-JWT signing key — see loadJwtSecretOrKeyring.
210
- const jwtSecret = requireEnv("JWT_SECRET", envSource);
211
- const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
212
- if (typeof jwtSecretOrKeyring === "object" && Object.hasOwn(jwtSecretOrKeyring.keys, "legacy")) {
213
- // Once per process — JWT_SECRET stays a required env (requireEnv above +
214
- // resolveAuthMail hmacSecret fallback). Retiring the *legacy verify key*
215
- // means setting auth.mail.hmacSecret explicitly and rotating JWT_SECRET
216
- // to a fresh value that is no longer in any in-flight token; unsetting
217
- // JWT_SECRET entirely hard-crashes boot.
218
- warnLegacyJwtSecretOnce();
219
- }
220
- const jwtIssuer = readEnv("JWT_ISSUER", envSource);
221
- const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
222
- // options.auth.trustedProxyHops wins; falls back
223
- // to the env var so ops can close the XFF-spoofing hole per-deployment
224
- // without a code change (mirrors instanceId's env-first pattern above).
225
- const trustedProxyHops = resolveTrustedProxyHops({
226
- option: options.trustedProxyHops,
227
- authOption: options.auth?.trustedProxyHops,
228
- envValue: readEnv(TRUSTED_PROXY_HOPS_ENV, envSource),
229
- nodeEnv: process.env["NODE_ENV"],
230
- });
231
- if (trustedProxyHops === undefined) {
232
- // With 0 hops every request appears to come from the proxy pod, so all IP-keyed
233
- // rate limiters collapse into one shared bucket behind a reverse proxy.
234
- log.warn(`trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
235
- "Behind a reverse proxy all clients share one rate-limit bucket, so the default L1/L2 limits throttle the whole site at once.");
236
- }
237
- const resolvedRateLimit = resolveRateLimit(options.rateLimit);
238
- const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
239
- log.info(`booting Kumiko stack on port ${port}`);
240
- // auth.mail is expanded into the explicit flow fields before feeding both
241
- // the feature side (buildComposeAuthOptions) and the routes fragment below.
242
- // From here on, always read effectiveAuth instead of options.auth.
243
- const effectiveAuth = options.auth
244
- ? resolveAuthMail(options.auth, jwtSecret, envSource, {
245
- mailConfigured: bootOptionWiring.mailConfigured,
246
- })
247
- : undefined;
248
- const authPublicOrigin = effectiveAuth
249
- ? resolveAuthPublicOrigin(effectiveAuth, envSource, (message) => {
250
- log.warn(message);
251
- })
252
- : undefined;
253
- const { unsubscribeLinks, unsubscribeRoutes } = resolveUnsubscribeWiring({
254
- unsubscribe: options.unsubscribe,
255
- publicOrigin: effectiveAuth?.publicOrigin,
256
- mailBaseUrl: effectiveAuth?.mail?.baseUrl,
257
- envSource,
258
- warn: (message) => createFallbackLogger("runProdApp").warn(message),
259
- });
260
- // 3. Feature registry. Auth mode automatically mixes config/user/tenant/
261
- // auth-email-password via composeFeatures, using the same feature list as
262
- // runDevApp and the per-app schema generator. Build it before any connection
263
- // so boot mode can validate wiring without opening Postgres or Redis sockets.
264
- const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
265
- const features = composeFeatures([...options.features, ...bootOptionWiring.features], {
266
- includeBundled: !!effectiveAuth,
267
- ...(composeAuthOptions && { authOptions: composeAuthOptions }),
268
- });
269
- const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
270
- validateBoot(features, {
271
- env: envSource,
272
- ...(bootCrypto.entityFieldCipher && { entityFieldCipher: bootCrypto.entityFieldCipher }),
273
- ...options.validateBootOptions,
274
- });
275
- warnIfNonUtcServerTimeZone();
276
- validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
277
- assertPiiBootInvariants(features, {
278
- kms: bootKms.kms,
279
- blindIndexKey: bootKms.blindIndexKey,
280
- allowPlaintextPii: bootKms.allowPlaintextPii,
281
- mode: "prod",
282
- });
283
- const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
284
- const registry = createRegistry(features);
285
- assertSessionBootInvariants({
286
- hasAuth: Boolean(effectiveAuth),
287
- sessionStoreProviderMounted: registry.getExtensionUsages(EXT_SESSION_STORE).length > 0,
288
- mode: "prod",
289
- });
105
+ const config = await resolveProdBootConfig(options, decryptedEnvSource, log, registerAbortCleanup);
106
+ const composed = composeProdFeatures(options, config, log);
107
+ const { bootKms } = config;
108
+ const { features, registry } = composed;
290
109
  // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
291
110
  // is constructed at all in this branch (the eager `new Redis(...)` below
292
111
  // would otherwise open a TCP connect just to immediately disconnect it).
@@ -300,540 +119,41 @@ export async function runProdApp(options) {
300
119
  }
301
120
  return makeDryRunHandle();
302
121
  }
303
- // 4. Connections — Postgres + Redis. The Redis client is shared by
304
- // idempotency, event-dedup, entity-cache, rate-limit; failing to
305
- // construct here surfaces the misconfig immediately. `new Redis(...)`
306
- // connects eagerly, so it must stay AFTER the boot-mode exit above.
307
- // KMS health gate: an app configured for crypto-shredding must not boot
308
- // against an unreachable key store — a silent start would answer every
309
- // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
310
- if (bootKms.kms) {
311
- const kms = bootKms.kms;
312
- const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
313
- processName: "runProdApp",
314
- });
315
- if (!kmsHealth.ok) {
316
- throw new Error(`[runProdApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`);
317
- }
318
- }
319
- const { db, close: closeDb } = createDbConnection({
320
- url: databaseUrl,
321
- ...dbConnectionOptionsFromEnv(envSource),
322
- ...serverTimeoutOptionsFromEnv(envSource),
323
- });
324
- const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
325
- url: databaseUrl,
326
- ...outsideTransactionPoolOptionsFromEnv(envSource),
327
- ...serverTimeoutOptionsFromEnv(envSource),
328
- });
329
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
330
- logRedisConnectionState(redis, log);
331
- await waitForDatabase(db, { processName: "runProdApp" });
332
- // Tier-Composition auto-wire: scan features for a
333
- // tenantTierResolver-extension. If found, build the resolver here (db +
334
- // registry are available) before the dispatcher is constructed. App-Author sees
335
- // nothing — `createTierEngineFeature(opts)` mounts + framework auto-wires.
336
- // One bus per process: the tier resolver, the dispatcher and the file-provider
337
- // resolver all subscribe to it (invalidation across the API pods).
338
- const cacheSync = createRedisCacheSyncBus({
339
- redisUrl,
340
- clientOptions: redisClientOptionsFromEnv(envSource),
341
- });
342
- let resolvedEffectiveFeatures;
343
- const tierResolverUsage = findTierResolverUsage(features);
344
- if (tierResolverUsage) {
345
- resolvedEffectiveFeatures = await requireTierResolverPlugin(tierResolverUsage).build({
346
- db,
347
- registry,
348
- cacheSync,
349
- });
350
- }
351
- if (bootOptionWiring.featureToggles) {
352
- const bound = await bootOptionWiring.featureToggles.bind({
353
- db,
354
- registry,
355
- cacheSync,
356
- tierResolver: resolvedEffectiveFeatures,
357
- });
358
- resolvedEffectiveFeatures = bound.resolver;
359
- }
360
- // 5. Schema-drift gate (without drizzle, using kumiko/migrations). `kumiko
361
- // schema apply` runs as a deploy step before container rollout. Boot only
362
- // verifies that every kumiko/migrations/*.sql file is applied in
363
- // _kumiko_migrations with an unchanged checksum and that every table in
364
- // kumiko/migrations/.snapshot.json exists. Drift aborts boot rather than being auto-healed, avoiding
365
- // races between container replicas. `migrations: false` opts out for custom
366
- // schema setups.
367
- if (options.migrations !== false) {
368
- const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
369
- log.info(`checking schema drift (${migrationsDir})`);
370
- try {
371
- await assertKumikoSchemaCurrent(db, migrationsDir);
372
- }
373
- catch (err) {
374
- if (err instanceof SchemaDriftError) {
375
- log.error(`BOOT ABORTED — ${err.message}`);
376
- }
377
- throw err;
378
- }
379
- }
380
- // 6. Pipeline pieces — same default config as runDevApp's setupTestStack.
381
- const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
382
- const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
383
- const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
384
- // 7. When no lifecycle is supplied, createApiEntrypoint builds and owns it;
385
- // the handle exposes it for SIGTERM.
386
- //
387
- // extraContext and anonymousAccess accept either an object or a factory that
388
- // receives {db, redis, registry}; factories are useful when boot-time database
389
- // access is needed, such as a subdomain-to-tenant resolver.
390
- //
391
- // Build the SSE broker here so extraContext factories can close over it, and
392
- // pass the same broker into createApiEntrypoint (sseBroker option) so
393
- // server-internal broadcasts and app-specific publishes share one broker. Use
394
- // createDefaultSseBroker so REDIS_URL selects a Redis-backed broker; an
395
- // unconditional in-memory broker would silently override buildServer's
396
- // Redis-backed default (ServerOptions.sseBroker always wins) and prevent
397
- // cross-replica fanout.
398
- const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
399
- const deps = {
400
- db,
401
- redis,
402
- registry,
403
- sseBroker,
404
- env: envSource,
405
- ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
406
- ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
407
- };
408
- const resolvedExtraContext = typeof options.extraContext === "function"
409
- ? options.extraContext(deps)
410
- : (options.extraContext ?? {});
411
- // Install framework defaults first; app values in resolvedExtraContext always
412
- // win (for example, an app-specific configResolver).
413
- // The app-wide cipher is resolved lazily, so entities without encrypted
414
- // fields never access it.
415
- configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
416
- // Subject-key KMS for pii-annotated fields (crypto-shredding).
417
- // No adapter only gets this far when assertPiiBootInvariants above let it
418
- // through: no PII carriers, or an explicit allowPlaintextPii opt-out. The
419
- // engine then stays off and those fields are stored in plaintext.
420
- configurePiiSubjectKms(bootKms.kms);
421
- // Blind-index key boot gate: with KMS enabled, lookupable fields are stored as
422
- // ciphertext. Without the key, equality lookups such as login by email cannot
423
- // work, so fail fast rather than allowing silently broken authentication.
424
- configureBlindIndexKey(bootKms.blindIndexKey);
425
- const bootLogger = loggerFromExtraContext(resolvedExtraContext);
426
- if (bootLogger)
427
- setProcessLogger(bootLogger);
428
- const autoExtraContext = buildBootExtraContext({
429
- db,
122
+ const connections = await connectProdDependencies(config, log, registerAbortCleanup, options.databaseWait);
123
+ // Tier-composition auto-wire: if a feature carries a tenantTierResolver extension, the
124
+ // resolver is built here, before the dispatcher exists. The app author only mounts
125
+ // `createTierEngineFeature(opts)`; the framework does the wiring.
126
+ const effectiveFeatures = await buildEffectiveFeaturesResolver({
430
127
  features,
431
- envSource,
432
- registry,
433
- hasAuth: !!effectiveAuth,
434
- sseBroker,
435
- redis,
436
- crypto: bootCrypto,
437
- ...(bootKms.kms && { kms: bootKms.kms }),
438
- ...(bootLogger && { log: bootLogger }),
439
- ...(unsubscribeLinks && { unsubscribeLinks }),
440
- ...(options.appOverrides && { appOverrides: options.appOverrides }),
441
- });
442
- const extraContext = {
443
- ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
444
- ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
445
- };
446
- const baseAnonymousAccess = typeof options.anonymousAccess === "function"
447
- ? options.anonymousAccess(deps)
448
- : options.anonymousAccess;
449
- // tenantResolver / tenantExists come from auth-foundation providers.
450
- const resolvedAnonymousAccess = await resolveAnonymousAccessFromRegistry(baseAnonymousAccess, {
451
- db,
452
- registry,
453
- warn: (message) => createFallbackLogger("runProdApp").warn(message),
454
- });
455
- // Sessions are wired directly because db has already been created; unlike
456
- // runDevApp, no late binding is needed. Once a sessionChecker is wired, JWTs
457
- // without sid are rejected, so production sessions cannot be bypassed.
458
- // sessionMassRevoker (the fourth callback from createSessionCallbacks) is not
459
- // part of AuthRoutesConfig; bindAutoRevokeFromFeature hands it to the sessions
460
- // feature so password change and reset revoke all sessions. Secure by default: the sessionStore provider
461
- // goes through resolveSessionStore.
462
- const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
463
- if (mfaFeature)
464
- bindMfaTokenSecretsFromFeature(mfaFeature)?.(jwtSecret, envSource);
465
- if (mfaFeature && registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
466
- throw new Error(`[runProdApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
467
- "createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
468
- "POST /auth/switch-tenant would mint sessions without the second factor");
469
- }
470
- // Hoisted above buildProdSessionAuth: that call also wires MFA-enable/
471
- // disable → PAT-revoke (bindRevokeAllPatTokensFromFeature), so it needs
472
- // patFeature too.
473
- const patFeature = features.find((f) => f.name === PAT_FEATURE);
474
- const sessionAuthFragment = shouldWireProdSessions(Boolean(effectiveAuth), registry.getExtensionUsages(EXT_SESSION_STORE).length > 0)
475
- ? await buildProdSessionAuth(db, registry, sessionsFeature, mfaFeature, patFeature)
476
- : undefined;
477
- // Password-change → PAT-revoke: independent of sessions/MFA being mounted
478
- // (buildProdSessionAuth only runs when a sessionStore provider is
479
- // registered), so wired unconditionally here whenever PAT itself is.
480
- if (patFeature) {
481
- wireProdPatAutoRevoke(db, patFeature);
482
- }
483
- // Token-verifier opt-in: any provider feature (personal-access-tokens, a
484
- // future auth-provider-jwt, ...) self-registers via
485
- // r.useExtension(EXT_TOKEN_VERIFIER, ...) — wire one generic resolver
486
- // whenever at least one is mounted, resolved by shape at request-time.
487
- // PAT keeps its own per-token rate limiter (patRateLimiter), unrelated to
488
- // verification.
489
- const hasTokenVerifierProviders = registry.getExtensionUsages(EXT_TOKEN_VERIFIER).length > 0;
490
- let patAuthFragment;
491
- if (effectiveAuth && patFeature && hasTokenVerifierProviders) {
492
- const rl = patRateLimitFromFeature(patFeature);
493
- patAuthFragment = {
494
- tokenVerifier: (rawToken) => resolveTokenVerifier({ db, registry }, rawToken),
495
- patRateLimiter: createRedisLoginRateLimiter(redis, rl.maxRequests, rl.windowMs, "pat"),
496
- };
497
- }
498
- const baseEntrypointOptions = {
499
- registry,
500
- context: {
501
- db,
502
- redis,
503
- entityCache,
504
- registry,
505
- ...extraContext,
506
- },
507
- sseBroker,
508
- jwtSecret: jwtSecretOrKeyring,
509
- ...(jwtIssuer && { jwtIssuer }),
510
- ...(instanceId && { instanceId }),
511
- dispatcherOptions: {
512
- idempotency,
513
- cacheSync,
514
- outsideTransactionDb,
515
- ...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
516
- },
517
- eventDedup,
518
- ...(options.observability && { observability: options.observability }),
519
- ...(options.metrics && { metrics: options.metrics }),
520
- ...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
521
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
522
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
523
- ...((options.extraRoutes || options.unsubscribe || bootOptionWiring.extraRoutes.length > 0) && {
524
- extraRoutes: [
525
- ...(options.extraRoutes ?? []),
526
- ...unsubscribeRoutes,
527
- ...bootOptionWiring.extraRoutes,
528
- ],
529
- }),
530
- ...(effectiveAuth && {
531
- auth: {
532
- membershipQuery: TenantQueries.memberships,
533
- userQuery: UserQueries.findForAuth,
534
- loginHandler: AuthHandlers.login,
535
- loginErrorStatusMap: effectiveAuth.loginErrorStatusMap ?? {
536
- [AuthErrors.invalidCredentials]: 401,
537
- [AuthErrors.noMembership]: 403,
538
- },
539
- // Redis-backed, not the in-memory default createAuthRoutes falls
540
- // back to — an in-process limiter only rate-limits within a single
541
- // replica, so a multi-instance prod deployment would silently give
542
- // each replica its own bucket. Redis is required infra
543
- // here already (REDIS_URL), so this is free.
544
- loginRateLimit: createRedisLoginRateLimiter(redis),
545
- ...(effectiveAuth.cookieDomain !== undefined && {
546
- cookieDomain: effectiveAuth.cookieDomain,
547
- }),
548
- ...(effectiveAuth.secureCookies !== undefined && {
549
- secureCookies: effectiveAuth.secureCookies,
550
- }),
551
- ...(effectiveAuth.retiredCookieDomains !== undefined && {
552
- retiredCookieDomains: effectiveAuth.retiredCookieDomains,
553
- }),
554
- ...(effectiveAuth.allowedOrigins !== undefined && {
555
- allowedOrigins: effectiveAuth.allowedOrigins,
556
- }),
557
- ...(effectiveAuth.unsafeSkipOriginCheck !== undefined && {
558
- unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
559
- }),
560
- ...(authPublicOrigin !== undefined && { publicOrigin: authPublicOrigin }),
561
- ...(trustedProxyHops !== undefined && { trustedProxyHops }),
562
- ...(effectiveAuth.postAuthLanding !== undefined && {
563
- postAuthLanding: effectiveAuth.postAuthLanding,
564
- }),
565
- ...sessionAuthFragment,
566
- ...patAuthFragment,
567
- ...(mfaFeature && {
568
- mfaVerifyHandler: AuthMfaHandlers.verify,
569
- switchTenantMfaGateHandler: AuthHandlers.switchTenantMfaGate,
570
- mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
571
- mfaPreauthConfirmHandler: AuthMfaHandlers.enableConfirmPreauth,
572
- mfaVerifyRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-verify"),
573
- mfaPreauthEnableStartRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-start"),
574
- mfaPreauthConfirmRateLimit: createRedisLoginRateLimiter(redis, undefined, undefined, "mfa-preauth-confirm"),
575
- }),
576
- ...(effectiveAuth.passwordReset && {
577
- passwordReset: {
578
- requestHandler: AuthHandlers.requestPasswordReset,
579
- confirmHandler: AuthHandlers.resetPassword,
580
- },
581
- }),
582
- ...(effectiveAuth.emailVerification && {
583
- emailVerification: {
584
- requestHandler: AuthHandlers.requestEmailVerification,
585
- confirmHandler: AuthHandlers.verifyEmail,
586
- },
587
- }),
588
- ...(effectiveAuth.accountUnlock && {
589
- accountUnlock: {
590
- requestHandler: AuthHandlers.requestAccountUnlock,
591
- confirmHandler: AuthHandlers.confirmAccountUnlock,
592
- },
593
- }),
594
- ...(effectiveAuth.signup && {
595
- signup: {
596
- requestHandler: AuthHandlers.signupRequest,
597
- confirmHandler: AuthHandlers.signupConfirm,
598
- },
599
- }),
600
- ...(effectiveAuth.invite && {
601
- invite: {
602
- acceptHandler: AuthHandlers.inviteAccept,
603
- acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
604
- signupCompleteHandler: AuthHandlers.inviteSignupComplete,
605
- infoHandler: AuthQueries.inviteInfo,
606
- },
607
- }),
608
- },
609
- }),
610
- ...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
611
- };
612
- // Deployment topology. By default, createAllInOneEntrypoint runs HTTP, both
613
- // job lanes (two runners, each scheduling only its own lane's crons, so
614
- // nothing fires twice) and the event dispatcher inline. That way worker-lane
615
- // crons (run-export-jobs defaults to runIn:"worker") and MSPs run without a
616
- // separate worker process, the gap the data export used to stall on.
617
- // runSingleInstance:false means API-only: a dedicated worker must then take
618
- // over the worker lane and MSPs, while API-lane jobs keep running locally.
619
- // The legacy eventDispatcher.disabled maps to runSingleInstance:false so
620
- // existing callers keep working; the explicit flag takes precedence.
621
- const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
622
- const hasJobs = registry.getAllJobs().size > 0;
623
- const queueNamePrefix = options.jobs?.queueNamePrefix;
624
- const jobLogger = jobRunLoggerCallbacks(registry, db);
625
- const dispatcherTunables = options.eventDispatcher?.pollIntervalMs !== undefined
626
- ? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
627
- : {};
628
- const entrypoint = runSingleInstance
629
- ? createAllInOneEntrypoint({
630
- ...baseEntrypointOptions,
631
- redisUrl,
632
- ...jobLogger,
633
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
634
- ...(options.jobs?.getActiveTenantIds !== undefined && {
635
- getActiveTenantIds: options.jobs.getActiveTenantIds,
636
- }),
637
- ...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
638
- })
639
- : createApiEntrypoint({
640
- ...baseEntrypointOptions,
641
- ...(hasJobs && {
642
- jobs: {
643
- redisUrl,
644
- runLocalJobs: true,
645
- ...jobLogger,
646
- ...(queueNamePrefix !== undefined && { queueNamePrefix }),
647
- ...(options.jobs?.getActiveTenantIds !== undefined && {
648
- getActiveTenantIds: options.jobs.getActiveTenantIds,
649
- }),
650
- },
651
- }),
652
- });
653
- // The broker built above is always ours (runProdApp never accepts a
654
- // caller-supplied sseBroker), so — same reasoning as buildServer's own
655
- // ownedRedisSseBroker hook — closing it on shutdown is our job, not the
656
- // entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
657
- // (which already registered the eventDispatcher/jobRunner hooks above),
658
- // so it drains LIFO before those, matching the close-before-teardown
659
- // order established for buildServer's own hook.
660
- entrypoint.lifecycle.registerShutdownHook("prodAppCacheSync", async () => {
661
- await cacheSync.close();
662
- });
663
- if (ownedRedisSseBroker) {
664
- const broker = ownedRedisSseBroker;
665
- entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
666
- await broker.close();
667
- });
668
- }
669
- // `extraRoutes` itself is already mounted — buildServer wires it inside
670
- // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
671
- // runs. `wire` is the remaining escape-hatch for app-wired co-running
672
- // components that need the system-write dispatcher but don't declare a
673
- // route.
674
- if (options.wire) {
675
- await options.wire({
676
- db,
677
- redis,
678
- registry,
679
- ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
680
- ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
681
- metricsFor: createMetricsForFactory(entrypoint.observability.meter),
682
- dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
683
- dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
684
- });
685
- }
686
- // 9. Seeds: admin first, then config-seeds from r.config({seeds}),
687
- // then app-specific. All idempotent — runProdApp doesn't gate
688
- // "first boot" via flag, every seed-step checks its own
689
- // preconditions. Config-seeds rely on a deterministic
690
- // aggregate-id so re-boot becomes a version_conflict skip.
691
- if (effectiveAuth?.admin) {
692
- await seedAdminGuarded(db, effectiveAuth.admin);
693
- }
694
- for (const seed of bootOptionWiring.shortcutSeeds) {
695
- await seed(db);
696
- }
697
- await applyBootSeeds({
128
+ featureToggles: config.bootOptionWiring.featureToggles,
129
+ db: connections.db,
698
130
  registry,
699
- db,
700
- ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
131
+ cacheSync: connections.cacheSync,
701
132
  });
702
- const customSeeds = bootOptionWiring.customSeeds;
703
- const hasSeeds = customSeeds.length > 0 || options.seedsDir !== undefined;
704
- // Seeds are bootstrap writes, not tenant traffic: they always get the unlimited resolver,
705
- // even when the app has a real one, so a handler's rateLimit can neither fail nor drain them.
706
- const seedContext = hasSeeds
707
- ? withFileProviderResolver(registry, {
708
- db,
709
- redis,
710
- entityCache,
711
- registry,
712
- ...extraContext,
713
- rateLimit: createUnlimitedRateLimitResolver(),
714
- }, cacheSync)
715
- : undefined;
716
- const seedDispatcher = seedContext
717
- ? withSeedErrorContext(createDispatcher(registry, seedContext, {
718
- ...baseEntrypointOptions.dispatcherOptions,
719
- ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
720
- }))
721
- : undefined;
722
- if (seedDispatcher && seedContext) {
723
- const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
724
- for (const seed of customSeeds) {
725
- await seed({
726
- db,
727
- registry,
728
- dispatcher: seedDispatcher,
729
- createFileRef,
730
- ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
731
- ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
732
- });
733
- }
734
- }
735
- // ES operations and seed migrations run after applyBootSeeds and the existing
736
- // seeds array. Declarative seeds insert missing records; seed migrations
737
- // diff and update state that existing seeds cannot revise, such as membership
738
- // roles changed after initial seed creation.
739
- if (seedDispatcher && options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
740
- await createEsOperationsTable(db);
741
- await runPendingSeedMigrations({
742
- db,
743
- seedsDir: options.seedsDir,
744
- appliedBy: "boot",
745
- registry, // The dry-run validator catches handler QN typos before writing.
746
- // @wrapper-known semantic-alias
747
- createContext: (dbRunner) => createSeedMigrationContext({ dispatcher: seedDispatcher, dbRunner }),
748
- });
749
- }
750
- await entrypoint.start();
751
- await recordAppStartedOnBoot({ db, envSource });
752
- startPiiEventBackfillOnBoot({
753
- db,
754
- registry,
755
- lifecycle: entrypoint.lifecycle,
756
- envSource,
757
- });
758
- // 11. Build the fetch-handler. Static-fallback for non-/api/ paths
759
- // wired via a wrapper so Hono owns /api/* + extraRoutes and disk
760
- // owns the rest. Tests use this directly; listen() wraps it in
761
- // Bun.serve.
762
- const cspScriptHashes = await resolveCspScriptHashes(options.staticDir, options.securityHeaders);
763
- const hostDispatchScriptHashes = options.hostDispatch !== undefined && options.staticDir !== undefined
764
- ? await resolveHostDispatchScriptHashes(options.staticDir, options.securityHeaders, cspScriptHashes)
765
- : [];
766
- const fetchHandler = withSecurityHeaders(bootOptionWiring.wrapFetch(options.staticDir
767
- ? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
768
- ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
769
- : undefined, entrypoint.dispatcher, trustedProxyHops ?? 0, hostDispatchScriptHashes)
770
- : // No staticDir (split-deploy / API-only container) → app.fetch's
771
- // response goes straight to the client, bypassing buildStaticFallback
772
- // (and with it tryHonoFirst) entirely. Must strip the router-miss
773
- // marker here too, same reason as the /api/* passthrough in
774
- // run-prod-app-static-files.ts (see try-hono-first.ts).
775
- async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress))), options.securityHeaders, cspScriptHashes);
776
- // 11. Mark lifecycle ready — health/ready flips to 200 after this.
133
+ await assertProdSchemaCurrent(connections.db, options.migrations, log);
134
+ const requestContext = await buildProdRequestContext(options, config, composed, connections);
135
+ const authWiring = await wireProdAuth(config, composed, connections);
136
+ const entrypointOptions = buildProdEntrypointOptions(options, config, composed, connections, requestContext, authWiring, effectiveFeatures);
137
+ const entrypoint = createProdEntrypoint(options, config, composed, connections, entrypointOptions);
138
+ registerProdShutdownHooks(entrypoint, connections.cacheSync, requestContext.ownedRedisSseBroker);
139
+ await runProdWireHook(options, composed, connections, entrypoint);
140
+ await runProdBootSeeds({ options, config, composed, connections, requestContext }, entrypoint, entrypointOptions.dispatcherOptions);
141
+ await startProdBackgroundWork(entrypoint, config, composed, connections);
142
+ const fetch = await buildProdFetchHandler(options, config, entrypoint);
143
+ // Mark lifecycle ready — health/ready flips to 200 after this.
777
144
  entrypoint.lifecycle.markReady();
778
- // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
779
- const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders, cspScriptHashes);
780
- const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
781
- let uninstallShutdownSignals;
782
- let uninstallFatalHandlers;
783
- const handle = {
145
+ const handle = createProdAppHandle({
146
+ options,
147
+ config,
148
+ composed,
149
+ connections,
784
150
  entrypoint,
785
- fetch: fetchHandler,
786
- webSocketUpgradeFetch,
787
- listen: async (listenPort = port) => {
788
- // Bun.serve provides production HTTP. Tests run under Node, so they call the
789
- // fetch handler directly instead. buildBunServeOptions centralizes the
790
- // options shape, including `idleTimeout: 0` for SSE.
791
- if (typeof Bun === "undefined") {
792
- // Throw a clear error instead of a raw ReferenceError when listen() is called
793
- // under Node/vitest without autoListen:false, making the runtime
794
- // requirement explicit.
795
- throw new Error("[runProdApp] listen() requires Bun runtime (Bun.serve). " +
796
- "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.");
797
- }
798
- handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler, options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry), { upgradeFetch: webSocketUpgradeFetch }));
799
- // SIGTERM/SIGINT — graceful shutdown. Only registered when we
800
- // actually own a Bun-server, otherwise the test process picks up
801
- // signals it shouldn't respond to.
802
- if (options.signals !== false) {
803
- uninstallShutdownSignals?.();
804
- uninstallShutdownSignals = installShutdownSignals({
805
- stop: () => handle.stop(),
806
- ...(options.signals?.drainTimeoutMs !== undefined && {
807
- drainTimeoutMs: options.signals.drainTimeoutMs,
808
- }),
809
- });
810
- uninstallFatalHandlers?.();
811
- uninstallFatalHandlers = installFatalErrorHandlers({
812
- logger: log,
813
- stop: () => handle.stop(),
814
- });
815
- }
816
- log.info(`ready on http://0.0.0.0:${listenPort}`);
817
- },
818
- stop: async () => {
819
- uninstallFatalHandlers?.();
820
- uninstallFatalHandlers = undefined;
821
- await entrypoint.stop();
822
- if (handle.server)
823
- await stopServerGracefully(handle.server);
824
- await flushEscapeHatchAudits();
825
- await closeDb();
826
- await closeOutsideTransactionDb();
827
- redis.disconnect();
828
- await bootKms.close();
829
- uninstallShutdownSignals?.();
830
- uninstallShutdownSignals = undefined;
831
- uninstallProcessLogger();
832
- },
833
- };
834
- // 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
835
- // false because Bun.serve isn't available under vitest/node).
836
- // Production path: `await runProdApp({...})` and the server is up.
151
+ fetch,
152
+ log,
153
+ });
154
+ // Auto-listen unless explicitly suppressed (tests pass autoListen:
155
+ // false because Bun.serve isn't available under vitest/node).
156
+ // Production path: `await runProdApp({...})` and the server is up.
837
157
  if (options.autoListen !== false) {
838
158
  await handle.listen();
839
159
  }