@spfn/core 0.2.0-beta.9 → 0.3.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +467 -305
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +810 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/{boss-DI1r4kTS.d.ts → boss-D16fO2oG.d.ts} +41 -1
- package/dist/cache/index.js +42 -30
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +121 -13
- package/dist/codegen/index.js +212 -15
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +615 -6
- package/dist/config/index.js +124 -5
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +220 -0
- package/dist/contract/index.js +558 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/index.d.ts +528 -85
- package/dist/db/index.js +831 -122
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-DfDP39Nq.d.ts +167 -0
- package/dist/env/index.d.ts +26 -2
- package/dist/env/index.js +15 -5
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.d.ts +26 -19
- package/dist/env/loader.js +32 -25
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +418 -5
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +33 -3
- package/dist/event/index.js +24 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +42 -3
- package/dist/event/sse/client.js +128 -45
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +12 -5
- package/dist/event/sse/index.js +280 -32
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +59 -0
- package/dist/event/ws/client.js +273 -0
- package/dist/event/ws/client.js.map +1 -0
- package/dist/event/ws/index.d.ts +94 -0
- package/dist/event/ws/index.js +272 -0
- package/dist/event/ws/index.js.map +1 -0
- package/dist/job/index.d.ts +2 -2
- package/dist/job/index.js +155 -42
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.d.ts +5 -0
- package/dist/logger/index.js +14 -0
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +347 -9
- package/dist/middleware/index.js +1462 -15
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +42 -28
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +35 -51
- package/dist/nextjs/server.js +126 -60
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +152 -0
- package/dist/ops/index.js +500 -0
- package/dist/ops/index.js.map +1 -0
- package/dist/route/index.d.ts +8 -694
- package/dist/route/index.js +111 -22
- package/dist/route/index.js.map +1 -1
- package/dist/router-Qbssr11H.d.ts +676 -0
- package/dist/security/index.d.ts +83 -0
- package/dist/security/index.js +173 -0
- package/dist/security/index.js.map +1 -0
- package/dist/server/index.d.ts +491 -22
- package/dist/server/index.js +1887 -308
- package/dist/server/index.js.map +1 -1
- package/dist/token-manager-BT5EnUAR.d.ts +278 -0
- package/dist/types-2AbaW4Ie.d.ts +205 -0
- package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
- package/dist/types-D1c57Ko-.d.ts +115 -0
- package/dist/types-ZQODsBft.d.ts +282 -0
- package/package.json +244 -208
- package/dist/router-Di7ENoah.d.ts +0 -151
- package/dist/types-B-e_f2dQ.d.ts +0 -121
- package/docs/cache.md +0 -133
- package/docs/codegen.md +0 -74
- package/docs/database.md +0 -346
- package/docs/entity.md +0 -539
- package/docs/env.md +0 -477
- package/docs/errors.md +0 -319
- package/docs/event.md +0 -116
- package/docs/job.md +0 -131
- package/docs/logger.md +0 -108
- package/docs/middleware.md +0 -337
- package/docs/nextjs.md +0 -241
- package/docs/repository.md +0 -496
- package/docs/route.md +0 -497
- package/docs/server.md +0 -307
package/dist/server/index.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { env } from '@spfn/core/config';
|
|
2
|
-
import {
|
|
3
|
-
import { existsSync } from 'fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
3
|
import { resolve, join } from 'path';
|
|
4
|
+
import { parse } from 'dotenv';
|
|
5
|
+
import { logger } from '@spfn/core/logger';
|
|
5
6
|
import { Hono } from 'hono';
|
|
6
7
|
import { cors } from 'hono/cors';
|
|
7
|
-
import { registerRoutes } from '@spfn/core/route';
|
|
8
|
-
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
|
|
8
|
+
import { registerRoutes, defineMiddleware } from '@spfn/core/route';
|
|
9
|
+
import { ErrorHandler, RequestLogger, setRateLimitPolicies, setRateLimitFailClosedDefault, rateLimit } from '@spfn/core/middleware';
|
|
10
|
+
import { setDefaultSafeFetchPolicy } from '@spfn/core/security';
|
|
9
11
|
import { streamSSE } from 'hono/streaming';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
+
import { randomBytes, createHash } from 'crypto';
|
|
13
|
+
import { Agent, setGlobalDispatcher } from 'undici';
|
|
14
|
+
import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, migrationTargets, closeDatabase } from '@spfn/core/db';
|
|
12
15
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
13
16
|
import { serve } from '@hono/node-server';
|
|
14
17
|
import PgBoss from 'pg-boss';
|
|
@@ -94,6 +97,11 @@ function formatError(error) {
|
|
|
94
97
|
const stackLines = error.stack.split("\n").slice(1);
|
|
95
98
|
lines.push(...stackLines);
|
|
96
99
|
}
|
|
100
|
+
if (error.cause instanceof Error) {
|
|
101
|
+
lines.push(`Caused by: ${formatError(error.cause)}`);
|
|
102
|
+
} else if (error.cause !== void 0) {
|
|
103
|
+
lines.push(`Caused by: ${String(error.cause)}`);
|
|
104
|
+
}
|
|
97
105
|
return lines.join("\n");
|
|
98
106
|
}
|
|
99
107
|
function formatContext(context) {
|
|
@@ -163,6 +171,19 @@ function formatConsole(metadata, colorize = true) {
|
|
|
163
171
|
}
|
|
164
172
|
return output;
|
|
165
173
|
}
|
|
174
|
+
function formatErrorCauseChain(error) {
|
|
175
|
+
const result = {
|
|
176
|
+
name: error.name,
|
|
177
|
+
message: error.message,
|
|
178
|
+
stack: error.stack
|
|
179
|
+
};
|
|
180
|
+
if (error.cause instanceof Error) {
|
|
181
|
+
result.cause = formatErrorCauseChain(error.cause);
|
|
182
|
+
} else if (error.cause !== void 0) {
|
|
183
|
+
result.cause = String(error.cause);
|
|
184
|
+
}
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
166
187
|
function formatJSON(metadata) {
|
|
167
188
|
const obj = {
|
|
168
189
|
timestamp: formatTimestamp(metadata.timestamp),
|
|
@@ -176,11 +197,7 @@ function formatJSON(metadata) {
|
|
|
176
197
|
obj.context = metadata.context;
|
|
177
198
|
}
|
|
178
199
|
if (metadata.error) {
|
|
179
|
-
obj.error =
|
|
180
|
-
name: metadata.error.name,
|
|
181
|
-
message: metadata.error.message,
|
|
182
|
-
stack: metadata.error.stack
|
|
183
|
-
};
|
|
200
|
+
obj.error = formatErrorCauseChain(metadata.error);
|
|
184
201
|
}
|
|
185
202
|
return JSON.stringify(obj);
|
|
186
203
|
}
|
|
@@ -314,36 +331,163 @@ var init_formatters = __esm({
|
|
|
314
331
|
};
|
|
315
332
|
}
|
|
316
333
|
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
var envLogger = logger.child("@spfn/core:env-loader");
|
|
335
|
+
function getEnvFiles(nodeEnv, server) {
|
|
336
|
+
const files = [
|
|
337
|
+
".env",
|
|
338
|
+
`.env.${nodeEnv}`
|
|
339
|
+
];
|
|
340
|
+
if (nodeEnv !== "test") {
|
|
341
|
+
files.push(".env.local");
|
|
342
|
+
}
|
|
343
|
+
files.push(`.env.${nodeEnv}.local`);
|
|
344
|
+
if (server) {
|
|
345
|
+
files.push(".env.server");
|
|
346
|
+
}
|
|
347
|
+
return files;
|
|
348
|
+
}
|
|
349
|
+
function parseEnvFile(filePath) {
|
|
350
|
+
if (!existsSync(filePath)) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return parse(readFileSync(filePath, "utf-8"));
|
|
354
|
+
}
|
|
355
|
+
function loadEnv(options = {}) {
|
|
356
|
+
const {
|
|
357
|
+
cwd = process.cwd(),
|
|
358
|
+
nodeEnv = process.env.NODE_ENV || "local",
|
|
359
|
+
server = true,
|
|
360
|
+
debug = false,
|
|
361
|
+
override = false
|
|
362
|
+
} = options;
|
|
363
|
+
const envFiles = getEnvFiles(nodeEnv, server);
|
|
364
|
+
const loadedFiles = [];
|
|
365
|
+
const existingKeys = new Set(Object.keys(process.env));
|
|
366
|
+
const merged = {};
|
|
367
|
+
for (const fileName of envFiles) {
|
|
368
|
+
const filePath = resolve(cwd, fileName);
|
|
369
|
+
const parsed = parseEnvFile(filePath);
|
|
370
|
+
if (parsed === null) {
|
|
371
|
+
continue;
|
|
332
372
|
}
|
|
373
|
+
loadedFiles.push(fileName);
|
|
374
|
+
Object.assign(merged, parsed);
|
|
375
|
+
}
|
|
376
|
+
const loadedKeys = [];
|
|
377
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
378
|
+
if (!override && existingKeys.has(key)) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
process.env[key] = value;
|
|
382
|
+
loadedKeys.push(key);
|
|
383
|
+
}
|
|
384
|
+
if (debug && loadedFiles.length > 0) {
|
|
385
|
+
envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
|
|
386
|
+
envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
|
|
387
|
+
}
|
|
388
|
+
return { loadedFiles, loadedKeys };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// src/server/dotenv-loader.ts
|
|
392
|
+
var warned = false;
|
|
393
|
+
function loadEnvFiles() {
|
|
394
|
+
if (!warned) {
|
|
395
|
+
warned = true;
|
|
396
|
+
console.warn(
|
|
397
|
+
'[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
|
|
398
|
+
);
|
|
333
399
|
}
|
|
400
|
+
loadEnv();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/event/sse/bounded-writer.ts
|
|
404
|
+
function createBoundedWriter(stream, maxQueue, onClose) {
|
|
405
|
+
const pending = [];
|
|
406
|
+
let flushing = false;
|
|
407
|
+
let closed = false;
|
|
408
|
+
const flush = async () => {
|
|
409
|
+
if (flushing || closed) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
flushing = true;
|
|
413
|
+
while (pending.length > 0 && !closed) {
|
|
414
|
+
const frame = pending.shift();
|
|
415
|
+
try {
|
|
416
|
+
await stream.writeSSE(frame);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
flushing = false;
|
|
419
|
+
onClose(err instanceof Error ? err.message : String(err));
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
flushing = false;
|
|
424
|
+
};
|
|
425
|
+
return {
|
|
426
|
+
enqueue(frame) {
|
|
427
|
+
if (closed) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
pending.push(frame);
|
|
431
|
+
if (pending.length > maxQueue) {
|
|
432
|
+
onClose("outbound queue overflow (slow consumer)");
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
void flush();
|
|
436
|
+
},
|
|
437
|
+
close() {
|
|
438
|
+
closed = true;
|
|
439
|
+
pending.length = 0;
|
|
440
|
+
},
|
|
441
|
+
get queued() {
|
|
442
|
+
return pending.length;
|
|
443
|
+
}
|
|
444
|
+
};
|
|
334
445
|
}
|
|
446
|
+
|
|
447
|
+
// src/event/sse/handler.ts
|
|
335
448
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
336
|
-
|
|
449
|
+
var frameCache = /* @__PURE__ */ new WeakMap();
|
|
450
|
+
function serializeFrame(eventName, payload) {
|
|
451
|
+
if (payload === null || typeof payload !== "object") {
|
|
452
|
+
return JSON.stringify({ event: eventName, data: payload });
|
|
453
|
+
}
|
|
454
|
+
let byEvent = frameCache.get(payload);
|
|
455
|
+
if (!byEvent) {
|
|
456
|
+
byEvent = /* @__PURE__ */ new Map();
|
|
457
|
+
frameCache.set(payload, byEvent);
|
|
458
|
+
}
|
|
459
|
+
let serialized = byEvent.get(eventName);
|
|
460
|
+
if (serialized === void 0) {
|
|
461
|
+
serialized = JSON.stringify({ event: eventName, data: payload });
|
|
462
|
+
byEvent.set(eventName, serialized);
|
|
463
|
+
}
|
|
464
|
+
return serialized;
|
|
465
|
+
}
|
|
466
|
+
var DEFAULT_MAX_QUEUE = 1e3;
|
|
467
|
+
function createSSEHandler(router, config = {}, tokenManager) {
|
|
337
468
|
const {
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
|
|
469
|
+
// 10s — keep-alive가 중간 프록시(GCP LB/nginx)의 idle 타임아웃(흔히 ~15-30s)보다 자주 와야
|
|
470
|
+
// 첫 ping 전 침묵 구간에 연결이 끊기지 않는다. 30s 기본은 idle 타임아웃보다 길어 SSE가
|
|
471
|
+
// 데이터 없는 동안 끊기던 문제가 있었다.
|
|
472
|
+
pingInterval = 1e4,
|
|
473
|
+
auth: authConfig,
|
|
474
|
+
maxQueue = DEFAULT_MAX_QUEUE
|
|
475
|
+
} = config;
|
|
341
476
|
return async (c) => {
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
477
|
+
const subject = await authenticateToken(c, tokenManager);
|
|
478
|
+
if (subject === false) {
|
|
479
|
+
return c.json({ error: "Missing token parameter" }, 401);
|
|
480
|
+
}
|
|
481
|
+
if (subject === null) {
|
|
482
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
483
|
+
}
|
|
484
|
+
if (subject) {
|
|
485
|
+
c.set("sseSubject", subject);
|
|
486
|
+
}
|
|
487
|
+
const requestedEvents = parseRequestedEvents(c);
|
|
488
|
+
if (!requestedEvents) {
|
|
344
489
|
return c.json({ error: "Missing events parameter" }, 400);
|
|
345
490
|
}
|
|
346
|
-
const requestedEvents = eventsParam.split(",").map((e) => e.trim());
|
|
347
491
|
const validEventNames = router.eventNames;
|
|
348
492
|
const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
|
|
349
493
|
if (invalidEvents.length > 0) {
|
|
@@ -353,62 +497,94 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
353
497
|
validEvents: validEventNames
|
|
354
498
|
}, 400);
|
|
355
499
|
}
|
|
500
|
+
const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
|
|
501
|
+
if (allowedEvents === null) {
|
|
502
|
+
return c.json({ error: "Not authorized for any requested events" }, 403);
|
|
503
|
+
}
|
|
356
504
|
sseLogger.debug("SSE connection requested", {
|
|
357
|
-
events:
|
|
505
|
+
events: allowedEvents,
|
|
506
|
+
subject: subject || void 0,
|
|
358
507
|
clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
|
|
359
508
|
});
|
|
509
|
+
c.header("X-Accel-Buffering", "no");
|
|
360
510
|
return streamSSE(c, async (stream) => {
|
|
361
511
|
const unsubscribes = [];
|
|
362
512
|
let messageId = 0;
|
|
363
|
-
|
|
513
|
+
let connectionDead = false;
|
|
514
|
+
let pingTimer;
|
|
515
|
+
let writer;
|
|
516
|
+
let onClosed;
|
|
517
|
+
const cleanup = (reason) => {
|
|
518
|
+
if (connectionDead) return;
|
|
519
|
+
connectionDead = true;
|
|
520
|
+
clearInterval(pingTimer);
|
|
521
|
+
unsubscribes.forEach((fn) => fn());
|
|
522
|
+
writer?.close();
|
|
523
|
+
onClosed?.();
|
|
524
|
+
sseLogger.info("SSE dead connection cleaned up", {
|
|
525
|
+
events: allowedEvents,
|
|
526
|
+
reason
|
|
527
|
+
});
|
|
528
|
+
};
|
|
529
|
+
writer = createBoundedWriter(stream, maxQueue, (reason) => {
|
|
530
|
+
sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
|
|
531
|
+
cleanup(reason);
|
|
532
|
+
});
|
|
533
|
+
writer.enqueue({
|
|
534
|
+
event: "connected",
|
|
535
|
+
data: JSON.stringify({
|
|
536
|
+
subscribedEvents: allowedEvents,
|
|
537
|
+
timestamp: Date.now()
|
|
538
|
+
})
|
|
539
|
+
});
|
|
540
|
+
for (const eventName of allowedEvents) {
|
|
364
541
|
const eventDef = router.events[eventName];
|
|
365
542
|
if (!eventDef) {
|
|
366
543
|
continue;
|
|
367
544
|
}
|
|
368
545
|
const unsubscribe = eventDef.subscribe((payload) => {
|
|
546
|
+
if (connectionDead) return;
|
|
547
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
548
|
+
if (!authConfig.filter[eventName](subject, payload)) {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
369
552
|
messageId++;
|
|
370
|
-
const message = {
|
|
371
|
-
event: eventName,
|
|
372
|
-
data: payload
|
|
373
|
-
};
|
|
374
553
|
sseLogger.debug("SSE sending event", {
|
|
375
554
|
event: eventName,
|
|
376
555
|
messageId
|
|
377
556
|
});
|
|
378
|
-
|
|
557
|
+
writer.enqueue({
|
|
379
558
|
id: String(messageId),
|
|
380
559
|
event: eventName,
|
|
381
|
-
data:
|
|
560
|
+
data: serializeFrame(eventName, payload)
|
|
382
561
|
});
|
|
383
562
|
});
|
|
384
563
|
unsubscribes.push(unsubscribe);
|
|
385
564
|
}
|
|
386
565
|
sseLogger.info("SSE connection established", {
|
|
387
|
-
events:
|
|
566
|
+
events: allowedEvents,
|
|
388
567
|
subscriptionCount: unsubscribes.length
|
|
389
568
|
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
subscribedEvents: requestedEvents,
|
|
394
|
-
timestamp: Date.now()
|
|
395
|
-
})
|
|
396
|
-
});
|
|
397
|
-
const pingTimer = setInterval(() => {
|
|
398
|
-
void stream.writeSSE({
|
|
569
|
+
pingTimer = setInterval(() => {
|
|
570
|
+
if (connectionDead) return;
|
|
571
|
+
writer.enqueue({
|
|
399
572
|
event: "ping",
|
|
400
573
|
data: JSON.stringify({ timestamp: Date.now() })
|
|
401
574
|
});
|
|
402
575
|
}, pingInterval);
|
|
403
576
|
const abortSignal = c.req.raw.signal;
|
|
404
|
-
|
|
405
|
-
await
|
|
577
|
+
if (!abortSignal.aborted && !connectionDead) {
|
|
578
|
+
await new Promise((resolve2) => {
|
|
579
|
+
const onAbort = () => resolve2();
|
|
580
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
581
|
+
onClosed = () => {
|
|
582
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
583
|
+
resolve2();
|
|
584
|
+
};
|
|
585
|
+
});
|
|
406
586
|
}
|
|
407
|
-
|
|
408
|
-
unsubscribes.forEach((fn) => fn());
|
|
409
|
-
sseLogger.info("SSE connection closed", {
|
|
410
|
-
events: requestedEvents
|
|
411
|
-
});
|
|
587
|
+
cleanup();
|
|
412
588
|
}, async (err) => {
|
|
413
589
|
sseLogger.error("SSE stream error", {
|
|
414
590
|
error: err.message
|
|
@@ -416,8 +592,731 @@ function createSSEHandler(router, config2 = {}) {
|
|
|
416
592
|
});
|
|
417
593
|
};
|
|
418
594
|
}
|
|
595
|
+
async function authenticateToken(c, tokenManager) {
|
|
596
|
+
if (!tokenManager) {
|
|
597
|
+
return void 0;
|
|
598
|
+
}
|
|
599
|
+
const token = c.req.query("token");
|
|
600
|
+
if (!token) {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
return await tokenManager.verify(token);
|
|
604
|
+
}
|
|
605
|
+
function parseRequestedEvents(c) {
|
|
606
|
+
const eventsParam = c.req.query("events");
|
|
607
|
+
if (!eventsParam) {
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
return eventsParam.split(",").map((e) => e.trim());
|
|
611
|
+
}
|
|
612
|
+
async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
613
|
+
if (!subject || !authConfig?.authorize) {
|
|
614
|
+
return requestedEvents;
|
|
615
|
+
}
|
|
616
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
617
|
+
if (allowed.length === 0) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
return allowed;
|
|
621
|
+
}
|
|
622
|
+
function hashToken(token) {
|
|
623
|
+
return createHash("sha256").update(token).digest("hex");
|
|
624
|
+
}
|
|
625
|
+
var InMemoryTokenStore = class {
|
|
626
|
+
tokens = /* @__PURE__ */ new Map();
|
|
627
|
+
async set(token, data) {
|
|
628
|
+
this.tokens.set(hashToken(token), data);
|
|
629
|
+
}
|
|
630
|
+
async consume(token) {
|
|
631
|
+
const key = hashToken(token);
|
|
632
|
+
const data = this.tokens.get(key);
|
|
633
|
+
if (!data) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
this.tokens.delete(key);
|
|
637
|
+
return data;
|
|
638
|
+
}
|
|
639
|
+
async cleanup() {
|
|
640
|
+
const now = Date.now();
|
|
641
|
+
for (const [key, data] of this.tokens) {
|
|
642
|
+
if (data.expiresAt <= now) {
|
|
643
|
+
this.tokens.delete(key);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
var CacheTokenStore = class {
|
|
649
|
+
constructor(cache2) {
|
|
650
|
+
this.cache = cache2;
|
|
651
|
+
}
|
|
652
|
+
prefix = "sse:token:";
|
|
653
|
+
async set(token, data) {
|
|
654
|
+
const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
|
|
655
|
+
await this.cache.set(
|
|
656
|
+
this.prefix + hashToken(token),
|
|
657
|
+
JSON.stringify(data),
|
|
658
|
+
"EX",
|
|
659
|
+
ttlSeconds
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
async consume(token) {
|
|
663
|
+
const key = this.prefix + hashToken(token);
|
|
664
|
+
let raw = null;
|
|
665
|
+
if (this.cache.getdel) {
|
|
666
|
+
raw = await this.cache.getdel(key);
|
|
667
|
+
} else {
|
|
668
|
+
raw = await this.cache.get(key);
|
|
669
|
+
if (raw) {
|
|
670
|
+
await this.cache.del(key);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (!raw) {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
return JSON.parse(raw);
|
|
677
|
+
}
|
|
678
|
+
async cleanup() {
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
var SSETokenManager = class {
|
|
682
|
+
store;
|
|
683
|
+
ttl;
|
|
684
|
+
cleanupTimer = null;
|
|
685
|
+
constructor(config) {
|
|
686
|
+
this.ttl = config?.ttl ?? 3e4;
|
|
687
|
+
this.store = config?.store ?? new InMemoryTokenStore();
|
|
688
|
+
const cleanupInterval = config?.cleanupInterval ?? 6e4;
|
|
689
|
+
this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
|
|
690
|
+
this.cleanupTimer.unref();
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* How long an issued token stays valid, in milliseconds.
|
|
694
|
+
*
|
|
695
|
+
* Exposed so a caller that has to report an expiry to its client computes the same
|
|
696
|
+
* number this manager stamps on the token, instead of assuming the default.
|
|
697
|
+
*/
|
|
698
|
+
get ttlMs() {
|
|
699
|
+
return this.ttl;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Issue a new one-time-use token for the given subject
|
|
703
|
+
*/
|
|
704
|
+
async issue(subject) {
|
|
705
|
+
const token = randomBytes(32).toString("hex");
|
|
706
|
+
await this.store.set(token, {
|
|
707
|
+
subject,
|
|
708
|
+
expiresAt: Date.now() + this.ttl
|
|
709
|
+
});
|
|
710
|
+
return token;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Verify and consume a token
|
|
714
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
715
|
+
*/
|
|
716
|
+
async verify(token) {
|
|
717
|
+
const data = await this.store.consume(token);
|
|
718
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
return data.subject;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Cleanup timer and resources
|
|
725
|
+
*/
|
|
726
|
+
destroy() {
|
|
727
|
+
if (this.cleanupTimer) {
|
|
728
|
+
clearInterval(this.cleanupTimer);
|
|
729
|
+
this.cleanupTimer = null;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var transportLogger = logger.child("@spfn/core:event-transport");
|
|
734
|
+
var DEFAULT_CHANNEL_PREFIX = "spfn:sse:";
|
|
735
|
+
var SUBSCRIBER_QUIT_TIMEOUT_MS = 5e3;
|
|
736
|
+
var PUBLISH_TIMEOUT_MS = 5e3;
|
|
737
|
+
var SUBSCRIBE_TIMEOUT_MS = 5e3;
|
|
738
|
+
var WARN_THROTTLE_MS = 3e4;
|
|
739
|
+
var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
|
|
740
|
+
function withTimeout(promise, ms, label) {
|
|
741
|
+
return new Promise((resolve2, reject) => {
|
|
742
|
+
const timer = setTimeout(() => {
|
|
743
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
744
|
+
}, ms);
|
|
745
|
+
timer.unref?.();
|
|
746
|
+
promise.then(
|
|
747
|
+
(value) => {
|
|
748
|
+
clearTimeout(timer);
|
|
749
|
+
resolve2(value);
|
|
750
|
+
},
|
|
751
|
+
(err) => {
|
|
752
|
+
clearTimeout(timer);
|
|
753
|
+
reject(err);
|
|
754
|
+
}
|
|
755
|
+
);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
var warnThrottle = /* @__PURE__ */ new Map();
|
|
759
|
+
function throttledWarn(key, message, fields) {
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
const entry = warnThrottle.get(key);
|
|
762
|
+
if (entry && now - entry.last < WARN_THROTTLE_MS) {
|
|
763
|
+
entry.suppressed++;
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
transportLogger.warn(message, entry && entry.suppressed > 0 ? { ...fields, suppressedSinceLast: entry.suppressed } : fields);
|
|
767
|
+
warnThrottle.set(key, { last: now, suppressed: 0 });
|
|
768
|
+
}
|
|
769
|
+
var STATE_KEY = Symbol.for("@spfn/core:event-transport");
|
|
770
|
+
var state = globalThis[STATE_KEY] ??= {
|
|
771
|
+
pubSubCache: void 0,
|
|
772
|
+
subscriber: void 0,
|
|
773
|
+
channelPrefix: void 0,
|
|
774
|
+
wired: /* @__PURE__ */ new Map()
|
|
775
|
+
};
|
|
776
|
+
function resolveChannelPrefix(override) {
|
|
777
|
+
return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
|
|
778
|
+
}
|
|
779
|
+
function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
780
|
+
let subscriber;
|
|
781
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
782
|
+
let degradedUntil = 0;
|
|
783
|
+
const ensureSubscriber = () => {
|
|
784
|
+
if (subscriber) {
|
|
785
|
+
return subscriber;
|
|
786
|
+
}
|
|
787
|
+
subscriber = client.duplicate();
|
|
788
|
+
onSubscriber?.(subscriber);
|
|
789
|
+
subscriber.on("message", (channel, raw) => {
|
|
790
|
+
const handler = handlers.get(channel);
|
|
791
|
+
if (!handler) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
let payload;
|
|
795
|
+
try {
|
|
796
|
+
payload = JSON.parse(raw);
|
|
797
|
+
} catch {
|
|
798
|
+
transportLogger.warn("Pub/sub message parse failed \u2014 dropped", { channel });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
void handler(payload);
|
|
802
|
+
});
|
|
803
|
+
subscriber.on("error", (err) => {
|
|
804
|
+
throttledWarn("subscriber-error", "Pub/sub subscriber error", { error: err.message });
|
|
805
|
+
});
|
|
806
|
+
return subscriber;
|
|
807
|
+
};
|
|
808
|
+
const subscriberHealthy = () => !!subscriber && (subscriber.status === void 0 || subscriber.status === "ready");
|
|
809
|
+
const deliverLocal = (channel, message) => {
|
|
810
|
+
const handler = handlers.get(channel);
|
|
811
|
+
if (handler) {
|
|
812
|
+
void handler(message);
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
return {
|
|
816
|
+
publish: async (name, message) => {
|
|
817
|
+
const channel = channelPrefix + name;
|
|
818
|
+
let serialized;
|
|
819
|
+
try {
|
|
820
|
+
serialized = JSON.stringify(message ?? null);
|
|
821
|
+
} catch (err) {
|
|
822
|
+
transportLogger.warn("Pub/sub payload serialization failed \u2014 local delivery only", {
|
|
823
|
+
channel,
|
|
824
|
+
error: err instanceof Error ? err.message : String(err)
|
|
825
|
+
});
|
|
826
|
+
deliverLocal(channel, message ?? null);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (serialized === void 0) {
|
|
830
|
+
transportLogger.warn("Pub/sub payload not serializable (function/symbol) \u2014 local delivery only", {
|
|
831
|
+
channel
|
|
832
|
+
});
|
|
833
|
+
deliverLocal(channel, message ?? null);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const ready = client.status === void 0 || client.status === "ready";
|
|
837
|
+
const now = Date.now();
|
|
838
|
+
if (!ready || degradedUntil > 0 && now < degradedUntil) {
|
|
839
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (degradedUntil > 0) {
|
|
843
|
+
degradedUntil = now + PUBLISH_TIMEOUT_MS;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
|
|
847
|
+
degradedUntil = 0;
|
|
848
|
+
if (!subscriberHealthy()) {
|
|
849
|
+
throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
|
|
850
|
+
channel
|
|
851
|
+
});
|
|
852
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
853
|
+
}
|
|
854
|
+
} catch (err) {
|
|
855
|
+
degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
|
|
856
|
+
throttledWarn("publish-failed", "Pub/sub publish failed \u2014 local delivery only", {
|
|
857
|
+
channel,
|
|
858
|
+
error: err instanceof Error ? err.message : String(err)
|
|
859
|
+
});
|
|
860
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
861
|
+
}
|
|
862
|
+
},
|
|
863
|
+
subscribe: async (name, handler) => {
|
|
864
|
+
const channel = channelPrefix + name;
|
|
865
|
+
const sub = ensureSubscriber();
|
|
866
|
+
try {
|
|
867
|
+
await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, "pub/sub subscribe");
|
|
868
|
+
} catch (err) {
|
|
869
|
+
if (handlers.size === 0 && subscriber === sub) {
|
|
870
|
+
subscriber = void 0;
|
|
871
|
+
void sub.quit?.().catch(() => void 0);
|
|
872
|
+
}
|
|
873
|
+
throw err;
|
|
874
|
+
}
|
|
875
|
+
handlers.set(channel, handler);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
async function wireEventRouterCache(router, options = {}) {
|
|
880
|
+
if (options.multiInstance === false) {
|
|
881
|
+
const events2 = router.events;
|
|
882
|
+
const stillRedis = Object.keys(events2).filter((k) => state.wired.has(events2[k].name)).length;
|
|
883
|
+
if (stillRedis > 0) {
|
|
884
|
+
transportLogger.warn("multiInstance:false has no effect on events already wired to redis by another router", {
|
|
885
|
+
redisWired: stillRedis
|
|
886
|
+
});
|
|
887
|
+
} else {
|
|
888
|
+
transportLogger.info("Event transport: in-process (multiInstance disabled)");
|
|
889
|
+
}
|
|
890
|
+
return "in-process";
|
|
891
|
+
}
|
|
892
|
+
const pubSubCache = await resolvePubSubCache(options.channelPrefix);
|
|
893
|
+
if (!pubSubCache) {
|
|
894
|
+
transportLogger.info("Event transport: in-process (no cache configured)");
|
|
895
|
+
return "in-process";
|
|
896
|
+
}
|
|
897
|
+
const events = router.events;
|
|
898
|
+
let wired = 0;
|
|
899
|
+
let degraded = 0;
|
|
900
|
+
let alreadyWired = 0;
|
|
901
|
+
for (const key of Object.keys(events)) {
|
|
902
|
+
const event = events[key];
|
|
903
|
+
if (state.wired.has(event.name)) {
|
|
904
|
+
alreadyWired++;
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
await event.useCache(pubSubCache);
|
|
909
|
+
state.wired.set(event.name, event);
|
|
910
|
+
wired++;
|
|
911
|
+
} catch (err) {
|
|
912
|
+
degraded++;
|
|
913
|
+
transportLogger.warn("Event cache wiring failed \u2014 staying in-process for this event", {
|
|
914
|
+
event: event.name,
|
|
915
|
+
error: err instanceof Error ? err.message : String(err)
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (wired + alreadyWired === 0) {
|
|
920
|
+
transportLogger.warn("Event transport: redis configured but no event wired \u2014 running in-process", {
|
|
921
|
+
total: Object.keys(events).length,
|
|
922
|
+
degraded
|
|
923
|
+
});
|
|
924
|
+
return "in-process";
|
|
925
|
+
}
|
|
926
|
+
transportLogger.info("Event transport: redis (cross-pod fan-out)", {
|
|
927
|
+
total: Object.keys(events).length,
|
|
928
|
+
wired,
|
|
929
|
+
degraded,
|
|
930
|
+
alreadyWired
|
|
931
|
+
});
|
|
932
|
+
return "redis";
|
|
933
|
+
}
|
|
934
|
+
async function resolvePubSubCache(channelPrefix) {
|
|
935
|
+
const resolvedPrefix = resolveChannelPrefix(channelPrefix);
|
|
936
|
+
if (state.pubSubCache) {
|
|
937
|
+
if (state.channelPrefix !== resolvedPrefix) {
|
|
938
|
+
transportLogger.warn("Conflicting channelPrefix ignored \u2014 the transport prefix is process-global (first wiring wins).", {
|
|
939
|
+
active: state.channelPrefix,
|
|
940
|
+
ignored: resolvedPrefix
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
return state.pubSubCache;
|
|
944
|
+
}
|
|
945
|
+
let client;
|
|
946
|
+
try {
|
|
947
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
948
|
+
client = getCache2();
|
|
949
|
+
} catch {
|
|
950
|
+
return void 0;
|
|
951
|
+
}
|
|
952
|
+
if (!client) {
|
|
953
|
+
return void 0;
|
|
954
|
+
}
|
|
955
|
+
if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX) {
|
|
956
|
+
transportLogger.warn(
|
|
957
|
+
"Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage."
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
state.channelPrefix = resolvedPrefix;
|
|
961
|
+
state.pubSubCache = createRedisPubSubCache(
|
|
962
|
+
client,
|
|
963
|
+
resolvedPrefix,
|
|
964
|
+
(connection) => {
|
|
965
|
+
state.subscriber = connection;
|
|
966
|
+
}
|
|
967
|
+
);
|
|
968
|
+
return state.pubSubCache;
|
|
969
|
+
}
|
|
970
|
+
async function closeEventTransport() {
|
|
971
|
+
const subscriber = state.subscriber;
|
|
972
|
+
for (const event of state.wired.values()) {
|
|
973
|
+
event._resetCache?.();
|
|
974
|
+
}
|
|
975
|
+
state.subscriber = void 0;
|
|
976
|
+
state.pubSubCache = void 0;
|
|
977
|
+
state.channelPrefix = void 0;
|
|
978
|
+
state.wired.clear();
|
|
979
|
+
if (!subscriber?.quit) {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
await Promise.race([
|
|
983
|
+
subscriber.quit().catch(() => void 0),
|
|
984
|
+
new Promise((resolve2) => {
|
|
985
|
+
const timer = setTimeout(resolve2, SUBSCRIBER_QUIT_TIMEOUT_MS);
|
|
986
|
+
timer.unref?.();
|
|
987
|
+
})
|
|
988
|
+
]);
|
|
989
|
+
}
|
|
990
|
+
var serverLogger = logger.child("@spfn/core:server");
|
|
991
|
+
|
|
992
|
+
// src/server/migration-gate.ts
|
|
993
|
+
var SNAPSHOT_TTL_MS = 3e4;
|
|
994
|
+
var cache = null;
|
|
995
|
+
var PendingMigrationsError = class extends Error {
|
|
996
|
+
targets;
|
|
997
|
+
constructor(message, targets) {
|
|
998
|
+
super(message);
|
|
999
|
+
this.name = "PendingMigrationsError";
|
|
1000
|
+
this.targets = targets;
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
function resetMigrationSnapshot() {
|
|
1004
|
+
cache = null;
|
|
1005
|
+
}
|
|
1006
|
+
async function inspect(cwd, db) {
|
|
1007
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1008
|
+
if (!hasMigrationTargets(cwd)) {
|
|
1009
|
+
return { state: "skipped", checkedAt, reason: "no function package or project migrations found" };
|
|
1010
|
+
}
|
|
1011
|
+
let database = db;
|
|
1012
|
+
if (!database) {
|
|
1013
|
+
try {
|
|
1014
|
+
database = getDatabase();
|
|
1015
|
+
} catch {
|
|
1016
|
+
return { state: "skipped", checkedAt, reason: "no database initialized" };
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
const status = await collectMigrationStatus(database, cwd);
|
|
1021
|
+
return { state: "ok", checkedAt, status, pending: countPendingMigrations(status) };
|
|
1022
|
+
} catch (error) {
|
|
1023
|
+
return {
|
|
1024
|
+
state: "unavailable",
|
|
1025
|
+
checkedAt,
|
|
1026
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async function getMigrationSnapshot(options = {}) {
|
|
1031
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1032
|
+
const ttl = options.ttlMs ?? SNAPSHOT_TTL_MS;
|
|
1033
|
+
const now = Date.now();
|
|
1034
|
+
if (!options.force && cache && cache.cwd === cwd && now - cache.at < ttl) {
|
|
1035
|
+
return cache.value;
|
|
1036
|
+
}
|
|
1037
|
+
const value = await inspect(cwd, options.db);
|
|
1038
|
+
cache = { at: now, cwd, value };
|
|
1039
|
+
return value;
|
|
1040
|
+
}
|
|
1041
|
+
function pendingIsAllowed(config) {
|
|
1042
|
+
return config?.migrations?.allowPending ?? env.SPFN_ALLOW_PENDING_MIGRATIONS === true;
|
|
1043
|
+
}
|
|
1044
|
+
async function runMigrationBootGate(config, cwd = process.cwd()) {
|
|
1045
|
+
const snapshot = await getMigrationSnapshot({ cwd, force: true });
|
|
1046
|
+
if (snapshot.state === "skipped") {
|
|
1047
|
+
serverLogger.debug(`Migration gate skipped: ${snapshot.reason}`);
|
|
1048
|
+
return snapshot;
|
|
1049
|
+
}
|
|
1050
|
+
if (snapshot.state === "unavailable") {
|
|
1051
|
+
serverLogger.warn(
|
|
1052
|
+
`Could not verify migration status: ${snapshot.reason}. Starting anyway \u2014 run \`pnpm spfn db status\` to check.`
|
|
1053
|
+
);
|
|
1054
|
+
return snapshot;
|
|
1055
|
+
}
|
|
1056
|
+
const targets = pendingMigrationTargets(snapshot.status);
|
|
1057
|
+
if (targets.length === 0) {
|
|
1058
|
+
serverLogger.debug("Migration gate passed: database is up to date");
|
|
1059
|
+
return snapshot;
|
|
1060
|
+
}
|
|
1061
|
+
const lines = formatPendingMigrations(targets);
|
|
1062
|
+
const summary = pendingMigrationsSummary(targets);
|
|
1063
|
+
if (pendingIsAllowed(config)) {
|
|
1064
|
+
serverLogger.warn(`Starting with pending migrations \u2014 ${summary}`);
|
|
1065
|
+
lines.forEach((line) => serverLogger.warn(` ${line}`));
|
|
1066
|
+
serverLogger.warn(` Requests hitting the missing columns will fail. ${RUN_MIGRATIONS_HINT}`);
|
|
1067
|
+
return snapshot;
|
|
1068
|
+
}
|
|
1069
|
+
serverLogger.error(`Refusing to start: ${summary}`);
|
|
1070
|
+
lines.forEach((line) => serverLogger.error(` ${line}`));
|
|
1071
|
+
serverLogger.error(` ${RUN_MIGRATIONS_HINT}`);
|
|
1072
|
+
serverLogger.error(" To start anyway: SPFN_ALLOW_PENDING_MIGRATIONS=true (or `spfn dev --allow-pending-migrations`)");
|
|
1073
|
+
throw new PendingMigrationsError(
|
|
1074
|
+
`${summary}. ${RUN_MIGRATIONS_HINT}, or set SPFN_ALLOW_PENDING_MIGRATIONS=true to start anyway.`,
|
|
1075
|
+
targets.map((target) => target.name)
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/server/shutdown-manager.ts
|
|
1080
|
+
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
1081
|
+
var DEFAULT_HOOK_ORDER = 100;
|
|
1082
|
+
var DRAIN_POLL_INTERVAL = 500;
|
|
1083
|
+
var ShutdownManager = class {
|
|
1084
|
+
state = "running";
|
|
1085
|
+
hooks = [];
|
|
1086
|
+
operations = /* @__PURE__ */ new Map();
|
|
1087
|
+
operationCounter = 0;
|
|
1088
|
+
/**
|
|
1089
|
+
* Register a shutdown hook
|
|
1090
|
+
*
|
|
1091
|
+
* Hooks run in order during shutdown, after all tracked operations drain.
|
|
1092
|
+
* Each hook has its own timeout — failure does not block subsequent hooks.
|
|
1093
|
+
*
|
|
1094
|
+
* @example
|
|
1095
|
+
* shutdown.onShutdown('ai-service', async () => {
|
|
1096
|
+
* await aiService.cancelPending();
|
|
1097
|
+
* }, { timeout: 30000, order: 10 });
|
|
1098
|
+
*/
|
|
1099
|
+
onShutdown(name, handler, options) {
|
|
1100
|
+
this.hooks.push({
|
|
1101
|
+
name,
|
|
1102
|
+
handler,
|
|
1103
|
+
timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
|
|
1104
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER
|
|
1105
|
+
});
|
|
1106
|
+
this.hooks.sort((a, b) => a.order - b.order);
|
|
1107
|
+
serverLogger.debug(`Shutdown hook registered: ${name}`, {
|
|
1108
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER,
|
|
1109
|
+
timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Track a long-running operation
|
|
1114
|
+
*
|
|
1115
|
+
* During shutdown (drain phase), the process waits for ALL tracked
|
|
1116
|
+
* operations to complete before proceeding with cleanup.
|
|
1117
|
+
*
|
|
1118
|
+
* If shutdown has already started, the operation is rejected immediately.
|
|
1119
|
+
*
|
|
1120
|
+
* @returns The operation result (pass-through)
|
|
1121
|
+
*
|
|
1122
|
+
* @example
|
|
1123
|
+
* const result = await shutdown.trackOperation(
|
|
1124
|
+
* 'ai-generate',
|
|
1125
|
+
* aiService.generate(prompt)
|
|
1126
|
+
* );
|
|
1127
|
+
*/
|
|
1128
|
+
async trackOperation(name, operation) {
|
|
1129
|
+
if (this.state !== "running") {
|
|
1130
|
+
throw new Error(`Cannot start operation '${name}': server is shutting down`);
|
|
1131
|
+
}
|
|
1132
|
+
const id = `${name}-${++this.operationCounter}`;
|
|
1133
|
+
this.operations.set(id, {
|
|
1134
|
+
name,
|
|
1135
|
+
startedAt: Date.now()
|
|
1136
|
+
});
|
|
1137
|
+
serverLogger.debug(`Operation tracked: ${id}`, {
|
|
1138
|
+
activeOperations: this.operations.size
|
|
1139
|
+
});
|
|
1140
|
+
try {
|
|
1141
|
+
return await operation;
|
|
1142
|
+
} finally {
|
|
1143
|
+
this.operations.delete(id);
|
|
1144
|
+
serverLogger.debug(`Operation completed: ${id}`, {
|
|
1145
|
+
activeOperations: this.operations.size
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Whether the server is shutting down
|
|
1151
|
+
*
|
|
1152
|
+
* Use this to reject new work early (e.g., return 503 in route handlers).
|
|
1153
|
+
*/
|
|
1154
|
+
isShuttingDown() {
|
|
1155
|
+
return this.state !== "running";
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Number of currently active tracked operations
|
|
1159
|
+
*/
|
|
1160
|
+
getActiveOperationCount() {
|
|
1161
|
+
return this.operations.size;
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Mark shutdown as started immediately
|
|
1165
|
+
*
|
|
1166
|
+
* Call this at the very beginning of the shutdown sequence so that:
|
|
1167
|
+
* - Health check returns 503 right away
|
|
1168
|
+
* - trackOperation() rejects new work
|
|
1169
|
+
* - isShuttingDown() returns true
|
|
1170
|
+
*/
|
|
1171
|
+
beginShutdown() {
|
|
1172
|
+
if (this.state !== "running") {
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
this.state = "draining";
|
|
1176
|
+
serverLogger.info("Shutdown manager: state set to draining");
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Execute the full shutdown sequence
|
|
1180
|
+
*
|
|
1181
|
+
* 1. State → draining (reject new operations)
|
|
1182
|
+
* 2. Wait for all tracked operations to complete (drain)
|
|
1183
|
+
* 3. Run shutdown hooks in order
|
|
1184
|
+
* 4. State → closed
|
|
1185
|
+
*
|
|
1186
|
+
* @param drainTimeout - Max time to wait for operations to drain (ms)
|
|
1187
|
+
*/
|
|
1188
|
+
async execute(drainTimeout) {
|
|
1189
|
+
if (this.state === "closed") {
|
|
1190
|
+
serverLogger.warn("ShutdownManager.execute() called but already closed");
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
this.state = "draining";
|
|
1194
|
+
serverLogger.info("Shutdown manager: draining started", {
|
|
1195
|
+
activeOperations: this.operations.size,
|
|
1196
|
+
registeredHooks: this.hooks.length,
|
|
1197
|
+
drainTimeout: `${drainTimeout}ms`
|
|
1198
|
+
});
|
|
1199
|
+
await this.drain(drainTimeout);
|
|
1200
|
+
await this.executeHooks();
|
|
1201
|
+
this.state = "closed";
|
|
1202
|
+
serverLogger.info("Shutdown manager: all hooks executed");
|
|
1203
|
+
}
|
|
1204
|
+
// ========================================================================
|
|
1205
|
+
// Private
|
|
1206
|
+
// ========================================================================
|
|
1207
|
+
/**
|
|
1208
|
+
* Wait for all tracked operations to complete, up to drainTimeout
|
|
1209
|
+
*/
|
|
1210
|
+
async drain(drainTimeout) {
|
|
1211
|
+
if (this.operations.size === 0) {
|
|
1212
|
+
serverLogger.info("Shutdown manager: no active operations, drain skipped");
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
|
|
1216
|
+
const deadline = Date.now() + drainTimeout;
|
|
1217
|
+
while (this.operations.size > 0 && Date.now() < deadline) {
|
|
1218
|
+
const remaining = deadline - Date.now();
|
|
1219
|
+
const ops = Array.from(this.operations.values()).map((op) => ({
|
|
1220
|
+
name: op.name,
|
|
1221
|
+
elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
|
|
1222
|
+
}));
|
|
1223
|
+
serverLogger.info("Shutdown manager: drain in progress", {
|
|
1224
|
+
activeOperations: this.operations.size,
|
|
1225
|
+
remainingTimeout: `${Math.round(remaining / 1e3)}s`,
|
|
1226
|
+
operations: ops
|
|
1227
|
+
});
|
|
1228
|
+
await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
|
|
1229
|
+
}
|
|
1230
|
+
if (this.operations.size > 0) {
|
|
1231
|
+
const abandoned = Array.from(this.operations.values()).map((op) => op.name);
|
|
1232
|
+
serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
|
|
1233
|
+
abandoned
|
|
1234
|
+
});
|
|
1235
|
+
} else {
|
|
1236
|
+
serverLogger.info("Shutdown manager: all operations drained successfully");
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Execute registered shutdown hooks in order
|
|
1241
|
+
*/
|
|
1242
|
+
async executeHooks() {
|
|
1243
|
+
if (this.hooks.length === 0) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
|
|
1247
|
+
for (const hook of this.hooks) {
|
|
1248
|
+
serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
|
|
1249
|
+
try {
|
|
1250
|
+
await withTimeout2(
|
|
1251
|
+
hook.handler(),
|
|
1252
|
+
hook.timeout,
|
|
1253
|
+
`Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
|
|
1254
|
+
);
|
|
1255
|
+
serverLogger.info(`Shutdown hook [${hook.name}] completed`);
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
serverLogger.error(
|
|
1258
|
+
`Shutdown hook [${hook.name}] failed`,
|
|
1259
|
+
error
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
var instance = null;
|
|
1266
|
+
function getShutdownManager() {
|
|
1267
|
+
if (!instance) {
|
|
1268
|
+
instance = new ShutdownManager();
|
|
1269
|
+
}
|
|
1270
|
+
return instance;
|
|
1271
|
+
}
|
|
1272
|
+
function resetShutdownManager() {
|
|
1273
|
+
instance = null;
|
|
1274
|
+
}
|
|
1275
|
+
function sleep(ms) {
|
|
1276
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1277
|
+
}
|
|
1278
|
+
async function withTimeout2(promise, timeout, message) {
|
|
1279
|
+
let timeoutId;
|
|
1280
|
+
return Promise.race([
|
|
1281
|
+
promise.finally(() => {
|
|
1282
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1283
|
+
}),
|
|
1284
|
+
new Promise((_, reject) => {
|
|
1285
|
+
timeoutId = setTimeout(() => {
|
|
1286
|
+
reject(new Error(message));
|
|
1287
|
+
}, timeout);
|
|
1288
|
+
})
|
|
1289
|
+
]);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// src/server/helpers.ts
|
|
1293
|
+
async function readMigrationHealth() {
|
|
1294
|
+
const snapshot = await getMigrationSnapshot();
|
|
1295
|
+
if (snapshot.state !== "ok") {
|
|
1296
|
+
return {
|
|
1297
|
+
status: "unknown",
|
|
1298
|
+
pending: 0,
|
|
1299
|
+
checkedAt: snapshot.checkedAt,
|
|
1300
|
+
targets: [],
|
|
1301
|
+
reason: snapshot.reason
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
return {
|
|
1305
|
+
status: snapshot.pending > 0 ? "pending" : "up_to_date",
|
|
1306
|
+
pending: snapshot.pending,
|
|
1307
|
+
checkedAt: snapshot.checkedAt,
|
|
1308
|
+
targets: migrationTargets(snapshot.status)
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
419
1311
|
function createHealthCheckHandler(detailed) {
|
|
420
1312
|
return async (c) => {
|
|
1313
|
+
const shutdownManager = getShutdownManager();
|
|
1314
|
+
if (shutdownManager.isShuttingDown()) {
|
|
1315
|
+
return c.json({
|
|
1316
|
+
status: "shutting_down",
|
|
1317
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1318
|
+
}, 503);
|
|
1319
|
+
}
|
|
421
1320
|
const response = {
|
|
422
1321
|
status: "ok",
|
|
423
1322
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -462,11 +1361,32 @@ function createHealthCheckHandler(detailed) {
|
|
|
462
1361
|
};
|
|
463
1362
|
const hasErrors = dbStatus === "error" || dbStatus === "not_initialized" || redisStatus === "error";
|
|
464
1363
|
response.status = hasErrors ? "degraded" : "ok";
|
|
1364
|
+
response.migrations = await readMigrationHealth();
|
|
465
1365
|
}
|
|
466
1366
|
const statusCode = response.status === "ok" ? 200 : 503;
|
|
467
1367
|
return c.json(response, statusCode);
|
|
468
1368
|
};
|
|
469
1369
|
}
|
|
1370
|
+
function resolveEndpointMiddlewares(config) {
|
|
1371
|
+
const candidates = [
|
|
1372
|
+
...config.middlewares ?? [],
|
|
1373
|
+
...config.routes?._globalMiddlewares ?? []
|
|
1374
|
+
];
|
|
1375
|
+
for (const pkgRouter of config.routes?._packageRouters ?? []) {
|
|
1376
|
+
candidates.push(...pkgRouter._globalMiddlewares ?? []);
|
|
1377
|
+
}
|
|
1378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1379
|
+
return candidates.filter((mw) => {
|
|
1380
|
+
if (!mw.name) {
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
if (seen.has(mw.name)) {
|
|
1384
|
+
return false;
|
|
1385
|
+
}
|
|
1386
|
+
seen.add(mw.name);
|
|
1387
|
+
return true;
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
470
1390
|
function applyServerTimeouts(server, timeouts) {
|
|
471
1391
|
if ("timeout" in server) {
|
|
472
1392
|
server.timeout = timeouts.request;
|
|
@@ -474,34 +1394,49 @@ function applyServerTimeouts(server, timeouts) {
|
|
|
474
1394
|
server.headersTimeout = timeouts.headers;
|
|
475
1395
|
}
|
|
476
1396
|
}
|
|
477
|
-
function getTimeoutConfig(
|
|
1397
|
+
function getTimeoutConfig(config) {
|
|
478
1398
|
return {
|
|
479
|
-
request:
|
|
480
|
-
keepAlive:
|
|
481
|
-
headers:
|
|
1399
|
+
request: config?.request ?? env.SERVER_TIMEOUT,
|
|
1400
|
+
keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
|
|
1401
|
+
headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
|
|
482
1402
|
};
|
|
483
1403
|
}
|
|
484
|
-
function getShutdownTimeout(
|
|
485
|
-
return
|
|
1404
|
+
function getShutdownTimeout(config) {
|
|
1405
|
+
return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
|
|
486
1406
|
}
|
|
487
|
-
function
|
|
1407
|
+
function getFetchTimeoutConfig(config) {
|
|
1408
|
+
return {
|
|
1409
|
+
connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
|
|
1410
|
+
headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
|
|
1411
|
+
body: config?.body ?? env.FETCH_BODY_TIMEOUT
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
function applyGlobalFetchTimeouts(timeouts) {
|
|
1415
|
+
const agent = new Agent({
|
|
1416
|
+
connect: { timeout: timeouts.connect },
|
|
1417
|
+
headersTimeout: timeouts.headers,
|
|
1418
|
+
bodyTimeout: timeouts.body
|
|
1419
|
+
});
|
|
1420
|
+
setGlobalDispatcher(agent);
|
|
1421
|
+
}
|
|
1422
|
+
function buildMiddlewareOrder(config) {
|
|
488
1423
|
const order = [];
|
|
489
|
-
const middlewareConfig =
|
|
1424
|
+
const middlewareConfig = config.middleware ?? {};
|
|
490
1425
|
const enableLogger = middlewareConfig.logger !== false;
|
|
491
1426
|
const enableCors = middlewareConfig.cors !== false;
|
|
492
1427
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
493
1428
|
if (enableLogger) order.push("RequestLogger");
|
|
494
1429
|
if (enableCors) order.push("CORS");
|
|
495
|
-
|
|
496
|
-
if (
|
|
1430
|
+
config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
|
|
1431
|
+
if (config.beforeRoutes) order.push("beforeRoutes hook");
|
|
497
1432
|
order.push("Routes");
|
|
498
|
-
if (
|
|
1433
|
+
if (config.afterRoutes) order.push("afterRoutes hook");
|
|
499
1434
|
if (enableErrorHandler) order.push("ErrorHandler");
|
|
500
1435
|
return order;
|
|
501
1436
|
}
|
|
502
|
-
function buildStartupConfig(
|
|
503
|
-
const middlewareConfig =
|
|
504
|
-
const healthCheckConfig =
|
|
1437
|
+
function buildStartupConfig(config, timeouts) {
|
|
1438
|
+
const middlewareConfig = config.middleware ?? {};
|
|
1439
|
+
const healthCheckConfig = config.healthCheck ?? {};
|
|
505
1440
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
506
1441
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
507
1442
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
@@ -510,7 +1445,7 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
510
1445
|
logger: middlewareConfig.logger !== false,
|
|
511
1446
|
cors: middlewareConfig.cors !== false,
|
|
512
1447
|
errorHandler: middlewareConfig.errorHandler !== false,
|
|
513
|
-
custom:
|
|
1448
|
+
custom: config.use?.length ?? 0
|
|
514
1449
|
},
|
|
515
1450
|
healthCheck: healthCheckEnabled ? {
|
|
516
1451
|
enabled: true,
|
|
@@ -518,8 +1453,8 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
518
1453
|
detailed: healthCheckDetailed
|
|
519
1454
|
} : { enabled: false },
|
|
520
1455
|
hooks: {
|
|
521
|
-
beforeRoutes: !!
|
|
522
|
-
afterRoutes: !!
|
|
1456
|
+
beforeRoutes: !!config.beforeRoutes,
|
|
1457
|
+
afterRoutes: !!config.afterRoutes
|
|
523
1458
|
},
|
|
524
1459
|
timeout: {
|
|
525
1460
|
request: `${timeouts.request}ms`,
|
|
@@ -527,23 +1462,24 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
527
1462
|
headers: `${timeouts.headers}ms`
|
|
528
1463
|
},
|
|
529
1464
|
shutdown: {
|
|
530
|
-
timeout: `${
|
|
1465
|
+
timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
|
|
531
1466
|
}
|
|
532
1467
|
};
|
|
533
1468
|
}
|
|
534
|
-
var serverLogger = logger.child("@spfn/core:server");
|
|
535
1469
|
|
|
536
1470
|
// src/server/create-server.ts
|
|
537
|
-
|
|
1471
|
+
var rateLimitApplied = /* @__PURE__ */ new WeakSet();
|
|
1472
|
+
async function createServer(config) {
|
|
1473
|
+
applyOutboundFetch(config);
|
|
538
1474
|
const cwd = process.cwd();
|
|
539
1475
|
const appPath = join(cwd, "src", "server", "app.ts");
|
|
540
1476
|
const appJsPath = join(cwd, "src", "server", "app");
|
|
541
1477
|
if (existsSync(appPath) || existsSync(appJsPath)) {
|
|
542
|
-
return await loadCustomApp(appPath, appJsPath,
|
|
1478
|
+
return await loadCustomApp(appPath, appJsPath, config);
|
|
543
1479
|
}
|
|
544
|
-
return await createAutoConfiguredApp(
|
|
1480
|
+
return await createAutoConfiguredApp(config);
|
|
545
1481
|
}
|
|
546
|
-
async function loadCustomApp(appPath, appJsPath,
|
|
1482
|
+
async function loadCustomApp(appPath, appJsPath, config) {
|
|
547
1483
|
const actualPath = existsSync(appPath) ? appPath : appJsPath;
|
|
548
1484
|
const appModule = await import(actualPath);
|
|
549
1485
|
const appFactory = appModule.default;
|
|
@@ -551,15 +1487,16 @@ async function loadCustomApp(appPath, appJsPath, config2) {
|
|
|
551
1487
|
throw new Error("app.ts must export a default function that returns a Hono app");
|
|
552
1488
|
}
|
|
553
1489
|
const app = await appFactory();
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1490
|
+
applyRateLimit(config);
|
|
1491
|
+
if (config?.routes) {
|
|
1492
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1493
|
+
logRegisteredRoutes(routes, config?.debug ?? false);
|
|
557
1494
|
}
|
|
558
1495
|
return app;
|
|
559
1496
|
}
|
|
560
|
-
async function createAutoConfiguredApp(
|
|
1497
|
+
async function createAutoConfiguredApp(config) {
|
|
561
1498
|
const app = new Hono();
|
|
562
|
-
const middlewareConfig =
|
|
1499
|
+
const middlewareConfig = config?.middleware ?? {};
|
|
563
1500
|
const enableLogger = middlewareConfig.logger !== false;
|
|
564
1501
|
const enableCors = middlewareConfig.cors !== false;
|
|
565
1502
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
@@ -569,31 +1506,100 @@ async function createAutoConfiguredApp(config2) {
|
|
|
569
1506
|
await next();
|
|
570
1507
|
});
|
|
571
1508
|
}
|
|
572
|
-
applyDefaultMiddleware(app,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
await
|
|
1509
|
+
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1510
|
+
await applyProxyGuard(app, config);
|
|
1511
|
+
applyRateLimit(config);
|
|
1512
|
+
if (Array.isArray(config?.use)) {
|
|
1513
|
+
config.use.forEach((mw) => app.use("*", mw));
|
|
1514
|
+
}
|
|
1515
|
+
registerHealthCheckEndpoint(app, config);
|
|
1516
|
+
await executeBeforeRoutesHook(app, config);
|
|
1517
|
+
await loadAppRoutes(app, config);
|
|
1518
|
+
await registerSSEEndpoint(app, config);
|
|
1519
|
+
await executeAfterRoutesHook(app, config);
|
|
581
1520
|
if (enableErrorHandler) {
|
|
582
|
-
app.onError(ErrorHandler());
|
|
1521
|
+
app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
|
|
583
1522
|
}
|
|
584
1523
|
return app;
|
|
585
1524
|
}
|
|
586
|
-
function applyDefaultMiddleware(app,
|
|
1525
|
+
function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
|
|
587
1526
|
if (enableLogger) {
|
|
588
1527
|
app.use("*", RequestLogger());
|
|
589
1528
|
}
|
|
590
|
-
if (enableCors) {
|
|
591
|
-
const corsOptions =
|
|
592
|
-
app.use("*", cors(corsOptions));
|
|
1529
|
+
if (enableCors) {
|
|
1530
|
+
const corsOptions = config?.cors !== false ? config?.cors : void 0;
|
|
1531
|
+
app.use("*", cors(corsOptions));
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
async function applyProxyGuard(app, config) {
|
|
1535
|
+
const proxyGuardConfig = config?.proxyGuard;
|
|
1536
|
+
const mode = proxyGuardConfig?.mode ?? "off";
|
|
1537
|
+
if (mode === "off") {
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const { createProxyGuard, createCacheNonceStore, createInMemoryNonceStore } = await import('@spfn/core/middleware');
|
|
1541
|
+
let nonceStore;
|
|
1542
|
+
if (proxyGuardConfig?.nonce) {
|
|
1543
|
+
try {
|
|
1544
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1545
|
+
const cache2 = getCache2();
|
|
1546
|
+
if (cache2) {
|
|
1547
|
+
nonceStore = createCacheNonceStore(cache2);
|
|
1548
|
+
serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
|
|
1549
|
+
} else {
|
|
1550
|
+
nonceStore = createInMemoryNonceStore();
|
|
1551
|
+
serverLogger.info("Proxy-guard nonce replay rejection: in-memory (single instance only \u2014 use a cache for multi-instance)");
|
|
1552
|
+
}
|
|
1553
|
+
} catch {
|
|
1554
|
+
nonceStore = createInMemoryNonceStore();
|
|
1555
|
+
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const autoSkip = [config?.healthCheck?.path ?? "/health"];
|
|
1559
|
+
if (config?.events) {
|
|
1560
|
+
autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
|
|
1561
|
+
}
|
|
1562
|
+
if (config?.websockets) {
|
|
1563
|
+
autoSkip.push(config.websocketsConfig?.path ?? "/ws");
|
|
1564
|
+
}
|
|
1565
|
+
const skipPaths = [...proxyGuardConfig?.skipPaths ?? [], ...autoSkip];
|
|
1566
|
+
app.use("*", createProxyGuard({
|
|
1567
|
+
mode,
|
|
1568
|
+
secret: proxyGuardConfig?.secret,
|
|
1569
|
+
previousSecrets: proxyGuardConfig?.previousSecrets,
|
|
1570
|
+
windowMs: proxyGuardConfig?.windowMs,
|
|
1571
|
+
allowedOrigins: proxyGuardConfig?.allowedOrigins,
|
|
1572
|
+
maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
|
|
1573
|
+
nonceStore,
|
|
1574
|
+
nonceFailClosed: proxyGuardConfig?.nonceFailClosed,
|
|
1575
|
+
skipPaths
|
|
1576
|
+
}));
|
|
1577
|
+
serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
|
|
1578
|
+
}
|
|
1579
|
+
function applyRateLimit(config) {
|
|
1580
|
+
const rl = config?.rateLimit;
|
|
1581
|
+
setRateLimitPolicies(rl?.policies);
|
|
1582
|
+
setRateLimitFailClosedDefault(env.RATE_LIMIT_FAIL_CLOSED);
|
|
1583
|
+
const enabled = (rl?.mode ?? env.RATE_LIMIT_MODE) === "on";
|
|
1584
|
+
if (!enabled || !config || rateLimitApplied.has(config)) {
|
|
1585
|
+
return;
|
|
593
1586
|
}
|
|
1587
|
+
rateLimitApplied.add(config);
|
|
1588
|
+
const defaultPolicy = {
|
|
1589
|
+
limit: rl?.default?.limit ?? env.RATE_LIMIT_DEFAULT_LIMIT,
|
|
1590
|
+
windowMs: rl?.default?.windowMs ?? env.RATE_LIMIT_DEFAULT_WINDOW_MS,
|
|
1591
|
+
failClosed: rl?.default?.failClosed ?? env.RATE_LIMIT_FAIL_CLOSED
|
|
1592
|
+
};
|
|
1593
|
+
const globalRateLimit = defineMiddleware("rateLimit", rateLimit(defaultPolicy));
|
|
1594
|
+
config.middlewares = [globalRateLimit, ...config.middlewares ?? []];
|
|
1595
|
+
serverLogger.info(`\u2713 Rate limit default enabled (${defaultPolicy.limit} per ${defaultPolicy.windowMs}ms)`);
|
|
1596
|
+
}
|
|
1597
|
+
function applyOutboundFetch(config) {
|
|
1598
|
+
const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
|
|
1599
|
+
setDefaultSafeFetchPolicy(policy);
|
|
594
1600
|
}
|
|
595
|
-
function registerHealthCheckEndpoint(app,
|
|
596
|
-
const healthCheckConfig =
|
|
1601
|
+
function registerHealthCheckEndpoint(app, config) {
|
|
1602
|
+
const healthCheckConfig = config?.healthCheck ?? {};
|
|
597
1603
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
598
1604
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
599
1605
|
const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
|
|
@@ -602,15 +1608,15 @@ function registerHealthCheckEndpoint(app, config2) {
|
|
|
602
1608
|
serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
|
|
603
1609
|
}
|
|
604
1610
|
}
|
|
605
|
-
async function executeBeforeRoutesHook(app,
|
|
606
|
-
if (
|
|
607
|
-
await
|
|
1611
|
+
async function executeBeforeRoutesHook(app, config) {
|
|
1612
|
+
if (config?.lifecycle?.beforeRoutes) {
|
|
1613
|
+
await config.lifecycle.beforeRoutes(app);
|
|
608
1614
|
}
|
|
609
1615
|
}
|
|
610
|
-
async function loadAppRoutes(app,
|
|
611
|
-
const debug = isDebugMode(
|
|
612
|
-
if (
|
|
613
|
-
const routes = registerRoutes(app,
|
|
1616
|
+
async function loadAppRoutes(app, config) {
|
|
1617
|
+
const debug = isDebugMode(config);
|
|
1618
|
+
if (config?.routes) {
|
|
1619
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
614
1620
|
logRegisteredRoutes(routes, debug);
|
|
615
1621
|
} else if (debug) {
|
|
616
1622
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
@@ -631,76 +1637,158 @@ function logRegisteredRoutes(routes, debug) {
|
|
|
631
1637
|
serverLogger.info(`\u2713 Routes registered (${routes.length}):
|
|
632
1638
|
${routeLines}`);
|
|
633
1639
|
}
|
|
634
|
-
async function executeAfterRoutesHook(app,
|
|
635
|
-
if (
|
|
636
|
-
await
|
|
1640
|
+
async function executeAfterRoutesHook(app, config) {
|
|
1641
|
+
if (config?.lifecycle?.afterRoutes) {
|
|
1642
|
+
await config.lifecycle.afterRoutes(app);
|
|
637
1643
|
}
|
|
638
1644
|
}
|
|
639
|
-
function registerSSEEndpoint(app,
|
|
640
|
-
if (!
|
|
1645
|
+
async function registerSSEEndpoint(app, config) {
|
|
1646
|
+
if (!config?.events) {
|
|
641
1647
|
return;
|
|
642
1648
|
}
|
|
643
|
-
const eventsConfig =
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
|
|
1649
|
+
const eventsConfig = config.eventsConfig ?? {};
|
|
1650
|
+
const streamPath = eventsConfig.path ?? "/events/stream";
|
|
1651
|
+
const authConfig = eventsConfig.auth;
|
|
1652
|
+
const debug = isDebugMode(config);
|
|
1653
|
+
let tokenManager;
|
|
1654
|
+
if (authConfig?.enabled) {
|
|
1655
|
+
let store = authConfig.store;
|
|
1656
|
+
if (!store) {
|
|
1657
|
+
try {
|
|
1658
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1659
|
+
const cache2 = getCache2();
|
|
1660
|
+
if (cache2) {
|
|
1661
|
+
store = new CacheTokenStore(cache2);
|
|
1662
|
+
if (debug) {
|
|
1663
|
+
serverLogger.info("SSE token store: cache (Redis/Valkey)");
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
} catch {
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
1670
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
1671
|
+
ttl: authConfig.tokenTtl,
|
|
1672
|
+
store
|
|
1673
|
+
});
|
|
1674
|
+
const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
|
|
1675
|
+
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
1676
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1677
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
1678
|
+
const subject = getSubject(c);
|
|
1679
|
+
if (!subject) {
|
|
1680
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
1681
|
+
}
|
|
1682
|
+
const token = await tokenManager.issue(subject);
|
|
1683
|
+
return c.json({ token });
|
|
1684
|
+
});
|
|
1685
|
+
if (debug) {
|
|
1686
|
+
serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const transport = await wireEventRouterCache(config.events, {
|
|
1690
|
+
multiInstance: eventsConfig.multiInstance,
|
|
1691
|
+
channelPrefix: eventsConfig.channelPrefix
|
|
1692
|
+
});
|
|
1693
|
+
app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
|
|
647
1694
|
if (debug) {
|
|
648
|
-
const eventNames =
|
|
649
|
-
serverLogger.info(`\u2713 SSE endpoint registered at ${
|
|
650
|
-
events: eventNames
|
|
1695
|
+
const eventNames = config.events.eventNames;
|
|
1696
|
+
serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
|
|
1697
|
+
events: eventNames,
|
|
1698
|
+
auth: !!authConfig?.enabled,
|
|
1699
|
+
transport
|
|
651
1700
|
});
|
|
652
1701
|
}
|
|
653
1702
|
}
|
|
654
|
-
function isDebugMode(
|
|
655
|
-
return
|
|
1703
|
+
function isDebugMode(config) {
|
|
1704
|
+
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
656
1705
|
}
|
|
657
1706
|
var jobLogger = logger.child("@spfn/core:job");
|
|
658
|
-
|
|
659
|
-
|
|
1707
|
+
function requiresSSLWithoutVerification(connectionString) {
|
|
1708
|
+
try {
|
|
1709
|
+
const url = new URL(connectionString);
|
|
1710
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
1711
|
+
return sslmode === "require" || sslmode === "prefer";
|
|
1712
|
+
} catch {
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
function stripSslModeFromUrl(connectionString) {
|
|
1717
|
+
const url = new URL(connectionString);
|
|
1718
|
+
url.searchParams.delete("sslmode");
|
|
1719
|
+
return url.toString();
|
|
1720
|
+
}
|
|
1721
|
+
var BOSS_KEY = Symbol.for("spfn:boss-instance");
|
|
1722
|
+
var CONFIG_KEY = Symbol.for("spfn:boss-config");
|
|
1723
|
+
var g = globalThis;
|
|
1724
|
+
function getBossInstance() {
|
|
1725
|
+
return g[BOSS_KEY] ?? null;
|
|
1726
|
+
}
|
|
1727
|
+
function setBossInstance(instance2) {
|
|
1728
|
+
g[BOSS_KEY] = instance2;
|
|
1729
|
+
}
|
|
1730
|
+
function getBossConfig() {
|
|
1731
|
+
return g[CONFIG_KEY] ?? null;
|
|
1732
|
+
}
|
|
1733
|
+
function setBossConfig(config) {
|
|
1734
|
+
g[CONFIG_KEY] = config;
|
|
1735
|
+
}
|
|
660
1736
|
async function initBoss(options) {
|
|
661
|
-
|
|
1737
|
+
const existing = getBossInstance();
|
|
1738
|
+
if (existing) {
|
|
662
1739
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
663
|
-
return
|
|
1740
|
+
return existing;
|
|
664
1741
|
}
|
|
665
1742
|
jobLogger.info("Initializing pg-boss...");
|
|
666
|
-
|
|
1743
|
+
setBossConfig(options);
|
|
1744
|
+
const needsSSL = requiresSSLWithoutVerification(options.connectionString);
|
|
667
1745
|
const pgBossOptions = {
|
|
668
|
-
|
|
1746
|
+
// pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
|
|
1747
|
+
// ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
|
|
1748
|
+
connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
|
|
669
1749
|
schema: options.schema ?? "spfn_queue",
|
|
670
1750
|
maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
|
|
671
1751
|
};
|
|
1752
|
+
if (needsSSL) {
|
|
1753
|
+
pgBossOptions.ssl = { rejectUnauthorized: false };
|
|
1754
|
+
}
|
|
672
1755
|
if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
|
|
673
1756
|
pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
|
|
674
1757
|
}
|
|
675
|
-
|
|
676
|
-
|
|
1758
|
+
const boss = new PgBoss(pgBossOptions);
|
|
1759
|
+
boss.on("error", (error) => {
|
|
677
1760
|
jobLogger.error("pg-boss error:", error);
|
|
678
1761
|
});
|
|
679
|
-
await
|
|
1762
|
+
await boss.start();
|
|
1763
|
+
setBossInstance(boss);
|
|
680
1764
|
jobLogger.info("pg-boss started successfully");
|
|
681
|
-
return
|
|
1765
|
+
return boss;
|
|
682
1766
|
}
|
|
683
1767
|
function getBoss() {
|
|
684
|
-
return
|
|
1768
|
+
return getBossInstance();
|
|
685
1769
|
}
|
|
686
1770
|
async function stopBoss() {
|
|
687
|
-
|
|
1771
|
+
const boss = getBossInstance();
|
|
1772
|
+
if (!boss) {
|
|
688
1773
|
return;
|
|
689
1774
|
}
|
|
690
1775
|
jobLogger.info("Stopping pg-boss...");
|
|
691
1776
|
try {
|
|
692
|
-
await
|
|
1777
|
+
await boss.stop({ graceful: true, timeout: 3e4 });
|
|
693
1778
|
jobLogger.info("pg-boss stopped gracefully");
|
|
694
1779
|
} catch (error) {
|
|
695
1780
|
jobLogger.error("Error stopping pg-boss:", error);
|
|
696
1781
|
throw error;
|
|
697
1782
|
} finally {
|
|
698
|
-
|
|
699
|
-
|
|
1783
|
+
setBossInstance(null);
|
|
1784
|
+
setBossConfig(null);
|
|
700
1785
|
}
|
|
701
1786
|
}
|
|
702
1787
|
function shouldClearOnStart() {
|
|
703
|
-
return
|
|
1788
|
+
return getBossConfig()?.clearOnStart ?? false;
|
|
1789
|
+
}
|
|
1790
|
+
function shouldSweepOrphanSchedules() {
|
|
1791
|
+
return getBossConfig()?.sweepOrphanSchedules ?? false;
|
|
704
1792
|
}
|
|
705
1793
|
|
|
706
1794
|
// src/job/job-router.ts
|
|
@@ -730,6 +1818,9 @@ function getDefaultJobOptions(options) {
|
|
|
730
1818
|
return {
|
|
731
1819
|
retryLimit: options?.retryLimit ?? 3,
|
|
732
1820
|
retryDelay: options?.retryDelay ?? 1e3,
|
|
1821
|
+
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
1822
|
+
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
1823
|
+
retryBackoff: options?.retryBackoff ?? true,
|
|
733
1824
|
expireInSeconds: options?.expireInSeconds ?? 300
|
|
734
1825
|
};
|
|
735
1826
|
}
|
|
@@ -754,44 +1845,97 @@ async function registerJobs(router) {
|
|
|
754
1845
|
}
|
|
755
1846
|
jobLogger2.info("Existing jobs cleared");
|
|
756
1847
|
}
|
|
1848
|
+
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
757
1849
|
for (const job2 of jobs) {
|
|
758
|
-
|
|
1850
|
+
if (job2.cronExpression) {
|
|
1851
|
+
registeredCronNames.add(job2.name);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (shouldSweepOrphanSchedules()) {
|
|
1855
|
+
await sweepOrphanSchedules(boss);
|
|
759
1856
|
}
|
|
760
1857
|
jobLogger2.info("All jobs registered successfully");
|
|
761
1858
|
}
|
|
1859
|
+
var registeredCronNames = /* @__PURE__ */ new Set();
|
|
1860
|
+
async function sweepOrphanSchedules(boss) {
|
|
1861
|
+
if (registeredCronNames.size === 0) {
|
|
1862
|
+
jobLogger2.debug("No cron jobs declared; skipping orphan schedule sweep");
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
try {
|
|
1866
|
+
const schedules = await boss.getSchedules();
|
|
1867
|
+
const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
|
|
1868
|
+
if (orphans.length === 0) {
|
|
1869
|
+
jobLogger2.debug("No orphan schedules found");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
await Promise.all(orphans.map(async (orphan) => {
|
|
1873
|
+
try {
|
|
1874
|
+
await boss.unschedule(orphan.name, orphan.key);
|
|
1875
|
+
jobLogger2.info(`Removed orphan schedule: ${orphan.name}`);
|
|
1876
|
+
} catch (error) {
|
|
1877
|
+
jobLogger2.error(`Failed to remove orphan schedule: ${orphan.name}`, {
|
|
1878
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
}));
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
jobLogger2.error("Orphan schedule sweep failed", {
|
|
1884
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
762
1888
|
async function ensureQueue(boss, queueName) {
|
|
763
1889
|
await boss.createQueue(queueName);
|
|
764
1890
|
}
|
|
1891
|
+
async function executeJobHandler(job2, pgBossJob) {
|
|
1892
|
+
jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
1893
|
+
const startTime = Date.now();
|
|
1894
|
+
try {
|
|
1895
|
+
if (job2.inputSchema) {
|
|
1896
|
+
await job2.handler(pgBossJob.data);
|
|
1897
|
+
} else {
|
|
1898
|
+
await job2.handler();
|
|
1899
|
+
}
|
|
1900
|
+
const duration = Date.now() - startTime;
|
|
1901
|
+
jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
1902
|
+
jobId: pgBossJob.id,
|
|
1903
|
+
duration
|
|
1904
|
+
});
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
const duration = Date.now() - startTime;
|
|
1907
|
+
jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
1908
|
+
jobId: pgBossJob.id,
|
|
1909
|
+
duration,
|
|
1910
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1911
|
+
});
|
|
1912
|
+
throw error;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
765
1915
|
async function registerWorker(boss, job2, queueName) {
|
|
766
1916
|
await ensureQueue(boss, queueName);
|
|
1917
|
+
const batchSize = job2.options?.batchSize ?? 1;
|
|
1918
|
+
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
767
1919
|
await boss.work(
|
|
768
1920
|
queueName,
|
|
769
|
-
{ batchSize
|
|
770
|
-
async (
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
jobId: pgBossJob.id,
|
|
783
|
-
duration
|
|
784
|
-
});
|
|
785
|
-
} catch (error) {
|
|
786
|
-
const duration = Date.now() - startTime;
|
|
787
|
-
jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
788
|
-
jobId: pgBossJob.id,
|
|
789
|
-
duration,
|
|
790
|
-
error: error instanceof Error ? error.message : String(error)
|
|
791
|
-
});
|
|
792
|
-
throw error;
|
|
1921
|
+
{ batchSize, pollingIntervalSeconds },
|
|
1922
|
+
async (pgBossJobs) => {
|
|
1923
|
+
if (batchSize <= 1) {
|
|
1924
|
+
await executeJobHandler(job2, pgBossJobs[0]);
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
const results = await Promise.allSettled(
|
|
1928
|
+
pgBossJobs.map((pgBossJob) => executeJobHandler(job2, pgBossJob))
|
|
1929
|
+
);
|
|
1930
|
+
const failedIds = [];
|
|
1931
|
+
for (let i = 0; i < results.length; i++) {
|
|
1932
|
+
if (results[i].status === "rejected") {
|
|
1933
|
+
failedIds.push(pgBossJobs[i].id);
|
|
793
1934
|
}
|
|
794
1935
|
}
|
|
1936
|
+
if (failedIds.length > 0) {
|
|
1937
|
+
await boss.fail(queueName, failedIds);
|
|
1938
|
+
}
|
|
795
1939
|
}
|
|
796
1940
|
);
|
|
797
1941
|
}
|
|
@@ -851,6 +1995,261 @@ async function registerJob(job2) {
|
|
|
851
1995
|
await queueRunOnceJob(boss, job2);
|
|
852
1996
|
jobLogger2.debug(`Job registered: ${job2.name}`);
|
|
853
1997
|
}
|
|
1998
|
+
var wsLogger = logger.child("@spfn/core:ws");
|
|
1999
|
+
function isOriginAllowed(origin, allowList) {
|
|
2000
|
+
if (!allowList) {
|
|
2001
|
+
return true;
|
|
2002
|
+
}
|
|
2003
|
+
if (!origin) {
|
|
2004
|
+
return true;
|
|
2005
|
+
}
|
|
2006
|
+
return allowList.has(origin);
|
|
2007
|
+
}
|
|
2008
|
+
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
2009
|
+
const WebSocketServer = await loadWSServer();
|
|
2010
|
+
const {
|
|
2011
|
+
pingInterval = 3e4,
|
|
2012
|
+
path = "/ws",
|
|
2013
|
+
maxPayload = 1048576,
|
|
2014
|
+
maxBufferedBytes = 1048576,
|
|
2015
|
+
maxConnections = 1e4,
|
|
2016
|
+
maxConnectionsPerSubject = 0,
|
|
2017
|
+
allowedOrigins,
|
|
2018
|
+
auth: authConfig
|
|
2019
|
+
} = config;
|
|
2020
|
+
const originAllowList = allowedOrigins && allowedOrigins.length > 0 ? new Set(allowedOrigins) : null;
|
|
2021
|
+
if (authConfig?.enabled && !tokenManager) {
|
|
2022
|
+
throw new Error(
|
|
2023
|
+
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
2024
|
+
);
|
|
2025
|
+
}
|
|
2026
|
+
const wss = new WebSocketServer({ server, path, maxPayload });
|
|
2027
|
+
const clients = /* @__PURE__ */ new Set();
|
|
2028
|
+
const subjectCounts = /* @__PURE__ */ new Map();
|
|
2029
|
+
wss.on("connection", (ws, req) => {
|
|
2030
|
+
if (!isOriginAllowed(req.headers?.origin, originAllowList)) {
|
|
2031
|
+
wsLogger.warn("WebSocket rejected: disallowed origin", { origin: req.headers?.origin });
|
|
2032
|
+
ws.close(1008, "Origin not allowed");
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (clients.size >= maxConnections) {
|
|
2036
|
+
ws.close(1013, "Server at capacity");
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
clients.add(ws);
|
|
2040
|
+
ws.on("close", () => clients.delete(ws));
|
|
2041
|
+
handleConnection(ws, req, router, authConfig, tokenManager, {
|
|
2042
|
+
pingInterval,
|
|
2043
|
+
maxBufferedBytes,
|
|
2044
|
+
maxConnectionsPerSubject,
|
|
2045
|
+
subjectCounts
|
|
2046
|
+
}).catch((err) => {
|
|
2047
|
+
wsLogger.error("WebSocket connection handler error", err);
|
|
2048
|
+
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
2049
|
+
});
|
|
2050
|
+
});
|
|
2051
|
+
wss.on("error", (err) => {
|
|
2052
|
+
wsLogger.error("WebSocket server error", err);
|
|
2053
|
+
});
|
|
2054
|
+
wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
|
|
2055
|
+
events: router.eventNames,
|
|
2056
|
+
auth: !!authConfig?.enabled
|
|
2057
|
+
});
|
|
2058
|
+
return () => new Promise((resolve2, reject) => {
|
|
2059
|
+
for (const client of clients) {
|
|
2060
|
+
client.close(1001, "Server shutting down");
|
|
2061
|
+
}
|
|
2062
|
+
clients.clear();
|
|
2063
|
+
wss.close((err) => {
|
|
2064
|
+
if (err) reject(err);
|
|
2065
|
+
else resolve2();
|
|
2066
|
+
});
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, opts) {
|
|
2070
|
+
const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;
|
|
2071
|
+
let pingTimer;
|
|
2072
|
+
let connectionUnsubscribes = [];
|
|
2073
|
+
let subscribedEvents = [];
|
|
2074
|
+
ws.on("close", () => {
|
|
2075
|
+
clearInterval(pingTimer);
|
|
2076
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
2077
|
+
if (subscribedEvents.length > 0)
|
|
2078
|
+
wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
|
|
2079
|
+
});
|
|
2080
|
+
const url = parseURL(req);
|
|
2081
|
+
if (!url) {
|
|
2082
|
+
ws.close(1002, "Invalid request URL");
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
|
|
2086
|
+
if (subject === false) {
|
|
2087
|
+
ws.close(4001, "Missing token");
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
if (subject === null) {
|
|
2091
|
+
ws.close(4001, "Invalid or expired token");
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
const requestedEvents = parseRequestedEvents2(url, router.eventNames);
|
|
2095
|
+
if (requestedEvents.length === 0) {
|
|
2096
|
+
ws.close(4e3, "No valid event names specified");
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
|
|
2100
|
+
if (allowedEvents === null) {
|
|
2101
|
+
ws.close(4003, "Not authorized for any requested events");
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (maxConnectionsPerSubject > 0 && typeof subject === "string") {
|
|
2105
|
+
const current = subjectCounts.get(subject) ?? 0;
|
|
2106
|
+
if (current >= maxConnectionsPerSubject) {
|
|
2107
|
+
ws.close(1013, "Too many connections for this subject");
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
subjectCounts.set(subject, current + 1);
|
|
2111
|
+
ws.on("close", () => {
|
|
2112
|
+
const remaining = (subjectCounts.get(subject) ?? 1) - 1;
|
|
2113
|
+
if (remaining <= 0) subjectCounts.delete(subject);
|
|
2114
|
+
else subjectCounts.set(subject, remaining);
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
subscribedEvents = allowedEvents;
|
|
2118
|
+
wsLogger.info("WebSocket connection established", {
|
|
2119
|
+
events: allowedEvents,
|
|
2120
|
+
subject: subject ?? void 0
|
|
2121
|
+
});
|
|
2122
|
+
const connection = createConnection(ws, maxBufferedBytes);
|
|
2123
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);
|
|
2124
|
+
if (ws.readyState !== 1) {
|
|
2125
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
2126
|
+
connectionUnsubscribes = [];
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
ws.on("message", (data) => {
|
|
2130
|
+
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
2131
|
+
});
|
|
2132
|
+
if (pingInterval > 0) {
|
|
2133
|
+
ws.isAlive = true;
|
|
2134
|
+
ws.on("pong", () => {
|
|
2135
|
+
ws.isAlive = true;
|
|
2136
|
+
});
|
|
2137
|
+
pingTimer = setInterval(() => {
|
|
2138
|
+
if (ws.readyState !== 1) return;
|
|
2139
|
+
if (ws.isAlive === false) {
|
|
2140
|
+
ws.terminate();
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
ws.isAlive = false;
|
|
2144
|
+
ws.ping();
|
|
2145
|
+
}, pingInterval);
|
|
2146
|
+
}
|
|
2147
|
+
connection.send("__connected", {
|
|
2148
|
+
subscribedEvents: allowedEvents,
|
|
2149
|
+
timestamp: Date.now()
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
function parseURL(req) {
|
|
2153
|
+
try {
|
|
2154
|
+
return new URL(req.url ?? "/", "ws://localhost");
|
|
2155
|
+
} catch {
|
|
2156
|
+
return null;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
async function resolveSubject(url, tokenManager) {
|
|
2160
|
+
if (!tokenManager) {
|
|
2161
|
+
return void 0;
|
|
2162
|
+
}
|
|
2163
|
+
const token = url.searchParams.get("token");
|
|
2164
|
+
if (!token) {
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
return await tokenManager.verify(token);
|
|
2168
|
+
}
|
|
2169
|
+
function parseRequestedEvents2(url, validEventNames) {
|
|
2170
|
+
const eventsParam = url.searchParams.get("events");
|
|
2171
|
+
if (!eventsParam) {
|
|
2172
|
+
return [];
|
|
2173
|
+
}
|
|
2174
|
+
return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
|
|
2175
|
+
}
|
|
2176
|
+
async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
2177
|
+
if (!subject || !authConfig?.authorize) {
|
|
2178
|
+
return requestedEvents;
|
|
2179
|
+
}
|
|
2180
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
2181
|
+
return allowed.length === 0 ? null : allowed;
|
|
2182
|
+
}
|
|
2183
|
+
function safeSend(ws, frame, maxBufferedBytes) {
|
|
2184
|
+
if (ws.readyState !== 1) return;
|
|
2185
|
+
if (ws.bufferedAmount > maxBufferedBytes) {
|
|
2186
|
+
ws.close(1013, "Send buffer overflow");
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
try {
|
|
2190
|
+
ws.send(JSON.stringify(frame));
|
|
2191
|
+
} catch {
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
function createConnection(ws, maxBufferedBytes) {
|
|
2195
|
+
return {
|
|
2196
|
+
send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),
|
|
2197
|
+
close: (code, reason) => ws.close(code, reason)
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes) {
|
|
2201
|
+
const unsubscribes = [];
|
|
2202
|
+
for (const eventName of allowedEvents) {
|
|
2203
|
+
const eventDef = router.events[eventName];
|
|
2204
|
+
if (!eventDef) continue;
|
|
2205
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
2206
|
+
if (ws.readyState !== 1) return;
|
|
2207
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
2208
|
+
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
2209
|
+
}
|
|
2210
|
+
safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);
|
|
2211
|
+
});
|
|
2212
|
+
unsubscribes.push(unsubscribe);
|
|
2213
|
+
}
|
|
2214
|
+
return unsubscribes;
|
|
2215
|
+
}
|
|
2216
|
+
async function onClientMessage(data, router, connection, subject) {
|
|
2217
|
+
let message;
|
|
2218
|
+
try {
|
|
2219
|
+
message = JSON.parse(data.toString());
|
|
2220
|
+
} catch {
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
const { type, data: payload } = message;
|
|
2224
|
+
if (!type) return;
|
|
2225
|
+
const handler = router.messages[type];
|
|
2226
|
+
if (!handler) return;
|
|
2227
|
+
try {
|
|
2228
|
+
await handler({ payload, subject, ws: connection });
|
|
2229
|
+
} catch (err) {
|
|
2230
|
+
wsLogger.error(`WebSocket message handler error: ${type}`, err);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
function resolveWSServerCtor(mod) {
|
|
2234
|
+
return mod.WebSocketServer ?? mod.Server ?? mod.default?.WebSocketServer ?? mod.default?.Server;
|
|
2235
|
+
}
|
|
2236
|
+
async function loadWSServer() {
|
|
2237
|
+
let mod;
|
|
2238
|
+
try {
|
|
2239
|
+
mod = await import('ws');
|
|
2240
|
+
} catch {
|
|
2241
|
+
throw new Error(
|
|
2242
|
+
'@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
const WSS = resolveWSServerCtor(mod);
|
|
2246
|
+
if (typeof WSS !== "function") {
|
|
2247
|
+
throw new Error(
|
|
2248
|
+
"WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2251
|
+
return WSS;
|
|
2252
|
+
}
|
|
854
2253
|
function getNetworkAddress() {
|
|
855
2254
|
const nets = networkInterfaces();
|
|
856
2255
|
for (const name of Object.keys(nets)) {
|
|
@@ -888,16 +2287,16 @@ function printBanner(options) {
|
|
|
888
2287
|
}
|
|
889
2288
|
|
|
890
2289
|
// src/server/validation.ts
|
|
891
|
-
function validateServerConfig(
|
|
892
|
-
if (
|
|
893
|
-
if (!Number.isInteger(
|
|
2290
|
+
function validateServerConfig(config) {
|
|
2291
|
+
if (config.port !== void 0) {
|
|
2292
|
+
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
|
|
894
2293
|
throw new Error(
|
|
895
|
-
`Invalid port: ${
|
|
2294
|
+
`Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
|
|
896
2295
|
);
|
|
897
2296
|
}
|
|
898
2297
|
}
|
|
899
|
-
if (
|
|
900
|
-
const { request, keepAlive, headers } =
|
|
2298
|
+
if (config.timeout) {
|
|
2299
|
+
const { request, keepAlive, headers } = config.timeout;
|
|
901
2300
|
if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
|
|
902
2301
|
throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
|
|
903
2302
|
}
|
|
@@ -913,16 +2312,16 @@ function validateServerConfig(config2) {
|
|
|
913
2312
|
);
|
|
914
2313
|
}
|
|
915
2314
|
}
|
|
916
|
-
if (
|
|
917
|
-
const timeout =
|
|
2315
|
+
if (config.shutdown?.timeout !== void 0) {
|
|
2316
|
+
const timeout = config.shutdown.timeout;
|
|
918
2317
|
if (timeout < 0 || !Number.isFinite(timeout)) {
|
|
919
2318
|
throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
|
|
920
2319
|
}
|
|
921
2320
|
}
|
|
922
|
-
if (
|
|
923
|
-
if (!
|
|
2321
|
+
if (config.healthCheck?.path) {
|
|
2322
|
+
if (!config.healthCheck.path.startsWith("/")) {
|
|
924
2323
|
throw new Error(
|
|
925
|
-
`Invalid healthCheck.path: "${
|
|
2324
|
+
`Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
|
|
926
2325
|
);
|
|
927
2326
|
}
|
|
928
2327
|
}
|
|
@@ -931,9 +2330,7 @@ var DEFAULT_MAX_LISTENERS = 15;
|
|
|
931
2330
|
var TIMEOUTS = {
|
|
932
2331
|
SERVER_CLOSE: 5e3,
|
|
933
2332
|
DATABASE_CLOSE: 5e3,
|
|
934
|
-
REDIS_CLOSE: 5e3
|
|
935
|
-
PRODUCTION_ERROR_SHUTDOWN: 1e4
|
|
936
|
-
};
|
|
2333
|
+
REDIS_CLOSE: 5e3};
|
|
937
2334
|
var CONFIG_FILE_PATHS = [
|
|
938
2335
|
".spfn/server/server.config.mjs",
|
|
939
2336
|
".spfn/server/server.config",
|
|
@@ -941,9 +2338,9 @@ var CONFIG_FILE_PATHS = [
|
|
|
941
2338
|
"src/server/server.config.ts"
|
|
942
2339
|
];
|
|
943
2340
|
var processHandlersRegistered = false;
|
|
944
|
-
async function startServer(
|
|
945
|
-
|
|
946
|
-
const finalConfig = await loadAndMergeConfig(
|
|
2341
|
+
async function startServer(config) {
|
|
2342
|
+
loadEnv();
|
|
2343
|
+
const finalConfig = await loadAndMergeConfig(config);
|
|
947
2344
|
const { host, port, debug } = finalConfig;
|
|
948
2345
|
validateServerConfig(finalConfig);
|
|
949
2346
|
if (!host || !port) {
|
|
@@ -957,10 +2354,17 @@ async function startServer(config2) {
|
|
|
957
2354
|
};
|
|
958
2355
|
try {
|
|
959
2356
|
await initializeInfrastructure(finalConfig);
|
|
2357
|
+
await runMigrationBootGate(finalConfig);
|
|
960
2358
|
const app = await createServer(finalConfig);
|
|
961
2359
|
const server = startHttpServer(app, host, port);
|
|
2360
|
+
let wsCleanup;
|
|
2361
|
+
if (finalConfig.websockets) {
|
|
2362
|
+
wsCleanup = await initializeWebSocket(server, app, finalConfig);
|
|
2363
|
+
}
|
|
962
2364
|
const timeouts = getTimeoutConfig(finalConfig.timeout);
|
|
963
2365
|
applyServerTimeouts(server, timeouts);
|
|
2366
|
+
const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
|
|
2367
|
+
applyGlobalFetchTimeouts(fetchTimeouts);
|
|
964
2368
|
logServerTimeouts(timeouts);
|
|
965
2369
|
printBanner({
|
|
966
2370
|
mode: debug ? "Development" : "Production",
|
|
@@ -968,7 +2372,7 @@ async function startServer(config2) {
|
|
|
968
2372
|
port
|
|
969
2373
|
});
|
|
970
2374
|
logServerStarted(debug, host, port, finalConfig, timeouts);
|
|
971
|
-
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
|
|
2375
|
+
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
|
|
972
2376
|
const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
|
|
973
2377
|
registerProcessHandlers(shutdown);
|
|
974
2378
|
const serverInstance = {
|
|
@@ -977,11 +2381,6 @@ async function startServer(config2) {
|
|
|
977
2381
|
config: finalConfig,
|
|
978
2382
|
close: async () => {
|
|
979
2383
|
serverLogger.info("Manual server shutdown requested");
|
|
980
|
-
if (shutdownState.isShuttingDown) {
|
|
981
|
-
serverLogger.warn("Shutdown already in progress, ignoring manual close request");
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
shutdownState.isShuttingDown = true;
|
|
985
2384
|
await shutdownServer();
|
|
986
2385
|
}
|
|
987
2386
|
};
|
|
@@ -1001,7 +2400,7 @@ async function startServer(config2) {
|
|
|
1001
2400
|
throw error;
|
|
1002
2401
|
}
|
|
1003
2402
|
}
|
|
1004
|
-
async function loadAndMergeConfig(
|
|
2403
|
+
async function loadAndMergeConfig(config) {
|
|
1005
2404
|
const cwd = process.cwd();
|
|
1006
2405
|
let fileConfig = {};
|
|
1007
2406
|
let loadedConfigPath = null;
|
|
@@ -1025,26 +2424,26 @@ async function loadAndMergeConfig(config2) {
|
|
|
1025
2424
|
}
|
|
1026
2425
|
return {
|
|
1027
2426
|
...fileConfig,
|
|
1028
|
-
...
|
|
1029
|
-
port:
|
|
1030
|
-
host:
|
|
2427
|
+
...config,
|
|
2428
|
+
port: config?.port ?? fileConfig?.port ?? env.PORT,
|
|
2429
|
+
host: config?.host ?? fileConfig?.host ?? env.HOST
|
|
1031
2430
|
};
|
|
1032
2431
|
}
|
|
1033
|
-
function getInfrastructureConfig(
|
|
2432
|
+
function getInfrastructureConfig(config) {
|
|
1034
2433
|
return {
|
|
1035
|
-
database:
|
|
1036
|
-
redis:
|
|
2434
|
+
database: config.infrastructure?.database !== false,
|
|
2435
|
+
redis: config.infrastructure?.redis !== false
|
|
1037
2436
|
};
|
|
1038
2437
|
}
|
|
1039
|
-
async function initializeInfrastructure(
|
|
1040
|
-
if (
|
|
2438
|
+
async function initializeInfrastructure(config) {
|
|
2439
|
+
if (config.lifecycle?.beforeInfrastructure) {
|
|
1041
2440
|
serverLogger.debug("Executing beforeInfrastructure hook...");
|
|
1042
|
-
await
|
|
2441
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
1043
2442
|
}
|
|
1044
|
-
const infraConfig = getInfrastructureConfig(
|
|
2443
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1045
2444
|
if (infraConfig.database) {
|
|
1046
2445
|
serverLogger.debug("Initializing database...");
|
|
1047
|
-
await initDatabase(
|
|
2446
|
+
await initDatabase(config.database);
|
|
1048
2447
|
} else {
|
|
1049
2448
|
serverLogger.debug("Database initialization disabled");
|
|
1050
2449
|
}
|
|
@@ -1054,11 +2453,11 @@ async function initializeInfrastructure(config2) {
|
|
|
1054
2453
|
} else {
|
|
1055
2454
|
serverLogger.debug("Redis initialization disabled");
|
|
1056
2455
|
}
|
|
1057
|
-
if (
|
|
2456
|
+
if (config.lifecycle?.afterInfrastructure) {
|
|
1058
2457
|
serverLogger.debug("Executing afterInfrastructure hook...");
|
|
1059
|
-
await
|
|
2458
|
+
await config.lifecycle.afterInfrastructure();
|
|
1060
2459
|
}
|
|
1061
|
-
if (
|
|
2460
|
+
if (config.jobs) {
|
|
1062
2461
|
const dbUrl = env.DATABASE_URL;
|
|
1063
2462
|
if (!dbUrl) {
|
|
1064
2463
|
throw new Error(
|
|
@@ -1068,22 +2467,22 @@ async function initializeInfrastructure(config2) {
|
|
|
1068
2467
|
serverLogger.debug("Initializing pg-boss...");
|
|
1069
2468
|
await initBoss({
|
|
1070
2469
|
connectionString: dbUrl,
|
|
1071
|
-
...
|
|
2470
|
+
...config.jobsConfig
|
|
1072
2471
|
});
|
|
1073
2472
|
serverLogger.debug("Registering jobs...");
|
|
1074
|
-
await registerJobs(
|
|
2473
|
+
await registerJobs(config.jobs);
|
|
1075
2474
|
}
|
|
1076
|
-
if (
|
|
1077
|
-
const infraConfig2 = getInfrastructureConfig(
|
|
2475
|
+
if (config.workflows) {
|
|
2476
|
+
const infraConfig2 = getInfrastructureConfig(config);
|
|
1078
2477
|
if (!infraConfig2.database) {
|
|
1079
2478
|
throw new Error(
|
|
1080
2479
|
"Workflows require database connection. Ensure database is enabled in infrastructure config."
|
|
1081
2480
|
);
|
|
1082
2481
|
}
|
|
1083
2482
|
serverLogger.debug("Initializing workflow engine...");
|
|
1084
|
-
|
|
2483
|
+
config.workflows._init(
|
|
1085
2484
|
getDatabase(),
|
|
1086
|
-
|
|
2485
|
+
config.workflowsConfig
|
|
1087
2486
|
);
|
|
1088
2487
|
serverLogger.info("Workflow engine initialized");
|
|
1089
2488
|
}
|
|
@@ -1096,8 +2495,52 @@ function startHttpServer(app, host, port) {
|
|
|
1096
2495
|
hostname: host
|
|
1097
2496
|
});
|
|
1098
2497
|
}
|
|
1099
|
-
function
|
|
1100
|
-
const
|
|
2498
|
+
async function initializeWebSocket(server, app, config) {
|
|
2499
|
+
const wsRouter = config.websockets;
|
|
2500
|
+
const wsConfig = config.websocketsConfig ?? {};
|
|
2501
|
+
const authConfig = wsConfig.auth;
|
|
2502
|
+
const wsPath = wsConfig.path ?? "/ws";
|
|
2503
|
+
const debug = config.debug ?? process.env.NODE_ENV === "development";
|
|
2504
|
+
let tokenManager;
|
|
2505
|
+
if (authConfig?.enabled) {
|
|
2506
|
+
let store = authConfig.store;
|
|
2507
|
+
if (!store) {
|
|
2508
|
+
try {
|
|
2509
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
2510
|
+
const cache2 = getCache2();
|
|
2511
|
+
if (cache2) {
|
|
2512
|
+
store = new CacheTokenStore(cache2);
|
|
2513
|
+
if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
|
|
2514
|
+
}
|
|
2515
|
+
} catch {
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
2519
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
2520
|
+
ttl: authConfig.tokenTtl,
|
|
2521
|
+
store
|
|
2522
|
+
});
|
|
2523
|
+
const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
|
|
2524
|
+
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
2525
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
2526
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
2527
|
+
const subject = getSubject(c);
|
|
2528
|
+
if (!subject) {
|
|
2529
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
2530
|
+
}
|
|
2531
|
+
const token = await tokenManager.issue(subject);
|
|
2532
|
+
return c.json({ token });
|
|
2533
|
+
});
|
|
2534
|
+
if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
|
|
2535
|
+
}
|
|
2536
|
+
await wireEventRouterCache(wsRouter, {
|
|
2537
|
+
multiInstance: wsConfig.multiInstance,
|
|
2538
|
+
channelPrefix: wsConfig.channelPrefix
|
|
2539
|
+
});
|
|
2540
|
+
return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
|
|
2541
|
+
}
|
|
2542
|
+
function logMiddlewareOrder(config) {
|
|
2543
|
+
const middlewareOrder = buildMiddlewareOrder(config);
|
|
1101
2544
|
serverLogger.debug("Middleware execution order", {
|
|
1102
2545
|
order: middlewareOrder
|
|
1103
2546
|
});
|
|
@@ -1109,8 +2552,8 @@ function logServerTimeouts(timeouts) {
|
|
|
1109
2552
|
headers: `${timeouts.headers}ms`
|
|
1110
2553
|
});
|
|
1111
2554
|
}
|
|
1112
|
-
function logServerStarted(debug, host, port,
|
|
1113
|
-
const startupConfig = buildStartupConfig(
|
|
2555
|
+
function logServerStarted(debug, host, port, config, timeouts) {
|
|
2556
|
+
const startupConfig = buildStartupConfig(config, timeouts);
|
|
1114
2557
|
serverLogger.info("Server started successfully", {
|
|
1115
2558
|
mode: debug ? "development" : "production",
|
|
1116
2559
|
host,
|
|
@@ -1118,65 +2561,84 @@ function logServerStarted(debug, host, port, config2, timeouts) {
|
|
|
1118
2561
|
config: startupConfig
|
|
1119
2562
|
});
|
|
1120
2563
|
}
|
|
1121
|
-
function createShutdownHandler(server,
|
|
2564
|
+
function createShutdownHandler(server, config, shutdownState, wsCleanup) {
|
|
1122
2565
|
return async () => {
|
|
1123
2566
|
if (shutdownState.isShuttingDown) {
|
|
1124
2567
|
serverLogger.debug("Shutdown already in progress for this instance, skipping");
|
|
1125
2568
|
return;
|
|
1126
2569
|
}
|
|
1127
2570
|
shutdownState.isShuttingDown = true;
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
timeoutId = setTimeout(() => {
|
|
1145
|
-
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1146
|
-
}, TIMEOUTS.SERVER_CLOSE);
|
|
1147
|
-
})
|
|
1148
|
-
]).catch((error) => {
|
|
1149
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1150
|
-
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1151
|
-
});
|
|
1152
|
-
if (config2.jobs) {
|
|
1153
|
-
serverLogger.debug("Stopping pg-boss...");
|
|
2571
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
2572
|
+
const shutdownManager = getShutdownManager();
|
|
2573
|
+
shutdownManager.beginShutdown();
|
|
2574
|
+
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
2575
|
+
await closeHttpServer(server);
|
|
2576
|
+
if (wsCleanup) {
|
|
2577
|
+
serverLogger.info("Phase 1.5: Closing WebSocket server...");
|
|
2578
|
+
try {
|
|
2579
|
+
await wsCleanup();
|
|
2580
|
+
serverLogger.info("WebSocket server closed");
|
|
2581
|
+
} catch (error) {
|
|
2582
|
+
serverLogger.error("WebSocket server close failed", error);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
if (config.jobs) {
|
|
2586
|
+
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1154
2587
|
try {
|
|
1155
2588
|
await stopBoss();
|
|
2589
|
+
serverLogger.info("pg-boss stopped");
|
|
1156
2590
|
} catch (error) {
|
|
1157
2591
|
serverLogger.error("pg-boss stop failed", error);
|
|
1158
2592
|
}
|
|
1159
2593
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
2594
|
+
const drainTimeout = Math.floor(shutdownTimeout * 0.8);
|
|
2595
|
+
serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
|
|
2596
|
+
await shutdownManager.execute(drainTimeout);
|
|
2597
|
+
if (config.lifecycle?.beforeShutdown) {
|
|
2598
|
+
serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
|
|
1162
2599
|
try {
|
|
1163
|
-
await
|
|
2600
|
+
await config.lifecycle.beforeShutdown();
|
|
1164
2601
|
} catch (error) {
|
|
1165
|
-
serverLogger.error("beforeShutdown hook failed", error);
|
|
2602
|
+
serverLogger.error("beforeShutdown lifecycle hook failed", error);
|
|
1166
2603
|
}
|
|
1167
2604
|
}
|
|
1168
|
-
|
|
2605
|
+
serverLogger.info("Phase 5: Closing infrastructure...");
|
|
2606
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1169
2607
|
if (infraConfig.database) {
|
|
1170
|
-
serverLogger.debug("Closing database connections...");
|
|
1171
2608
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1172
2609
|
}
|
|
1173
2610
|
if (infraConfig.redis) {
|
|
1174
|
-
|
|
2611
|
+
await closeEventTransport();
|
|
1175
2612
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1176
2613
|
}
|
|
1177
2614
|
serverLogger.info("Server shutdown completed");
|
|
1178
2615
|
};
|
|
1179
2616
|
}
|
|
2617
|
+
async function closeHttpServer(server) {
|
|
2618
|
+
let timeoutId;
|
|
2619
|
+
await Promise.race([
|
|
2620
|
+
new Promise((resolve2, reject) => {
|
|
2621
|
+
server.close((err) => {
|
|
2622
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2623
|
+
if (err) {
|
|
2624
|
+
serverLogger.error("HTTP server close error", err);
|
|
2625
|
+
reject(err);
|
|
2626
|
+
} else {
|
|
2627
|
+
serverLogger.info("HTTP server closed");
|
|
2628
|
+
resolve2();
|
|
2629
|
+
}
|
|
2630
|
+
});
|
|
2631
|
+
}),
|
|
2632
|
+
new Promise((_, reject) => {
|
|
2633
|
+
timeoutId = setTimeout(() => {
|
|
2634
|
+
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
2635
|
+
}, TIMEOUTS.SERVER_CLOSE);
|
|
2636
|
+
})
|
|
2637
|
+
]).catch((error) => {
|
|
2638
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2639
|
+
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
1180
2642
|
async function closeInfrastructure(closeFn, name, timeout) {
|
|
1181
2643
|
let timeoutId;
|
|
1182
2644
|
try {
|
|
@@ -1196,14 +2658,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
|
|
|
1196
2658
|
serverLogger.error(`${name} close failed or timed out`, error);
|
|
1197
2659
|
}
|
|
1198
2660
|
}
|
|
1199
|
-
function createGracefulShutdown(shutdownServer,
|
|
2661
|
+
function createGracefulShutdown(shutdownServer, config, shutdownState) {
|
|
1200
2662
|
return async (signal) => {
|
|
1201
2663
|
if (shutdownState.isShuttingDown) {
|
|
1202
2664
|
serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
|
|
1203
2665
|
return;
|
|
1204
2666
|
}
|
|
1205
2667
|
serverLogger.info(`${signal} received, starting graceful shutdown...`);
|
|
1206
|
-
const shutdownTimeout = getShutdownTimeout(
|
|
2668
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1207
2669
|
let timeoutId;
|
|
1208
2670
|
try {
|
|
1209
2671
|
await Promise.race([
|
|
@@ -1231,31 +2693,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
|
|
|
1231
2693
|
}
|
|
1232
2694
|
};
|
|
1233
2695
|
}
|
|
1234
|
-
function handleProcessError(errorType
|
|
1235
|
-
|
|
1236
|
-
const isDevelopment = env.NODE_ENV === "development";
|
|
1237
|
-
if (isDevelopment || process.env.WATCH_MODE === "true") {
|
|
1238
|
-
serverLogger.info("Exiting immediately for clean restart");
|
|
1239
|
-
process.exit(1);
|
|
1240
|
-
} else if (isProduction) {
|
|
1241
|
-
serverLogger.info(`Attempting graceful shutdown after ${errorType}`);
|
|
1242
|
-
const forceExitTimer = setTimeout(() => {
|
|
1243
|
-
serverLogger.error(`Forced exit after ${TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN}ms - graceful shutdown did not complete`);
|
|
1244
|
-
process.exit(1);
|
|
1245
|
-
}, TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN);
|
|
1246
|
-
shutdown(errorType).then(() => {
|
|
1247
|
-
clearTimeout(forceExitTimer);
|
|
1248
|
-
serverLogger.info("Graceful shutdown completed, exiting");
|
|
1249
|
-
process.exit(0);
|
|
1250
|
-
}).catch((shutdownError) => {
|
|
1251
|
-
clearTimeout(forceExitTimer);
|
|
1252
|
-
serverLogger.error("Graceful shutdown failed", shutdownError);
|
|
1253
|
-
process.exit(1);
|
|
1254
|
-
});
|
|
1255
|
-
} else {
|
|
1256
|
-
serverLogger.info("Exiting immediately");
|
|
1257
|
-
process.exit(1);
|
|
1258
|
-
}
|
|
2696
|
+
function handleProcessError(errorType) {
|
|
2697
|
+
serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
|
|
1259
2698
|
}
|
|
1260
2699
|
function registerProcessHandlers(shutdown) {
|
|
1261
2700
|
if (processHandlersRegistered) {
|
|
@@ -1290,7 +2729,7 @@ function registerProcessHandlers(shutdown) {
|
|
|
1290
2729
|
} else {
|
|
1291
2730
|
serverLogger.error("Uncaught exception", error);
|
|
1292
2731
|
}
|
|
1293
|
-
handleProcessError("UNCAUGHT_EXCEPTION"
|
|
2732
|
+
handleProcessError("UNCAUGHT_EXCEPTION");
|
|
1294
2733
|
});
|
|
1295
2734
|
process.on("unhandledRejection", (reason, promise) => {
|
|
1296
2735
|
if (reason instanceof Error) {
|
|
@@ -1308,25 +2747,89 @@ function registerProcessHandlers(shutdown) {
|
|
|
1308
2747
|
promise
|
|
1309
2748
|
});
|
|
1310
2749
|
}
|
|
1311
|
-
handleProcessError("UNHANDLED_REJECTION"
|
|
2750
|
+
handleProcessError("UNHANDLED_REJECTION");
|
|
1312
2751
|
});
|
|
1313
2752
|
serverLogger.debug("Process-level shutdown handlers registered successfully");
|
|
1314
2753
|
}
|
|
1315
|
-
async function cleanupOnFailure(
|
|
2754
|
+
async function cleanupOnFailure(config) {
|
|
1316
2755
|
try {
|
|
1317
2756
|
serverLogger.debug("Cleaning up after initialization failure...");
|
|
1318
|
-
const infraConfig = getInfrastructureConfig(
|
|
2757
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1319
2758
|
if (infraConfig.database) {
|
|
1320
2759
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1321
2760
|
}
|
|
1322
2761
|
if (infraConfig.redis) {
|
|
2762
|
+
await closeEventTransport();
|
|
1323
2763
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1324
2764
|
}
|
|
2765
|
+
resetShutdownManager();
|
|
1325
2766
|
serverLogger.debug("Cleanup completed");
|
|
1326
2767
|
} catch (cleanupError) {
|
|
1327
2768
|
serverLogger.error("Cleanup failed", cleanupError);
|
|
1328
2769
|
}
|
|
1329
2770
|
}
|
|
2771
|
+
function getInfrastructure(config) {
|
|
2772
|
+
return {
|
|
2773
|
+
database: config?.infrastructure?.database !== false,
|
|
2774
|
+
redis: config?.infrastructure?.redis !== false
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
function withServerlessDefaults(config) {
|
|
2778
|
+
if (!config) {
|
|
2779
|
+
return config;
|
|
2780
|
+
}
|
|
2781
|
+
return {
|
|
2782
|
+
...config,
|
|
2783
|
+
database: {
|
|
2784
|
+
...config.database,
|
|
2785
|
+
healthCheck: { enabled: false, ...config.database?.healthCheck }
|
|
2786
|
+
}
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function warnIfJobsConfigured(config) {
|
|
2790
|
+
if (config?.jobs) {
|
|
2791
|
+
serverLogger.warn(
|
|
2792
|
+
"Jobs are configured but this is a serverless target: the in-process pg-boss worker is NOT started here, so enqueued jobs will not be processed on this deployment. Drain the queue from a scheduled endpoint (e.g. Vercel Cron \u2192 a route that processes a batch) or run jobs on an always-on target."
|
|
2793
|
+
);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
async function initInfrastructure(config) {
|
|
2797
|
+
const infra = getInfrastructure(config);
|
|
2798
|
+
if (infra.database) {
|
|
2799
|
+
await initDatabase(config?.database);
|
|
2800
|
+
}
|
|
2801
|
+
if (infra.redis) {
|
|
2802
|
+
await initCache();
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
var appPromise;
|
|
2806
|
+
function createServerlessApp(config) {
|
|
2807
|
+
if (!appPromise) {
|
|
2808
|
+
appPromise = buildServerlessApp(config);
|
|
2809
|
+
}
|
|
2810
|
+
return appPromise;
|
|
2811
|
+
}
|
|
2812
|
+
async function buildServerlessApp(rawConfig) {
|
|
2813
|
+
loadEnv();
|
|
2814
|
+
const config = withServerlessDefaults(rawConfig);
|
|
2815
|
+
warnIfJobsConfigured(config);
|
|
2816
|
+
await initInfrastructure(config);
|
|
2817
|
+
return createServer(config);
|
|
2818
|
+
}
|
|
2819
|
+
function resetServerlessApp() {
|
|
2820
|
+
appPromise = void 0;
|
|
2821
|
+
}
|
|
2822
|
+
async function provisionInfrastructure(config) {
|
|
2823
|
+
loadEnv();
|
|
2824
|
+
if (config?.lifecycle?.beforeInfrastructure) {
|
|
2825
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
2826
|
+
}
|
|
2827
|
+
await initInfrastructure(config);
|
|
2828
|
+
if (config?.lifecycle?.afterInfrastructure) {
|
|
2829
|
+
await config.lifecycle.afterInfrastructure();
|
|
2830
|
+
}
|
|
2831
|
+
serverLogger.info("Provisioning complete");
|
|
2832
|
+
}
|
|
1330
2833
|
|
|
1331
2834
|
// src/server/config-builder.ts
|
|
1332
2835
|
function collectHooks(lifecycles, key) {
|
|
@@ -1387,12 +2890,56 @@ var ServerConfigBuilder = class {
|
|
|
1387
2890
|
this.config.middlewares = middlewares;
|
|
1388
2891
|
return this;
|
|
1389
2892
|
}
|
|
2893
|
+
/**
|
|
2894
|
+
* Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
|
|
2895
|
+
*/
|
|
2896
|
+
proxyGuard(proxyGuard) {
|
|
2897
|
+
this.config.proxyGuard = proxyGuard;
|
|
2898
|
+
return this;
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* Configure rate limiting: an optional global default limiter plus the named
|
|
2902
|
+
* policies that `rateLimitPolicy(name, fallback)` tags resolve against.
|
|
2903
|
+
*
|
|
2904
|
+
* @example
|
|
2905
|
+
* ```typescript
|
|
2906
|
+
* .rateLimit({
|
|
2907
|
+
* mode: 'on',
|
|
2908
|
+
* default: { limit: 100, windowMs: 60_000 },
|
|
2909
|
+
* policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
|
|
2910
|
+
* })
|
|
2911
|
+
* ```
|
|
2912
|
+
*/
|
|
2913
|
+
rateLimit(rateLimit2) {
|
|
2914
|
+
this.config.rateLimit = rateLimit2;
|
|
2915
|
+
return this;
|
|
2916
|
+
}
|
|
2917
|
+
/**
|
|
2918
|
+
* Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
|
|
2919
|
+
* callbacks). Private/reserved IPs are blocked by default.
|
|
2920
|
+
*
|
|
2921
|
+
* @example
|
|
2922
|
+
* ```typescript
|
|
2923
|
+
* .outboundFetch({ allowHosts: ['hooks.slack.com'] })
|
|
2924
|
+
* ```
|
|
2925
|
+
*/
|
|
2926
|
+
outboundFetch(outboundFetch) {
|
|
2927
|
+
this.config.outboundFetch = outboundFetch;
|
|
2928
|
+
return this;
|
|
2929
|
+
}
|
|
1390
2930
|
/**
|
|
1391
2931
|
* Register define-route based router
|
|
1392
2932
|
*
|
|
1393
|
-
*
|
|
1394
|
-
*
|
|
1395
|
-
*
|
|
2933
|
+
* Router-level middleware (`.use()`) and package routers (`.packages()`) travel
|
|
2934
|
+
* with the router itself and are applied by `registerRoutes` when the routes are
|
|
2935
|
+
* mounted — this method only records which router to mount.
|
|
2936
|
+
*
|
|
2937
|
+
* It deliberately does **not** copy `router._globalMiddlewares` into
|
|
2938
|
+
* `config.middlewares`: that copy used to make `registerRoutes` see the same
|
|
2939
|
+
* middleware twice (once from the config list, once from the router it was
|
|
2940
|
+
* handed) and attach both to every route. Middleware that verifies a JWT survives
|
|
2941
|
+
* running twice; middleware that consumes one-shot state — a nonce replay ledger —
|
|
2942
|
+
* rejects its own request the second time round.
|
|
1396
2943
|
*
|
|
1397
2944
|
* @example
|
|
1398
2945
|
* ```typescript
|
|
@@ -1403,29 +2950,12 @@ var ServerConfigBuilder = class {
|
|
|
1403
2950
|
* .use([authMiddleware]);
|
|
1404
2951
|
*
|
|
1405
2952
|
* export default defineServerConfig()
|
|
1406
|
-
* .routes(appRouter) //
|
|
2953
|
+
* .routes(appRouter) // .use() middleware applied once, at registration
|
|
1407
2954
|
* .build();
|
|
1408
2955
|
* ```
|
|
1409
2956
|
*/
|
|
1410
2957
|
routes(router) {
|
|
1411
2958
|
this.config.routes = router;
|
|
1412
|
-
const allGlobalMiddlewares = [];
|
|
1413
|
-
if (router._globalMiddlewares?.length > 0) {
|
|
1414
|
-
allGlobalMiddlewares.push(...router._globalMiddlewares);
|
|
1415
|
-
}
|
|
1416
|
-
if (router._packageRouters?.length > 0) {
|
|
1417
|
-
for (const pkgRouter of router._packageRouters) {
|
|
1418
|
-
if (pkgRouter._globalMiddlewares?.length > 0) {
|
|
1419
|
-
allGlobalMiddlewares.push(...pkgRouter._globalMiddlewares);
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
}
|
|
1423
|
-
if (allGlobalMiddlewares.length > 0) {
|
|
1424
|
-
this.config.middlewares = [
|
|
1425
|
-
...this.config.middlewares || [],
|
|
1426
|
-
...allGlobalMiddlewares
|
|
1427
|
-
];
|
|
1428
|
-
}
|
|
1429
2959
|
return this;
|
|
1430
2960
|
}
|
|
1431
2961
|
/**
|
|
@@ -1447,10 +2977,10 @@ var ServerConfigBuilder = class {
|
|
|
1447
2977
|
* .build();
|
|
1448
2978
|
* ```
|
|
1449
2979
|
*/
|
|
1450
|
-
jobs(router,
|
|
2980
|
+
jobs(router, config) {
|
|
1451
2981
|
this.config.jobs = router;
|
|
1452
|
-
if (
|
|
1453
|
-
this.config.jobsConfig =
|
|
2982
|
+
if (config) {
|
|
2983
|
+
this.config.jobsConfig = config;
|
|
1454
2984
|
}
|
|
1455
2985
|
return this;
|
|
1456
2986
|
}
|
|
@@ -1482,10 +3012,44 @@ var ServerConfigBuilder = class {
|
|
|
1482
3012
|
* .events(eventRouter, { path: '/sse' })
|
|
1483
3013
|
* ```
|
|
1484
3014
|
*/
|
|
1485
|
-
events(router,
|
|
3015
|
+
events(router, config) {
|
|
1486
3016
|
this.config.events = router;
|
|
1487
|
-
if (
|
|
1488
|
-
this.config.eventsConfig =
|
|
3017
|
+
if (config) {
|
|
3018
|
+
this.config.eventsConfig = config;
|
|
3019
|
+
}
|
|
3020
|
+
return this;
|
|
3021
|
+
}
|
|
3022
|
+
/**
|
|
3023
|
+
* Register WebSocket router for bidirectional real-time communication
|
|
3024
|
+
*
|
|
3025
|
+
* Enables type-safe WebSocket connections with:
|
|
3026
|
+
* - Server→client event push (via defineEvent + emit)
|
|
3027
|
+
* - Client→server message handling (via messages in defineWSRouter)
|
|
3028
|
+
*
|
|
3029
|
+
* @example
|
|
3030
|
+
* ```typescript
|
|
3031
|
+
* // src/server/ws.ts
|
|
3032
|
+
* export const wsRouter = defineWSRouter({
|
|
3033
|
+
* events: { userUpdated, notification },
|
|
3034
|
+
* messages: {
|
|
3035
|
+
* ping: ({ ws }) => ws.send('pong', {}),
|
|
3036
|
+
* },
|
|
3037
|
+
* });
|
|
3038
|
+
*
|
|
3039
|
+
* // server.config.ts
|
|
3040
|
+
* export default defineServerConfig()
|
|
3041
|
+
* .websockets(wsRouter) // → WS /ws
|
|
3042
|
+
* .websockets(wsRouter, {
|
|
3043
|
+
* path: '/realtime', // custom path
|
|
3044
|
+
* auth: { enabled: true }, // token authentication
|
|
3045
|
+
* })
|
|
3046
|
+
* .build();
|
|
3047
|
+
* ```
|
|
3048
|
+
*/
|
|
3049
|
+
websockets(router, config) {
|
|
3050
|
+
this.config.websockets = router;
|
|
3051
|
+
if (config) {
|
|
3052
|
+
this.config.websocketsConfig = config;
|
|
1489
3053
|
}
|
|
1490
3054
|
return this;
|
|
1491
3055
|
}
|
|
@@ -1531,6 +3095,21 @@ var ServerConfigBuilder = class {
|
|
|
1531
3095
|
this.config.infrastructure = infrastructure;
|
|
1532
3096
|
return this;
|
|
1533
3097
|
}
|
|
3098
|
+
/**
|
|
3099
|
+
* Configure the migration boot gate
|
|
3100
|
+
*
|
|
3101
|
+
* @example
|
|
3102
|
+
* ```typescript
|
|
3103
|
+
* // A harness that applies migrations itself, after the server is up
|
|
3104
|
+
* export default defineServerConfig()
|
|
3105
|
+
* .migrations({ allowPending: true })
|
|
3106
|
+
* .build();
|
|
3107
|
+
* ```
|
|
3108
|
+
*/
|
|
3109
|
+
migrations(migrations) {
|
|
3110
|
+
this.config.migrations = migrations;
|
|
3111
|
+
return this;
|
|
3112
|
+
}
|
|
1534
3113
|
/**
|
|
1535
3114
|
* Register workflow router for workflow orchestration
|
|
1536
3115
|
*
|
|
@@ -1551,10 +3130,10 @@ var ServerConfigBuilder = class {
|
|
|
1551
3130
|
* .build();
|
|
1552
3131
|
* ```
|
|
1553
3132
|
*/
|
|
1554
|
-
workflows(router,
|
|
3133
|
+
workflows(router, config) {
|
|
1555
3134
|
this.config.workflows = router;
|
|
1556
|
-
if (
|
|
1557
|
-
this.config.workflowsConfig =
|
|
3135
|
+
if (config) {
|
|
3136
|
+
this.config.workflowsConfig = config;
|
|
1558
3137
|
}
|
|
1559
3138
|
return this;
|
|
1560
3139
|
}
|
|
@@ -1605,6 +3184,6 @@ function defineServerConfig() {
|
|
|
1605
3184
|
return new ServerConfigBuilder();
|
|
1606
3185
|
}
|
|
1607
3186
|
|
|
1608
|
-
export { createServer, defineServerConfig, loadEnvFiles, startServer };
|
|
3187
|
+
export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
|
|
1609
3188
|
//# sourceMappingURL=index.js.map
|
|
1610
3189
|
//# sourceMappingURL=index.js.map
|