@spfn/core 0.2.0-beta.6 → 0.2.0-beta.66
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 +183 -1281
- package/dist/authz/index.d.ts +34 -0
- package/dist/authz/index.js +415 -0
- package/dist/authz/index.js.map +1 -0
- package/dist/{boss-DI1r4kTS.d.ts → boss-gXhgctn6.d.ts} +40 -0
- package/dist/cache/index.js +42 -30
- package/dist/cache/index.js.map +1 -1
- package/dist/codegen/index.d.ts +55 -8
- package/dist/codegen/index.js +183 -7
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +585 -6
- package/dist/config/index.js +116 -5
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +379 -75
- package/dist/db/index.js +625 -113
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-DuXD8Hvu.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 +20 -2
- 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 +271 -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 +243 -2
- package/dist/middleware/index.js +1323 -13
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +2 -2
- package/dist/nextjs/index.js +77 -31
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +53 -23
- package/dist/nextjs/server.js +197 -66
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +138 -146
- package/dist/route/index.js +238 -22
- package/dist/route/index.js.map +1 -1
- 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 +458 -17
- package/dist/server/index.js +1756 -277
- package/dist/server/index.js.map +1 -1
- package/dist/{router-Di7ENoah.d.ts → token-manager-jKD_EsSE.d.ts} +121 -1
- package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
- package/dist/types-BFB72jbM.d.ts +282 -0
- package/dist/types-DVjf37yO.d.ts +205 -0
- package/docs/file-upload.md +717 -0
- package/package.json +237 -208
- package/dist/types-B-e_f2dQ.d.ts +0 -121
package/dist/server/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
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 {
|
|
12
|
+
import { randomBytes, createHash } from 'crypto';
|
|
13
|
+
import { Agent, setGlobalDispatcher } from 'undici';
|
|
11
14
|
import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
|
|
12
15
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
13
16
|
import { serve } from '@hono/node-server';
|
|
@@ -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;
|
|
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;
|
|
332
380
|
}
|
|
381
|
+
process.env[key] = value;
|
|
382
|
+
loadedKeys.push(key);
|
|
333
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
|
+
);
|
|
399
|
+
}
|
|
400
|
+
loadEnv();
|
|
334
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
|
+
};
|
|
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,617 @@ 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(cache) {
|
|
650
|
+
this.cache = cache;
|
|
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
|
+
* Issue a new one-time-use token for the given subject
|
|
694
|
+
*/
|
|
695
|
+
async issue(subject) {
|
|
696
|
+
const token = randomBytes(32).toString("hex");
|
|
697
|
+
await this.store.set(token, {
|
|
698
|
+
subject,
|
|
699
|
+
expiresAt: Date.now() + this.ttl
|
|
700
|
+
});
|
|
701
|
+
return token;
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Verify and consume a token
|
|
705
|
+
* @returns subject string if valid, null if invalid/expired/already consumed
|
|
706
|
+
*/
|
|
707
|
+
async verify(token) {
|
|
708
|
+
const data = await this.store.consume(token);
|
|
709
|
+
if (!data || data.expiresAt <= Date.now()) {
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
return data.subject;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Cleanup timer and resources
|
|
716
|
+
*/
|
|
717
|
+
destroy() {
|
|
718
|
+
if (this.cleanupTimer) {
|
|
719
|
+
clearInterval(this.cleanupTimer);
|
|
720
|
+
this.cleanupTimer = null;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
var transportLogger = logger.child("@spfn/core:event-transport");
|
|
725
|
+
var DEFAULT_CHANNEL_PREFIX = "spfn:sse:";
|
|
726
|
+
var SUBSCRIBER_QUIT_TIMEOUT_MS = 5e3;
|
|
727
|
+
var PUBLISH_TIMEOUT_MS = 5e3;
|
|
728
|
+
var SUBSCRIBE_TIMEOUT_MS = 5e3;
|
|
729
|
+
var WARN_THROTTLE_MS = 3e4;
|
|
730
|
+
var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
|
|
731
|
+
function withTimeout(promise, ms, label) {
|
|
732
|
+
return new Promise((resolve2, reject) => {
|
|
733
|
+
const timer = setTimeout(() => {
|
|
734
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
735
|
+
}, ms);
|
|
736
|
+
timer.unref?.();
|
|
737
|
+
promise.then(
|
|
738
|
+
(value) => {
|
|
739
|
+
clearTimeout(timer);
|
|
740
|
+
resolve2(value);
|
|
741
|
+
},
|
|
742
|
+
(err) => {
|
|
743
|
+
clearTimeout(timer);
|
|
744
|
+
reject(err);
|
|
745
|
+
}
|
|
746
|
+
);
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
var warnThrottle = /* @__PURE__ */ new Map();
|
|
750
|
+
function throttledWarn(key, message, fields) {
|
|
751
|
+
const now = Date.now();
|
|
752
|
+
const entry = warnThrottle.get(key);
|
|
753
|
+
if (entry && now - entry.last < WARN_THROTTLE_MS) {
|
|
754
|
+
entry.suppressed++;
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
transportLogger.warn(message, entry && entry.suppressed > 0 ? { ...fields, suppressedSinceLast: entry.suppressed } : fields);
|
|
758
|
+
warnThrottle.set(key, { last: now, suppressed: 0 });
|
|
759
|
+
}
|
|
760
|
+
var STATE_KEY = Symbol.for("@spfn/core:event-transport");
|
|
761
|
+
var state = globalThis[STATE_KEY] ??= {
|
|
762
|
+
pubSubCache: void 0,
|
|
763
|
+
subscriber: void 0,
|
|
764
|
+
channelPrefix: void 0,
|
|
765
|
+
wired: /* @__PURE__ */ new Map()
|
|
766
|
+
};
|
|
767
|
+
function resolveChannelPrefix(override) {
|
|
768
|
+
return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
|
|
769
|
+
}
|
|
770
|
+
function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
|
|
771
|
+
let subscriber;
|
|
772
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
773
|
+
let degradedUntil = 0;
|
|
774
|
+
const ensureSubscriber = () => {
|
|
775
|
+
if (subscriber) {
|
|
776
|
+
return subscriber;
|
|
777
|
+
}
|
|
778
|
+
subscriber = client.duplicate();
|
|
779
|
+
onSubscriber?.(subscriber);
|
|
780
|
+
subscriber.on("message", (channel, raw) => {
|
|
781
|
+
const handler = handlers.get(channel);
|
|
782
|
+
if (!handler) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
let payload;
|
|
786
|
+
try {
|
|
787
|
+
payload = JSON.parse(raw);
|
|
788
|
+
} catch {
|
|
789
|
+
transportLogger.warn("Pub/sub message parse failed \u2014 dropped", { channel });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
void handler(payload);
|
|
793
|
+
});
|
|
794
|
+
subscriber.on("error", (err) => {
|
|
795
|
+
throttledWarn("subscriber-error", "Pub/sub subscriber error", { error: err.message });
|
|
796
|
+
});
|
|
797
|
+
return subscriber;
|
|
798
|
+
};
|
|
799
|
+
const subscriberHealthy = () => !!subscriber && (subscriber.status === void 0 || subscriber.status === "ready");
|
|
800
|
+
const deliverLocal = (channel, message) => {
|
|
801
|
+
const handler = handlers.get(channel);
|
|
802
|
+
if (handler) {
|
|
803
|
+
void handler(message);
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
return {
|
|
807
|
+
publish: async (name, message) => {
|
|
808
|
+
const channel = channelPrefix + name;
|
|
809
|
+
let serialized;
|
|
810
|
+
try {
|
|
811
|
+
serialized = JSON.stringify(message ?? null);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
transportLogger.warn("Pub/sub payload serialization failed \u2014 local delivery only", {
|
|
814
|
+
channel,
|
|
815
|
+
error: err instanceof Error ? err.message : String(err)
|
|
816
|
+
});
|
|
817
|
+
deliverLocal(channel, message ?? null);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (serialized === void 0) {
|
|
821
|
+
transportLogger.warn("Pub/sub payload not serializable (function/symbol) \u2014 local delivery only", {
|
|
822
|
+
channel
|
|
823
|
+
});
|
|
824
|
+
deliverLocal(channel, message ?? null);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const ready = client.status === void 0 || client.status === "ready";
|
|
828
|
+
const now = Date.now();
|
|
829
|
+
if (!ready || degradedUntil > 0 && now < degradedUntil) {
|
|
830
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (degradedUntil > 0) {
|
|
834
|
+
degradedUntil = now + PUBLISH_TIMEOUT_MS;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
|
|
838
|
+
degradedUntil = 0;
|
|
839
|
+
if (!subscriberHealthy()) {
|
|
840
|
+
throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
|
|
841
|
+
channel
|
|
842
|
+
});
|
|
843
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
844
|
+
}
|
|
845
|
+
} catch (err) {
|
|
846
|
+
degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
|
|
847
|
+
throttledWarn("publish-failed", "Pub/sub publish failed \u2014 local delivery only", {
|
|
848
|
+
channel,
|
|
849
|
+
error: err instanceof Error ? err.message : String(err)
|
|
850
|
+
});
|
|
851
|
+
deliverLocal(channel, JSON.parse(serialized));
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
subscribe: async (name, handler) => {
|
|
855
|
+
const channel = channelPrefix + name;
|
|
856
|
+
const sub = ensureSubscriber();
|
|
857
|
+
try {
|
|
858
|
+
await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, "pub/sub subscribe");
|
|
859
|
+
} catch (err) {
|
|
860
|
+
if (handlers.size === 0 && subscriber === sub) {
|
|
861
|
+
subscriber = void 0;
|
|
862
|
+
void sub.quit?.().catch(() => void 0);
|
|
863
|
+
}
|
|
864
|
+
throw err;
|
|
865
|
+
}
|
|
866
|
+
handlers.set(channel, handler);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
async function wireEventRouterCache(router, options = {}) {
|
|
871
|
+
if (options.multiInstance === false) {
|
|
872
|
+
const events2 = router.events;
|
|
873
|
+
const stillRedis = Object.keys(events2).filter((k) => state.wired.has(events2[k].name)).length;
|
|
874
|
+
if (stillRedis > 0) {
|
|
875
|
+
transportLogger.warn("multiInstance:false has no effect on events already wired to redis by another router", {
|
|
876
|
+
redisWired: stillRedis
|
|
877
|
+
});
|
|
878
|
+
} else {
|
|
879
|
+
transportLogger.info("Event transport: in-process (multiInstance disabled)");
|
|
880
|
+
}
|
|
881
|
+
return "in-process";
|
|
882
|
+
}
|
|
883
|
+
const pubSubCache = await resolvePubSubCache(options.channelPrefix);
|
|
884
|
+
if (!pubSubCache) {
|
|
885
|
+
transportLogger.info("Event transport: in-process (no cache configured)");
|
|
886
|
+
return "in-process";
|
|
887
|
+
}
|
|
888
|
+
const events = router.events;
|
|
889
|
+
let wired = 0;
|
|
890
|
+
let degraded = 0;
|
|
891
|
+
let alreadyWired = 0;
|
|
892
|
+
for (const key of Object.keys(events)) {
|
|
893
|
+
const event = events[key];
|
|
894
|
+
if (state.wired.has(event.name)) {
|
|
895
|
+
alreadyWired++;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
try {
|
|
899
|
+
await event.useCache(pubSubCache);
|
|
900
|
+
state.wired.set(event.name, event);
|
|
901
|
+
wired++;
|
|
902
|
+
} catch (err) {
|
|
903
|
+
degraded++;
|
|
904
|
+
transportLogger.warn("Event cache wiring failed \u2014 staying in-process for this event", {
|
|
905
|
+
event: event.name,
|
|
906
|
+
error: err instanceof Error ? err.message : String(err)
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (wired + alreadyWired === 0) {
|
|
911
|
+
transportLogger.warn("Event transport: redis configured but no event wired \u2014 running in-process", {
|
|
912
|
+
total: Object.keys(events).length,
|
|
913
|
+
degraded
|
|
914
|
+
});
|
|
915
|
+
return "in-process";
|
|
916
|
+
}
|
|
917
|
+
transportLogger.info("Event transport: redis (cross-pod fan-out)", {
|
|
918
|
+
total: Object.keys(events).length,
|
|
919
|
+
wired,
|
|
920
|
+
degraded,
|
|
921
|
+
alreadyWired
|
|
922
|
+
});
|
|
923
|
+
return "redis";
|
|
924
|
+
}
|
|
925
|
+
async function resolvePubSubCache(channelPrefix) {
|
|
926
|
+
const resolvedPrefix = resolveChannelPrefix(channelPrefix);
|
|
927
|
+
if (state.pubSubCache) {
|
|
928
|
+
if (state.channelPrefix !== resolvedPrefix) {
|
|
929
|
+
transportLogger.warn("Conflicting channelPrefix ignored \u2014 the transport prefix is process-global (first wiring wins).", {
|
|
930
|
+
active: state.channelPrefix,
|
|
931
|
+
ignored: resolvedPrefix
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
return state.pubSubCache;
|
|
935
|
+
}
|
|
936
|
+
let client;
|
|
937
|
+
try {
|
|
938
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
939
|
+
client = getCache2();
|
|
940
|
+
} catch {
|
|
941
|
+
return void 0;
|
|
942
|
+
}
|
|
943
|
+
if (!client) {
|
|
944
|
+
return void 0;
|
|
945
|
+
}
|
|
946
|
+
if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX) {
|
|
947
|
+
transportLogger.warn(
|
|
948
|
+
"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."
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
state.channelPrefix = resolvedPrefix;
|
|
952
|
+
state.pubSubCache = createRedisPubSubCache(
|
|
953
|
+
client,
|
|
954
|
+
resolvedPrefix,
|
|
955
|
+
(connection) => {
|
|
956
|
+
state.subscriber = connection;
|
|
957
|
+
}
|
|
958
|
+
);
|
|
959
|
+
return state.pubSubCache;
|
|
960
|
+
}
|
|
961
|
+
async function closeEventTransport() {
|
|
962
|
+
const subscriber = state.subscriber;
|
|
963
|
+
for (const event of state.wired.values()) {
|
|
964
|
+
event._resetCache?.();
|
|
965
|
+
}
|
|
966
|
+
state.subscriber = void 0;
|
|
967
|
+
state.pubSubCache = void 0;
|
|
968
|
+
state.channelPrefix = void 0;
|
|
969
|
+
state.wired.clear();
|
|
970
|
+
if (!subscriber?.quit) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
await Promise.race([
|
|
974
|
+
subscriber.quit().catch(() => void 0),
|
|
975
|
+
new Promise((resolve2) => {
|
|
976
|
+
const timer = setTimeout(resolve2, SUBSCRIBER_QUIT_TIMEOUT_MS);
|
|
977
|
+
timer.unref?.();
|
|
978
|
+
})
|
|
979
|
+
]);
|
|
980
|
+
}
|
|
981
|
+
var serverLogger = logger.child("@spfn/core:server");
|
|
982
|
+
|
|
983
|
+
// src/server/shutdown-manager.ts
|
|
984
|
+
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
985
|
+
var DEFAULT_HOOK_ORDER = 100;
|
|
986
|
+
var DRAIN_POLL_INTERVAL = 500;
|
|
987
|
+
var ShutdownManager = class {
|
|
988
|
+
state = "running";
|
|
989
|
+
hooks = [];
|
|
990
|
+
operations = /* @__PURE__ */ new Map();
|
|
991
|
+
operationCounter = 0;
|
|
992
|
+
/**
|
|
993
|
+
* Register a shutdown hook
|
|
994
|
+
*
|
|
995
|
+
* Hooks run in order during shutdown, after all tracked operations drain.
|
|
996
|
+
* Each hook has its own timeout — failure does not block subsequent hooks.
|
|
997
|
+
*
|
|
998
|
+
* @example
|
|
999
|
+
* shutdown.onShutdown('ai-service', async () => {
|
|
1000
|
+
* await aiService.cancelPending();
|
|
1001
|
+
* }, { timeout: 30000, order: 10 });
|
|
1002
|
+
*/
|
|
1003
|
+
onShutdown(name, handler, options) {
|
|
1004
|
+
this.hooks.push({
|
|
1005
|
+
name,
|
|
1006
|
+
handler,
|
|
1007
|
+
timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
|
|
1008
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER
|
|
1009
|
+
});
|
|
1010
|
+
this.hooks.sort((a, b) => a.order - b.order);
|
|
1011
|
+
serverLogger.debug(`Shutdown hook registered: ${name}`, {
|
|
1012
|
+
order: options?.order ?? DEFAULT_HOOK_ORDER,
|
|
1013
|
+
timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Track a long-running operation
|
|
1018
|
+
*
|
|
1019
|
+
* During shutdown (drain phase), the process waits for ALL tracked
|
|
1020
|
+
* operations to complete before proceeding with cleanup.
|
|
1021
|
+
*
|
|
1022
|
+
* If shutdown has already started, the operation is rejected immediately.
|
|
1023
|
+
*
|
|
1024
|
+
* @returns The operation result (pass-through)
|
|
1025
|
+
*
|
|
1026
|
+
* @example
|
|
1027
|
+
* const result = await shutdown.trackOperation(
|
|
1028
|
+
* 'ai-generate',
|
|
1029
|
+
* aiService.generate(prompt)
|
|
1030
|
+
* );
|
|
1031
|
+
*/
|
|
1032
|
+
async trackOperation(name, operation) {
|
|
1033
|
+
if (this.state !== "running") {
|
|
1034
|
+
throw new Error(`Cannot start operation '${name}': server is shutting down`);
|
|
1035
|
+
}
|
|
1036
|
+
const id = `${name}-${++this.operationCounter}`;
|
|
1037
|
+
this.operations.set(id, {
|
|
1038
|
+
name,
|
|
1039
|
+
startedAt: Date.now()
|
|
1040
|
+
});
|
|
1041
|
+
serverLogger.debug(`Operation tracked: ${id}`, {
|
|
1042
|
+
activeOperations: this.operations.size
|
|
1043
|
+
});
|
|
1044
|
+
try {
|
|
1045
|
+
return await operation;
|
|
1046
|
+
} finally {
|
|
1047
|
+
this.operations.delete(id);
|
|
1048
|
+
serverLogger.debug(`Operation completed: ${id}`, {
|
|
1049
|
+
activeOperations: this.operations.size
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Whether the server is shutting down
|
|
1055
|
+
*
|
|
1056
|
+
* Use this to reject new work early (e.g., return 503 in route handlers).
|
|
1057
|
+
*/
|
|
1058
|
+
isShuttingDown() {
|
|
1059
|
+
return this.state !== "running";
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Number of currently active tracked operations
|
|
1063
|
+
*/
|
|
1064
|
+
getActiveOperationCount() {
|
|
1065
|
+
return this.operations.size;
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Mark shutdown as started immediately
|
|
1069
|
+
*
|
|
1070
|
+
* Call this at the very beginning of the shutdown sequence so that:
|
|
1071
|
+
* - Health check returns 503 right away
|
|
1072
|
+
* - trackOperation() rejects new work
|
|
1073
|
+
* - isShuttingDown() returns true
|
|
1074
|
+
*/
|
|
1075
|
+
beginShutdown() {
|
|
1076
|
+
if (this.state !== "running") {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
this.state = "draining";
|
|
1080
|
+
serverLogger.info("Shutdown manager: state set to draining");
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Execute the full shutdown sequence
|
|
1084
|
+
*
|
|
1085
|
+
* 1. State → draining (reject new operations)
|
|
1086
|
+
* 2. Wait for all tracked operations to complete (drain)
|
|
1087
|
+
* 3. Run shutdown hooks in order
|
|
1088
|
+
* 4. State → closed
|
|
1089
|
+
*
|
|
1090
|
+
* @param drainTimeout - Max time to wait for operations to drain (ms)
|
|
1091
|
+
*/
|
|
1092
|
+
async execute(drainTimeout) {
|
|
1093
|
+
if (this.state === "closed") {
|
|
1094
|
+
serverLogger.warn("ShutdownManager.execute() called but already closed");
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
this.state = "draining";
|
|
1098
|
+
serverLogger.info("Shutdown manager: draining started", {
|
|
1099
|
+
activeOperations: this.operations.size,
|
|
1100
|
+
registeredHooks: this.hooks.length,
|
|
1101
|
+
drainTimeout: `${drainTimeout}ms`
|
|
1102
|
+
});
|
|
1103
|
+
await this.drain(drainTimeout);
|
|
1104
|
+
await this.executeHooks();
|
|
1105
|
+
this.state = "closed";
|
|
1106
|
+
serverLogger.info("Shutdown manager: all hooks executed");
|
|
1107
|
+
}
|
|
1108
|
+
// ========================================================================
|
|
1109
|
+
// Private
|
|
1110
|
+
// ========================================================================
|
|
1111
|
+
/**
|
|
1112
|
+
* Wait for all tracked operations to complete, up to drainTimeout
|
|
1113
|
+
*/
|
|
1114
|
+
async drain(drainTimeout) {
|
|
1115
|
+
if (this.operations.size === 0) {
|
|
1116
|
+
serverLogger.info("Shutdown manager: no active operations, drain skipped");
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
|
|
1120
|
+
const deadline = Date.now() + drainTimeout;
|
|
1121
|
+
while (this.operations.size > 0 && Date.now() < deadline) {
|
|
1122
|
+
const remaining = deadline - Date.now();
|
|
1123
|
+
const ops = Array.from(this.operations.values()).map((op) => ({
|
|
1124
|
+
name: op.name,
|
|
1125
|
+
elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
|
|
1126
|
+
}));
|
|
1127
|
+
serverLogger.info("Shutdown manager: drain in progress", {
|
|
1128
|
+
activeOperations: this.operations.size,
|
|
1129
|
+
remainingTimeout: `${Math.round(remaining / 1e3)}s`,
|
|
1130
|
+
operations: ops
|
|
1131
|
+
});
|
|
1132
|
+
await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
|
|
1133
|
+
}
|
|
1134
|
+
if (this.operations.size > 0) {
|
|
1135
|
+
const abandoned = Array.from(this.operations.values()).map((op) => op.name);
|
|
1136
|
+
serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
|
|
1137
|
+
abandoned
|
|
1138
|
+
});
|
|
1139
|
+
} else {
|
|
1140
|
+
serverLogger.info("Shutdown manager: all operations drained successfully");
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Execute registered shutdown hooks in order
|
|
1145
|
+
*/
|
|
1146
|
+
async executeHooks() {
|
|
1147
|
+
if (this.hooks.length === 0) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
|
|
1151
|
+
for (const hook of this.hooks) {
|
|
1152
|
+
serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
|
|
1153
|
+
try {
|
|
1154
|
+
await withTimeout2(
|
|
1155
|
+
hook.handler(),
|
|
1156
|
+
hook.timeout,
|
|
1157
|
+
`Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
|
|
1158
|
+
);
|
|
1159
|
+
serverLogger.info(`Shutdown hook [${hook.name}] completed`);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
serverLogger.error(
|
|
1162
|
+
`Shutdown hook [${hook.name}] failed`,
|
|
1163
|
+
error
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
var instance = null;
|
|
1170
|
+
function getShutdownManager() {
|
|
1171
|
+
if (!instance) {
|
|
1172
|
+
instance = new ShutdownManager();
|
|
1173
|
+
}
|
|
1174
|
+
return instance;
|
|
1175
|
+
}
|
|
1176
|
+
function resetShutdownManager() {
|
|
1177
|
+
instance = null;
|
|
1178
|
+
}
|
|
1179
|
+
function sleep(ms) {
|
|
1180
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1181
|
+
}
|
|
1182
|
+
async function withTimeout2(promise, timeout, message) {
|
|
1183
|
+
let timeoutId;
|
|
1184
|
+
return Promise.race([
|
|
1185
|
+
promise.finally(() => {
|
|
1186
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1187
|
+
}),
|
|
1188
|
+
new Promise((_, reject) => {
|
|
1189
|
+
timeoutId = setTimeout(() => {
|
|
1190
|
+
reject(new Error(message));
|
|
1191
|
+
}, timeout);
|
|
1192
|
+
})
|
|
1193
|
+
]);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// src/server/helpers.ts
|
|
419
1197
|
function createHealthCheckHandler(detailed) {
|
|
420
1198
|
return async (c) => {
|
|
1199
|
+
const shutdownManager = getShutdownManager();
|
|
1200
|
+
if (shutdownManager.isShuttingDown()) {
|
|
1201
|
+
return c.json({
|
|
1202
|
+
status: "shutting_down",
|
|
1203
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1204
|
+
}, 503);
|
|
1205
|
+
}
|
|
421
1206
|
const response = {
|
|
422
1207
|
status: "ok",
|
|
423
1208
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -474,34 +1259,49 @@ function applyServerTimeouts(server, timeouts) {
|
|
|
474
1259
|
server.headersTimeout = timeouts.headers;
|
|
475
1260
|
}
|
|
476
1261
|
}
|
|
477
|
-
function getTimeoutConfig(
|
|
1262
|
+
function getTimeoutConfig(config) {
|
|
1263
|
+
return {
|
|
1264
|
+
request: config?.request ?? env.SERVER_TIMEOUT,
|
|
1265
|
+
keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
|
|
1266
|
+
headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function getShutdownTimeout(config) {
|
|
1270
|
+
return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
|
|
1271
|
+
}
|
|
1272
|
+
function getFetchTimeoutConfig(config) {
|
|
478
1273
|
return {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
1274
|
+
connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
|
|
1275
|
+
headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
|
|
1276
|
+
body: config?.body ?? env.FETCH_BODY_TIMEOUT
|
|
482
1277
|
};
|
|
483
1278
|
}
|
|
484
|
-
function
|
|
485
|
-
|
|
1279
|
+
function applyGlobalFetchTimeouts(timeouts) {
|
|
1280
|
+
const agent = new Agent({
|
|
1281
|
+
connect: { timeout: timeouts.connect },
|
|
1282
|
+
headersTimeout: timeouts.headers,
|
|
1283
|
+
bodyTimeout: timeouts.body
|
|
1284
|
+
});
|
|
1285
|
+
setGlobalDispatcher(agent);
|
|
486
1286
|
}
|
|
487
|
-
function buildMiddlewareOrder(
|
|
1287
|
+
function buildMiddlewareOrder(config) {
|
|
488
1288
|
const order = [];
|
|
489
|
-
const middlewareConfig =
|
|
1289
|
+
const middlewareConfig = config.middleware ?? {};
|
|
490
1290
|
const enableLogger = middlewareConfig.logger !== false;
|
|
491
1291
|
const enableCors = middlewareConfig.cors !== false;
|
|
492
1292
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
493
1293
|
if (enableLogger) order.push("RequestLogger");
|
|
494
1294
|
if (enableCors) order.push("CORS");
|
|
495
|
-
|
|
496
|
-
if (
|
|
1295
|
+
config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
|
|
1296
|
+
if (config.beforeRoutes) order.push("beforeRoutes hook");
|
|
497
1297
|
order.push("Routes");
|
|
498
|
-
if (
|
|
1298
|
+
if (config.afterRoutes) order.push("afterRoutes hook");
|
|
499
1299
|
if (enableErrorHandler) order.push("ErrorHandler");
|
|
500
1300
|
return order;
|
|
501
1301
|
}
|
|
502
|
-
function buildStartupConfig(
|
|
503
|
-
const middlewareConfig =
|
|
504
|
-
const healthCheckConfig =
|
|
1302
|
+
function buildStartupConfig(config, timeouts) {
|
|
1303
|
+
const middlewareConfig = config.middleware ?? {};
|
|
1304
|
+
const healthCheckConfig = config.healthCheck ?? {};
|
|
505
1305
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
506
1306
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
507
1307
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
@@ -510,7 +1310,7 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
510
1310
|
logger: middlewareConfig.logger !== false,
|
|
511
1311
|
cors: middlewareConfig.cors !== false,
|
|
512
1312
|
errorHandler: middlewareConfig.errorHandler !== false,
|
|
513
|
-
custom:
|
|
1313
|
+
custom: config.use?.length ?? 0
|
|
514
1314
|
},
|
|
515
1315
|
healthCheck: healthCheckEnabled ? {
|
|
516
1316
|
enabled: true,
|
|
@@ -518,8 +1318,8 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
518
1318
|
detailed: healthCheckDetailed
|
|
519
1319
|
} : { enabled: false },
|
|
520
1320
|
hooks: {
|
|
521
|
-
beforeRoutes: !!
|
|
522
|
-
afterRoutes: !!
|
|
1321
|
+
beforeRoutes: !!config.beforeRoutes,
|
|
1322
|
+
afterRoutes: !!config.afterRoutes
|
|
523
1323
|
},
|
|
524
1324
|
timeout: {
|
|
525
1325
|
request: `${timeouts.request}ms`,
|
|
@@ -527,23 +1327,24 @@ function buildStartupConfig(config2, timeouts) {
|
|
|
527
1327
|
headers: `${timeouts.headers}ms`
|
|
528
1328
|
},
|
|
529
1329
|
shutdown: {
|
|
530
|
-
timeout: `${
|
|
1330
|
+
timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
|
|
531
1331
|
}
|
|
532
1332
|
};
|
|
533
1333
|
}
|
|
534
|
-
var serverLogger = logger.child("@spfn/core:server");
|
|
535
1334
|
|
|
536
1335
|
// src/server/create-server.ts
|
|
537
|
-
|
|
1336
|
+
var rateLimitApplied = /* @__PURE__ */ new WeakSet();
|
|
1337
|
+
async function createServer(config) {
|
|
1338
|
+
applyOutboundFetch(config);
|
|
538
1339
|
const cwd = process.cwd();
|
|
539
1340
|
const appPath = join(cwd, "src", "server", "app.ts");
|
|
540
1341
|
const appJsPath = join(cwd, "src", "server", "app");
|
|
541
1342
|
if (existsSync(appPath) || existsSync(appJsPath)) {
|
|
542
|
-
return await loadCustomApp(appPath, appJsPath,
|
|
1343
|
+
return await loadCustomApp(appPath, appJsPath, config);
|
|
543
1344
|
}
|
|
544
|
-
return await createAutoConfiguredApp(
|
|
1345
|
+
return await createAutoConfiguredApp(config);
|
|
545
1346
|
}
|
|
546
|
-
async function loadCustomApp(appPath, appJsPath,
|
|
1347
|
+
async function loadCustomApp(appPath, appJsPath, config) {
|
|
547
1348
|
const actualPath = existsSync(appPath) ? appPath : appJsPath;
|
|
548
1349
|
const appModule = await import(actualPath);
|
|
549
1350
|
const appFactory = appModule.default;
|
|
@@ -551,15 +1352,16 @@ async function loadCustomApp(appPath, appJsPath, config2) {
|
|
|
551
1352
|
throw new Error("app.ts must export a default function that returns a Hono app");
|
|
552
1353
|
}
|
|
553
1354
|
const app = await appFactory();
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1355
|
+
applyRateLimit(config);
|
|
1356
|
+
if (config?.routes) {
|
|
1357
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1358
|
+
logRegisteredRoutes(routes, config?.debug ?? false);
|
|
557
1359
|
}
|
|
558
1360
|
return app;
|
|
559
1361
|
}
|
|
560
|
-
async function createAutoConfiguredApp(
|
|
1362
|
+
async function createAutoConfiguredApp(config) {
|
|
561
1363
|
const app = new Hono();
|
|
562
|
-
const middlewareConfig =
|
|
1364
|
+
const middlewareConfig = config?.middleware ?? {};
|
|
563
1365
|
const enableLogger = middlewareConfig.logger !== false;
|
|
564
1366
|
const enableCors = middlewareConfig.cors !== false;
|
|
565
1367
|
const enableErrorHandler = middlewareConfig.errorHandler !== false;
|
|
@@ -569,31 +1371,100 @@ async function createAutoConfiguredApp(config2) {
|
|
|
569
1371
|
await next();
|
|
570
1372
|
});
|
|
571
1373
|
}
|
|
572
|
-
applyDefaultMiddleware(app,
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
await
|
|
1374
|
+
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1375
|
+
await applyProxyGuard(app, config);
|
|
1376
|
+
applyRateLimit(config);
|
|
1377
|
+
if (Array.isArray(config?.use)) {
|
|
1378
|
+
config.use.forEach((mw) => app.use("*", mw));
|
|
1379
|
+
}
|
|
1380
|
+
registerHealthCheckEndpoint(app, config);
|
|
1381
|
+
await executeBeforeRoutesHook(app, config);
|
|
1382
|
+
await loadAppRoutes(app, config);
|
|
1383
|
+
await registerSSEEndpoint(app, config);
|
|
1384
|
+
await executeAfterRoutesHook(app, config);
|
|
581
1385
|
if (enableErrorHandler) {
|
|
582
|
-
app.onError(ErrorHandler());
|
|
1386
|
+
app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
|
|
583
1387
|
}
|
|
584
1388
|
return app;
|
|
585
1389
|
}
|
|
586
|
-
function applyDefaultMiddleware(app,
|
|
1390
|
+
function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
|
|
587
1391
|
if (enableLogger) {
|
|
588
1392
|
app.use("*", RequestLogger());
|
|
589
1393
|
}
|
|
590
1394
|
if (enableCors) {
|
|
591
|
-
const corsOptions =
|
|
1395
|
+
const corsOptions = config?.cors !== false ? config?.cors : void 0;
|
|
592
1396
|
app.use("*", cors(corsOptions));
|
|
593
1397
|
}
|
|
594
1398
|
}
|
|
595
|
-
function
|
|
596
|
-
const
|
|
1399
|
+
async function applyProxyGuard(app, config) {
|
|
1400
|
+
const proxyGuardConfig = config?.proxyGuard;
|
|
1401
|
+
const mode = proxyGuardConfig?.mode ?? "off";
|
|
1402
|
+
if (mode === "off") {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
const { createProxyGuard, createCacheNonceStore, createInMemoryNonceStore } = await import('@spfn/core/middleware');
|
|
1406
|
+
let nonceStore;
|
|
1407
|
+
if (proxyGuardConfig?.nonce) {
|
|
1408
|
+
try {
|
|
1409
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1410
|
+
const cache = getCache2();
|
|
1411
|
+
if (cache) {
|
|
1412
|
+
nonceStore = createCacheNonceStore(cache);
|
|
1413
|
+
serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
|
|
1414
|
+
} else {
|
|
1415
|
+
nonceStore = createInMemoryNonceStore();
|
|
1416
|
+
serverLogger.info("Proxy-guard nonce replay rejection: in-memory (single instance only \u2014 use a cache for multi-instance)");
|
|
1417
|
+
}
|
|
1418
|
+
} catch {
|
|
1419
|
+
nonceStore = createInMemoryNonceStore();
|
|
1420
|
+
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const autoSkip = [config?.healthCheck?.path ?? "/health"];
|
|
1424
|
+
if (config?.events) {
|
|
1425
|
+
autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
|
|
1426
|
+
}
|
|
1427
|
+
if (config?.websockets) {
|
|
1428
|
+
autoSkip.push(config.websocketsConfig?.path ?? "/ws");
|
|
1429
|
+
}
|
|
1430
|
+
const skipPaths = [...proxyGuardConfig?.skipPaths ?? [], ...autoSkip];
|
|
1431
|
+
app.use("*", createProxyGuard({
|
|
1432
|
+
mode,
|
|
1433
|
+
secret: proxyGuardConfig?.secret,
|
|
1434
|
+
previousSecrets: proxyGuardConfig?.previousSecrets,
|
|
1435
|
+
windowMs: proxyGuardConfig?.windowMs,
|
|
1436
|
+
allowedOrigins: proxyGuardConfig?.allowedOrigins,
|
|
1437
|
+
maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
|
|
1438
|
+
nonceStore,
|
|
1439
|
+
nonceFailClosed: proxyGuardConfig?.nonceFailClosed,
|
|
1440
|
+
skipPaths
|
|
1441
|
+
}));
|
|
1442
|
+
serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
|
|
1443
|
+
}
|
|
1444
|
+
function applyRateLimit(config) {
|
|
1445
|
+
const rl = config?.rateLimit;
|
|
1446
|
+
setRateLimitPolicies(rl?.policies);
|
|
1447
|
+
setRateLimitFailClosedDefault(env.RATE_LIMIT_FAIL_CLOSED);
|
|
1448
|
+
const enabled = (rl?.mode ?? env.RATE_LIMIT_MODE) === "on";
|
|
1449
|
+
if (!enabled || !config || rateLimitApplied.has(config)) {
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
rateLimitApplied.add(config);
|
|
1453
|
+
const defaultPolicy = {
|
|
1454
|
+
limit: rl?.default?.limit ?? env.RATE_LIMIT_DEFAULT_LIMIT,
|
|
1455
|
+
windowMs: rl?.default?.windowMs ?? env.RATE_LIMIT_DEFAULT_WINDOW_MS,
|
|
1456
|
+
failClosed: rl?.default?.failClosed ?? env.RATE_LIMIT_FAIL_CLOSED
|
|
1457
|
+
};
|
|
1458
|
+
const globalRateLimit = defineMiddleware("rateLimit", rateLimit(defaultPolicy));
|
|
1459
|
+
config.middlewares = [globalRateLimit, ...config.middlewares ?? []];
|
|
1460
|
+
serverLogger.info(`\u2713 Rate limit default enabled (${defaultPolicy.limit} per ${defaultPolicy.windowMs}ms)`);
|
|
1461
|
+
}
|
|
1462
|
+
function applyOutboundFetch(config) {
|
|
1463
|
+
const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
|
|
1464
|
+
setDefaultSafeFetchPolicy(policy);
|
|
1465
|
+
}
|
|
1466
|
+
function registerHealthCheckEndpoint(app, config) {
|
|
1467
|
+
const healthCheckConfig = config?.healthCheck ?? {};
|
|
597
1468
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
598
1469
|
const healthCheckPath = healthCheckConfig.path ?? "/health";
|
|
599
1470
|
const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
|
|
@@ -602,15 +1473,15 @@ function registerHealthCheckEndpoint(app, config2) {
|
|
|
602
1473
|
serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
|
|
603
1474
|
}
|
|
604
1475
|
}
|
|
605
|
-
async function executeBeforeRoutesHook(app,
|
|
606
|
-
if (
|
|
607
|
-
await
|
|
1476
|
+
async function executeBeforeRoutesHook(app, config) {
|
|
1477
|
+
if (config?.lifecycle?.beforeRoutes) {
|
|
1478
|
+
await config.lifecycle.beforeRoutes(app);
|
|
608
1479
|
}
|
|
609
1480
|
}
|
|
610
|
-
async function loadAppRoutes(app,
|
|
611
|
-
const debug = isDebugMode(
|
|
612
|
-
if (
|
|
613
|
-
const routes = registerRoutes(app,
|
|
1481
|
+
async function loadAppRoutes(app, config) {
|
|
1482
|
+
const debug = isDebugMode(config);
|
|
1483
|
+
if (config?.routes) {
|
|
1484
|
+
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
614
1485
|
logRegisteredRoutes(routes, debug);
|
|
615
1486
|
} else if (debug) {
|
|
616
1487
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
@@ -631,76 +1502,158 @@ function logRegisteredRoutes(routes, debug) {
|
|
|
631
1502
|
serverLogger.info(`\u2713 Routes registered (${routes.length}):
|
|
632
1503
|
${routeLines}`);
|
|
633
1504
|
}
|
|
634
|
-
async function executeAfterRoutesHook(app,
|
|
635
|
-
if (
|
|
636
|
-
await
|
|
1505
|
+
async function executeAfterRoutesHook(app, config) {
|
|
1506
|
+
if (config?.lifecycle?.afterRoutes) {
|
|
1507
|
+
await config.lifecycle.afterRoutes(app);
|
|
637
1508
|
}
|
|
638
1509
|
}
|
|
639
|
-
function registerSSEEndpoint(app,
|
|
640
|
-
if (!
|
|
1510
|
+
async function registerSSEEndpoint(app, config) {
|
|
1511
|
+
if (!config?.events) {
|
|
641
1512
|
return;
|
|
642
1513
|
}
|
|
643
|
-
const eventsConfig =
|
|
644
|
-
const
|
|
645
|
-
const
|
|
646
|
-
|
|
1514
|
+
const eventsConfig = config.eventsConfig ?? {};
|
|
1515
|
+
const streamPath = eventsConfig.path ?? "/events/stream";
|
|
1516
|
+
const authConfig = eventsConfig.auth;
|
|
1517
|
+
const debug = isDebugMode(config);
|
|
1518
|
+
let tokenManager;
|
|
1519
|
+
if (authConfig?.enabled) {
|
|
1520
|
+
let store = authConfig.store;
|
|
1521
|
+
if (!store) {
|
|
1522
|
+
try {
|
|
1523
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
1524
|
+
const cache = getCache2();
|
|
1525
|
+
if (cache) {
|
|
1526
|
+
store = new CacheTokenStore(cache);
|
|
1527
|
+
if (debug) {
|
|
1528
|
+
serverLogger.info("SSE token store: cache (Redis/Valkey)");
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
} catch {
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
1535
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
1536
|
+
ttl: authConfig.tokenTtl,
|
|
1537
|
+
store
|
|
1538
|
+
});
|
|
1539
|
+
const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
|
|
1540
|
+
const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
|
|
1541
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1542
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
1543
|
+
const subject = getSubject(c);
|
|
1544
|
+
if (!subject) {
|
|
1545
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
1546
|
+
}
|
|
1547
|
+
const token = await tokenManager.issue(subject);
|
|
1548
|
+
return c.json({ token });
|
|
1549
|
+
});
|
|
1550
|
+
if (debug) {
|
|
1551
|
+
serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const transport = await wireEventRouterCache(config.events, {
|
|
1555
|
+
multiInstance: eventsConfig.multiInstance,
|
|
1556
|
+
channelPrefix: eventsConfig.channelPrefix
|
|
1557
|
+
});
|
|
1558
|
+
app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
|
|
647
1559
|
if (debug) {
|
|
648
|
-
const eventNames =
|
|
649
|
-
serverLogger.info(`\u2713 SSE endpoint registered at ${
|
|
650
|
-
events: eventNames
|
|
1560
|
+
const eventNames = config.events.eventNames;
|
|
1561
|
+
serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
|
|
1562
|
+
events: eventNames,
|
|
1563
|
+
auth: !!authConfig?.enabled,
|
|
1564
|
+
transport
|
|
651
1565
|
});
|
|
652
1566
|
}
|
|
653
1567
|
}
|
|
654
|
-
function isDebugMode(
|
|
655
|
-
return
|
|
1568
|
+
function isDebugMode(config) {
|
|
1569
|
+
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
656
1570
|
}
|
|
657
1571
|
var jobLogger = logger.child("@spfn/core:job");
|
|
658
|
-
|
|
659
|
-
|
|
1572
|
+
function requiresSSLWithoutVerification(connectionString) {
|
|
1573
|
+
try {
|
|
1574
|
+
const url = new URL(connectionString);
|
|
1575
|
+
const sslmode = url.searchParams.get("sslmode");
|
|
1576
|
+
return sslmode === "require" || sslmode === "prefer";
|
|
1577
|
+
} catch {
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
function stripSslModeFromUrl(connectionString) {
|
|
1582
|
+
const url = new URL(connectionString);
|
|
1583
|
+
url.searchParams.delete("sslmode");
|
|
1584
|
+
return url.toString();
|
|
1585
|
+
}
|
|
1586
|
+
var BOSS_KEY = Symbol.for("spfn:boss-instance");
|
|
1587
|
+
var CONFIG_KEY = Symbol.for("spfn:boss-config");
|
|
1588
|
+
var g = globalThis;
|
|
1589
|
+
function getBossInstance() {
|
|
1590
|
+
return g[BOSS_KEY] ?? null;
|
|
1591
|
+
}
|
|
1592
|
+
function setBossInstance(instance2) {
|
|
1593
|
+
g[BOSS_KEY] = instance2;
|
|
1594
|
+
}
|
|
1595
|
+
function getBossConfig() {
|
|
1596
|
+
return g[CONFIG_KEY] ?? null;
|
|
1597
|
+
}
|
|
1598
|
+
function setBossConfig(config) {
|
|
1599
|
+
g[CONFIG_KEY] = config;
|
|
1600
|
+
}
|
|
660
1601
|
async function initBoss(options) {
|
|
661
|
-
|
|
1602
|
+
const existing = getBossInstance();
|
|
1603
|
+
if (existing) {
|
|
662
1604
|
jobLogger.warn("pg-boss already initialized, returning existing instance");
|
|
663
|
-
return
|
|
1605
|
+
return existing;
|
|
664
1606
|
}
|
|
665
1607
|
jobLogger.info("Initializing pg-boss...");
|
|
666
|
-
|
|
1608
|
+
setBossConfig(options);
|
|
1609
|
+
const needsSSL = requiresSSLWithoutVerification(options.connectionString);
|
|
667
1610
|
const pgBossOptions = {
|
|
668
|
-
|
|
1611
|
+
// pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
|
|
1612
|
+
// ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
|
|
1613
|
+
connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
|
|
669
1614
|
schema: options.schema ?? "spfn_queue",
|
|
670
1615
|
maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
|
|
671
1616
|
};
|
|
1617
|
+
if (needsSSL) {
|
|
1618
|
+
pgBossOptions.ssl = { rejectUnauthorized: false };
|
|
1619
|
+
}
|
|
672
1620
|
if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
|
|
673
1621
|
pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
|
|
674
1622
|
}
|
|
675
|
-
|
|
676
|
-
|
|
1623
|
+
const boss = new PgBoss(pgBossOptions);
|
|
1624
|
+
boss.on("error", (error) => {
|
|
677
1625
|
jobLogger.error("pg-boss error:", error);
|
|
678
1626
|
});
|
|
679
|
-
await
|
|
1627
|
+
await boss.start();
|
|
1628
|
+
setBossInstance(boss);
|
|
680
1629
|
jobLogger.info("pg-boss started successfully");
|
|
681
|
-
return
|
|
1630
|
+
return boss;
|
|
682
1631
|
}
|
|
683
1632
|
function getBoss() {
|
|
684
|
-
return
|
|
1633
|
+
return getBossInstance();
|
|
685
1634
|
}
|
|
686
1635
|
async function stopBoss() {
|
|
687
|
-
|
|
1636
|
+
const boss = getBossInstance();
|
|
1637
|
+
if (!boss) {
|
|
688
1638
|
return;
|
|
689
1639
|
}
|
|
690
1640
|
jobLogger.info("Stopping pg-boss...");
|
|
691
1641
|
try {
|
|
692
|
-
await
|
|
1642
|
+
await boss.stop({ graceful: true, timeout: 3e4 });
|
|
693
1643
|
jobLogger.info("pg-boss stopped gracefully");
|
|
694
1644
|
} catch (error) {
|
|
695
1645
|
jobLogger.error("Error stopping pg-boss:", error);
|
|
696
1646
|
throw error;
|
|
697
1647
|
} finally {
|
|
698
|
-
|
|
699
|
-
|
|
1648
|
+
setBossInstance(null);
|
|
1649
|
+
setBossConfig(null);
|
|
700
1650
|
}
|
|
701
1651
|
}
|
|
702
1652
|
function shouldClearOnStart() {
|
|
703
|
-
return
|
|
1653
|
+
return getBossConfig()?.clearOnStart ?? false;
|
|
1654
|
+
}
|
|
1655
|
+
function shouldSweepOrphanSchedules() {
|
|
1656
|
+
return getBossConfig()?.sweepOrphanSchedules ?? false;
|
|
704
1657
|
}
|
|
705
1658
|
|
|
706
1659
|
// src/job/job-router.ts
|
|
@@ -730,6 +1683,9 @@ function getDefaultJobOptions(options) {
|
|
|
730
1683
|
return {
|
|
731
1684
|
retryLimit: options?.retryLimit ?? 3,
|
|
732
1685
|
retryDelay: options?.retryDelay ?? 1e3,
|
|
1686
|
+
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
1687
|
+
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
1688
|
+
retryBackoff: options?.retryBackoff ?? true,
|
|
733
1689
|
expireInSeconds: options?.expireInSeconds ?? 300
|
|
734
1690
|
};
|
|
735
1691
|
}
|
|
@@ -754,44 +1710,97 @@ async function registerJobs(router) {
|
|
|
754
1710
|
}
|
|
755
1711
|
jobLogger2.info("Existing jobs cleared");
|
|
756
1712
|
}
|
|
1713
|
+
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
757
1714
|
for (const job2 of jobs) {
|
|
758
|
-
|
|
1715
|
+
if (job2.cronExpression) {
|
|
1716
|
+
registeredCronNames.add(job2.name);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (shouldSweepOrphanSchedules()) {
|
|
1720
|
+
await sweepOrphanSchedules(boss);
|
|
759
1721
|
}
|
|
760
1722
|
jobLogger2.info("All jobs registered successfully");
|
|
761
1723
|
}
|
|
1724
|
+
var registeredCronNames = /* @__PURE__ */ new Set();
|
|
1725
|
+
async function sweepOrphanSchedules(boss) {
|
|
1726
|
+
if (registeredCronNames.size === 0) {
|
|
1727
|
+
jobLogger2.debug("No cron jobs declared; skipping orphan schedule sweep");
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
try {
|
|
1731
|
+
const schedules = await boss.getSchedules();
|
|
1732
|
+
const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
|
|
1733
|
+
if (orphans.length === 0) {
|
|
1734
|
+
jobLogger2.debug("No orphan schedules found");
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
await Promise.all(orphans.map(async (orphan) => {
|
|
1738
|
+
try {
|
|
1739
|
+
await boss.unschedule(orphan.name, orphan.key);
|
|
1740
|
+
jobLogger2.info(`Removed orphan schedule: ${orphan.name}`);
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
jobLogger2.error(`Failed to remove orphan schedule: ${orphan.name}`, {
|
|
1743
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
}));
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
jobLogger2.error("Orphan schedule sweep failed", {
|
|
1749
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
762
1753
|
async function ensureQueue(boss, queueName) {
|
|
763
1754
|
await boss.createQueue(queueName);
|
|
764
1755
|
}
|
|
1756
|
+
async function executeJobHandler(job2, pgBossJob) {
|
|
1757
|
+
jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
1758
|
+
const startTime = Date.now();
|
|
1759
|
+
try {
|
|
1760
|
+
if (job2.inputSchema) {
|
|
1761
|
+
await job2.handler(pgBossJob.data);
|
|
1762
|
+
} else {
|
|
1763
|
+
await job2.handler();
|
|
1764
|
+
}
|
|
1765
|
+
const duration = Date.now() - startTime;
|
|
1766
|
+
jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
1767
|
+
jobId: pgBossJob.id,
|
|
1768
|
+
duration
|
|
1769
|
+
});
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
const duration = Date.now() - startTime;
|
|
1772
|
+
jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
1773
|
+
jobId: pgBossJob.id,
|
|
1774
|
+
duration,
|
|
1775
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1776
|
+
});
|
|
1777
|
+
throw error;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
765
1780
|
async function registerWorker(boss, job2, queueName) {
|
|
766
1781
|
await ensureQueue(boss, queueName);
|
|
1782
|
+
const batchSize = job2.options?.batchSize ?? 1;
|
|
1783
|
+
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
767
1784
|
await boss.work(
|
|
768
1785
|
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;
|
|
1786
|
+
{ batchSize, pollingIntervalSeconds },
|
|
1787
|
+
async (pgBossJobs) => {
|
|
1788
|
+
if (batchSize <= 1) {
|
|
1789
|
+
await executeJobHandler(job2, pgBossJobs[0]);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
const results = await Promise.allSettled(
|
|
1793
|
+
pgBossJobs.map((pgBossJob) => executeJobHandler(job2, pgBossJob))
|
|
1794
|
+
);
|
|
1795
|
+
const failedIds = [];
|
|
1796
|
+
for (let i = 0; i < results.length; i++) {
|
|
1797
|
+
if (results[i].status === "rejected") {
|
|
1798
|
+
failedIds.push(pgBossJobs[i].id);
|
|
793
1799
|
}
|
|
794
1800
|
}
|
|
1801
|
+
if (failedIds.length > 0) {
|
|
1802
|
+
await boss.fail(queueName, failedIds);
|
|
1803
|
+
}
|
|
795
1804
|
}
|
|
796
1805
|
);
|
|
797
1806
|
}
|
|
@@ -851,6 +1860,261 @@ async function registerJob(job2) {
|
|
|
851
1860
|
await queueRunOnceJob(boss, job2);
|
|
852
1861
|
jobLogger2.debug(`Job registered: ${job2.name}`);
|
|
853
1862
|
}
|
|
1863
|
+
var wsLogger = logger.child("@spfn/core:ws");
|
|
1864
|
+
function isOriginAllowed(origin, allowList) {
|
|
1865
|
+
if (!allowList) {
|
|
1866
|
+
return true;
|
|
1867
|
+
}
|
|
1868
|
+
if (!origin) {
|
|
1869
|
+
return true;
|
|
1870
|
+
}
|
|
1871
|
+
return allowList.has(origin);
|
|
1872
|
+
}
|
|
1873
|
+
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
1874
|
+
const WebSocketServer = await loadWSServer();
|
|
1875
|
+
const {
|
|
1876
|
+
pingInterval = 3e4,
|
|
1877
|
+
path = "/ws",
|
|
1878
|
+
maxPayload = 1048576,
|
|
1879
|
+
maxBufferedBytes = 1048576,
|
|
1880
|
+
maxConnections = 1e4,
|
|
1881
|
+
maxConnectionsPerSubject = 0,
|
|
1882
|
+
allowedOrigins,
|
|
1883
|
+
auth: authConfig
|
|
1884
|
+
} = config;
|
|
1885
|
+
const originAllowList = allowedOrigins && allowedOrigins.length > 0 ? new Set(allowedOrigins) : null;
|
|
1886
|
+
if (authConfig?.enabled && !tokenManager) {
|
|
1887
|
+
throw new Error(
|
|
1888
|
+
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
const wss = new WebSocketServer({ server, path, maxPayload });
|
|
1892
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1893
|
+
const subjectCounts = /* @__PURE__ */ new Map();
|
|
1894
|
+
wss.on("connection", (ws, req) => {
|
|
1895
|
+
if (!isOriginAllowed(req.headers?.origin, originAllowList)) {
|
|
1896
|
+
wsLogger.warn("WebSocket rejected: disallowed origin", { origin: req.headers?.origin });
|
|
1897
|
+
ws.close(1008, "Origin not allowed");
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if (clients.size >= maxConnections) {
|
|
1901
|
+
ws.close(1013, "Server at capacity");
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
clients.add(ws);
|
|
1905
|
+
ws.on("close", () => clients.delete(ws));
|
|
1906
|
+
handleConnection(ws, req, router, authConfig, tokenManager, {
|
|
1907
|
+
pingInterval,
|
|
1908
|
+
maxBufferedBytes,
|
|
1909
|
+
maxConnectionsPerSubject,
|
|
1910
|
+
subjectCounts
|
|
1911
|
+
}).catch((err) => {
|
|
1912
|
+
wsLogger.error("WebSocket connection handler error", err);
|
|
1913
|
+
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
1914
|
+
});
|
|
1915
|
+
});
|
|
1916
|
+
wss.on("error", (err) => {
|
|
1917
|
+
wsLogger.error("WebSocket server error", err);
|
|
1918
|
+
});
|
|
1919
|
+
wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
|
|
1920
|
+
events: router.eventNames,
|
|
1921
|
+
auth: !!authConfig?.enabled
|
|
1922
|
+
});
|
|
1923
|
+
return () => new Promise((resolve2, reject) => {
|
|
1924
|
+
for (const client of clients) {
|
|
1925
|
+
client.close(1001, "Server shutting down");
|
|
1926
|
+
}
|
|
1927
|
+
clients.clear();
|
|
1928
|
+
wss.close((err) => {
|
|
1929
|
+
if (err) reject(err);
|
|
1930
|
+
else resolve2();
|
|
1931
|
+
});
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, opts) {
|
|
1935
|
+
const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;
|
|
1936
|
+
let pingTimer;
|
|
1937
|
+
let connectionUnsubscribes = [];
|
|
1938
|
+
let subscribedEvents = [];
|
|
1939
|
+
ws.on("close", () => {
|
|
1940
|
+
clearInterval(pingTimer);
|
|
1941
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
1942
|
+
if (subscribedEvents.length > 0)
|
|
1943
|
+
wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
|
|
1944
|
+
});
|
|
1945
|
+
const url = parseURL(req);
|
|
1946
|
+
if (!url) {
|
|
1947
|
+
ws.close(1002, "Invalid request URL");
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
|
|
1951
|
+
if (subject === false) {
|
|
1952
|
+
ws.close(4001, "Missing token");
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
if (subject === null) {
|
|
1956
|
+
ws.close(4001, "Invalid or expired token");
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const requestedEvents = parseRequestedEvents2(url, router.eventNames);
|
|
1960
|
+
if (requestedEvents.length === 0) {
|
|
1961
|
+
ws.close(4e3, "No valid event names specified");
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
|
|
1965
|
+
if (allowedEvents === null) {
|
|
1966
|
+
ws.close(4003, "Not authorized for any requested events");
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (maxConnectionsPerSubject > 0 && typeof subject === "string") {
|
|
1970
|
+
const current = subjectCounts.get(subject) ?? 0;
|
|
1971
|
+
if (current >= maxConnectionsPerSubject) {
|
|
1972
|
+
ws.close(1013, "Too many connections for this subject");
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
subjectCounts.set(subject, current + 1);
|
|
1976
|
+
ws.on("close", () => {
|
|
1977
|
+
const remaining = (subjectCounts.get(subject) ?? 1) - 1;
|
|
1978
|
+
if (remaining <= 0) subjectCounts.delete(subject);
|
|
1979
|
+
else subjectCounts.set(subject, remaining);
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
subscribedEvents = allowedEvents;
|
|
1983
|
+
wsLogger.info("WebSocket connection established", {
|
|
1984
|
+
events: allowedEvents,
|
|
1985
|
+
subject: subject ?? void 0
|
|
1986
|
+
});
|
|
1987
|
+
const connection = createConnection(ws, maxBufferedBytes);
|
|
1988
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);
|
|
1989
|
+
if (ws.readyState !== 1) {
|
|
1990
|
+
connectionUnsubscribes.forEach((fn) => fn());
|
|
1991
|
+
connectionUnsubscribes = [];
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
ws.on("message", (data) => {
|
|
1995
|
+
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
1996
|
+
});
|
|
1997
|
+
if (pingInterval > 0) {
|
|
1998
|
+
ws.isAlive = true;
|
|
1999
|
+
ws.on("pong", () => {
|
|
2000
|
+
ws.isAlive = true;
|
|
2001
|
+
});
|
|
2002
|
+
pingTimer = setInterval(() => {
|
|
2003
|
+
if (ws.readyState !== 1) return;
|
|
2004
|
+
if (ws.isAlive === false) {
|
|
2005
|
+
ws.terminate();
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
ws.isAlive = false;
|
|
2009
|
+
ws.ping();
|
|
2010
|
+
}, pingInterval);
|
|
2011
|
+
}
|
|
2012
|
+
connection.send("__connected", {
|
|
2013
|
+
subscribedEvents: allowedEvents,
|
|
2014
|
+
timestamp: Date.now()
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
function parseURL(req) {
|
|
2018
|
+
try {
|
|
2019
|
+
return new URL(req.url ?? "/", "ws://localhost");
|
|
2020
|
+
} catch {
|
|
2021
|
+
return null;
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function resolveSubject(url, tokenManager) {
|
|
2025
|
+
if (!tokenManager) {
|
|
2026
|
+
return void 0;
|
|
2027
|
+
}
|
|
2028
|
+
const token = url.searchParams.get("token");
|
|
2029
|
+
if (!token) {
|
|
2030
|
+
return false;
|
|
2031
|
+
}
|
|
2032
|
+
return await tokenManager.verify(token);
|
|
2033
|
+
}
|
|
2034
|
+
function parseRequestedEvents2(url, validEventNames) {
|
|
2035
|
+
const eventsParam = url.searchParams.get("events");
|
|
2036
|
+
if (!eventsParam) {
|
|
2037
|
+
return [];
|
|
2038
|
+
}
|
|
2039
|
+
return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
|
|
2040
|
+
}
|
|
2041
|
+
async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
2042
|
+
if (!subject || !authConfig?.authorize) {
|
|
2043
|
+
return requestedEvents;
|
|
2044
|
+
}
|
|
2045
|
+
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
2046
|
+
return allowed.length === 0 ? null : allowed;
|
|
2047
|
+
}
|
|
2048
|
+
function safeSend(ws, frame, maxBufferedBytes) {
|
|
2049
|
+
if (ws.readyState !== 1) return;
|
|
2050
|
+
if (ws.bufferedAmount > maxBufferedBytes) {
|
|
2051
|
+
ws.close(1013, "Send buffer overflow");
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
try {
|
|
2055
|
+
ws.send(JSON.stringify(frame));
|
|
2056
|
+
} catch {
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
function createConnection(ws, maxBufferedBytes) {
|
|
2060
|
+
return {
|
|
2061
|
+
send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),
|
|
2062
|
+
close: (code, reason) => ws.close(code, reason)
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes) {
|
|
2066
|
+
const unsubscribes = [];
|
|
2067
|
+
for (const eventName of allowedEvents) {
|
|
2068
|
+
const eventDef = router.events[eventName];
|
|
2069
|
+
if (!eventDef) continue;
|
|
2070
|
+
const unsubscribe = eventDef.subscribe((payload) => {
|
|
2071
|
+
if (ws.readyState !== 1) return;
|
|
2072
|
+
if (subject && authConfig?.filter?.[eventName]) {
|
|
2073
|
+
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
2074
|
+
}
|
|
2075
|
+
safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);
|
|
2076
|
+
});
|
|
2077
|
+
unsubscribes.push(unsubscribe);
|
|
2078
|
+
}
|
|
2079
|
+
return unsubscribes;
|
|
2080
|
+
}
|
|
2081
|
+
async function onClientMessage(data, router, connection, subject) {
|
|
2082
|
+
let message;
|
|
2083
|
+
try {
|
|
2084
|
+
message = JSON.parse(data.toString());
|
|
2085
|
+
} catch {
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
const { type, data: payload } = message;
|
|
2089
|
+
if (!type) return;
|
|
2090
|
+
const handler = router.messages[type];
|
|
2091
|
+
if (!handler) return;
|
|
2092
|
+
try {
|
|
2093
|
+
await handler({ payload, subject, ws: connection });
|
|
2094
|
+
} catch (err) {
|
|
2095
|
+
wsLogger.error(`WebSocket message handler error: ${type}`, err);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
function resolveWSServerCtor(mod) {
|
|
2099
|
+
return mod.WebSocketServer ?? mod.Server ?? mod.default?.WebSocketServer ?? mod.default?.Server;
|
|
2100
|
+
}
|
|
2101
|
+
async function loadWSServer() {
|
|
2102
|
+
let mod;
|
|
2103
|
+
try {
|
|
2104
|
+
mod = await import('ws');
|
|
2105
|
+
} catch {
|
|
2106
|
+
throw new Error(
|
|
2107
|
+
'@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
|
|
2108
|
+
);
|
|
2109
|
+
}
|
|
2110
|
+
const WSS = resolveWSServerCtor(mod);
|
|
2111
|
+
if (typeof WSS !== "function") {
|
|
2112
|
+
throw new Error(
|
|
2113
|
+
"WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
|
|
2114
|
+
);
|
|
2115
|
+
}
|
|
2116
|
+
return WSS;
|
|
2117
|
+
}
|
|
854
2118
|
function getNetworkAddress() {
|
|
855
2119
|
const nets = networkInterfaces();
|
|
856
2120
|
for (const name of Object.keys(nets)) {
|
|
@@ -888,16 +2152,16 @@ function printBanner(options) {
|
|
|
888
2152
|
}
|
|
889
2153
|
|
|
890
2154
|
// src/server/validation.ts
|
|
891
|
-
function validateServerConfig(
|
|
892
|
-
if (
|
|
893
|
-
if (!Number.isInteger(
|
|
2155
|
+
function validateServerConfig(config) {
|
|
2156
|
+
if (config.port !== void 0) {
|
|
2157
|
+
if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
|
|
894
2158
|
throw new Error(
|
|
895
|
-
`Invalid port: ${
|
|
2159
|
+
`Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
|
|
896
2160
|
);
|
|
897
2161
|
}
|
|
898
2162
|
}
|
|
899
|
-
if (
|
|
900
|
-
const { request, keepAlive, headers } =
|
|
2163
|
+
if (config.timeout) {
|
|
2164
|
+
const { request, keepAlive, headers } = config.timeout;
|
|
901
2165
|
if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
|
|
902
2166
|
throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
|
|
903
2167
|
}
|
|
@@ -913,16 +2177,16 @@ function validateServerConfig(config2) {
|
|
|
913
2177
|
);
|
|
914
2178
|
}
|
|
915
2179
|
}
|
|
916
|
-
if (
|
|
917
|
-
const timeout =
|
|
2180
|
+
if (config.shutdown?.timeout !== void 0) {
|
|
2181
|
+
const timeout = config.shutdown.timeout;
|
|
918
2182
|
if (timeout < 0 || !Number.isFinite(timeout)) {
|
|
919
2183
|
throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
|
|
920
2184
|
}
|
|
921
2185
|
}
|
|
922
|
-
if (
|
|
923
|
-
if (!
|
|
2186
|
+
if (config.healthCheck?.path) {
|
|
2187
|
+
if (!config.healthCheck.path.startsWith("/")) {
|
|
924
2188
|
throw new Error(
|
|
925
|
-
`Invalid healthCheck.path: "${
|
|
2189
|
+
`Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
|
|
926
2190
|
);
|
|
927
2191
|
}
|
|
928
2192
|
}
|
|
@@ -931,9 +2195,7 @@ var DEFAULT_MAX_LISTENERS = 15;
|
|
|
931
2195
|
var TIMEOUTS = {
|
|
932
2196
|
SERVER_CLOSE: 5e3,
|
|
933
2197
|
DATABASE_CLOSE: 5e3,
|
|
934
|
-
REDIS_CLOSE: 5e3
|
|
935
|
-
PRODUCTION_ERROR_SHUTDOWN: 1e4
|
|
936
|
-
};
|
|
2198
|
+
REDIS_CLOSE: 5e3};
|
|
937
2199
|
var CONFIG_FILE_PATHS = [
|
|
938
2200
|
".spfn/server/server.config.mjs",
|
|
939
2201
|
".spfn/server/server.config",
|
|
@@ -941,9 +2203,9 @@ var CONFIG_FILE_PATHS = [
|
|
|
941
2203
|
"src/server/server.config.ts"
|
|
942
2204
|
];
|
|
943
2205
|
var processHandlersRegistered = false;
|
|
944
|
-
async function startServer(
|
|
945
|
-
|
|
946
|
-
const finalConfig = await loadAndMergeConfig(
|
|
2206
|
+
async function startServer(config) {
|
|
2207
|
+
loadEnv();
|
|
2208
|
+
const finalConfig = await loadAndMergeConfig(config);
|
|
947
2209
|
const { host, port, debug } = finalConfig;
|
|
948
2210
|
validateServerConfig(finalConfig);
|
|
949
2211
|
if (!host || !port) {
|
|
@@ -959,8 +2221,14 @@ async function startServer(config2) {
|
|
|
959
2221
|
await initializeInfrastructure(finalConfig);
|
|
960
2222
|
const app = await createServer(finalConfig);
|
|
961
2223
|
const server = startHttpServer(app, host, port);
|
|
2224
|
+
let wsCleanup;
|
|
2225
|
+
if (finalConfig.websockets) {
|
|
2226
|
+
wsCleanup = await initializeWebSocket(server, app, finalConfig);
|
|
2227
|
+
}
|
|
962
2228
|
const timeouts = getTimeoutConfig(finalConfig.timeout);
|
|
963
2229
|
applyServerTimeouts(server, timeouts);
|
|
2230
|
+
const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
|
|
2231
|
+
applyGlobalFetchTimeouts(fetchTimeouts);
|
|
964
2232
|
logServerTimeouts(timeouts);
|
|
965
2233
|
printBanner({
|
|
966
2234
|
mode: debug ? "Development" : "Production",
|
|
@@ -968,7 +2236,7 @@ async function startServer(config2) {
|
|
|
968
2236
|
port
|
|
969
2237
|
});
|
|
970
2238
|
logServerStarted(debug, host, port, finalConfig, timeouts);
|
|
971
|
-
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
|
|
2239
|
+
const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
|
|
972
2240
|
const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
|
|
973
2241
|
registerProcessHandlers(shutdown);
|
|
974
2242
|
const serverInstance = {
|
|
@@ -977,11 +2245,6 @@ async function startServer(config2) {
|
|
|
977
2245
|
config: finalConfig,
|
|
978
2246
|
close: async () => {
|
|
979
2247
|
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
2248
|
await shutdownServer();
|
|
986
2249
|
}
|
|
987
2250
|
};
|
|
@@ -1001,7 +2264,7 @@ async function startServer(config2) {
|
|
|
1001
2264
|
throw error;
|
|
1002
2265
|
}
|
|
1003
2266
|
}
|
|
1004
|
-
async function loadAndMergeConfig(
|
|
2267
|
+
async function loadAndMergeConfig(config) {
|
|
1005
2268
|
const cwd = process.cwd();
|
|
1006
2269
|
let fileConfig = {};
|
|
1007
2270
|
let loadedConfigPath = null;
|
|
@@ -1025,26 +2288,26 @@ async function loadAndMergeConfig(config2) {
|
|
|
1025
2288
|
}
|
|
1026
2289
|
return {
|
|
1027
2290
|
...fileConfig,
|
|
1028
|
-
...
|
|
1029
|
-
port:
|
|
1030
|
-
host:
|
|
2291
|
+
...config,
|
|
2292
|
+
port: config?.port ?? fileConfig?.port ?? env.PORT,
|
|
2293
|
+
host: config?.host ?? fileConfig?.host ?? env.HOST
|
|
1031
2294
|
};
|
|
1032
2295
|
}
|
|
1033
|
-
function getInfrastructureConfig(
|
|
2296
|
+
function getInfrastructureConfig(config) {
|
|
1034
2297
|
return {
|
|
1035
|
-
database:
|
|
1036
|
-
redis:
|
|
2298
|
+
database: config.infrastructure?.database !== false,
|
|
2299
|
+
redis: config.infrastructure?.redis !== false
|
|
1037
2300
|
};
|
|
1038
2301
|
}
|
|
1039
|
-
async function initializeInfrastructure(
|
|
1040
|
-
if (
|
|
2302
|
+
async function initializeInfrastructure(config) {
|
|
2303
|
+
if (config.lifecycle?.beforeInfrastructure) {
|
|
1041
2304
|
serverLogger.debug("Executing beforeInfrastructure hook...");
|
|
1042
|
-
await
|
|
2305
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
1043
2306
|
}
|
|
1044
|
-
const infraConfig = getInfrastructureConfig(
|
|
2307
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1045
2308
|
if (infraConfig.database) {
|
|
1046
2309
|
serverLogger.debug("Initializing database...");
|
|
1047
|
-
await initDatabase(
|
|
2310
|
+
await initDatabase(config.database);
|
|
1048
2311
|
} else {
|
|
1049
2312
|
serverLogger.debug("Database initialization disabled");
|
|
1050
2313
|
}
|
|
@@ -1054,11 +2317,11 @@ async function initializeInfrastructure(config2) {
|
|
|
1054
2317
|
} else {
|
|
1055
2318
|
serverLogger.debug("Redis initialization disabled");
|
|
1056
2319
|
}
|
|
1057
|
-
if (
|
|
2320
|
+
if (config.lifecycle?.afterInfrastructure) {
|
|
1058
2321
|
serverLogger.debug("Executing afterInfrastructure hook...");
|
|
1059
|
-
await
|
|
2322
|
+
await config.lifecycle.afterInfrastructure();
|
|
1060
2323
|
}
|
|
1061
|
-
if (
|
|
2324
|
+
if (config.jobs) {
|
|
1062
2325
|
const dbUrl = env.DATABASE_URL;
|
|
1063
2326
|
if (!dbUrl) {
|
|
1064
2327
|
throw new Error(
|
|
@@ -1068,10 +2331,24 @@ async function initializeInfrastructure(config2) {
|
|
|
1068
2331
|
serverLogger.debug("Initializing pg-boss...");
|
|
1069
2332
|
await initBoss({
|
|
1070
2333
|
connectionString: dbUrl,
|
|
1071
|
-
...
|
|
2334
|
+
...config.jobsConfig
|
|
1072
2335
|
});
|
|
1073
2336
|
serverLogger.debug("Registering jobs...");
|
|
1074
|
-
await registerJobs(
|
|
2337
|
+
await registerJobs(config.jobs);
|
|
2338
|
+
}
|
|
2339
|
+
if (config.workflows) {
|
|
2340
|
+
const infraConfig2 = getInfrastructureConfig(config);
|
|
2341
|
+
if (!infraConfig2.database) {
|
|
2342
|
+
throw new Error(
|
|
2343
|
+
"Workflows require database connection. Ensure database is enabled in infrastructure config."
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
serverLogger.debug("Initializing workflow engine...");
|
|
2347
|
+
config.workflows._init(
|
|
2348
|
+
getDatabase(),
|
|
2349
|
+
config.workflowsConfig
|
|
2350
|
+
);
|
|
2351
|
+
serverLogger.info("Workflow engine initialized");
|
|
1075
2352
|
}
|
|
1076
2353
|
}
|
|
1077
2354
|
function startHttpServer(app, host, port) {
|
|
@@ -1082,8 +2359,52 @@ function startHttpServer(app, host, port) {
|
|
|
1082
2359
|
hostname: host
|
|
1083
2360
|
});
|
|
1084
2361
|
}
|
|
1085
|
-
function
|
|
1086
|
-
const
|
|
2362
|
+
async function initializeWebSocket(server, app, config) {
|
|
2363
|
+
const wsRouter = config.websockets;
|
|
2364
|
+
const wsConfig = config.websocketsConfig ?? {};
|
|
2365
|
+
const authConfig = wsConfig.auth;
|
|
2366
|
+
const wsPath = wsConfig.path ?? "/ws";
|
|
2367
|
+
const debug = config.debug ?? process.env.NODE_ENV === "development";
|
|
2368
|
+
let tokenManager;
|
|
2369
|
+
if (authConfig?.enabled) {
|
|
2370
|
+
let store = authConfig.store;
|
|
2371
|
+
if (!store) {
|
|
2372
|
+
try {
|
|
2373
|
+
const { getCache: getCache2 } = await import('@spfn/core/cache');
|
|
2374
|
+
const cache = getCache2();
|
|
2375
|
+
if (cache) {
|
|
2376
|
+
store = new CacheTokenStore(cache);
|
|
2377
|
+
if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
|
|
2378
|
+
}
|
|
2379
|
+
} catch {
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
|
|
2383
|
+
tokenManager = externalManager ?? new SSETokenManager({
|
|
2384
|
+
ttl: authConfig.tokenTtl,
|
|
2385
|
+
store
|
|
2386
|
+
});
|
|
2387
|
+
const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
|
|
2388
|
+
const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
|
|
2389
|
+
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
2390
|
+
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
2391
|
+
const subject = getSubject(c);
|
|
2392
|
+
if (!subject) {
|
|
2393
|
+
return c.json({ error: "Unable to identify subject" }, 401);
|
|
2394
|
+
}
|
|
2395
|
+
const token = await tokenManager.issue(subject);
|
|
2396
|
+
return c.json({ token });
|
|
2397
|
+
});
|
|
2398
|
+
if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
|
|
2399
|
+
}
|
|
2400
|
+
await wireEventRouterCache(wsRouter, {
|
|
2401
|
+
multiInstance: wsConfig.multiInstance,
|
|
2402
|
+
channelPrefix: wsConfig.channelPrefix
|
|
2403
|
+
});
|
|
2404
|
+
return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
|
|
2405
|
+
}
|
|
2406
|
+
function logMiddlewareOrder(config) {
|
|
2407
|
+
const middlewareOrder = buildMiddlewareOrder(config);
|
|
1087
2408
|
serverLogger.debug("Middleware execution order", {
|
|
1088
2409
|
order: middlewareOrder
|
|
1089
2410
|
});
|
|
@@ -1095,8 +2416,8 @@ function logServerTimeouts(timeouts) {
|
|
|
1095
2416
|
headers: `${timeouts.headers}ms`
|
|
1096
2417
|
});
|
|
1097
2418
|
}
|
|
1098
|
-
function logServerStarted(debug, host, port,
|
|
1099
|
-
const startupConfig = buildStartupConfig(
|
|
2419
|
+
function logServerStarted(debug, host, port, config, timeouts) {
|
|
2420
|
+
const startupConfig = buildStartupConfig(config, timeouts);
|
|
1100
2421
|
serverLogger.info("Server started successfully", {
|
|
1101
2422
|
mode: debug ? "development" : "production",
|
|
1102
2423
|
host,
|
|
@@ -1104,65 +2425,84 @@ function logServerStarted(debug, host, port, config2, timeouts) {
|
|
|
1104
2425
|
config: startupConfig
|
|
1105
2426
|
});
|
|
1106
2427
|
}
|
|
1107
|
-
function createShutdownHandler(server,
|
|
2428
|
+
function createShutdownHandler(server, config, shutdownState, wsCleanup) {
|
|
1108
2429
|
return async () => {
|
|
1109
2430
|
if (shutdownState.isShuttingDown) {
|
|
1110
2431
|
serverLogger.debug("Shutdown already in progress for this instance, skipping");
|
|
1111
2432
|
return;
|
|
1112
2433
|
}
|
|
1113
2434
|
shutdownState.isShuttingDown = true;
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
timeoutId = setTimeout(() => {
|
|
1131
|
-
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
1132
|
-
}, TIMEOUTS.SERVER_CLOSE);
|
|
1133
|
-
})
|
|
1134
|
-
]).catch((error) => {
|
|
1135
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
1136
|
-
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
1137
|
-
});
|
|
1138
|
-
if (config2.jobs) {
|
|
1139
|
-
serverLogger.debug("Stopping pg-boss...");
|
|
2435
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
2436
|
+
const shutdownManager = getShutdownManager();
|
|
2437
|
+
shutdownManager.beginShutdown();
|
|
2438
|
+
serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
|
|
2439
|
+
await closeHttpServer(server);
|
|
2440
|
+
if (wsCleanup) {
|
|
2441
|
+
serverLogger.info("Phase 1.5: Closing WebSocket server...");
|
|
2442
|
+
try {
|
|
2443
|
+
await wsCleanup();
|
|
2444
|
+
serverLogger.info("WebSocket server closed");
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
serverLogger.error("WebSocket server close failed", error);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
if (config.jobs) {
|
|
2450
|
+
serverLogger.info("Phase 2: Stopping pg-boss...");
|
|
1140
2451
|
try {
|
|
1141
2452
|
await stopBoss();
|
|
2453
|
+
serverLogger.info("pg-boss stopped");
|
|
1142
2454
|
} catch (error) {
|
|
1143
2455
|
serverLogger.error("pg-boss stop failed", error);
|
|
1144
2456
|
}
|
|
1145
2457
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
2458
|
+
const drainTimeout = Math.floor(shutdownTimeout * 0.8);
|
|
2459
|
+
serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
|
|
2460
|
+
await shutdownManager.execute(drainTimeout);
|
|
2461
|
+
if (config.lifecycle?.beforeShutdown) {
|
|
2462
|
+
serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
|
|
1148
2463
|
try {
|
|
1149
|
-
await
|
|
2464
|
+
await config.lifecycle.beforeShutdown();
|
|
1150
2465
|
} catch (error) {
|
|
1151
|
-
serverLogger.error("beforeShutdown hook failed", error);
|
|
2466
|
+
serverLogger.error("beforeShutdown lifecycle hook failed", error);
|
|
1152
2467
|
}
|
|
1153
2468
|
}
|
|
1154
|
-
|
|
2469
|
+
serverLogger.info("Phase 5: Closing infrastructure...");
|
|
2470
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1155
2471
|
if (infraConfig.database) {
|
|
1156
|
-
serverLogger.debug("Closing database connections...");
|
|
1157
2472
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1158
2473
|
}
|
|
1159
2474
|
if (infraConfig.redis) {
|
|
1160
|
-
|
|
2475
|
+
await closeEventTransport();
|
|
1161
2476
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1162
2477
|
}
|
|
1163
2478
|
serverLogger.info("Server shutdown completed");
|
|
1164
2479
|
};
|
|
1165
2480
|
}
|
|
2481
|
+
async function closeHttpServer(server) {
|
|
2482
|
+
let timeoutId;
|
|
2483
|
+
await Promise.race([
|
|
2484
|
+
new Promise((resolve2, reject) => {
|
|
2485
|
+
server.close((err) => {
|
|
2486
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2487
|
+
if (err) {
|
|
2488
|
+
serverLogger.error("HTTP server close error", err);
|
|
2489
|
+
reject(err);
|
|
2490
|
+
} else {
|
|
2491
|
+
serverLogger.info("HTTP server closed");
|
|
2492
|
+
resolve2();
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
}),
|
|
2496
|
+
new Promise((_, reject) => {
|
|
2497
|
+
timeoutId = setTimeout(() => {
|
|
2498
|
+
reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
|
|
2499
|
+
}, TIMEOUTS.SERVER_CLOSE);
|
|
2500
|
+
})
|
|
2501
|
+
]).catch((error) => {
|
|
2502
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
2503
|
+
serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
1166
2506
|
async function closeInfrastructure(closeFn, name, timeout) {
|
|
1167
2507
|
let timeoutId;
|
|
1168
2508
|
try {
|
|
@@ -1182,14 +2522,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
|
|
|
1182
2522
|
serverLogger.error(`${name} close failed or timed out`, error);
|
|
1183
2523
|
}
|
|
1184
2524
|
}
|
|
1185
|
-
function createGracefulShutdown(shutdownServer,
|
|
2525
|
+
function createGracefulShutdown(shutdownServer, config, shutdownState) {
|
|
1186
2526
|
return async (signal) => {
|
|
1187
2527
|
if (shutdownState.isShuttingDown) {
|
|
1188
2528
|
serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
|
|
1189
2529
|
return;
|
|
1190
2530
|
}
|
|
1191
2531
|
serverLogger.info(`${signal} received, starting graceful shutdown...`);
|
|
1192
|
-
const shutdownTimeout = getShutdownTimeout(
|
|
2532
|
+
const shutdownTimeout = getShutdownTimeout(config.shutdown);
|
|
1193
2533
|
let timeoutId;
|
|
1194
2534
|
try {
|
|
1195
2535
|
await Promise.race([
|
|
@@ -1217,31 +2557,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
|
|
|
1217
2557
|
}
|
|
1218
2558
|
};
|
|
1219
2559
|
}
|
|
1220
|
-
function handleProcessError(errorType
|
|
1221
|
-
|
|
1222
|
-
const isDevelopment = env.NODE_ENV === "development";
|
|
1223
|
-
if (isDevelopment || process.env.WATCH_MODE === "true") {
|
|
1224
|
-
serverLogger.info("Exiting immediately for clean restart");
|
|
1225
|
-
process.exit(1);
|
|
1226
|
-
} else if (isProduction) {
|
|
1227
|
-
serverLogger.info(`Attempting graceful shutdown after ${errorType}`);
|
|
1228
|
-
const forceExitTimer = setTimeout(() => {
|
|
1229
|
-
serverLogger.error(`Forced exit after ${TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN}ms - graceful shutdown did not complete`);
|
|
1230
|
-
process.exit(1);
|
|
1231
|
-
}, TIMEOUTS.PRODUCTION_ERROR_SHUTDOWN);
|
|
1232
|
-
shutdown(errorType).then(() => {
|
|
1233
|
-
clearTimeout(forceExitTimer);
|
|
1234
|
-
serverLogger.info("Graceful shutdown completed, exiting");
|
|
1235
|
-
process.exit(0);
|
|
1236
|
-
}).catch((shutdownError) => {
|
|
1237
|
-
clearTimeout(forceExitTimer);
|
|
1238
|
-
serverLogger.error("Graceful shutdown failed", shutdownError);
|
|
1239
|
-
process.exit(1);
|
|
1240
|
-
});
|
|
1241
|
-
} else {
|
|
1242
|
-
serverLogger.info("Exiting immediately");
|
|
1243
|
-
process.exit(1);
|
|
1244
|
-
}
|
|
2560
|
+
function handleProcessError(errorType) {
|
|
2561
|
+
serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
|
|
1245
2562
|
}
|
|
1246
2563
|
function registerProcessHandlers(shutdown) {
|
|
1247
2564
|
if (processHandlersRegistered) {
|
|
@@ -1276,7 +2593,7 @@ function registerProcessHandlers(shutdown) {
|
|
|
1276
2593
|
} else {
|
|
1277
2594
|
serverLogger.error("Uncaught exception", error);
|
|
1278
2595
|
}
|
|
1279
|
-
handleProcessError("UNCAUGHT_EXCEPTION"
|
|
2596
|
+
handleProcessError("UNCAUGHT_EXCEPTION");
|
|
1280
2597
|
});
|
|
1281
2598
|
process.on("unhandledRejection", (reason, promise) => {
|
|
1282
2599
|
if (reason instanceof Error) {
|
|
@@ -1294,25 +2611,89 @@ function registerProcessHandlers(shutdown) {
|
|
|
1294
2611
|
promise
|
|
1295
2612
|
});
|
|
1296
2613
|
}
|
|
1297
|
-
handleProcessError("UNHANDLED_REJECTION"
|
|
2614
|
+
handleProcessError("UNHANDLED_REJECTION");
|
|
1298
2615
|
});
|
|
1299
2616
|
serverLogger.debug("Process-level shutdown handlers registered successfully");
|
|
1300
2617
|
}
|
|
1301
|
-
async function cleanupOnFailure(
|
|
2618
|
+
async function cleanupOnFailure(config) {
|
|
1302
2619
|
try {
|
|
1303
2620
|
serverLogger.debug("Cleaning up after initialization failure...");
|
|
1304
|
-
const infraConfig = getInfrastructureConfig(
|
|
2621
|
+
const infraConfig = getInfrastructureConfig(config);
|
|
1305
2622
|
if (infraConfig.database) {
|
|
1306
2623
|
await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
|
|
1307
2624
|
}
|
|
1308
2625
|
if (infraConfig.redis) {
|
|
2626
|
+
await closeEventTransport();
|
|
1309
2627
|
await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
|
|
1310
2628
|
}
|
|
2629
|
+
resetShutdownManager();
|
|
1311
2630
|
serverLogger.debug("Cleanup completed");
|
|
1312
2631
|
} catch (cleanupError) {
|
|
1313
2632
|
serverLogger.error("Cleanup failed", cleanupError);
|
|
1314
2633
|
}
|
|
1315
2634
|
}
|
|
2635
|
+
function getInfrastructure(config) {
|
|
2636
|
+
return {
|
|
2637
|
+
database: config?.infrastructure?.database !== false,
|
|
2638
|
+
redis: config?.infrastructure?.redis !== false
|
|
2639
|
+
};
|
|
2640
|
+
}
|
|
2641
|
+
function withServerlessDefaults(config) {
|
|
2642
|
+
if (!config) {
|
|
2643
|
+
return config;
|
|
2644
|
+
}
|
|
2645
|
+
return {
|
|
2646
|
+
...config,
|
|
2647
|
+
database: {
|
|
2648
|
+
...config.database,
|
|
2649
|
+
healthCheck: { enabled: false, ...config.database?.healthCheck }
|
|
2650
|
+
}
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
function warnIfJobsConfigured(config) {
|
|
2654
|
+
if (config?.jobs) {
|
|
2655
|
+
serverLogger.warn(
|
|
2656
|
+
"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."
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
async function initInfrastructure(config) {
|
|
2661
|
+
const infra = getInfrastructure(config);
|
|
2662
|
+
if (infra.database) {
|
|
2663
|
+
await initDatabase(config?.database);
|
|
2664
|
+
}
|
|
2665
|
+
if (infra.redis) {
|
|
2666
|
+
await initCache();
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
var appPromise;
|
|
2670
|
+
function createServerlessApp(config) {
|
|
2671
|
+
if (!appPromise) {
|
|
2672
|
+
appPromise = buildServerlessApp(config);
|
|
2673
|
+
}
|
|
2674
|
+
return appPromise;
|
|
2675
|
+
}
|
|
2676
|
+
async function buildServerlessApp(rawConfig) {
|
|
2677
|
+
loadEnv();
|
|
2678
|
+
const config = withServerlessDefaults(rawConfig);
|
|
2679
|
+
warnIfJobsConfigured(config);
|
|
2680
|
+
await initInfrastructure(config);
|
|
2681
|
+
return createServer(config);
|
|
2682
|
+
}
|
|
2683
|
+
function resetServerlessApp() {
|
|
2684
|
+
appPromise = void 0;
|
|
2685
|
+
}
|
|
2686
|
+
async function provisionInfrastructure(config) {
|
|
2687
|
+
loadEnv();
|
|
2688
|
+
if (config?.lifecycle?.beforeInfrastructure) {
|
|
2689
|
+
await config.lifecycle.beforeInfrastructure(config);
|
|
2690
|
+
}
|
|
2691
|
+
await initInfrastructure(config);
|
|
2692
|
+
if (config?.lifecycle?.afterInfrastructure) {
|
|
2693
|
+
await config.lifecycle.afterInfrastructure();
|
|
2694
|
+
}
|
|
2695
|
+
serverLogger.info("Provisioning complete");
|
|
2696
|
+
}
|
|
1316
2697
|
|
|
1317
2698
|
// src/server/config-builder.ts
|
|
1318
2699
|
function collectHooks(lifecycles, key) {
|
|
@@ -1373,6 +2754,43 @@ var ServerConfigBuilder = class {
|
|
|
1373
2754
|
this.config.middlewares = middlewares;
|
|
1374
2755
|
return this;
|
|
1375
2756
|
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
|
|
2759
|
+
*/
|
|
2760
|
+
proxyGuard(proxyGuard) {
|
|
2761
|
+
this.config.proxyGuard = proxyGuard;
|
|
2762
|
+
return this;
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* Configure rate limiting: an optional global default limiter plus the named
|
|
2766
|
+
* policies that `rateLimitPolicy(name, fallback)` tags resolve against.
|
|
2767
|
+
*
|
|
2768
|
+
* @example
|
|
2769
|
+
* ```typescript
|
|
2770
|
+
* .rateLimit({
|
|
2771
|
+
* mode: 'on',
|
|
2772
|
+
* default: { limit: 100, windowMs: 60_000 },
|
|
2773
|
+
* policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
|
|
2774
|
+
* })
|
|
2775
|
+
* ```
|
|
2776
|
+
*/
|
|
2777
|
+
rateLimit(rateLimit2) {
|
|
2778
|
+
this.config.rateLimit = rateLimit2;
|
|
2779
|
+
return this;
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
|
|
2783
|
+
* callbacks). Private/reserved IPs are blocked by default.
|
|
2784
|
+
*
|
|
2785
|
+
* @example
|
|
2786
|
+
* ```typescript
|
|
2787
|
+
* .outboundFetch({ allowHosts: ['hooks.slack.com'] })
|
|
2788
|
+
* ```
|
|
2789
|
+
*/
|
|
2790
|
+
outboundFetch(outboundFetch) {
|
|
2791
|
+
this.config.outboundFetch = outboundFetch;
|
|
2792
|
+
return this;
|
|
2793
|
+
}
|
|
1376
2794
|
/**
|
|
1377
2795
|
* Register define-route based router
|
|
1378
2796
|
*
|
|
@@ -1433,10 +2851,10 @@ var ServerConfigBuilder = class {
|
|
|
1433
2851
|
* .build();
|
|
1434
2852
|
* ```
|
|
1435
2853
|
*/
|
|
1436
|
-
jobs(router,
|
|
2854
|
+
jobs(router, config) {
|
|
1437
2855
|
this.config.jobs = router;
|
|
1438
|
-
if (
|
|
1439
|
-
this.config.jobsConfig =
|
|
2856
|
+
if (config) {
|
|
2857
|
+
this.config.jobsConfig = config;
|
|
1440
2858
|
}
|
|
1441
2859
|
return this;
|
|
1442
2860
|
}
|
|
@@ -1468,10 +2886,44 @@ var ServerConfigBuilder = class {
|
|
|
1468
2886
|
* .events(eventRouter, { path: '/sse' })
|
|
1469
2887
|
* ```
|
|
1470
2888
|
*/
|
|
1471
|
-
events(router,
|
|
2889
|
+
events(router, config) {
|
|
1472
2890
|
this.config.events = router;
|
|
1473
|
-
if (
|
|
1474
|
-
this.config.eventsConfig =
|
|
2891
|
+
if (config) {
|
|
2892
|
+
this.config.eventsConfig = config;
|
|
2893
|
+
}
|
|
2894
|
+
return this;
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Register WebSocket router for bidirectional real-time communication
|
|
2898
|
+
*
|
|
2899
|
+
* Enables type-safe WebSocket connections with:
|
|
2900
|
+
* - Server→client event push (via defineEvent + emit)
|
|
2901
|
+
* - Client→server message handling (via messages in defineWSRouter)
|
|
2902
|
+
*
|
|
2903
|
+
* @example
|
|
2904
|
+
* ```typescript
|
|
2905
|
+
* // src/server/ws.ts
|
|
2906
|
+
* export const wsRouter = defineWSRouter({
|
|
2907
|
+
* events: { userUpdated, notification },
|
|
2908
|
+
* messages: {
|
|
2909
|
+
* ping: ({ ws }) => ws.send('pong', {}),
|
|
2910
|
+
* },
|
|
2911
|
+
* });
|
|
2912
|
+
*
|
|
2913
|
+
* // server.config.ts
|
|
2914
|
+
* export default defineServerConfig()
|
|
2915
|
+
* .websockets(wsRouter) // → WS /ws
|
|
2916
|
+
* .websockets(wsRouter, {
|
|
2917
|
+
* path: '/realtime', // custom path
|
|
2918
|
+
* auth: { enabled: true }, // token authentication
|
|
2919
|
+
* })
|
|
2920
|
+
* .build();
|
|
2921
|
+
* ```
|
|
2922
|
+
*/
|
|
2923
|
+
websockets(router, config) {
|
|
2924
|
+
this.config.websockets = router;
|
|
2925
|
+
if (config) {
|
|
2926
|
+
this.config.websocketsConfig = config;
|
|
1475
2927
|
}
|
|
1476
2928
|
return this;
|
|
1477
2929
|
}
|
|
@@ -1517,6 +2969,33 @@ var ServerConfigBuilder = class {
|
|
|
1517
2969
|
this.config.infrastructure = infrastructure;
|
|
1518
2970
|
return this;
|
|
1519
2971
|
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Register workflow router for workflow orchestration
|
|
2974
|
+
*
|
|
2975
|
+
* Automatically initializes the workflow engine after database is ready.
|
|
2976
|
+
*
|
|
2977
|
+
* @example
|
|
2978
|
+
* ```typescript
|
|
2979
|
+
* import { defineWorkflowRouter } from '@spfn/workflow';
|
|
2980
|
+
*
|
|
2981
|
+
* const workflowRouter = defineWorkflowRouter([
|
|
2982
|
+
* provisionTenant,
|
|
2983
|
+
* deprovisionTenant,
|
|
2984
|
+
* ]);
|
|
2985
|
+
*
|
|
2986
|
+
* export default defineServerConfig()
|
|
2987
|
+
* .routes(appRouter)
|
|
2988
|
+
* .workflows(workflowRouter)
|
|
2989
|
+
* .build();
|
|
2990
|
+
* ```
|
|
2991
|
+
*/
|
|
2992
|
+
workflows(router, config) {
|
|
2993
|
+
this.config.workflows = router;
|
|
2994
|
+
if (config) {
|
|
2995
|
+
this.config.workflowsConfig = config;
|
|
2996
|
+
}
|
|
2997
|
+
return this;
|
|
2998
|
+
}
|
|
1520
2999
|
/**
|
|
1521
3000
|
* Configure lifecycle hooks
|
|
1522
3001
|
* Can be called multiple times - hooks will be executed in registration order
|
|
@@ -1564,6 +3043,6 @@ function defineServerConfig() {
|
|
|
1564
3043
|
return new ServerConfigBuilder();
|
|
1565
3044
|
}
|
|
1566
3045
|
|
|
1567
|
-
export { createServer, defineServerConfig, loadEnvFiles, startServer };
|
|
3046
|
+
export { createServer, createServerlessApp, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetServerlessApp, startServer };
|
|
1568
3047
|
//# sourceMappingURL=index.js.map
|
|
1569
3048
|
//# sourceMappingURL=index.js.map
|