@spfn/core 0.2.0-beta.9 → 0.3.0-beta.2

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