@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.
Files changed (73) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -1281
  3. package/dist/authz/index.d.ts +34 -0
  4. package/dist/authz/index.js +415 -0
  5. package/dist/authz/index.js.map +1 -0
  6. package/dist/{boss-DI1r4kTS.d.ts → boss-gXhgctn6.d.ts} +40 -0
  7. package/dist/cache/index.js +42 -30
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/codegen/index.d.ts +55 -8
  10. package/dist/codegen/index.js +183 -7
  11. package/dist/codegen/index.js.map +1 -1
  12. package/dist/config/index.d.ts +585 -6
  13. package/dist/config/index.js +116 -5
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/db/index.d.ts +379 -75
  16. package/dist/db/index.js +625 -113
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/define-middleware-DuXD8Hvu.d.ts +167 -0
  19. package/dist/env/index.d.ts +26 -2
  20. package/dist/env/index.js +15 -5
  21. package/dist/env/index.js.map +1 -1
  22. package/dist/env/loader.d.ts +26 -19
  23. package/dist/env/loader.js +32 -25
  24. package/dist/env/loader.js.map +1 -1
  25. package/dist/errors/index.d.ts +10 -0
  26. package/dist/errors/index.js +20 -2
  27. package/dist/errors/index.js.map +1 -1
  28. package/dist/event/index.d.ts +33 -3
  29. package/dist/event/index.js +24 -3
  30. package/dist/event/index.js.map +1 -1
  31. package/dist/event/sse/client.d.ts +42 -3
  32. package/dist/event/sse/client.js +128 -45
  33. package/dist/event/sse/client.js.map +1 -1
  34. package/dist/event/sse/index.d.ts +12 -5
  35. package/dist/event/sse/index.js +271 -32
  36. package/dist/event/sse/index.js.map +1 -1
  37. package/dist/event/ws/client.d.ts +59 -0
  38. package/dist/event/ws/client.js +273 -0
  39. package/dist/event/ws/client.js.map +1 -0
  40. package/dist/event/ws/index.d.ts +94 -0
  41. package/dist/event/ws/index.js +272 -0
  42. package/dist/event/ws/index.js.map +1 -0
  43. package/dist/job/index.d.ts +2 -2
  44. package/dist/job/index.js +155 -42
  45. package/dist/job/index.js.map +1 -1
  46. package/dist/logger/index.d.ts +5 -0
  47. package/dist/logger/index.js +14 -0
  48. package/dist/logger/index.js.map +1 -1
  49. package/dist/middleware/index.d.ts +243 -2
  50. package/dist/middleware/index.js +1323 -13
  51. package/dist/middleware/index.js.map +1 -1
  52. package/dist/nextjs/index.d.ts +2 -2
  53. package/dist/nextjs/index.js +77 -31
  54. package/dist/nextjs/index.js.map +1 -1
  55. package/dist/nextjs/server.d.ts +53 -23
  56. package/dist/nextjs/server.js +197 -66
  57. package/dist/nextjs/server.js.map +1 -1
  58. package/dist/route/index.d.ts +138 -146
  59. package/dist/route/index.js +238 -22
  60. package/dist/route/index.js.map +1 -1
  61. package/dist/security/index.d.ts +83 -0
  62. package/dist/security/index.js +173 -0
  63. package/dist/security/index.js.map +1 -0
  64. package/dist/server/index.d.ts +458 -17
  65. package/dist/server/index.js +1756 -277
  66. package/dist/server/index.js.map +1 -1
  67. package/dist/{router-Di7ENoah.d.ts → token-manager-jKD_EsSE.d.ts} +121 -1
  68. package/dist/{types-D_N_U-Py.d.ts → types-7Mhoxnnt.d.ts} +21 -1
  69. package/dist/types-BFB72jbM.d.ts +282 -0
  70. package/dist/types-DVjf37yO.d.ts +205 -0
  71. package/docs/file-upload.md +717 -0
  72. package/package.json +237 -208
  73. package/dist/types-B-e_f2dQ.d.ts +0 -121
@@ -1,13 +1,16 @@
1
1
  import { env } from '@spfn/core/config';
