@cosmicdrift/kumiko-server-runtime 0.382.0 → 0.383.1
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/dist/boot/worker-boot-core.js +3 -1
- package/dist/fatal-error-handlers.d.ts +8 -0
- package/dist/fatal-error-handlers.js +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/process-logger.d.ts +3 -0
- package/dist/process-logger.js +42 -0
- package/dist/run-prod-app.js +53 -22
- package/dist/run-worker-app.js +19 -10
- package/dist/trusted-proxy-hops.d.ts +6 -0
- package/dist/trusted-proxy-hops.js +9 -0
- package/package.json +7 -7
- package/src/changes.json +19 -0
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// share every step that decides ciphers, blind indexes and config encryption.
|
|
6
6
|
import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
|
|
7
7
|
import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
-
import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
|
|
9
9
|
import { createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
11
11
|
import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
|
|
@@ -108,10 +108,12 @@ export async function bootWorkerProcess(options, envSource, profile) {
|
|
|
108
108
|
const { db, close: closeDb } = createDbConnection({
|
|
109
109
|
url: databaseUrl,
|
|
110
110
|
...dbConnectionOptionsFromEnv(envSource),
|
|
111
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
111
112
|
});
|
|
112
113
|
const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
|
|
113
114
|
url: databaseUrl,
|
|
114
115
|
...outsideTransactionPoolOptionsFromEnv(envSource),
|
|
116
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
115
117
|
});
|
|
116
118
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
117
119
|
const cacheSync = createRedisCacheSyncBus({
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Logger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
export type FatalErrorHandlersOptions = {
|
|
3
|
+
readonly logger: Pick<Logger, "error">;
|
|
4
|
+
readonly stop: () => Promise<void>;
|
|
5
|
+
readonly exit?: (code: number) => void;
|
|
6
|
+
readonly hardExitMs?: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function installFatalErrorHandlers(options: FatalErrorHandlersOptions): () => void;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { scrubLogText } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
const DEFAULT_HARD_EXIT_MS = 10_000;
|
|
3
|
+
function describeFatal(reason) {
|
|
4
|
+
if (reason instanceof Error) {
|
|
5
|
+
return {
|
|
6
|
+
message: scrubLogText(reason.message),
|
|
7
|
+
...(reason.stack !== undefined && { stack: scrubLogText(reason.stack) }),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
return { message: scrubLogText(String(reason)) };
|
|
11
|
+
}
|
|
12
|
+
// A process in an unknown state must not keep serving; the hard-exit timer
|
|
13
|
+
// covers a stop() that hangs on the very fault that triggered it.
|
|
14
|
+
export function installFatalErrorHandlers(options) {
|
|
15
|
+
const { logger, stop } = options;
|
|
16
|
+
const exit = options.exit ?? ((code) => process.exit(code));
|
|
17
|
+
const hardExitMs = options.hardExitMs ?? DEFAULT_HARD_EXIT_MS;
|
|
18
|
+
let handling = false;
|
|
19
|
+
const handle = (source) => (reason) => {
|
|
20
|
+
// skip: a second fault during shutdown is already covered by the running stop and hard-exit timer
|
|
21
|
+
if (handling)
|
|
22
|
+
return;
|
|
23
|
+
handling = true;
|
|
24
|
+
logger.error("fatal process error, shutting down", {
|
|
25
|
+
fatal: true,
|
|
26
|
+
source,
|
|
27
|
+
...describeFatal(reason),
|
|
28
|
+
});
|
|
29
|
+
const hardExitTimer = setTimeout(() => exit(1), hardExitMs);
|
|
30
|
+
hardExitTimer.unref();
|
|
31
|
+
void stop()
|
|
32
|
+
.catch((stopError) => {
|
|
33
|
+
logger.error("shutdown after fatal error failed", describeFatal(stopError));
|
|
34
|
+
})
|
|
35
|
+
.finally(() => {
|
|
36
|
+
clearTimeout(hardExitTimer);
|
|
37
|
+
exit(1);
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const onUncaughtException = handle("uncaughtException");
|
|
41
|
+
const onUnhandledRejection = handle("unhandledRejection");
|
|
42
|
+
process.on("uncaughtException", onUncaughtException);
|
|
43
|
+
process.on("unhandledRejection", onUnhandledRejection);
|
|
44
|
+
return () => {
|
|
45
|
+
process.off("uncaughtException", onUncaughtException);
|
|
46
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { assertStackBootRequirements } from "./boot-options/stack-boot-requireme
|
|
|
13
13
|
export { type BundledAssetDeclaration, type BundledAssetOptions, readBundledAsset, resolveBundledAsset, } from "./bundled-assets.ts";
|
|
14
14
|
export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features.ts";
|
|
15
15
|
export { type DiskFileRouteOptions, diskFileRoute } from "./disk-file-route.ts";
|
|
16
|
+
export { type FatalErrorHandlersOptions, installFatalErrorHandlers, } from "./fatal-error-handlers.ts";
|
|
16
17
|
export type { RateLimitOption } from "./rate-limit-defaults.ts";
|
|
17
18
|
export type { RunBootstrapOptions } from "./run-bootstrap.ts";
|
|
18
19
|
export { runBootstrap } from "./run-bootstrap.ts";
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { assertStackBootRequirements } from "./boot-options/stack-boot-requireme
|
|
|
12
12
|
export { readBundledAsset, resolveBundledAsset, } from "./bundled-assets.js";
|
|
13
13
|
export { composeFeatures } from "./compose-features.js";
|
|
14
14
|
export { diskFileRoute } from "./disk-file-route.js";
|
|
15
|
+
export { installFatalErrorHandlers, } from "./fatal-error-handlers.js";
|
|
15
16
|
export { runBootstrap } from "./run-bootstrap.js";
|
|
16
17
|
export { parseRunMode, requireEnv, runProdApp } from "./run-prod-app.js";
|
|
17
18
|
export { runWorkerApp } from "./run-worker-app.js";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createLogger, setProcessLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
2
|
+
import { loggerFromExtraContext } from "./run-prod-app-boot-context.js";
|
|
3
|
+
const CONSOLE_METHOD_BY_LEVEL = {
|
|
4
|
+
info: "log",
|
|
5
|
+
warn: "warn",
|
|
6
|
+
error: "error",
|
|
7
|
+
debug: "debug",
|
|
8
|
+
};
|
|
9
|
+
function createConsoleLogger(prefix) {
|
|
10
|
+
const write = (level) => (msg, data) => {
|
|
11
|
+
const method = CONSOLE_METHOD_BY_LEVEL[level];
|
|
12
|
+
const line = `[${prefix}] ${msg}`;
|
|
13
|
+
// biome-ignore lint/suspicious/noConsole: outside production, boot output stays human-readable on the console
|
|
14
|
+
console[method](...(data ? [line, data] : [line]));
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
info: write("info"),
|
|
18
|
+
warn: write("warn"),
|
|
19
|
+
error: write("error"),
|
|
20
|
+
debug: write("debug"),
|
|
21
|
+
child: () => createConsoleLogger(prefix),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// Function-form extraContext needs boot deps, so only a static one can supply the logger this early.
|
|
25
|
+
// Outside production no process logger is installed, so fallback loggers keep their console output.
|
|
26
|
+
export function installProcessLogger(extraContext, consolePrefix) {
|
|
27
|
+
const appLogger = typeof extraContext === "object" && extraContext !== null
|
|
28
|
+
? loggerFromExtraContext(Object.fromEntries(Object.entries(extraContext)))
|
|
29
|
+
: undefined;
|
|
30
|
+
if (appLogger) {
|
|
31
|
+
setProcessLogger(appLogger);
|
|
32
|
+
return appLogger;
|
|
33
|
+
}
|
|
34
|
+
if (process.env["NODE_ENV"] !== "production")
|
|
35
|
+
return createConsoleLogger(consolePrefix);
|
|
36
|
+
const logger = createLogger({ pretty: false });
|
|
37
|
+
setProcessLogger(logger);
|
|
38
|
+
return logger;
|
|
39
|
+
}
|
|
40
|
+
export function uninstallProcessLogger() {
|
|
41
|
+
setProcessLogger(null);
|
|
42
|
+
}
|
package/dist/run-prod-app.js
CHANGED
|
@@ -47,18 +47,18 @@ import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundl
|
|
|
47
47
|
import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
48
48
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
49
49
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
50
|
-
import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring,
|
|
50
|
+
import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
|
|
51
51
|
import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
52
|
-
import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
|
|
52
|
+
import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, serverTimeoutOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
|
|
53
53
|
import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
|
|
54
54
|
import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
55
55
|
import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
|
|
56
56
|
import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
|
|
57
|
-
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
57
|
+
import { createFallbackLogger, setProcessLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
58
58
|
import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
|
|
59
59
|
import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
|
|
60
60
|
import { createDispatcher, createEntityCache, createEventDedup, createIdempotencyGuard, createUnlimitedRateLimitResolver, flushEscapeHatchAudits, } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
61
|
-
import { createRedisCacheSyncBus, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
61
|
+
import { createRedisCacheSyncBus, logRedisConnectionState, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
|
|
62
62
|
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
63
63
|
import { Redis } from "ioredis";
|
|
64
64
|
import { applyBootSeeds } from "./boot/apply-boot-seeds.js";
|
|
@@ -74,7 +74,9 @@ import { assertStackBootRequirements } from "./boot-options/stack-boot-requireme
|
|
|
74
74
|
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
|
|
75
75
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
|
|
76
76
|
import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
|
|
77
|
+
import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
|
|
77
78
|
import { assertPiiBootInvariants } from "./pii-boot-gate.js";
|
|
79
|
+
import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
|
|
78
80
|
import { resolveRateLimit } from "./rate-limit-defaults.js";
|
|
79
81
|
import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
|
|
80
82
|
import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
|
|
@@ -84,6 +86,7 @@ import { withSecurityHeaders } from "./security-headers.js";
|
|
|
84
86
|
import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
|
|
85
87
|
import { assertSessionBootInvariants } from "./session-boot-gate.js";
|
|
86
88
|
import { shouldWireProdSessions } from "./session-wiring.js";
|
|
89
|
+
import { resolveTrustedProxyHops } from "./trusted-proxy-hops.js";
|
|
87
90
|
import { stripNoRouteMatchHeader } from "./try-hono-first.js";
|
|
88
91
|
export { parseRunMode } from "./boot/env-gate.js";
|
|
89
92
|
export { buildBunServeOptions } from "./bun-serve-options.js";
|
|
@@ -130,14 +133,28 @@ function makeDryRunHandle() {
|
|
|
130
133
|
stop: noop,
|
|
131
134
|
};
|
|
132
135
|
}
|
|
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
|
+
}
|
|
133
151
|
let warnedLegacyJwtSecret = false;
|
|
134
152
|
function warnLegacyJwtSecretOnce() {
|
|
135
153
|
// skip: already warned once this process — avoid log spam on every boot path.
|
|
136
154
|
if (warnedLegacyJwtSecret)
|
|
137
155
|
return;
|
|
138
156
|
warnedLegacyJwtSecret = true;
|
|
139
|
-
|
|
140
|
-
console.warn("[runProdApp] JWT keyring carries a legacy (pre-rotation) verify-only key — " +
|
|
157
|
+
createFallbackLogger("runProdApp").warn("JWT keyring carries a legacy (pre-rotation) verify-only key — " +
|
|
141
158
|
"it never expires on its own. JWT_SECRET remains a required env (hmacSecret " +
|
|
142
159
|
"fallback + boot requireEnv). To stop verifying with the pre-rotation secret: " +
|
|
143
160
|
"set auth.mail.hmacSecret explicitly, then rotate JWT_SECRET to a fresh value " +
|
|
@@ -167,6 +184,7 @@ export async function runProdApp(options) {
|
|
|
167
184
|
if (envGate.kind === "stopped")
|
|
168
185
|
return makeDryRunHandle();
|
|
169
186
|
const { runMode, decryptedEnvSource } = envGate;
|
|
187
|
+
const log = installProcessLogger(options.extraContext, "runProdApp");
|
|
170
188
|
// Stacks whose boot options are missing and the declarative boot options
|
|
171
189
|
// (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
|
|
172
190
|
// the env so an explicitly set variable wins.
|
|
@@ -204,20 +222,21 @@ export async function runProdApp(options) {
|
|
|
204
222
|
// kumiko-framework#1539 — options.auth.trustedProxyHops wins; falls back
|
|
205
223
|
// to the env var so ops can close the XFF-spoofing hole per-deployment
|
|
206
224
|
// without a code change (mirrors instanceId's env-first pattern above).
|
|
207
|
-
const trustedProxyHops =
|
|
208
|
-
options.
|
|
209
|
-
|
|
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
|
+
});
|
|
210
231
|
if (trustedProxyHops === undefined) {
|
|
211
232
|
// With 0 hops every request appears to come from the proxy pod, so all IP-keyed
|
|
212
233
|
// rate limiters collapse into one shared bucket behind a reverse proxy.
|
|
213
|
-
|
|
214
|
-
console.warn(`[runProdApp] trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
|
|
234
|
+
log.warn(`trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
|
|
215
235
|
"Behind a reverse proxy all clients share one rate-limit bucket, so the default L1/L2 limits throttle the whole site at once.");
|
|
216
236
|
}
|
|
217
237
|
const resolvedRateLimit = resolveRateLimit(options.rateLimit);
|
|
218
238
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
219
|
-
|
|
220
|
-
console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
|
|
239
|
+
log.info(`booting Kumiko stack on port ${port}`);
|
|
221
240
|
// auth.mail → expandiert in die expliziten Flow-Felder, bevor sie sowohl
|
|
222
241
|
// die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
|
|
223
242
|
// unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
|
|
@@ -228,8 +247,7 @@ export async function runProdApp(options) {
|
|
|
228
247
|
: undefined;
|
|
229
248
|
const authPublicOrigin = effectiveAuth
|
|
230
249
|
? resolveAuthPublicOrigin(effectiveAuth, envSource, (message) => {
|
|
231
|
-
|
|
232
|
-
console.warn(message);
|
|
250
|
+
log.warn(message);
|
|
233
251
|
})
|
|
234
252
|
: undefined;
|
|
235
253
|
const { unsubscribeLinks, unsubscribeRoutes } = resolveUnsubscribeWiring({
|
|
@@ -277,6 +295,7 @@ export async function runProdApp(options) {
|
|
|
277
295
|
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
278
296
|
console.log(`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`);
|
|
279
297
|
await bootKms.close();
|
|
298
|
+
uninstallProcessLogger();
|
|
280
299
|
if (options.envSource === undefined) {
|
|
281
300
|
process.exit(0);
|
|
282
301
|
}
|
|
@@ -301,12 +320,15 @@ export async function runProdApp(options) {
|
|
|
301
320
|
const { db, close: closeDb } = createDbConnection({
|
|
302
321
|
url: databaseUrl,
|
|
303
322
|
...dbConnectionOptionsFromEnv(envSource),
|
|
323
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
304
324
|
});
|
|
305
325
|
const { db: outsideTransactionDb, close: closeOutsideTransactionDb } = createDbConnection({
|
|
306
326
|
url: databaseUrl,
|
|
307
327
|
...outsideTransactionPoolOptionsFromEnv(envSource),
|
|
328
|
+
...serverTimeoutOptionsFromEnv(envSource),
|
|
308
329
|
});
|
|
309
330
|
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
331
|
+
logRedisConnectionState(redis, log);
|
|
310
332
|
await waitForDatabase(db, { processName: "runProdApp" });
|
|
311
333
|
// Sprint-8a Tier-Composition auto-wire: scan features for a
|
|
312
334
|
// tenantTierResolver-extension. If found, build the resolver here (db +
|
|
@@ -346,15 +368,13 @@ export async function runProdApp(options) {
|
|
|
346
368
|
// `migrations: false` für custom Schema-Setups.
|
|
347
369
|
if (options.migrations !== false) {
|
|
348
370
|
const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
|
|
349
|
-
|
|
350
|
-
console.log(`[runProdApp] checking schema drift (${migrationsDir})…`);
|
|
371
|
+
log.info(`checking schema drift (${migrationsDir})`);
|
|
351
372
|
try {
|
|
352
373
|
await assertKumikoSchemaCurrent(db, migrationsDir);
|
|
353
374
|
}
|
|
354
375
|
catch (err) {
|
|
355
376
|
if (err instanceof SchemaDriftError) {
|
|
356
|
-
|
|
357
|
-
console.error(`\n[runProdApp] BOOT ABORTED — ${err.message}\n`);
|
|
377
|
+
log.error(`BOOT ABORTED — ${err.message}`);
|
|
358
378
|
}
|
|
359
379
|
throw err;
|
|
360
380
|
}
|
|
@@ -413,6 +433,8 @@ export async function runProdApp(options) {
|
|
|
413
433
|
// silent-broken-auth.
|
|
414
434
|
configureBlindIndexKey(bootKms.blindIndexKey);
|
|
415
435
|
const bootLogger = loggerFromExtraContext(resolvedExtraContext);
|
|
436
|
+
if (bootLogger)
|
|
437
|
+
setProcessLogger(bootLogger);
|
|
416
438
|
const autoExtraContext = buildBootExtraContext({
|
|
417
439
|
db,
|
|
418
440
|
features,
|
|
@@ -769,6 +791,7 @@ export async function runProdApp(options) {
|
|
|
769
791
|
const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders, cspScriptHashes);
|
|
770
792
|
const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
|
|
771
793
|
let uninstallShutdownSignals;
|
|
794
|
+
let uninstallFatalHandlers;
|
|
772
795
|
const handle = {
|
|
773
796
|
entrypoint,
|
|
774
797
|
fetch: fetchHandler,
|
|
@@ -798,13 +821,20 @@ export async function runProdApp(options) {
|
|
|
798
821
|
drainTimeoutMs: options.signals.drainTimeoutMs,
|
|
799
822
|
}),
|
|
800
823
|
});
|
|
824
|
+
uninstallFatalHandlers?.();
|
|
825
|
+
uninstallFatalHandlers = installFatalErrorHandlers({
|
|
826
|
+
logger: log,
|
|
827
|
+
stop: () => handle.stop(),
|
|
828
|
+
});
|
|
801
829
|
}
|
|
802
|
-
|
|
803
|
-
console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
|
|
830
|
+
log.info(`ready on http://0.0.0.0:${listenPort}`);
|
|
804
831
|
},
|
|
805
832
|
stop: async () => {
|
|
833
|
+
uninstallFatalHandlers?.();
|
|
834
|
+
uninstallFatalHandlers = undefined;
|
|
806
835
|
await entrypoint.stop();
|
|
807
|
-
handle.server
|
|
836
|
+
if (handle.server)
|
|
837
|
+
await stopServerGracefully(handle.server);
|
|
808
838
|
await flushEscapeHatchAudits();
|
|
809
839
|
await closeDb();
|
|
810
840
|
await closeOutsideTransactionDb();
|
|
@@ -812,6 +842,7 @@ export async function runProdApp(options) {
|
|
|
812
842
|
await bootKms.close();
|
|
813
843
|
uninstallShutdownSignals?.();
|
|
814
844
|
uninstallShutdownSignals = undefined;
|
|
845
|
+
uninstallProcessLogger();
|
|
815
846
|
},
|
|
816
847
|
};
|
|
817
848
|
// 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
|
package/dist/run-worker-app.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
// Container/Coolify sets the same env vars as runProdApp:
|
|
16
16
|
// DATABASE_URL, REDIS_URL, JWT_SECRET — PORT is not needed.
|
|
17
17
|
import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
|
|
18
|
+
import { scrubLogText } from "@cosmicdrift/kumiko-framework/logging";
|
|
18
19
|
import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
|
|
20
|
+
import { logRedisConnectionState } from "@cosmicdrift/kumiko-framework/redis";
|
|
19
21
|
import { runEnvGate } from "./boot/env-gate.js";
|
|
20
22
|
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
21
23
|
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
@@ -24,6 +26,8 @@ import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
|
|
|
24
26
|
import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
|
|
25
27
|
import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
|
|
26
28
|
import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
|
|
29
|
+
import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
|
|
30
|
+
import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
|
|
27
31
|
function makeBootModeHandle() {
|
|
28
32
|
return {
|
|
29
33
|
// @cast-boundary boot-mode: no entrypoint exists because no boot ran —
|
|
@@ -49,8 +53,8 @@ export async function runWorkerApp(options) {
|
|
|
49
53
|
const resolvedEnvSource = await resolveGatedEnvSource(options);
|
|
50
54
|
if (resolvedEnvSource === undefined)
|
|
51
55
|
return makeBootModeHandle();
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
const log = installProcessLogger(options.extraContext, "runWorkerApp");
|
|
57
|
+
log.info("booting Kumiko worker");
|
|
54
58
|
assertWorkerStackRequirements(options);
|
|
55
59
|
const wiring = await resolveBootOptionWiring(options, resolvedEnvSource);
|
|
56
60
|
const envSource = Object.keys(wiring.envDefaults).length > 0
|
|
@@ -77,9 +81,11 @@ export async function runWorkerApp(options) {
|
|
|
77
81
|
}
|
|
78
82
|
if (boot.kind === "dry-run") {
|
|
79
83
|
await wiring.kms.close();
|
|
84
|
+
uninstallProcessLogger();
|
|
80
85
|
return makeBootModeHandle();
|
|
81
86
|
}
|
|
82
87
|
const { db, redis, registry, entrypoint } = boot;
|
|
88
|
+
logRedisConnectionState(redis, log);
|
|
83
89
|
// Metrics first: a port clash must fail the boot before start() begins
|
|
84
90
|
// consuming jobs/events. boot.close() runs the "metrics-server" shutdown
|
|
85
91
|
// hook, so a failing start() does not leak the listener.
|
|
@@ -97,11 +103,15 @@ export async function runWorkerApp(options) {
|
|
|
97
103
|
await wiring.kms.close().catch(() => { });
|
|
98
104
|
throw error;
|
|
99
105
|
}
|
|
106
|
+
let uninstallFatalHandlers;
|
|
100
107
|
const handle = {
|
|
101
108
|
entrypoint,
|
|
102
109
|
stop: async () => {
|
|
110
|
+
uninstallFatalHandlers?.();
|
|
111
|
+
uninstallFatalHandlers = undefined;
|
|
103
112
|
await boot.close();
|
|
104
113
|
await wiring.kms.close();
|
|
114
|
+
uninstallProcessLogger();
|
|
105
115
|
},
|
|
106
116
|
...(metricsServer && { metricsServer }),
|
|
107
117
|
};
|
|
@@ -130,16 +140,15 @@ export async function runWorkerApp(options) {
|
|
|
130
140
|
if (shuttingDown)
|
|
131
141
|
return;
|
|
132
142
|
shuttingDown = true;
|
|
133
|
-
|
|
134
|
-
console.log(`[runWorkerApp] ${signal} received — draining…`);
|
|
143
|
+
log.info(`${signal} received, draining`);
|
|
135
144
|
try {
|
|
136
145
|
await handle.stop();
|
|
137
|
-
|
|
138
|
-
console.log("[runWorkerApp] graceful shutdown complete.");
|
|
146
|
+
log.info("graceful shutdown complete");
|
|
139
147
|
}
|
|
140
148
|
catch (e) {
|
|
141
|
-
|
|
142
|
-
|
|
149
|
+
log.error("error during shutdown", {
|
|
150
|
+
error: scrubLogText(e instanceof Error ? e.message : String(e)),
|
|
151
|
+
});
|
|
143
152
|
}
|
|
144
153
|
finally {
|
|
145
154
|
process.exit(0);
|
|
@@ -147,7 +156,7 @@ export async function runWorkerApp(options) {
|
|
|
147
156
|
};
|
|
148
157
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
149
158
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
uninstallFatalHandlers = installFatalErrorHandlers({ logger: log, stop: () => handle.stop() });
|
|
160
|
+
log.info("ready, worker running");
|
|
152
161
|
return handle;
|
|
153
162
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
export function resolveTrustedProxyHops(input) {
|
|
3
|
+
const hops = input.option ?? input.authOption ?? parseTrustedProxyHopsEnv(input.envValue, "runProdApp");
|
|
4
|
+
if (hops === undefined && input.nodeEnv === "production") {
|
|
5
|
+
throw new Error(`[runProdApp] trustedProxyHops is required in production: pass the trustedProxyHops option or set ${TRUSTED_PROXY_HOPS_ENV} ` +
|
|
6
|
+
"(0 when the app is exposed directly). Without it every client behind a reverse proxy shares one rate-limit bucket.");
|
|
7
|
+
}
|
|
8
|
+
return hops;
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.383.1",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -88,18 +88,18 @@
|
|
|
88
88
|
}
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
92
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
93
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
94
|
-
"@cosmicdrift/kumiko-renderer-mail-html": "0.
|
|
95
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
91
|
+
"@cosmicdrift/kumiko-bundled-features": "0.383.1",
|
|
92
|
+
"@cosmicdrift/kumiko-framework": "0.383.1",
|
|
93
|
+
"@cosmicdrift/kumiko-renderer": "0.383.1",
|
|
94
|
+
"@cosmicdrift/kumiko-renderer-mail-html": "0.383.1",
|
|
95
|
+
"@cosmicdrift/kumiko-types": "0.383.1",
|
|
96
96
|
"bun-types": "^1.3.13",
|
|
97
97
|
"ioredis": "^5.10.1",
|
|
98
98
|
"temporal-polyfill": "^0.3.2",
|
|
99
99
|
"zod": "^4.4.3"
|
|
100
100
|
},
|
|
101
101
|
"devDependencies": {
|
|
102
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
102
|
+
"@cosmicdrift/kumiko-testing": "0.383.1"
|
|
103
103
|
},
|
|
104
104
|
"publishConfig": {
|
|
105
105
|
"registry": "https://registry.npmjs.org",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Production logs are plain JSON without the [runProdApp] prefix",
|
|
6
|
+
"detail": "Behavior change for log parsers and alert rules: runProdApp and runWorkerApp now write one JSON object per line from the process logger, without the former \"[runProdApp]\" text prefix. Match on the component field instead of the prefix."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.383.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "runProdApp requires trustedProxyHops in production",
|
|
12
|
+
"detail": "In production runProdApp refuses to boot when neither the trustedProxyHops option, the auth option nor KUMIKO_TRUSTED_PROXY_HOPS is set. An explicit 0 is allowed. create-kumiko-app templates write KUMIKO_TRUSTED_PROXY_HOPS=0 into .env.example.",
|
|
13
|
+
"migration": "set KUMIKO_TRUSTED_PROXY_HOPS=<hops> (0 when the app is exposed directly) or pass trustedProxyHops"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.383.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "Fatal-error handlers and graceful server stop",
|
|
19
|
+
"detail": "New installFatalErrorHandlers logs uncaughtException and unhandledRejection, stops the app and exits 1 (hard exit after 10 s). runProdApp and runWorkerApp install it. The HTTP server stop is awaited against a 10 s grace period and then forced before the database closes."
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "0.379.0",
|
|
4
23
|
"type": "improvement",
|