2
- import { config } from 'dotenv';
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 { logger } from '@spfn/core/logger';
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
- function loadEnvFiles() {
318
- const cwd = process.cwd();
319
- const nodeEnv = process.env.NODE_ENV || "development";
320
- const envFiles = [
321
- ".env.server.local",
322
- ".env.server",
323
- `.env.${nodeEnv}.local`,
324
- nodeEnv !== "test" ? ".env.local" : null,
325
- `.env.${nodeEnv}`,
326
- ".env"
327
- ].filter((file) => file !== null);
328
- for (const file of envFiles) {
329
- const filePath = resolve(cwd, file);
330
- if (existsSync(filePath)) {
331
- config({ path: filePath });
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
- function createSSEHandler(router, config2 = {}) {
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
- pingInterval = 3e4
339
- // headers: customHeaders = {}, // Reserved for future use
340
- } = config2;
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 eventsParam = c.req.query("events");
343
- if (!eventsParam) {
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: requestedEvents,
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
- for (const eventName of requestedEvents) {
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
- void stream.writeSSE({
557
+ writer.enqueue({
379
558
  id: String(messageId),
380
559
  event: eventName,
381
- data: JSON.stringify(message)
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: requestedEvents,
566
+ events: allowedEvents,
388
567
  subscriptionCount: unsubscribes.length
389
568
  });
390
- await stream.writeSSE({
391
- event: "connected",
392
- data: JSON.stringify({
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
- while (!abortSignal.aborted) {
405
- await stream.sleep(pingInterval);
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
- clearInterval(pingTimer);
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(config2) {
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
- request: config2?.request ?? env.SERVER_TIMEOUT,
480
- keepAlive: config2?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
481
- headers: config2?.headers ?? env.SERVER_HEADERS_TIMEOUT
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 getShutdownTimeout(config2) {
485
- return config2?.timeout ?? env.SHUTDOWN_TIMEOUT;
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(config2) {
1287
+ function buildMiddlewareOrder(config) {
488
1288
  const order = [];
489
- const middlewareConfig = config2.middleware ?? {};
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
- config2.use?.forEach((_, i) => order.push(`Custom[${i}]`));
496
- if (config2.beforeRoutes) order.push("beforeRoutes hook");
1295
+ config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
1296
+ if (config.beforeRoutes) order.push("beforeRoutes hook");
497
1297
  order.push("Routes");
498
- if (config2.afterRoutes) order.push("afterRoutes hook");
1298
+ if (config.afterRoutes) order.push("afterRoutes hook");
499
1299
  if (enableErrorHandler) order.push("ErrorHandler");
500
1300
  return order;
501
1301
  }
502
- function buildStartupConfig(config2, timeouts) {
503
- const middlewareConfig = config2.middleware ?? {};
504
- const healthCheckConfig = config2.healthCheck ?? {};
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: config2.use?.length ?? 0
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: !!config2.beforeRoutes,
522
- afterRoutes: !!config2.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: `${config2.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
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
- async function createServer(config2) {
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, config2);
1343
+ return await loadCustomApp(appPath, appJsPath, config);
543
1344
  }
544
- return await createAutoConfiguredApp(config2);
1345
+ return await createAutoConfiguredApp(config);
545
1346
  }
546
- async function loadCustomApp(appPath, appJsPath, config2) {
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
- if (config2?.routes) {
555
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
556
- logRegisteredRoutes(routes, config2?.debug ?? false);
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(config2) {
1362
+ async function createAutoConfiguredApp(config) {
561
1363
  const app = new Hono();
562
- const middlewareConfig = config2?.middleware ?? {};
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, config2, enableLogger, enableCors);
573
- if (Array.isArray(config2?.use)) {
574
- config2.use.forEach((mw) => app.use("*", mw));
575
- }
576
- registerHealthCheckEndpoint(app, config2);
577
- await executeBeforeRoutesHook(app, config2);
578
- await loadAppRoutes(app, config2);
579
- registerSSEEndpoint(app, config2);
580
- await executeAfterRoutesHook(app, config2);
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, config2, enableLogger, enableCors) {
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 = config2?.cors !== false ? config2?.cors : void 0;
1395
+ const corsOptions = config?.cors !== false ? config?.cors : void 0;
592
1396
  app.use("*", cors(corsOptions));
593
1397
  }
594
1398
  }
595
- function registerHealthCheckEndpoint(app, config2) {
596
- const healthCheckConfig = config2?.healthCheck ?? {};
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, config2) {
606
- if (config2?.lifecycle?.beforeRoutes) {
607
- await config2.lifecycle.beforeRoutes(app);
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, config2) {
611
- const debug = isDebugMode(config2);
612
- if (config2?.routes) {
613
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
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, config2) {
635
- if (config2?.lifecycle?.afterRoutes) {
636
- await config2.lifecycle.afterRoutes(app);
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, config2) {
640
- if (!config2?.events) {
1510
+ async function registerSSEEndpoint(app, config) {
1511
+ if (!config?.events) {
641
1512
  return;
642
1513
  }
643
- const eventsConfig = config2.eventsConfig ?? {};
644
- const path = eventsConfig.path ?? "/events/stream";
645
- const debug = isDebugMode(config2);
646
- app.get(path, createSSEHandler(config2.events, eventsConfig));
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 = config2.events.eventNames;
649
- serverLogger.info(`\u2713 SSE endpoint registered at ${path}`, {
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(config2) {
655
- return config2?.debug ?? process.env.NODE_ENV === "development";
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
- var bossInstance = null;
659
- var bossConfig = null;
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
- if (bossInstance) {
1602
+ const existing = getBossInstance();
1603
+ if (existing) {
662
1604
  jobLogger.warn("pg-boss already initialized, returning existing instance");
663
- return bossInstance;
1605
+ return existing;
664
1606
  }
665
1607
  jobLogger.info("Initializing pg-boss...");
666
- bossConfig = options;
1608
+ setBossConfig(options);
1609
+ const needsSSL = requiresSSLWithoutVerification(options.connectionString);
667
1610
  const pgBossOptions = {
668
- connectionString: options.connectionString,
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
- bossInstance = new PgBoss(pgBossOptions);
676
- bossInstance.on("error", (error) => {
1623
+ const boss = new PgBoss(pgBossOptions);
1624
+ boss.on("error", (error) => {
677
1625
  jobLogger.error("pg-boss error:", error);
678
1626
  });
679
- await bossInstance.start();
1627
+ await boss.start();
1628
+ setBossInstance(boss);
680
1629
  jobLogger.info("pg-boss started successfully");
681
- return bossInstance;
1630
+ return boss;
682
1631
  }
683
1632
  function getBoss() {
684
- return bossInstance;
1633
+ return getBossInstance();
685
1634
  }
686
1635
  async function stopBoss() {
687
- if (!bossInstance) {
1636
+ const boss = getBossInstance();
1637
+ if (!boss) {
688
1638
  return;
689
1639
  }
690
1640
  jobLogger.info("Stopping pg-boss...");
691
1641
  try {
692
- await bossInstance.stop({ graceful: true, timeout: 3e4 });
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
- bossInstance = null;
699
- bossConfig = null;
1648
+ setBossInstance(null);
1649
+ setBossConfig(null);
700
1650
  }
701
1651
  }
702
1652
  function shouldClearOnStart() {
703
- return bossConfig?.clearOnStart ?? false;
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
- await registerJob(job2);
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: 1 },
770
- async (jobs) => {
771
- for (const pgBossJob of jobs) {
772
- jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
773
- const startTime = Date.now();
774
- try {
775
- if (job2.inputSchema) {
776
- await job2.handler(pgBossJob.data);
777
- } else {
778
- await job2.handler();
779
- }
780
- const duration = Date.now() - startTime;
781
- jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
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(config2) {
892
- if (config2.port !== void 0) {
893
- if (!Number.isInteger(config2.port) || config2.port < 0 || config2.port > 65535) {
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: ${config2.port}. Port must be an integer between 0 and 65535.`
2159
+ `Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
896
2160
  );
897
2161
  }
898
2162
  }
899
- if (config2.timeout) {
900
- const { request, keepAlive, headers } = config2.timeout;
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 (config2.shutdown?.timeout !== void 0) {
917
- const timeout = config2.shutdown.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 (config2.healthCheck?.path) {
923
- if (!config2.healthCheck.path.startsWith("/")) {
2186
+ if (config.healthCheck?.path) {
2187
+ if (!config.healthCheck.path.startsWith("/")) {
924
2188
  throw new Error(
925
- `Invalid healthCheck.path: "${config2.healthCheck.path}". Must start with "/".`
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(config2) {
945
- loadEnvFiles();
946
- const finalConfig = await loadAndMergeConfig(config2);
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(config2) {
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
- ...config2,
1029
- port: config2?.port ?? fileConfig?.port ?? env.PORT,
1030
- host: config2?.host ?? fileConfig?.host ?? env.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(config2) {
2296
+ function getInfrastructureConfig(config) {
1034
2297
  return {
1035
- database: config2.infrastructure?.database !== false,
1036
- redis: config2.infrastructure?.redis !== false
2298
+ database: config.infrastructure?.database !== false,
2299
+ redis: config.infrastructure?.redis !== false
1037
2300
  };
1038
2301
  }
1039
- async function initializeInfrastructure(config2) {
1040
- if (config2.lifecycle?.beforeInfrastructure) {
2302
+ async function initializeInfrastructure(config) {
2303
+ if (config.lifecycle?.beforeInfrastructure) {
1041
2304
  serverLogger.debug("Executing beforeInfrastructure hook...");
1042
- await config2.lifecycle.beforeInfrastructure(config2);
2305
+ await config.lifecycle.beforeInfrastructure(config);
1043
2306
  }
1044
- const infraConfig = getInfrastructureConfig(config2);
2307
+ const infraConfig = getInfrastructureConfig(config);
1045
2308
  if (infraConfig.database) {
1046
2309
  serverLogger.debug("Initializing database...");
1047
- await initDatabase(config2.database);
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 (config2.lifecycle?.afterInfrastructure) {
2320
+ if (config.lifecycle?.afterInfrastructure) {
1058
2321
  serverLogger.debug("Executing afterInfrastructure hook...");
1059
- await config2.lifecycle.afterInfrastructure();
2322
+ await config.lifecycle.afterInfrastructure();
1060
2323
  }
1061
- if (config2.jobs) {
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
- ...config2.jobsConfig
2334
+ ...config.jobsConfig
1072
2335
  });
1073
2336
  serverLogger.debug("Registering jobs...");
1074
- await registerJobs(config2.jobs);
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 logMiddlewareOrder(config2) {
1086
- const middlewareOrder = buildMiddlewareOrder(config2);
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, config2, timeouts) {
1099
- const startupConfig = buildStartupConfig(config2, timeouts);
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, config2, shutdownState) {
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
- serverLogger.debug("Closing HTTP server...");
1115
- let timeoutId;
1116
- await Promise.race([
1117
- new Promise((resolve2, reject) => {
1118
- server.close((err) => {
1119
- if (timeoutId) clearTimeout(timeoutId);
1120
- if (err) {
1121
- serverLogger.error("HTTP server close error", err);
1122
- reject(err);
1123
- } else {
1124
- serverLogger.info("HTTP server closed");
1125
- resolve2();
1126
- }
1127
- });
1128
- }),
1129
- new Promise((_, reject) => {
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
- if (config2.lifecycle?.beforeShutdown) {
1147
- serverLogger.debug("Executing beforeShutdown hook...");
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 config2.lifecycle.beforeShutdown();
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
- const infraConfig = getInfrastructureConfig(config2);
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
- serverLogger.debug("Closing Redis connections...");
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, config2, shutdownState) {
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(config2.shutdown);
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, shutdown) {
1221
- const isProduction = env.NODE_ENV === "production";
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", shutdown);
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", shutdown);
2614
+ handleProcessError("UNHANDLED_REJECTION");
1298
2615
  });
1299
2616
  serverLogger.debug("Process-level shutdown handlers registered successfully");
1300
2617
  }
1301
- async function cleanupOnFailure(config2) {
2618
+ async function cleanupOnFailure(config) {
1302
2619
  try {
1303
2620
  serverLogger.debug("Cleaning up after initialization failure...");
1304
- const infraConfig = getInfrastructureConfig(config2);
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, config2) {
2854
+ jobs(router, config) {
1437
2855
  this.config.jobs = router;
1438
- if (config2) {
1439
- this.config.jobsConfig = config2;
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, config2) {
2889
+ events(router, config) {
1472
2890
  this.config.events = router;
1473
- if (config2) {
1474
- this.config.eventsConfig = config2;
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