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

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 (119) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +693 -279
  3. package/dist/app-config/index.d.ts +96 -0
  4. package/dist/app-config/index.js +58 -0
  5. package/dist/app-config/index.js.map +1 -0
  6. package/dist/authz/index.d.ts +34 -0
  7. package/dist/authz/index.js +430 -0
  8. package/dist/authz/index.js.map +1 -0
  9. package/dist/boss-BiDCbbAQ.d.ts +337 -0
  10. package/dist/cache/index.js +42 -30
  11. package/dist/cache/index.js.map +1 -1
  12. package/dist/codegen/index.d.ts +121 -13
  13. package/dist/codegen/index.js +212 -15
  14. package/dist/codegen/index.js.map +1 -1
  15. package/dist/config/index.d.ts +576 -30
  16. package/dist/config/index.js +136 -17
  17. package/dist/config/index.js.map +1 -1
  18. package/dist/contract/index.d.ts +221 -0
  19. package/dist/contract/index.js +558 -0
  20. package/dist/contract/index.js.map +1 -0
  21. package/dist/db/index.d.ts +704 -94
  22. package/dist/db/index.js +1105 -260
  23. package/dist/db/index.js.map +1 -1
  24. package/dist/define-middleware-CVKgqo8S.d.ts +167 -0
  25. package/dist/env/index.d.ts +26 -2
  26. package/dist/env/index.js +15 -5
  27. package/dist/env/index.js.map +1 -1
  28. package/dist/env/loader.d.ts +26 -19
  29. package/dist/env/loader.js +55 -25
  30. package/dist/env/loader.js.map +1 -1
  31. package/dist/errors/index.d.ts +10 -0
  32. package/dist/errors/index.js +40 -7
  33. package/dist/errors/index.js.map +1 -1
  34. package/dist/event/index.d.ts +33 -3
  35. package/dist/event/index.js +24 -3
  36. package/dist/event/index.js.map +1 -1
  37. package/dist/event/sse/client.d.ts +55 -3
  38. package/dist/event/sse/client.js +164 -45
  39. package/dist/event/sse/client.js.map +1 -1
  40. package/dist/event/sse/index.d.ts +12 -5
  41. package/dist/event/sse/index.js +280 -32
  42. package/dist/event/sse/index.js.map +1 -1
  43. package/dist/event/ws/client.d.ts +59 -0
  44. package/dist/event/ws/client.js +273 -0
  45. package/dist/event/ws/client.js.map +1 -0
  46. package/dist/event/ws/index.d.ts +94 -0
  47. package/dist/event/ws/index.js +272 -0
  48. package/dist/event/ws/index.js.map +1 -0
  49. package/dist/job/index.d.ts +48 -3
  50. package/dist/job/index.js +265 -74
  51. package/dist/job/index.js.map +1 -1
  52. package/dist/logger/index.d.ts +5 -0
  53. package/dist/logger/index.js +14 -12
  54. package/dist/logger/index.js.map +1 -1
  55. package/dist/middleware/index.d.ts +352 -10
  56. package/dist/middleware/index.js +1079 -13
  57. package/dist/middleware/index.js.map +1 -1
  58. package/dist/nextjs/index.d.ts +39 -3
  59. package/dist/nextjs/index.js +82 -29
  60. package/dist/nextjs/index.js.map +1 -1
  61. package/dist/nextjs/server.d.ts +69 -52
  62. package/dist/nextjs/server.js +140 -60
  63. package/dist/nextjs/server.js.map +1 -1
  64. package/dist/ops/index.d.ts +208 -0
  65. package/dist/ops/index.js +800 -0
  66. package/dist/ops/index.js.map +1 -0
  67. package/dist/route/index.d.ts +10 -694
  68. package/dist/route/index.js +112 -23
  69. package/dist/route/index.js.map +1 -1
  70. package/dist/route-builder-2ani2jEI.d.ts +553 -0
  71. package/dist/router-DJdpwuB6.d.ts +127 -0
  72. package/dist/security/index.d.ts +83 -0
  73. package/dist/security/index.js +173 -0
  74. package/dist/security/index.js.map +1 -0
  75. package/dist/server/index.d.ts +619 -31
  76. package/dist/server/index.js +2583 -360
  77. package/dist/server/index.js.map +1 -1
  78. package/dist/token-manager-BT5EnUAR.d.ts +278 -0
  79. package/dist/types-2AbaW4Ie.d.ts +205 -0
  80. package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
  81. package/dist/types-ClQVomgV.d.ts +115 -0
  82. package/dist/types-ZQODsBft.d.ts +282 -0
  83. package/docs/file-upload.md +195 -333
  84. package/package.json +250 -208
  85. package/src/cache/README.md +330 -0
  86. package/src/codegen/README.md +516 -0
  87. package/src/config/README.md +326 -0
  88. package/src/contract/README.md +326 -0
  89. package/src/db/README.md +589 -0
  90. package/src/db/manager/README.md +500 -0
  91. package/src/db/schema/README.md +354 -0
  92. package/src/db/transaction/README.md +822 -0
  93. package/src/env/README.md +651 -0
  94. package/src/errors/README.md +429 -0
  95. package/src/event/README.md +741 -0
  96. package/src/job/README.md +613 -0
  97. package/src/logger/README.md +321 -0
  98. package/src/middleware/README.md +634 -0
  99. package/src/nextjs/README.md +608 -0
  100. package/src/route/README.md +738 -0
  101. package/src/security/README.md +100 -0
  102. package/src/server/README.md +704 -0
  103. package/dist/boss-DI1r4kTS.d.ts +0 -244
  104. package/dist/router-Di7ENoah.d.ts +0 -151
  105. package/dist/types-B-e_f2dQ.d.ts +0 -121
  106. package/docs/cache.md +0 -133
  107. package/docs/codegen.md +0 -74
  108. package/docs/database.md +0 -346
  109. package/docs/entity.md +0 -539
  110. package/docs/env.md +0 -477
  111. package/docs/errors.md +0 -319
  112. package/docs/event.md +0 -116
  113. package/docs/job.md +0 -131
  114. package/docs/logger.md +0 -108
  115. package/docs/middleware.md +0 -337
  116. package/docs/nextjs.md +0 -241
  117. package/docs/repository.md +0 -496
  118. package/docs/route.md +0 -497
  119. package/docs/server.md +0 -307
@@ -1,18 +1,23 @@
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, closeDatabase, migrationTargets } from '@spfn/core/db';
12
15
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
16
+ import { FormatRegistry, Type } from '@sinclair/typebox';
13
17
  import { serve } from '@hono/node-server';
14
18
  import PgBoss from 'pg-boss';
15
19
  import { networkInterfaces } from 'os';
20
+ import { pathToFileURL } from 'url';
16
21
 
17
22
  var __defProp = Object.defineProperty;
18
23
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -94,6 +99,11 @@ function formatError(error) {
94
99
  const stackLines = error.stack.split("\n").slice(1);
95
100
  lines.push(...stackLines);
96
101
  }
102
+ if (error.cause instanceof Error) {
103
+ lines.push(`Caused by: ${formatError(error.cause)}`);
104
+ } else if (error.cause !== void 0) {
105
+ lines.push(`Caused by: ${String(error.cause)}`);
106
+ }
97
107
  return lines.join("\n");
98
108
  }
99
109
  function formatContext(context) {
@@ -163,6 +173,19 @@ function formatConsole(metadata, colorize = true) {
163
173
  }
164
174
  return output;
165
175
  }
176
+ function formatErrorCauseChain(error) {
177
+ const result = {
178
+ name: error.name,
179
+ message: error.message,
180
+ stack: error.stack
181
+ };
182
+ if (error.cause instanceof Error) {
183
+ result.cause = formatErrorCauseChain(error.cause);
184
+ } else if (error.cause !== void 0) {
185
+ result.cause = String(error.cause);
186
+ }
187
+ return result;
188
+ }
166
189
  function formatJSON(metadata) {
167
190
  const obj = {
168
191
  timestamp: formatTimestamp(metadata.timestamp),
@@ -176,11 +199,7 @@ function formatJSON(metadata) {
176
199
  obj.context = metadata.context;
177
200
  }
178
201
  if (metadata.error) {
179
- obj.error = {
180
- name: metadata.error.name,
181
- message: metadata.error.message,
182
- stack: metadata.error.stack
183
- };
202
+ obj.error = formatErrorCauseChain(metadata.error);
184
203
  }
185
204
  return JSON.stringify(obj);
186
205
  }
@@ -314,36 +333,186 @@ var init_formatters = __esm({
314
333
  };
315
334
  }
316
335
  });
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 });
336
+ var envLogger = logger.child("@spfn/core:env-loader");
337
+ function getEnvFiles(nodeEnv, server) {
338
+ const files = [
339
+ ".env",
340
+ `.env.${nodeEnv}`
341
+ ];
342
+ if (nodeEnv !== "test") {
343
+ files.push(".env.local");
344
+ }
345
+ files.push(`.env.${nodeEnv}.local`);
346
+ if (server) {
347
+ files.push(".env.server");
348
+ }
349
+ return files;
350
+ }
351
+ function parseEnvFile(filePath) {
352
+ if (!existsSync(filePath)) {
353
+ return null;
354
+ }
355
+ return parse(readFileSync(filePath, "utf-8"));
356
+ }
357
+ function loadEnv(options = {}) {
358
+ const nodeEnvAtEntry = process.env.NODE_ENV;
359
+ const {
360
+ cwd = process.cwd(),
361
+ nodeEnv = nodeEnvAtEntry || "local",
362
+ server = true,
363
+ debug = false,
364
+ override = false
365
+ } = options;
366
+ const envFiles = getEnvFiles(nodeEnv, server);
367
+ const loadedFiles = [];
368
+ const existingKeys = new Set(Object.keys(process.env));
369
+ const merged = {};
370
+ for (const fileName of envFiles) {
371
+ const filePath = resolve(cwd, fileName);
372
+ const parsed = parseEnvFile(filePath);
373
+ if (parsed === null) {
374
+ continue;
375
+ }
376
+ loadedFiles.push(fileName);
377
+ Object.assign(merged, parsed);
378
+ }
379
+ const loadedKeys = [];
380
+ for (const [key, value] of Object.entries(merged)) {
381
+ if (!override && existingKeys.has(key)) {
382
+ continue;
332
383
  }
384
+ process.env[key] = value;
385
+ loadedKeys.push(key);
386
+ }
387
+ if (debug && loadedFiles.length > 0) {
388
+ envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
389
+ envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
390
+ }
391
+ warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, options.nodeEnv, nodeEnv);
392
+ return { loadedFiles, loadedKeys };
393
+ }
394
+ function warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, explicitNodeEnv, resolvedNodeEnv) {
395
+ if (nodeEnvAtEntry || explicitNodeEnv) {
396
+ return;
397
+ }
398
+ const declared = process.env.NODE_ENV;
399
+ if (!declared) {
400
+ envLogger.warn(
401
+ `NODE_ENV is not set anywhere; env files were resolved for "${resolvedNodeEnv}". Set NODE_ENV in the process environment to choose the file set.`
402
+ );
403
+ return;
333
404
  }
405
+ if (declared === resolvedNodeEnv) {
406
+ return;
407
+ }
408
+ if (!existsSync(resolve(cwd, `.env.${declared}`))) {
409
+ return;
410
+ }
411
+ envLogger.warn(
412
+ `.env.${declared} was NOT loaded: NODE_ENV was not set when the process started, so env files were resolved for "${resolvedNodeEnv}" before a file declared NODE_ENV=${declared}. Set NODE_ENV in the process environment, not in a .env file.`
413
+ );
334
414
  }
415
+
416
+ // src/server/dotenv-loader.ts
417
+ var warned = false;
418
+ function loadEnvFiles() {
419
+ if (!warned) {
420
+ warned = true;
421
+ console.warn(
422
+ '[SPFN] loadEnvFiles() is deprecated. Use loadEnv() from "@spfn/core/env/loader" instead.'
423
+ );
424
+ }
425
+ loadEnv();
426
+ }
427
+
428
+ // src/event/sse/bounded-writer.ts
429
+ function createBoundedWriter(stream, maxQueue, onClose) {
430
+ const pending = [];
431
+ let flushing = false;
432
+ let closed = false;
433
+ const flush = async () => {
434
+ if (flushing || closed) {
435
+ return;
436
+ }
437
+ flushing = true;
438
+ while (pending.length > 0 && !closed) {
439
+ const frame = pending.shift();
440
+ try {
441
+ await stream.writeSSE(frame);
442
+ } catch (err) {
443
+ flushing = false;
444
+ onClose(err instanceof Error ? err.message : String(err));
445
+ return;
446
+ }
447
+ }
448
+ flushing = false;
449
+ };
450
+ return {
451
+ enqueue(frame) {
452
+ if (closed) {
453
+ return;
454
+ }
455
+ pending.push(frame);
456
+ if (pending.length > maxQueue) {
457
+ onClose("outbound queue overflow (slow consumer)");
458
+ return;
459
+ }
460
+ void flush();
461
+ },
462
+ close() {
463
+ closed = true;
464
+ pending.length = 0;
465
+ },
466
+ get queued() {
467
+ return pending.length;
468
+ }
469
+ };
470
+ }
471
+
472
+ // src/event/sse/handler.ts
335
473
  var sseLogger = logger.child("@spfn/core:sse");
336
- function createSSEHandler(router, config2 = {}) {
474
+ var frameCache = /* @__PURE__ */ new WeakMap();
475
+ function serializeFrame(eventName, payload) {
476
+ if (payload === null || typeof payload !== "object") {
477
+ return JSON.stringify({ event: eventName, data: payload });
478
+ }
479
+ let byEvent = frameCache.get(payload);
480
+ if (!byEvent) {
481
+ byEvent = /* @__PURE__ */ new Map();
482
+ frameCache.set(payload, byEvent);
483
+ }
484
+ let serialized = byEvent.get(eventName);
485
+ if (serialized === void 0) {
486
+ serialized = JSON.stringify({ event: eventName, data: payload });
487
+ byEvent.set(eventName, serialized);
488
+ }
489
+ return serialized;
490
+ }
491
+ var DEFAULT_MAX_QUEUE = 1e3;
492
+ function createSSEHandler(router, config = {}, tokenManager) {
337
493
  const {
338
- pingInterval = 3e4
339
- // headers: customHeaders = {}, // Reserved for future use
340
- } = config2;
494
+ // 10s — keep-alive가 중간 프록시(GCP LB/nginx)의 idle 타임아웃(흔히 ~15-30s)보다 자주 와야
495
+ // 첫 ping 전 침묵 구간에 연결이 끊기지 않는다. 30s 기본은 idle 타임아웃보다 길어 SSE가
496
+ // 데이터 없는 동안 끊기던 문제가 있었다.
497
+ pingInterval = 1e4,
498
+ auth: authConfig,
499
+ maxQueue = DEFAULT_MAX_QUEUE
500
+ } = config;
341
501
  return async (c) => {
342
- const eventsParam = c.req.query("events");
343
- if (!eventsParam) {
502
+ const subject = await authenticateToken(c, tokenManager);
503
+ if (subject === false) {
504
+ return c.json({ error: "Missing token parameter" }, 401);
505
+ }
506
+ if (subject === null) {
507
+ return c.json({ error: "Invalid or expired token" }, 401);
508
+ }
509
+ if (subject) {
510
+ c.set("sseSubject", subject);
511
+ }
512
+ const requestedEvents = parseRequestedEvents(c);
513
+ if (!requestedEvents) {
344
514
  return c.json({ error: "Missing events parameter" }, 400);
345
515
  }
346
- const requestedEvents = eventsParam.split(",").map((e) => e.trim());
347
516
  const validEventNames = router.eventNames;
348
517
  const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
349
518
  if (invalidEvents.length > 0) {
@@ -353,62 +522,94 @@ function createSSEHandler(router, config2 = {}) {
353
522
  validEvents: validEventNames
354
523
  }, 400);
355
524
  }
525
+ const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
526
+ if (allowedEvents === null) {
527
+ return c.json({ error: "Not authorized for any requested events" }, 403);
528
+ }
356
529
  sseLogger.debug("SSE connection requested", {
357
- events: requestedEvents,
530
+ events: allowedEvents,
531
+ subject: subject || void 0,
358
532
  clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
359
533
  });
534
+ c.header("X-Accel-Buffering", "no");
360
535
  return streamSSE(c, async (stream) => {
361
536
  const unsubscribes = [];
362
537
  let messageId = 0;
363
- for (const eventName of requestedEvents) {
538
+ let connectionDead = false;
539
+ let pingTimer;
540
+ let writer;
541
+ let onClosed;
542
+ const cleanup = (reason) => {
543
+ if (connectionDead) return;
544
+ connectionDead = true;
545
+ clearInterval(pingTimer);
546
+ unsubscribes.forEach((fn) => fn());
547
+ writer?.close();
548
+ onClosed?.();
549
+ sseLogger.info("SSE dead connection cleaned up", {
550
+ events: allowedEvents,
551
+ reason
552
+ });
553
+ };
554
+ writer = createBoundedWriter(stream, maxQueue, (reason) => {
555
+ sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
556
+ cleanup(reason);
557
+ });
558
+ writer.enqueue({
559
+ event: "connected",
560
+ data: JSON.stringify({
561
+ subscribedEvents: allowedEvents,
562
+ timestamp: Date.now()
563
+ })
564
+ });
565
+ for (const eventName of allowedEvents) {
364
566
  const eventDef = router.events[eventName];
365
567
  if (!eventDef) {
366
568
  continue;
367
569
  }
368
570
  const unsubscribe = eventDef.subscribe((payload) => {
571
+ if (connectionDead) return;
572
+ if (subject && authConfig?.filter?.[eventName]) {
573
+ if (!authConfig.filter[eventName](subject, payload)) {
574
+ return;
575
+ }
576
+ }
369
577
  messageId++;
370
- const message = {
371
- event: eventName,
372
- data: payload
373
- };
374
578
  sseLogger.debug("SSE sending event", {
375
579
  event: eventName,
376
580
  messageId
377
581
  });
378
- void stream.writeSSE({
582
+ writer.enqueue({
379
583
  id: String(messageId),
380
584
  event: eventName,
381
- data: JSON.stringify(message)
585
+ data: serializeFrame(eventName, payload)
382
586
  });
383
587
  });
384
588
  unsubscribes.push(unsubscribe);
385
589
  }
386
590
  sseLogger.info("SSE connection established", {
387
- events: requestedEvents,
591
+ events: allowedEvents,
388
592
  subscriptionCount: unsubscribes.length
389
593
  });
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({
594
+ pingTimer = setInterval(() => {
595
+ if (connectionDead) return;
596
+ writer.enqueue({
399
597
  event: "ping",
400
598
  data: JSON.stringify({ timestamp: Date.now() })
401
599
  });
402
600
  }, pingInterval);
403
601
  const abortSignal = c.req.raw.signal;
404
- while (!abortSignal.aborted) {
405
- await stream.sleep(pingInterval);
602
+ if (!abortSignal.aborted && !connectionDead) {
603
+ await new Promise((resolve2) => {
604
+ const onAbort = () => resolve2();
605
+ abortSignal.addEventListener("abort", onAbort, { once: true });
606
+ onClosed = () => {
607
+ abortSignal.removeEventListener("abort", onAbort);
608
+ resolve2();
609
+ };
610
+ });
406
611
  }
407
- clearInterval(pingTimer);
408
- unsubscribes.forEach((fn) => fn());
409
- sseLogger.info("SSE connection closed", {
410
- events: requestedEvents
411
- });
612
+ cleanup();
412
613
  }, async (err) => {
413
614
  sseLogger.error("SSE stream error", {
414
615
  error: err.message
@@ -416,30 +617,765 @@ function createSSEHandler(router, config2 = {}) {
416
617
  });
417
618
  };
418
619
  }
419
- function createHealthCheckHandler(detailed) {
620
+ async function authenticateToken(c, tokenManager) {
621
+ if (!tokenManager) {
622
+ return void 0;
623
+ }
624
+ const token = c.req.query("token");
625
+ if (!token) {
626
+ return false;
627
+ }
628
+ return await tokenManager.verify(token);
629
+ }
630
+ function parseRequestedEvents(c) {
631
+ const eventsParam = c.req.query("events");
632
+ if (!eventsParam) {
633
+ return null;
634
+ }
635
+ return eventsParam.split(",").map((e) => e.trim());
636
+ }
637
+ async function authorizeEvents(subject, requestedEvents, authConfig) {
638
+ if (!subject || !authConfig?.authorize) {
639
+ return requestedEvents;
640
+ }
641
+ const allowed = await authConfig.authorize(subject, requestedEvents);
642
+ if (allowed.length === 0) {
643
+ return null;
644
+ }
645
+ return allowed;
646
+ }
647
+ function hashToken(token) {
648
+ return createHash("sha256").update(token).digest("hex");
649
+ }
650
+ var InMemoryTokenStore = class {
651
+ tokens = /* @__PURE__ */ new Map();
652
+ async set(token, data) {
653
+ this.tokens.set(hashToken(token), data);
654
+ }
655
+ async consume(token) {
656
+ const key = hashToken(token);
657
+ const data = this.tokens.get(key);
658
+ if (!data) {
659
+ return null;
660
+ }
661
+ this.tokens.delete(key);
662
+ return data;
663
+ }
664
+ async cleanup() {
665
+ const now = Date.now();
666
+ for (const [key, data] of this.tokens) {
667
+ if (data.expiresAt <= now) {
668
+ this.tokens.delete(key);
669
+ }
670
+ }
671
+ }
672
+ };
673
+ var CacheTokenStore = class {
674
+ constructor(cache2) {
675
+ this.cache = cache2;
676
+ }
677
+ prefix = "sse:token:";
678
+ async set(token, data) {
679
+ const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
680
+ await this.cache.set(
681
+ this.prefix + hashToken(token),
682
+ JSON.stringify(data),
683
+ "EX",
684
+ ttlSeconds
685
+ );
686
+ }
687
+ async consume(token) {
688
+ const key = this.prefix + hashToken(token);
689
+ let raw = null;
690
+ if (this.cache.getdel) {
691
+ raw = await this.cache.getdel(key);
692
+ } else {
693
+ raw = await this.cache.get(key);
694
+ if (raw) {
695
+ await this.cache.del(key);
696
+ }
697
+ }
698
+ if (!raw) {
699
+ return null;
700
+ }
701
+ return JSON.parse(raw);
702
+ }
703
+ async cleanup() {
704
+ }
705
+ };
706
+ var SSETokenManager = class {
707
+ store;
708
+ ttl;
709
+ cleanupTimer = null;
710
+ constructor(config) {
711
+ this.ttl = config?.ttl ?? 3e4;
712
+ this.store = config?.store ?? new InMemoryTokenStore();
713
+ const cleanupInterval = config?.cleanupInterval ?? 6e4;
714
+ this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
715
+ this.cleanupTimer.unref();
716
+ }
717
+ /**
718
+ * How long an issued token stays valid, in milliseconds.
719
+ *
720
+ * Exposed so a caller that has to report an expiry to its client computes the same
721
+ * number this manager stamps on the token, instead of assuming the default.
722
+ */
723
+ get ttlMs() {
724
+ return this.ttl;
725
+ }
726
+ /**
727
+ * Issue a new one-time-use token for the given subject
728
+ */
729
+ async issue(subject) {
730
+ const token = randomBytes(32).toString("hex");
731
+ await this.store.set(token, {
732
+ subject,
733
+ expiresAt: Date.now() + this.ttl
734
+ });
735
+ return token;
736
+ }
737
+ /**
738
+ * Verify and consume a token
739
+ * @returns subject string if valid, null if invalid/expired/already consumed
740
+ */
741
+ async verify(token) {
742
+ const data = await this.store.consume(token);
743
+ if (!data || data.expiresAt <= Date.now()) {
744
+ return null;
745
+ }
746
+ return data.subject;
747
+ }
748
+ /**
749
+ * Cleanup timer and resources
750
+ */
751
+ destroy() {
752
+ if (this.cleanupTimer) {
753
+ clearInterval(this.cleanupTimer);
754
+ this.cleanupTimer = null;
755
+ }
756
+ }
757
+ };
758
+ var transportLogger = logger.child("@spfn/core:event-transport");
759
+ var DEFAULT_CHANNEL_PREFIX = "spfn:sse:";
760
+ var SUBSCRIBER_QUIT_TIMEOUT_MS = 5e3;
761
+ var PUBLISH_TIMEOUT_MS = 5e3;
762
+ var SUBSCRIBE_TIMEOUT_MS = 5e3;
763
+ var WARN_THROTTLE_MS = 3e4;
764
+ var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
765
+ function withTimeout(promise, ms, label) {
766
+ return new Promise((resolve2, reject) => {
767
+ const timer = setTimeout(() => {
768
+ reject(new Error(`${label} timed out after ${ms}ms`));
769
+ }, ms);
770
+ timer.unref?.();
771
+ promise.then(
772
+ (value) => {
773
+ clearTimeout(timer);
774
+ resolve2(value);
775
+ },
776
+ (err) => {
777
+ clearTimeout(timer);
778
+ reject(err);
779
+ }
780
+ );
781
+ });
782
+ }
783
+ var warnThrottle = /* @__PURE__ */ new Map();
784
+ function throttledWarn(key, message, fields) {
785
+ const now = Date.now();
786
+ const entry = warnThrottle.get(key);
787
+ if (entry && now - entry.last < WARN_THROTTLE_MS) {
788
+ entry.suppressed++;
789
+ return;
790
+ }
791
+ transportLogger.warn(message, entry && entry.suppressed > 0 ? { ...fields, suppressedSinceLast: entry.suppressed } : fields);
792
+ warnThrottle.set(key, { last: now, suppressed: 0 });
793
+ }
794
+ var STATE_KEY = Symbol.for("@spfn/core:event-transport");
795
+ var state = globalThis[STATE_KEY] ??= {
796
+ pubSubCache: void 0,
797
+ subscriber: void 0,
798
+ channelPrefix: void 0,
799
+ wired: /* @__PURE__ */ new Map()
800
+ };
801
+ function resolveChannelPrefix(override) {
802
+ return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
803
+ }
804
+ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
805
+ let subscriber;
806
+ const handlers = /* @__PURE__ */ new Map();
807
+ let degradedUntil = 0;
808
+ const ensureSubscriber = () => {
809
+ if (subscriber) {
810
+ return subscriber;
811
+ }
812
+ subscriber = client.duplicate();
813
+ onSubscriber?.(subscriber);
814
+ subscriber.on("message", (channel, raw) => {
815
+ const handler = handlers.get(channel);
816
+ if (!handler) {
817
+ return;
818
+ }
819
+ let payload;
820
+ try {
821
+ payload = JSON.parse(raw);
822
+ } catch {
823
+ transportLogger.warn("Pub/sub message parse failed \u2014 dropped", { channel });
824
+ return;
825
+ }
826
+ void handler(payload);
827
+ });
828
+ subscriber.on("error", (err) => {
829
+ throttledWarn("subscriber-error", "Pub/sub subscriber error", { error: err.message });
830
+ });
831
+ return subscriber;
832
+ };
833
+ const subscriberHealthy = () => !!subscriber && (subscriber.status === void 0 || subscriber.status === "ready");
834
+ const deliverLocal = (channel, message) => {
835
+ const handler = handlers.get(channel);
836
+ if (handler) {
837
+ void handler(message);
838
+ }
839
+ };
840
+ return {
841
+ publish: async (name, message) => {
842
+ const channel = channelPrefix + name;
843
+ let serialized;
844
+ try {
845
+ serialized = JSON.stringify(message ?? null);
846
+ } catch (err) {
847
+ transportLogger.warn("Pub/sub payload serialization failed \u2014 local delivery only", {
848
+ channel,
849
+ error: err instanceof Error ? err.message : String(err)
850
+ });
851
+ deliverLocal(channel, message ?? null);
852
+ return;
853
+ }
854
+ if (serialized === void 0) {
855
+ transportLogger.warn("Pub/sub payload not serializable (function/symbol) \u2014 local delivery only", {
856
+ channel
857
+ });
858
+ deliverLocal(channel, message ?? null);
859
+ return;
860
+ }
861
+ const ready = client.status === void 0 || client.status === "ready";
862
+ const now = Date.now();
863
+ if (!ready || degradedUntil > 0 && now < degradedUntil) {
864
+ deliverLocal(channel, JSON.parse(serialized));
865
+ return;
866
+ }
867
+ if (degradedUntil > 0) {
868
+ degradedUntil = now + PUBLISH_TIMEOUT_MS;
869
+ }
870
+ try {
871
+ await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
872
+ degradedUntil = 0;
873
+ if (!subscriberHealthy()) {
874
+ throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
875
+ channel
876
+ });
877
+ deliverLocal(channel, JSON.parse(serialized));
878
+ }
879
+ } catch (err) {
880
+ degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
881
+ throttledWarn("publish-failed", "Pub/sub publish failed \u2014 local delivery only", {
882
+ channel,
883
+ error: err instanceof Error ? err.message : String(err)
884
+ });
885
+ deliverLocal(channel, JSON.parse(serialized));
886
+ }
887
+ },
888
+ subscribe: async (name, handler) => {
889
+ const channel = channelPrefix + name;
890
+ const sub = ensureSubscriber();
891
+ try {
892
+ await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, "pub/sub subscribe");
893
+ } catch (err) {
894
+ if (handlers.size === 0 && subscriber === sub) {
895
+ subscriber = void 0;
896
+ void sub.quit?.().catch(() => void 0);
897
+ }
898
+ throw err;
899
+ }
900
+ handlers.set(channel, handler);
901
+ }
902
+ };
903
+ }
904
+ async function wireEventRouterCache(router, options = {}) {
905
+ if (options.multiInstance === false) {
906
+ const events2 = router.events;
907
+ const stillRedis = Object.keys(events2).filter((k) => state.wired.has(events2[k].name)).length;
908
+ if (stillRedis > 0) {
909
+ transportLogger.warn("multiInstance:false has no effect on events already wired to redis by another router", {
910
+ redisWired: stillRedis
911
+ });
912
+ } else {
913
+ transportLogger.info("Event transport: in-process (multiInstance disabled)");
914
+ }
915
+ return "in-process";
916
+ }
917
+ const pubSubCache = await resolvePubSubCache(options.channelPrefix);
918
+ if (!pubSubCache) {
919
+ transportLogger.info("Event transport: in-process (no cache configured)");
920
+ return "in-process";
921
+ }
922
+ const events = router.events;
923
+ let wired = 0;
924
+ let degraded = 0;
925
+ let alreadyWired = 0;
926
+ for (const key of Object.keys(events)) {
927
+ const event = events[key];
928
+ if (state.wired.has(event.name)) {
929
+ alreadyWired++;
930
+ continue;
931
+ }
932
+ try {
933
+ await event.useCache(pubSubCache);
934
+ state.wired.set(event.name, event);
935
+ wired++;
936
+ } catch (err) {
937
+ degraded++;
938
+ transportLogger.warn("Event cache wiring failed \u2014 staying in-process for this event", {
939
+ event: event.name,
940
+ error: err instanceof Error ? err.message : String(err)
941
+ });
942
+ }
943
+ }
944
+ if (wired + alreadyWired === 0) {
945
+ transportLogger.warn("Event transport: redis configured but no event wired \u2014 running in-process", {
946
+ total: Object.keys(events).length,
947
+ degraded
948
+ });
949
+ return "in-process";
950
+ }
951
+ transportLogger.info("Event transport: redis (cross-pod fan-out)", {
952
+ total: Object.keys(events).length,
953
+ wired,
954
+ degraded,
955
+ alreadyWired
956
+ });
957
+ return "redis";
958
+ }
959
+ async function resolvePubSubCache(channelPrefix) {
960
+ const resolvedPrefix = resolveChannelPrefix(channelPrefix);
961
+ if (state.pubSubCache) {
962
+ if (state.channelPrefix !== resolvedPrefix) {
963
+ transportLogger.warn("Conflicting channelPrefix ignored \u2014 the transport prefix is process-global (first wiring wins).", {
964
+ active: state.channelPrefix,
965
+ ignored: resolvedPrefix
966
+ });
967
+ }
968
+ return state.pubSubCache;
969
+ }
970
+ let client;
971
+ try {
972
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
973
+ client = getCache2();
974
+ } catch {
975
+ return void 0;
976
+ }
977
+ if (!client) {
978
+ return void 0;
979
+ }
980
+ if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX) {
981
+ transportLogger.warn(
982
+ "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."
983
+ );
984
+ }
985
+ state.channelPrefix = resolvedPrefix;
986
+ state.pubSubCache = createRedisPubSubCache(
987
+ client,
988
+ resolvedPrefix,
989
+ (connection) => {
990
+ state.subscriber = connection;
991
+ }
992
+ );
993
+ return state.pubSubCache;
994
+ }
995
+ async function closeEventTransport() {
996
+ const subscriber = state.subscriber;
997
+ for (const event of state.wired.values()) {
998
+ event._resetCache?.();
999
+ }
1000
+ state.subscriber = void 0;
1001
+ state.pubSubCache = void 0;
1002
+ state.channelPrefix = void 0;
1003
+ state.wired.clear();
1004
+ if (!subscriber?.quit) {
1005
+ return;
1006
+ }
1007
+ await Promise.race([
1008
+ subscriber.quit().catch(() => void 0),
1009
+ new Promise((resolve2) => {
1010
+ const timer = setTimeout(resolve2, SUBSCRIBER_QUIT_TIMEOUT_MS);
1011
+ timer.unref?.();
1012
+ })
1013
+ ]);
1014
+ }
1015
+ var serverLogger = logger.child("@spfn/core:server");
1016
+
1017
+ // src/server/migration-gate.ts
1018
+ var SNAPSHOT_TTL_MS = 3e4;
1019
+ var cache = null;
1020
+ var PendingMigrationsError = class extends Error {
1021
+ targets;
1022
+ constructor(message, targets) {
1023
+ super(message);
1024
+ this.name = "PendingMigrationsError";
1025
+ this.targets = targets;
1026
+ }
1027
+ };
1028
+ function resetMigrationSnapshot() {
1029
+ cache = null;
1030
+ }
1031
+ async function inspect(cwd, db) {
1032
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
1033
+ if (!hasMigrationTargets(cwd)) {
1034
+ return { state: "skipped", checkedAt, reason: "no function package or project migrations found" };
1035
+ }
1036
+ let database = db;
1037
+ if (!database) {
1038
+ try {
1039
+ database = getDatabase();
1040
+ } catch {
1041
+ return { state: "skipped", checkedAt, reason: "no database initialized" };
1042
+ }
1043
+ }
1044
+ try {
1045
+ const status = await collectMigrationStatus(database, cwd);
1046
+ return { state: "ok", checkedAt, status, pending: countPendingMigrations(status) };
1047
+ } catch (error) {
1048
+ return {
1049
+ state: "unavailable",
1050
+ checkedAt,
1051
+ reason: error instanceof Error ? error.message : String(error)
1052
+ };
1053
+ }
1054
+ }
1055
+ async function getMigrationSnapshot(options = {}) {
1056
+ const cwd = options.cwd ?? process.cwd();
1057
+ const ttl = options.ttlMs ?? SNAPSHOT_TTL_MS;
1058
+ const now = Date.now();
1059
+ if (!options.force && cache && cache.cwd === cwd && now - cache.at < ttl) {
1060
+ return cache.value;
1061
+ }
1062
+ const value = await inspect(cwd, options.db);
1063
+ cache = { at: now, cwd, value };
1064
+ return value;
1065
+ }
1066
+ function pendingIsAllowed(config) {
1067
+ return config?.migrations?.allowPending ?? env.SPFN_ALLOW_PENDING_MIGRATIONS === true;
1068
+ }
1069
+ async function runMigrationBootGate(config, cwd = process.cwd()) {
1070
+ const snapshot = await getMigrationSnapshot({ cwd, force: true });
1071
+ if (snapshot.state === "skipped") {
1072
+ serverLogger.debug(`Migration gate skipped: ${snapshot.reason}`);
1073
+ return snapshot;
1074
+ }
1075
+ if (snapshot.state === "unavailable") {
1076
+ serverLogger.warn(
1077
+ `Could not verify migration status: ${snapshot.reason}. Starting anyway \u2014 run \`pnpm spfn db status\` to check.`
1078
+ );
1079
+ return snapshot;
1080
+ }
1081
+ const targets = pendingMigrationTargets(snapshot.status);
1082
+ if (targets.length === 0) {
1083
+ serverLogger.debug("Migration gate passed: database is up to date");
1084
+ return snapshot;
1085
+ }
1086
+ const lines = formatPendingMigrations(targets);
1087
+ const summary = pendingMigrationsSummary(targets);
1088
+ if (pendingIsAllowed(config)) {
1089
+ serverLogger.warn(`Starting with pending migrations \u2014 ${summary}`);
1090
+ lines.forEach((line) => serverLogger.warn(` ${line}`));
1091
+ serverLogger.warn(` Requests hitting the missing columns will fail. ${RUN_MIGRATIONS_HINT}`);
1092
+ return snapshot;
1093
+ }
1094
+ serverLogger.error(`Refusing to start: ${summary}`);
1095
+ lines.forEach((line) => serverLogger.error(` ${line}`));
1096
+ serverLogger.error(` ${RUN_MIGRATIONS_HINT}`);
1097
+ serverLogger.error(" To start anyway: SPFN_ALLOW_PENDING_MIGRATIONS=true (or `spfn dev --allow-pending-migrations`)");
1098
+ throw new PendingMigrationsError(
1099
+ `${summary}. ${RUN_MIGRATIONS_HINT}, or set SPFN_ALLOW_PENDING_MIGRATIONS=true to start anyway.`,
1100
+ targets.map((target) => target.name)
1101
+ );
1102
+ }
1103
+
1104
+ // src/server/namespace.ts
1105
+ var CORE_NAMESPACE = "/_core";
1106
+ var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
1107
+ var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
1108
+ var LEGACY_HEALTH_PATH = "/health";
1109
+
1110
+ // src/server/shutdown-manager.ts
1111
+ var DEFAULT_HOOK_TIMEOUT = 1e4;
1112
+ var DEFAULT_HOOK_ORDER = 100;
1113
+ var DRAIN_POLL_INTERVAL = 500;
1114
+ var ShutdownManager = class {
1115
+ state = "running";
1116
+ hooks = [];
1117
+ operations = /* @__PURE__ */ new Map();
1118
+ operationCounter = 0;
1119
+ /**
1120
+ * Register a shutdown hook
1121
+ *
1122
+ * Hooks run in order during shutdown, after all tracked operations drain.
1123
+ * Each hook has its own timeout — failure does not block subsequent hooks.
1124
+ *
1125
+ * @example
1126
+ * shutdown.onShutdown('ai-service', async () => {
1127
+ * await aiService.cancelPending();
1128
+ * }, { timeout: 30000, order: 10 });
1129
+ */
1130
+ onShutdown(name, handler, options) {
1131
+ this.hooks.push({
1132
+ name,
1133
+ handler,
1134
+ timeout: options?.timeout ?? DEFAULT_HOOK_TIMEOUT,
1135
+ order: options?.order ?? DEFAULT_HOOK_ORDER
1136
+ });
1137
+ this.hooks.sort((a, b) => a.order - b.order);
1138
+ serverLogger.debug(`Shutdown hook registered: ${name}`, {
1139
+ order: options?.order ?? DEFAULT_HOOK_ORDER,
1140
+ timeout: `${options?.timeout ?? DEFAULT_HOOK_TIMEOUT}ms`
1141
+ });
1142
+ }
1143
+ /**
1144
+ * Track a long-running operation
1145
+ *
1146
+ * During shutdown (drain phase), the process waits for ALL tracked
1147
+ * operations to complete before proceeding with cleanup.
1148
+ *
1149
+ * If shutdown has already started, the operation is rejected immediately.
1150
+ *
1151
+ * @returns The operation result (pass-through)
1152
+ *
1153
+ * @example
1154
+ * const result = await shutdown.trackOperation(
1155
+ * 'ai-generate',
1156
+ * aiService.generate(prompt)
1157
+ * );
1158
+ */
1159
+ async trackOperation(name, operation) {
1160
+ if (this.state !== "running") {
1161
+ throw new Error(`Cannot start operation '${name}': server is shutting down`);
1162
+ }
1163
+ const id = `${name}-${++this.operationCounter}`;
1164
+ this.operations.set(id, {
1165
+ name,
1166
+ startedAt: Date.now()
1167
+ });
1168
+ serverLogger.debug(`Operation tracked: ${id}`, {
1169
+ activeOperations: this.operations.size
1170
+ });
1171
+ try {
1172
+ return await operation;
1173
+ } finally {
1174
+ this.operations.delete(id);
1175
+ serverLogger.debug(`Operation completed: ${id}`, {
1176
+ activeOperations: this.operations.size
1177
+ });
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Whether the server is shutting down
1182
+ *
1183
+ * Use this to reject new work early (e.g., return 503 in route handlers).
1184
+ */
1185
+ isShuttingDown() {
1186
+ return this.state !== "running";
1187
+ }
1188
+ /**
1189
+ * Number of currently active tracked operations
1190
+ */
1191
+ getActiveOperationCount() {
1192
+ return this.operations.size;
1193
+ }
1194
+ /**
1195
+ * Mark shutdown as started immediately
1196
+ *
1197
+ * Call this at the very beginning of the shutdown sequence so that:
1198
+ * - Health check returns 503 right away
1199
+ * - trackOperation() rejects new work
1200
+ * - isShuttingDown() returns true
1201
+ */
1202
+ beginShutdown() {
1203
+ if (this.state !== "running") {
1204
+ return;
1205
+ }
1206
+ this.state = "draining";
1207
+ serverLogger.info("Shutdown manager: state set to draining");
1208
+ }
1209
+ /**
1210
+ * Execute the full shutdown sequence
1211
+ *
1212
+ * 1. State → draining (reject new operations)
1213
+ * 2. Wait for all tracked operations to complete (drain)
1214
+ * 3. Run shutdown hooks in order
1215
+ * 4. State → closed
1216
+ *
1217
+ * @param drainTimeout - Max time to wait for operations to drain (ms)
1218
+ */
1219
+ async execute(drainTimeout) {
1220
+ if (this.state === "closed") {
1221
+ serverLogger.warn("ShutdownManager.execute() called but already closed");
1222
+ return;
1223
+ }
1224
+ this.state = "draining";
1225
+ serverLogger.info("Shutdown manager: draining started", {
1226
+ activeOperations: this.operations.size,
1227
+ registeredHooks: this.hooks.length,
1228
+ drainTimeout: `${drainTimeout}ms`
1229
+ });
1230
+ await this.drain(drainTimeout);
1231
+ await this.executeHooks();
1232
+ this.state = "closed";
1233
+ serverLogger.info("Shutdown manager: all hooks executed");
1234
+ }
1235
+ // ========================================================================
1236
+ // Private
1237
+ // ========================================================================
1238
+ /**
1239
+ * Wait for all tracked operations to complete, up to drainTimeout
1240
+ */
1241
+ async drain(drainTimeout) {
1242
+ if (this.operations.size === 0) {
1243
+ serverLogger.info("Shutdown manager: no active operations, drain skipped");
1244
+ return;
1245
+ }
1246
+ serverLogger.info(`Shutdown manager: waiting for ${this.operations.size} operations to drain...`);
1247
+ const deadline = Date.now() + drainTimeout;
1248
+ while (this.operations.size > 0 && Date.now() < deadline) {
1249
+ const remaining = deadline - Date.now();
1250
+ const ops = Array.from(this.operations.values()).map((op) => ({
1251
+ name: op.name,
1252
+ elapsed: `${Math.round((Date.now() - op.startedAt) / 1e3)}s`
1253
+ }));
1254
+ serverLogger.info("Shutdown manager: drain in progress", {
1255
+ activeOperations: this.operations.size,
1256
+ remainingTimeout: `${Math.round(remaining / 1e3)}s`,
1257
+ operations: ops
1258
+ });
1259
+ await sleep(Math.min(DRAIN_POLL_INTERVAL, remaining));
1260
+ }
1261
+ if (this.operations.size > 0) {
1262
+ const abandoned = Array.from(this.operations.values()).map((op) => op.name);
1263
+ serverLogger.warn("Shutdown manager: drain timeout \u2014 abandoning operations", {
1264
+ abandoned
1265
+ });
1266
+ } else {
1267
+ serverLogger.info("Shutdown manager: all operations drained successfully");
1268
+ }
1269
+ }
1270
+ /**
1271
+ * Execute registered shutdown hooks in order
1272
+ */
1273
+ async executeHooks() {
1274
+ if (this.hooks.length === 0) {
1275
+ return;
1276
+ }
1277
+ serverLogger.info(`Shutdown manager: executing ${this.hooks.length} hooks...`);
1278
+ for (const hook of this.hooks) {
1279
+ serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
1280
+ try {
1281
+ await withTimeout2(
1282
+ hook.handler(),
1283
+ hook.timeout,
1284
+ `Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
1285
+ );
1286
+ serverLogger.info(`Shutdown hook [${hook.name}] completed`);
1287
+ } catch (error) {
1288
+ serverLogger.error(
1289
+ `Shutdown hook [${hook.name}] failed`,
1290
+ error
1291
+ );
1292
+ }
1293
+ }
1294
+ }
1295
+ };
1296
+ var instance = null;
1297
+ function getShutdownManager() {
1298
+ if (!instance) {
1299
+ instance = new ShutdownManager();
1300
+ }
1301
+ return instance;
1302
+ }
1303
+ function resetShutdownManager() {
1304
+ instance = null;
1305
+ }
1306
+ function sleep(ms) {
1307
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1308
+ }
1309
+ async function withTimeout2(promise, timeout, message) {
1310
+ let timeoutId;
1311
+ return Promise.race([
1312
+ promise.finally(() => {
1313
+ if (timeoutId) clearTimeout(timeoutId);
1314
+ }),
1315
+ new Promise((_, reject) => {
1316
+ timeoutId = setTimeout(() => {
1317
+ reject(new Error(message));
1318
+ }, timeout);
1319
+ })
1320
+ ]);
1321
+ }
1322
+
1323
+ // src/server/helpers.ts
1324
+ async function readMigrationHealth() {
1325
+ const snapshot = await getMigrationSnapshot();
1326
+ if (snapshot.state !== "ok") {
1327
+ return {
1328
+ status: "unknown",
1329
+ pending: 0,
1330
+ checkedAt: snapshot.checkedAt,
1331
+ targets: [],
1332
+ reason: snapshot.reason
1333
+ };
1334
+ }
1335
+ return {
1336
+ status: snapshot.pending > 0 ? "pending" : "up_to_date",
1337
+ pending: snapshot.pending,
1338
+ checkedAt: snapshot.checkedAt,
1339
+ targets: migrationTargets(snapshot.status)
1340
+ };
1341
+ }
1342
+ function createHealthCheckHandler(detailed, infrastructure) {
420
1343
  return async (c) => {
1344
+ const shutdownManager = getShutdownManager();
1345
+ if (shutdownManager.isShuttingDown()) {
1346
+ return c.json({
1347
+ status: "shutting_down",
1348
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1349
+ }, 503);
1350
+ }
421
1351
  const response = {
422
1352
  status: "ok",
423
1353
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
424
1354
  };
425
1355
  if (detailed) {
1356
+ const databaseDisabled = infrastructure?.database === false;
1357
+ const redisDisabled = infrastructure?.redis === false;
426
1358
  let dbStatus = "unknown";
427
1359
  let dbError;
428
- try {
429
- const db = getDatabase();
1360
+ if (databaseDisabled) {
1361
+ dbStatus = "disabled";
1362
+ } else {
430
1363
  try {
431
- await db.execute("SELECT 1");
432
- dbStatus = "connected";
1364
+ const db = getDatabase();
1365
+ try {
1366
+ await db.execute("SELECT 1");
1367
+ dbStatus = "connected";
1368
+ } catch (error) {
1369
+ dbStatus = "error";
1370
+ dbError = error instanceof Error ? error.message : String(error);
1371
+ }
433
1372
  } catch (error) {
434
- dbStatus = "error";
435
- dbError = error instanceof Error ? error.message : String(error);
1373
+ dbStatus = "not_initialized";
1374
+ dbError = "Database not available";
436
1375
  }
437
- } catch (error) {
438
- dbStatus = "not_initialized";
439
- dbError = "Database not available";
440
1376
  }
441
- const redis = getCache();
442
- let redisStatus = redis ? "unknown" : "not_initialized";
1377
+ const redis = redisDisabled ? null : getCache();
1378
+ let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
443
1379
  let redisError;
444
1380
  if (redis) {
445
1381
  try {
@@ -462,11 +1398,32 @@ function createHealthCheckHandler(detailed) {
462
1398
  };
463
1399
  const hasErrors = dbStatus === "error" || dbStatus === "not_initialized" || redisStatus === "error";
464
1400
  response.status = hasErrors ? "degraded" : "ok";
1401
+ response.migrations = await readMigrationHealth();
465
1402
  }
466
1403
  const statusCode = response.status === "ok" ? 200 : 503;
467
1404
  return c.json(response, statusCode);
468
1405
  };
469
1406
  }
1407
+ function resolveEndpointMiddlewares(config) {
1408
+ const candidates = [
1409
+ ...config.middlewares ?? [],
1410
+ ...config.routes?._globalMiddlewares ?? []
1411
+ ];
1412
+ for (const pkgRouter of config.routes?._packageRouters ?? []) {
1413
+ candidates.push(...pkgRouter._globalMiddlewares ?? []);
1414
+ }
1415
+ const seen = /* @__PURE__ */ new Set();
1416
+ return candidates.filter((mw) => {
1417
+ if (!mw.name) {
1418
+ return true;
1419
+ }
1420
+ if (seen.has(mw.name)) {
1421
+ return false;
1422
+ }
1423
+ seen.add(mw.name);
1424
+ return true;
1425
+ });
1426
+ }
470
1427
  function applyServerTimeouts(server, timeouts) {
471
1428
  if ("timeout" in server) {
472
1429
  server.timeout = timeouts.request;
@@ -474,52 +1431,68 @@ function applyServerTimeouts(server, timeouts) {
474
1431
  server.headersTimeout = timeouts.headers;
475
1432
  }
476
1433
  }
477
- function getTimeoutConfig(config2) {
1434
+ function getTimeoutConfig(config) {
478
1435
  return {
479
- request: config2?.request ?? env.SERVER_TIMEOUT,
480
- keepAlive: config2?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
481
- headers: config2?.headers ?? env.SERVER_HEADERS_TIMEOUT
1436
+ request: config?.request ?? env.SERVER_TIMEOUT,
1437
+ keepAlive: config?.keepAlive ?? env.SERVER_KEEPALIVE_TIMEOUT,
1438
+ headers: config?.headers ?? env.SERVER_HEADERS_TIMEOUT
482
1439
  };
483
1440
  }
484
- function getShutdownTimeout(config2) {
485
- return config2?.timeout ?? env.SHUTDOWN_TIMEOUT;
1441
+ function getShutdownTimeout(config) {
1442
+ return config?.timeout ?? env.SHUTDOWN_TIMEOUT;
486
1443
  }
487
- function buildMiddlewareOrder(config2) {
1444
+ function getFetchTimeoutConfig(config) {
1445
+ return {
1446
+ connect: config?.connect ?? env.FETCH_CONNECT_TIMEOUT,
1447
+ headers: config?.headers ?? env.FETCH_HEADERS_TIMEOUT,
1448
+ body: config?.body ?? env.FETCH_BODY_TIMEOUT
1449
+ };
1450
+ }
1451
+ function applyGlobalFetchTimeouts(timeouts) {
1452
+ const agent = new Agent({
1453
+ connect: { timeout: timeouts.connect },
1454
+ headersTimeout: timeouts.headers,
1455
+ bodyTimeout: timeouts.body
1456
+ });
1457
+ setGlobalDispatcher(agent);
1458
+ }
1459
+ function buildMiddlewareOrder(config) {
488
1460
  const order = [];
489
- const middlewareConfig = config2.middleware ?? {};
1461
+ const middlewareConfig = config.middleware ?? {};
490
1462
  const enableLogger = middlewareConfig.logger !== false;
491
1463
  const enableCors = middlewareConfig.cors !== false;
492
1464
  const enableErrorHandler = middlewareConfig.errorHandler !== false;
493
1465
  if (enableLogger) order.push("RequestLogger");
494
1466
  if (enableCors) order.push("CORS");
495
- config2.use?.forEach((_, i) => order.push(`Custom[${i}]`));
496
- if (config2.beforeRoutes) order.push("beforeRoutes hook");
1467
+ config.use?.forEach((_, i) => order.push(`Custom[${i}]`));
1468
+ if (config.beforeRoutes) order.push("beforeRoutes hook");
497
1469
  order.push("Routes");
498
- if (config2.afterRoutes) order.push("afterRoutes hook");
1470
+ if (config.afterRoutes) order.push("afterRoutes hook");
499
1471
  if (enableErrorHandler) order.push("ErrorHandler");
500
1472
  return order;
501
1473
  }
502
- function buildStartupConfig(config2, timeouts) {
503
- const middlewareConfig = config2.middleware ?? {};
504
- const healthCheckConfig = config2.healthCheck ?? {};
1474
+ function buildStartupConfig(config, timeouts) {
1475
+ const middlewareConfig = config.middleware ?? {};
1476
+ const healthCheckConfig = config.healthCheck ?? {};
505
1477
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
506
- const healthCheckPath = healthCheckConfig.path ?? "/health";
1478
+ const healthCheckPath = healthCheckConfig.path;
507
1479
  const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
508
1480
  return {
509
1481
  middleware: {
510
1482
  logger: middlewareConfig.logger !== false,
511
1483
  cors: middlewareConfig.cors !== false,
512
1484
  errorHandler: middlewareConfig.errorHandler !== false,
513
- custom: config2.use?.length ?? 0
1485
+ custom: config.use?.length ?? 0
514
1486
  },
515
1487
  healthCheck: healthCheckEnabled ? {
516
1488
  enabled: true,
1489
+ corePath: CORE_HEALTH_PATH,
517
1490
  path: healthCheckPath,
518
1491
  detailed: healthCheckDetailed
519
1492
  } : { enabled: false },
520
1493
  hooks: {
521
- beforeRoutes: !!config2.beforeRoutes,
522
- afterRoutes: !!config2.afterRoutes
1494
+ beforeRoutes: !!config.beforeRoutes,
1495
+ afterRoutes: !!config.afterRoutes
523
1496
  },
524
1497
  timeout: {
525
1498
  request: `${timeouts.request}ms`,
@@ -527,23 +1500,396 @@ function buildStartupConfig(config2, timeouts) {
527
1500
  headers: `${timeouts.headers}ms`
528
1501
  },
529
1502
  shutdown: {
530
- timeout: `${config2.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
1503
+ timeout: `${config.shutdown?.timeout ?? env.SHUTDOWN_TIMEOUT}ms`
531
1504
  }
532
1505
  };
533
1506
  }
534
- var serverLogger = logger.child("@spfn/core:server");
1507
+
1508
+ // src/route/route-builder.ts
1509
+ var RouteBuilder = class _RouteBuilder {
1510
+ _method;
1511
+ _path;
1512
+ _input;
1513
+ _interceptor;
1514
+ _middlewares;
1515
+ _skipMiddlewares;
1516
+ _contract;
1517
+ /**
1518
+ * Create a new RouteBuilder with copied properties and optional overrides
1519
+ */
1520
+ clone(overrides) {
1521
+ const builder = new _RouteBuilder();
1522
+ builder._method = this._method;
1523
+ builder._path = this._path;
1524
+ builder._input = overrides?.input ?? this._input;
1525
+ builder._interceptor = overrides?.interceptor ?? this._interceptor;
1526
+ builder._middlewares = overrides?.middlewares ?? this._middlewares;
1527
+ builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
1528
+ builder._contract = overrides?.contract ?? this._contract;
1529
+ return builder;
1530
+ }
1531
+ /**
1532
+ * Define input schemas
1533
+ *
1534
+ * @example
1535
+ * ```ts
1536
+ * route.get('/users/:id')
1537
+ * .input({
1538
+ * params: Type.Object({ id: Type.String() }),
1539
+ * query: Type.Object({ page: Type.Number() }),
1540
+ * headers: Type.Object({ authorization: Type.String() })
1541
+ * })
1542
+ * .handler(async (c) => {
1543
+ * const { params, query, headers } = await c.data();
1544
+ * // params = { id: string }
1545
+ * // query = { page: number }
1546
+ * // headers = { authorization: string }
1547
+ * })
1548
+ * ```
1549
+ */
1550
+ input(input) {
1551
+ return this.clone({ input });
1552
+ }
1553
+ /**
1554
+ * Define fields injected by interceptors
1555
+ *
1556
+ * These fields are:
1557
+ * - Available in the handler (merged with input)
1558
+ * - Excluded from client types (codegen uses only input)
1559
+ * - Not validated by route input schema (injected by middleware)
1560
+ *
1561
+ * Use this when middleware/interceptors add fields to the request
1562
+ * before it reaches the handler.
1563
+ *
1564
+ * @example
1565
+ * ```ts
1566
+ * // Auth interceptor injects crypto key fields
1567
+ * route.post('/_auth/login')
1568
+ * .input({
1569
+ * body: Type.Object({
1570
+ * email: Type.String(),
1571
+ * password: Type.String()
1572
+ * })
1573
+ * })
1574
+ * .interceptor({
1575
+ * body: Type.Object({
1576
+ * publicKey: Type.String(),
1577
+ * keyId: Type.String(),
1578
+ * fingerprint: Type.String()
1579
+ * })
1580
+ * })
1581
+ * .handler(async (c) => {
1582
+ * const { body } = await c.data();
1583
+ * // body type: { email, password, publicKey, keyId, fingerprint }
1584
+ * // Client only sees: { email, password }
1585
+ * return loginService(body);
1586
+ * });
1587
+ * ```
1588
+ */
1589
+ interceptor(interceptor) {
1590
+ return this.clone({ interceptor });
1591
+ }
1592
+ /**
1593
+ * Add middlewares to the route
1594
+ *
1595
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1596
+ * Named middlewares that are already registered globally will be automatically
1597
+ * deduplicated to prevent double execution.
1598
+ *
1599
+ * @example
1600
+ * ```ts
1601
+ * import { authenticate } from '@spfn/auth/server/middleware';
1602
+ *
1603
+ * // With NamedMiddleware (auto-deduped if registered globally)
1604
+ * route.get('/users')
1605
+ * .use([authenticate, RateLimitMiddleware()])
1606
+ *
1607
+ * // With regular middleware handlers
1608
+ * route.get('/users')
1609
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1610
+ * ```
1611
+ */
1612
+ middleware(middlewares) {
1613
+ return this.clone({ middlewares });
1614
+ }
1615
+ /**
1616
+ * Add middlewares to the route (alias for `.middleware()`)
1617
+ *
1618
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1619
+ * Named middlewares that are already registered globally will be automatically
1620
+ * deduplicated to prevent double execution.
1621
+ *
1622
+ * @example
1623
+ * ```ts
1624
+ * import { authenticate } from '@spfn/auth/server/middleware';
1625
+ *
1626
+ * // With NamedMiddleware (auto-deduped if registered globally)
1627
+ * route.get('/users')
1628
+ * .use([authenticate, RateLimitMiddleware()])
1629
+ *
1630
+ * // With regular middleware handlers
1631
+ * route.get('/users')
1632
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1633
+ * ```
1634
+ */
1635
+ use(middlewares) {
1636
+ return this.middleware(middlewares);
1637
+ }
1638
+ /**
1639
+ * Skip server-level named middlewares
1640
+ *
1641
+ * Useful for public endpoints that should bypass auth or rate limiting
1642
+ *
1643
+ * @param middlewareNames - Array of middleware names to skip, or '*' to skip all
1644
+ *
1645
+ * @example
1646
+ * ```ts
1647
+ * // Skip specific middlewares
1648
+ * route.get('/status')
1649
+ * .skip(['auth', 'rateLimit'])
1650
+ * .handler(async (c) => c.json({ status: 'ok' }));
1651
+ *
1652
+ * // Skip only auth (still apply rate limiting)
1653
+ * route.get('/public-data')
1654
+ * .skip(['auth'])
1655
+ * .handler(async (c) => { ... });
1656
+ *
1657
+ * // Skip all middlewares
1658
+ * route.get('/public-health')
1659
+ * .skip('*')
1660
+ * .handler(async (c) => c.json({ status: 'ok' }));
1661
+ * ```
1662
+ */
1663
+ skip(middlewareNames) {
1664
+ return this.clone({ skipMiddlewares: middlewareNames });
1665
+ }
1666
+ /**
1667
+ * Publish this route as a versioned contract operation
1668
+ *
1669
+ * Marks the route as a promise to clients that are compiled and deployed
1670
+ * separately from the server — a mobile app, an external API consumer.
1671
+ * The `@spfn/core:contract` generator writes every contracted route into
1672
+ * `contracts/current.json`, and the build refuses a change that would break
1673
+ * an already-released client.
1674
+ *
1675
+ * Routes without `.contract()` are unaffected: they simply do not appear in
1676
+ * the contract. A web client needs nothing here — it derives its types from
1677
+ * the router in the same build.
1678
+ *
1679
+ * @example
1680
+ * ```ts
1681
+ * export const getUser = route.get('/users/:id')
1682
+ * .input({ params: Type.Object({ id: Type.String() }) })
1683
+ * .contract({
1684
+ * since: '1.2.0',
1685
+ * auth: 'clientProofV1',
1686
+ * requiresSession: true,
1687
+ * response: Type.Object({
1688
+ * id: Type.String(),
1689
+ * name: Type.String(),
1690
+ * email: Type.Optional(Type.String()),
1691
+ * }),
1692
+ * })
1693
+ * .handler(async (c) => { ... });
1694
+ * ```
1695
+ */
1696
+ contract(contract) {
1697
+ return this.clone({ contract });
1698
+ }
1699
+ /**
1700
+ * Define handler function
1701
+ *
1702
+ * Response type is automatically inferred from the return value.
1703
+ * Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
1704
+ *
1705
+ * @example
1706
+ * ```ts
1707
+ * // Direct return - type inferred from data
1708
+ * route.get('/users/:id')
1709
+ * .input({ params: Type.Object({ id: Type.String() }) })
1710
+ * .handler(async (c) => {
1711
+ * const { params } = await c.data();
1712
+ * return await getUser(params.id); // Type: User
1713
+ * })
1714
+ *
1715
+ * // Using c.created() - returns data with 201 status, type preserved
1716
+ * route.post('/users')
1717
+ * .input({ body: Type.Object({ name: Type.String() }) })
1718
+ * .handler(async (c) => {
1719
+ * const { body } = await c.data();
1720
+ * return c.created(await createUser(body)); // Type: User
1721
+ * })
1722
+ *
1723
+ * // Using c.paginated() - returns PaginatedResult<T>
1724
+ * route.get('/users')
1725
+ * .handler(async (c) => {
1726
+ * const users = await getUsers();
1727
+ * return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
1728
+ * })
1729
+ *
1730
+ * // Using c.noContent() - returns void
1731
+ * route.delete('/users/:id')
1732
+ * .handler(async (c) => {
1733
+ * await deleteUser(params.id);
1734
+ * return c.noContent(); // Type: void
1735
+ * })
1736
+ *
1737
+ * // Using c.json() - returns Response (type inference lost)
1738
+ * // Use only when you need custom status codes not covered by helpers
1739
+ * route.get('/custom')
1740
+ * .handler(async (c) => {
1741
+ * return c.json({ data }, 418); // Type: Response
1742
+ * })
1743
+ * ```
1744
+ */
1745
+ handler(fn) {
1746
+ return {
1747
+ method: this._method,
1748
+ path: this._path,
1749
+ input: this._input,
1750
+ interceptor: this._interceptor,
1751
+ middlewares: this._middlewares,
1752
+ skipMiddlewares: this._skipMiddlewares,
1753
+ contract: this._contract,
1754
+ handler: fn,
1755
+ _input: {},
1756
+ _interceptor: {},
1757
+ _response: {}
1758
+ };
1759
+ }
1760
+ };
1761
+ function createMethodRoute(method) {
1762
+ return (path) => {
1763
+ const builder = new RouteBuilder();
1764
+ builder._method = method;
1765
+ builder._path = path;
1766
+ return builder;
1767
+ };
1768
+ }
1769
+ var route = {
1770
+ get: createMethodRoute("GET"),
1771
+ post: createMethodRoute("POST"),
1772
+ put: createMethodRoute("PUT"),
1773
+ patch: createMethodRoute("PATCH"),
1774
+ delete: createMethodRoute("DELETE")
1775
+ };
1776
+
1777
+ // src/route/router.ts
1778
+ function createRouterInstance(routes, packageRouters = [], globalMiddlewares = [], contractVersion = null) {
1779
+ return {
1780
+ routes,
1781
+ _routes: routes,
1782
+ _packageRouters: packageRouters,
1783
+ _globalMiddlewares: globalMiddlewares,
1784
+ _contractVersion: contractVersion,
1785
+ packages(routers) {
1786
+ const newPackageRouters = [...this._packageRouters, ...routers];
1787
+ for (const pkgRouter of routers) {
1788
+ if (pkgRouter._packageRouters?.length > 0) {
1789
+ newPackageRouters.push(...pkgRouter._packageRouters);
1790
+ }
1791
+ }
1792
+ return createRouterInstance(
1793
+ this.routes,
1794
+ newPackageRouters,
1795
+ this._globalMiddlewares,
1796
+ this._contractVersion
1797
+ );
1798
+ },
1799
+ use(middlewares) {
1800
+ return createRouterInstance(
1801
+ this.routes,
1802
+ this._packageRouters,
1803
+ [...this._globalMiddlewares, ...middlewares],
1804
+ this._contractVersion
1805
+ );
1806
+ },
1807
+ contractVersion(version) {
1808
+ assertContractVersion(version);
1809
+ return createRouterInstance(
1810
+ this.routes,
1811
+ this._packageRouters,
1812
+ this._globalMiddlewares,
1813
+ version
1814
+ );
1815
+ }
1816
+ };
1817
+ }
1818
+ function assertContractVersion(version) {
1819
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
1820
+ throw new Error(
1821
+ `contractVersion("${version}") is not a version of the form major.minor.patch. The released snapshot is named from this value and releases are compared by it.`
1822
+ );
1823
+ }
1824
+ }
1825
+ function defineRouter(routes) {
1826
+ return createRouterInstance(routes);
1827
+ }
1828
+
1829
+ // src/route/validation.ts
1830
+ FormatRegistry.Set(
1831
+ "email",
1832
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
1833
+ );
1834
+ FormatRegistry.Set(
1835
+ "uri",
1836
+ (value) => /^https?:\/\/.+/.test(value)
1837
+ );
1838
+ FormatRegistry.Set(
1839
+ "uuid",
1840
+ (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
1841
+ );
1842
+ FormatRegistry.Set(
1843
+ "date",
1844
+ (value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
1845
+ );
1846
+ FormatRegistry.Set(
1847
+ "date-time",
1848
+ (value) => !isNaN(Date.parse(value))
1849
+ );
1850
+
1851
+ // src/server/server-time.ts
1852
+ var CORE_TIME_OPERATION_ID = "core.time";
1853
+ var ServerTimeResponseSchema = Type.Object({
1854
+ serverTimeMillis: Type.Integer()
1855
+ }, { additionalProperties: false });
1856
+ var systemServerClock = {
1857
+ now: () => Date.now()
1858
+ };
1859
+ function createCoreTimeRoute(clock = systemServerClock) {
1860
+ return route.get(CORE_TIME_PATH).skip("*").contract({
1861
+ since: "0.3.0",
1862
+ auth: "none",
1863
+ requiresSession: false,
1864
+ response: ServerTimeResponseSchema
1865
+ }).handler(async (c) => {
1866
+ const response = {
1867
+ serverTimeMillis: clock.now()
1868
+ };
1869
+ c.raw.header("Cache-Control", "no-store");
1870
+ return response;
1871
+ });
1872
+ }
1873
+ var CORE_TIME_ROUTE = createCoreTimeRoute();
1874
+ function createCoreTimeRouter(clock) {
1875
+ return defineRouter({
1876
+ [CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
1877
+ });
1878
+ }
535
1879
 
536
1880
  // src/server/create-server.ts
537
- async function createServer(config2) {
1881
+ var rateLimitApplied = /* @__PURE__ */ new WeakSet();
1882
+ async function createServer(config) {
1883
+ applyOutboundFetch(config);
538
1884
  const cwd = process.cwd();
539
1885
  const appPath = join(cwd, "src", "server", "app.ts");
540
1886
  const appJsPath = join(cwd, "src", "server", "app");
541
1887
  if (existsSync(appPath) || existsSync(appJsPath)) {
542
- return await loadCustomApp(appPath, appJsPath, config2);
1888
+ return await loadCustomApp(appPath, appJsPath, config);
543
1889
  }
544
- return await createAutoConfiguredApp(config2);
1890
+ return await createAutoConfiguredApp(config);
545
1891
  }
546
- async function loadCustomApp(appPath, appJsPath, config2) {
1892
+ async function loadCustomApp(appPath, appJsPath, config) {
547
1893
  const actualPath = existsSync(appPath) ? appPath : appJsPath;
548
1894
  const appModule = await import(actualPath);
549
1895
  const appFactory = appModule.default;
@@ -551,15 +1897,16 @@ async function loadCustomApp(appPath, appJsPath, config2) {
551
1897
  throw new Error("app.ts must export a default function that returns a Hono app");
552
1898
  }
553
1899
  const app = await appFactory();
554
- if (config2?.routes) {
555
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
556
- logRegisteredRoutes(routes, config2?.debug ?? false);
1900
+ applyRateLimit(config);
1901
+ if (config?.routes) {
1902
+ const routes = registerRoutes(app, config.routes, config.middlewares);
1903
+ logRegisteredRoutes(routes, config?.debug ?? false);
557
1904
  }
558
1905
  return app;
559
1906
  }
560
- async function createAutoConfiguredApp(config2) {
1907
+ async function createAutoConfiguredApp(config) {
561
1908
  const app = new Hono();
562
- const middlewareConfig = config2?.middleware ?? {};
1909
+ const middlewareConfig = config?.middleware ?? {};
563
1910
  const enableLogger = middlewareConfig.logger !== false;
564
1911
  const enableCors = middlewareConfig.cors !== false;
565
1912
  const enableErrorHandler = middlewareConfig.errorHandler !== false;
@@ -569,52 +1916,195 @@ async function createAutoConfiguredApp(config2) {
569
1916
  await next();
570
1917
  });
571
1918
  }
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);
1919
+ applyDefaultMiddleware(app, config, enableLogger, enableCors);
1920
+ await applyProxyGuard(app, config);
1921
+ applyRateLimit(config);
1922
+ registerCoreTimeEndpoint(app, config);
1923
+ if (Array.isArray(config?.use)) {
1924
+ config.use.forEach((mw) => app.use("*", mw));
1925
+ }
1926
+ registerCoreHealthEndpoint(app, config);
1927
+ await executeBeforeRoutesHook(app, config);
1928
+ const appRoutes = await loadAppRoutes(app, config);
1929
+ registerMovedHealthSignpost(app, config, appRoutes);
1930
+ warnOnShadowedOptInPath(config, appRoutes);
1931
+ warnOnCoreNamespaceRoutes(appRoutes);
1932
+ await registerSSEEndpoint(app, config);
1933
+ await executeAfterRoutesHook(app, config);
581
1934
  if (enableErrorHandler) {
582
- app.onError(ErrorHandler());
1935
+ app.onError(ErrorHandler({ onError: config?.middleware?.onError }));
583
1936
  }
584
1937
  return app;
585
1938
  }
586
- function applyDefaultMiddleware(app, config2, enableLogger, enableCors) {
1939
+ function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
587
1940
  if (enableLogger) {
588
1941
  app.use("*", RequestLogger());
589
1942
  }
590
1943
  if (enableCors) {
591
- const corsOptions = config2?.cors !== false ? config2?.cors : void 0;
1944
+ const corsOptions = config?.cors !== false ? config?.cors : void 0;
592
1945
  app.use("*", cors(corsOptions));
593
1946
  }
594
1947
  }
595
- function registerHealthCheckEndpoint(app, config2) {
596
- const healthCheckConfig = config2?.healthCheck ?? {};
597
- const healthCheckEnabled = healthCheckConfig.enabled !== false;
598
- const healthCheckPath = healthCheckConfig.path ?? "/health";
599
- const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
600
- if (healthCheckEnabled) {
601
- app.get(healthCheckPath, createHealthCheckHandler(healthCheckDetailed));
602
- serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
1948
+ async function applyProxyGuard(app, config) {
1949
+ const proxyGuardConfig = config?.proxyGuard;
1950
+ const mode = proxyGuardConfig?.mode ?? "off";
1951
+ if (mode === "off") {
1952
+ return;
1953
+ }
1954
+ const { createProxyGuard, createCacheNonceStore, createInMemoryNonceStore } = await import('@spfn/core/middleware');
1955
+ let nonceStore;
1956
+ if (proxyGuardConfig?.nonce) {
1957
+ try {
1958
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
1959
+ const cache2 = getCache2();
1960
+ if (cache2) {
1961
+ nonceStore = createCacheNonceStore(cache2);
1962
+ serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
1963
+ } else {
1964
+ nonceStore = createInMemoryNonceStore();
1965
+ serverLogger.info("Proxy-guard nonce replay rejection: in-memory (single instance only \u2014 use a cache for multi-instance)");
1966
+ }
1967
+ } catch {
1968
+ nonceStore = createInMemoryNonceStore();
1969
+ serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1970
+ }
1971
+ }
1972
+ const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
1973
+ if (config?.healthCheck?.path) {
1974
+ autoSkip.push(config.healthCheck.path);
1975
+ }
1976
+ if (config?.events) {
1977
+ autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
1978
+ }
1979
+ if (config?.websockets) {
1980
+ autoSkip.push(config.websocketsConfig?.path ?? "/ws");
1981
+ }
1982
+ const skipPaths = [...proxyGuardConfig?.skipPaths ?? [], ...autoSkip];
1983
+ app.use("*", createProxyGuard({
1984
+ mode,
1985
+ secret: proxyGuardConfig?.secret,
1986
+ previousSecrets: proxyGuardConfig?.previousSecrets,
1987
+ windowMs: proxyGuardConfig?.windowMs,
1988
+ allowedOrigins: proxyGuardConfig?.allowedOrigins,
1989
+ maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
1990
+ nonceStore,
1991
+ nonceFailClosed: proxyGuardConfig?.nonceFailClosed,
1992
+ skipPaths
1993
+ }));
1994
+ serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
1995
+ }
1996
+ function applyRateLimit(config) {
1997
+ const rl = config?.rateLimit;
1998
+ setRateLimitPolicies(rl?.policies);
1999
+ setRateLimitFailClosedDefault(env.RATE_LIMIT_FAIL_CLOSED);
2000
+ const enabled = (rl?.mode ?? env.RATE_LIMIT_MODE) === "on";
2001
+ if (!enabled || !config || rateLimitApplied.has(config)) {
2002
+ return;
2003
+ }
2004
+ rateLimitApplied.add(config);
2005
+ const defaultPolicy = {
2006
+ limit: rl?.default?.limit ?? env.RATE_LIMIT_DEFAULT_LIMIT,
2007
+ windowMs: rl?.default?.windowMs ?? env.RATE_LIMIT_DEFAULT_WINDOW_MS,
2008
+ failClosed: rl?.default?.failClosed ?? env.RATE_LIMIT_FAIL_CLOSED
2009
+ };
2010
+ const globalRateLimit = defineMiddleware("rateLimit", rateLimit(defaultPolicy));
2011
+ config.middlewares = [globalRateLimit, ...config.middlewares ?? []];
2012
+ serverLogger.info(`\u2713 Rate limit default enabled (${defaultPolicy.limit} per ${defaultPolicy.windowMs}ms)`);
2013
+ }
2014
+ function applyOutboundFetch(config) {
2015
+ const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
2016
+ setDefaultSafeFetchPolicy(policy);
2017
+ }
2018
+ function resolveHealthCheck(config) {
2019
+ const healthCheckConfig = config?.healthCheck ?? {};
2020
+ return {
2021
+ enabled: healthCheckConfig.enabled !== false,
2022
+ // No default. An unset `path` means the endpoint answers at
2023
+ // CORE_HEALTH_PATH and nowhere else — a default here is what used to put
2024
+ // it on /health without anyone asking.
2025
+ path: healthCheckConfig.path,
2026
+ detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
2027
+ };
2028
+ }
2029
+ function registerCoreTimeEndpoint(app, config) {
2030
+ registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
2031
+ serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
2032
+ }
2033
+ function registerCoreHealthEndpoint(app, config) {
2034
+ const { enabled, path, detailed } = resolveHealthCheck(config);
2035
+ if (!enabled) {
2036
+ return;
2037
+ }
2038
+ const handler = createHealthCheckHandler(detailed, config?.infrastructure);
2039
+ app.get(CORE_HEALTH_PATH, handler);
2040
+ serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
2041
+ if (path && path !== CORE_HEALTH_PATH) {
2042
+ app.get(path, handler);
2043
+ serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
2044
+ }
2045
+ }
2046
+ function registerMovedHealthSignpost(app, config, appRoutes) {
2047
+ const { enabled, path } = resolveHealthCheck(config);
2048
+ if (!enabled || path === LEGACY_HEALTH_PATH) {
2049
+ return;
603
2050
  }
2051
+ if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
2052
+ return;
2053
+ }
2054
+ let warned2 = false;
2055
+ app.get(LEGACY_HEALTH_PATH, (c) => {
2056
+ if (!warned2) {
2057
+ warned2 = true;
2058
+ serverLogger.warn(
2059
+ `\u26A0\uFE0F GET ${LEGACY_HEALTH_PATH} is answering 410: @spfn/core no longer serves it. Point your readiness probe, Dockerfile HEALTHCHECK and load balancer at ${CORE_HEALTH_PATH}, or restore this path with healthCheck({ path: '${LEGACY_HEALTH_PATH}' }). This notice is removed in the next release.`
2060
+ );
2061
+ }
2062
+ return c.json({
2063
+ error: "health endpoint moved",
2064
+ movedTo: CORE_HEALTH_PATH,
2065
+ detail: `@spfn/core no longer serves ${LEGACY_HEALTH_PATH}. Point your readiness probe, Dockerfile HEALTHCHECK and load balancer at ${CORE_HEALTH_PATH}, or restore this path with healthCheck({ path: '${LEGACY_HEALTH_PATH}' }).`
2066
+ }, 410);
2067
+ });
2068
+ }
2069
+ function warnOnShadowedOptInPath(config, appRoutes) {
2070
+ const { enabled, path } = resolveHealthCheck(config);
2071
+ if (!enabled || !path || path === CORE_HEALTH_PATH) {
2072
+ return;
2073
+ }
2074
+ const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
2075
+ if (shadowed.length === 0) {
2076
+ return;
2077
+ }
2078
+ serverLogger.warn(
2079
+ `\u26A0\uFE0F ${shadowed.map((r) => r.name).join(", ")} never runs: GET ${path} is served by the built-in health endpoint, which healthCheck({ path }) asked for and which is registered before app routes. Drop that option, or move the route to a path your app owns.`
2080
+ );
2081
+ }
2082
+ function warnOnCoreNamespaceRoutes(appRoutes) {
2083
+ const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
2084
+ if (inside.length === 0) {
2085
+ return;
2086
+ }
2087
+ const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
2088
+ serverLogger.warn(
2089
+ `\u26A0\uFE0F ${names} never runs: ${CORE_NAMESPACE}/ belongs to @spfn/core and its endpoints are registered before app routes. Move the route to a path your app owns.`
2090
+ );
604
2091
  }
605
- async function executeBeforeRoutesHook(app, config2) {
606
- if (config2?.lifecycle?.beforeRoutes) {
607
- await config2.lifecycle.beforeRoutes(app);
2092
+ async function executeBeforeRoutesHook(app, config) {
2093
+ if (config?.lifecycle?.beforeRoutes) {
2094
+ await config.lifecycle.beforeRoutes(app);
608
2095
  }
609
2096
  }
610
- async function loadAppRoutes(app, config2) {
611
- const debug = isDebugMode(config2);
612
- if (config2?.routes) {
613
- const routes = registerRoutes(app, config2.routes, config2.middlewares);
2097
+ async function loadAppRoutes(app, config) {
2098
+ const debug = isDebugMode(config);
2099
+ if (config?.routes) {
2100
+ const routes = registerRoutes(app, config.routes, config.middlewares);
614
2101
  logRegisteredRoutes(routes, debug);
615
- } else if (debug) {
2102
+ return routes;
2103
+ }
2104
+ if (debug) {
616
2105
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
617
2106
  }
2107
+ return [];
618
2108
  }
619
2109
  function logRegisteredRoutes(routes, debug) {
620
2110
  if (routes.length === 0) {
@@ -631,78 +2121,174 @@ function logRegisteredRoutes(routes, debug) {
631
2121
  serverLogger.info(`\u2713 Routes registered (${routes.length}):
632
2122
  ${routeLines}`);
633
2123
  }
634
- async function executeAfterRoutesHook(app, config2) {
635
- if (config2?.lifecycle?.afterRoutes) {
636
- await config2.lifecycle.afterRoutes(app);
2124
+ async function executeAfterRoutesHook(app, config) {
2125
+ if (config?.lifecycle?.afterRoutes) {
2126
+ await config.lifecycle.afterRoutes(app);
637
2127
  }
638
2128
  }
639
- function registerSSEEndpoint(app, config2) {
640
- if (!config2?.events) {
2129
+ async function registerSSEEndpoint(app, config) {
2130
+ if (!config?.events) {
641
2131
  return;
642
2132
  }
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));
2133
+ const eventsConfig = config.eventsConfig ?? {};
2134
+ const streamPath = eventsConfig.path ?? "/events/stream";
2135
+ const authConfig = eventsConfig.auth;
2136
+ const debug = isDebugMode(config);
2137
+ let tokenManager;
2138
+ if (authConfig?.enabled) {
2139
+ let store = authConfig.store;
2140
+ if (!store) {
2141
+ try {
2142
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
2143
+ const cache2 = getCache2();
2144
+ if (cache2) {
2145
+ store = new CacheTokenStore(cache2);
2146
+ if (debug) {
2147
+ serverLogger.info("SSE token store: cache (Redis/Valkey)");
2148
+ }
2149
+ }
2150
+ } catch {
2151
+ }
2152
+ }
2153
+ const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
2154
+ tokenManager = externalManager ?? new SSETokenManager({
2155
+ ttl: authConfig.tokenTtl,
2156
+ store
2157
+ });
2158
+ const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
2159
+ const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
2160
+ const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
2161
+ app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
2162
+ const subject = getSubject(c);
2163
+ if (!subject) {
2164
+ return c.json({ error: "Unable to identify subject" }, 401);
2165
+ }
2166
+ const token = await tokenManager.issue(subject);
2167
+ return c.json({ token });
2168
+ });
2169
+ if (debug) {
2170
+ serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
2171
+ }
2172
+ }
2173
+ const transport = await wireEventRouterCache(config.events, {
2174
+ multiInstance: eventsConfig.multiInstance,
2175
+ channelPrefix: eventsConfig.channelPrefix
2176
+ });
2177
+ app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
647
2178
  if (debug) {
648
- const eventNames = config2.events.eventNames;
649
- serverLogger.info(`\u2713 SSE endpoint registered at ${path}`, {
650
- events: eventNames
2179
+ const eventNames = config.events.eventNames;
2180
+ serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
2181
+ events: eventNames,
2182
+ auth: !!authConfig?.enabled,
2183
+ transport
651
2184
  });
652
2185
  }
653
2186
  }
654
- function isDebugMode(config2) {
655
- return config2?.debug ?? process.env.NODE_ENV === "development";
2187
+ function isDebugMode(config) {
2188
+ return config?.debug ?? process.env.NODE_ENV === "development";
656
2189
  }
657
2190
  var jobLogger = logger.child("@spfn/core:job");
658
- var bossInstance = null;
659
- var bossConfig = null;
2191
+ function requiresSSLWithoutVerification(connectionString) {
2192
+ try {
2193
+ const url = new URL(connectionString);
2194
+ const sslmode = url.searchParams.get("sslmode");
2195
+ return sslmode === "require" || sslmode === "prefer";
2196
+ } catch {
2197
+ return false;
2198
+ }
2199
+ }
2200
+ function stripSslModeFromUrl(connectionString) {
2201
+ const url = new URL(connectionString);
2202
+ url.searchParams.delete("sslmode");
2203
+ return url.toString();
2204
+ }
2205
+ var BOSS_KEY = Symbol.for("spfn:boss-instance");
2206
+ var CONFIG_KEY = Symbol.for("spfn:boss-config");
2207
+ var g = globalThis;
2208
+ function getBossInstance() {
2209
+ return g[BOSS_KEY] ?? null;
2210
+ }
2211
+ function setBossInstance(instance2) {
2212
+ g[BOSS_KEY] = instance2;
2213
+ }
2214
+ function getBossConfig() {
2215
+ return g[CONFIG_KEY] ?? null;
2216
+ }
2217
+ function setBossConfig(config) {
2218
+ g[CONFIG_KEY] = config;
2219
+ }
660
2220
  async function initBoss(options) {
661
- if (bossInstance) {
2221
+ const existing = getBossInstance();
2222
+ if (existing) {
662
2223
  jobLogger.warn("pg-boss already initialized, returning existing instance");
663
- return bossInstance;
2224
+ return existing;
664
2225
  }
665
2226
  jobLogger.info("Initializing pg-boss...");
666
- bossConfig = options;
2227
+ setBossConfig(options);
2228
+ const needsSSL = requiresSSLWithoutVerification(options.connectionString);
667
2229
  const pgBossOptions = {
668
- connectionString: options.connectionString,
2230
+ // pg 드라이버가 URL의 sslmode=require를 verify-full로 해석해서
2231
+ // ssl 옵션을 무시하므로, URL에서 sslmode를 빼고 ssl 객체만 전달
2232
+ connectionString: needsSSL ? stripSslModeFromUrl(options.connectionString) : options.connectionString,
669
2233
  schema: options.schema ?? "spfn_queue",
670
2234
  maintenanceIntervalSeconds: options.maintenanceIntervalSeconds ?? 120
671
2235
  };
2236
+ if (needsSSL) {
2237
+ pgBossOptions.ssl = { rejectUnauthorized: false };
2238
+ }
672
2239
  if (options.monitorIntervalSeconds !== void 0 && options.monitorIntervalSeconds >= 1) {
673
2240
  pgBossOptions.monitorIntervalSeconds = options.monitorIntervalSeconds;
674
2241
  }
675
- bossInstance = new PgBoss(pgBossOptions);
676
- bossInstance.on("error", (error) => {
2242
+ const boss = new PgBoss(pgBossOptions);
2243
+ boss.on("error", (error) => {
677
2244
  jobLogger.error("pg-boss error:", error);
678
2245
  });
679
- await bossInstance.start();
2246
+ await boss.start();
2247
+ setBossInstance(boss);
680
2248
  jobLogger.info("pg-boss started successfully");
681
- return bossInstance;
2249
+ return boss;
682
2250
  }
683
2251
  function getBoss() {
684
- return bossInstance;
2252
+ return getBossInstance();
685
2253
  }
686
2254
  async function stopBoss() {
687
- if (!bossInstance) {
2255
+ const boss = getBossInstance();
2256
+ if (!boss) {
688
2257
  return;
689
2258
  }
690
2259
  jobLogger.info("Stopping pg-boss...");
691
2260
  try {
692
- await bossInstance.stop({ graceful: true, timeout: 3e4 });
2261
+ await boss.stop({ graceful: true, timeout: 3e4 });
693
2262
  jobLogger.info("pg-boss stopped gracefully");
694
2263
  } catch (error) {
695
2264
  jobLogger.error("Error stopping pg-boss:", error);
696
2265
  throw error;
697
2266
  } finally {
698
- bossInstance = null;
699
- bossConfig = null;
2267
+ setBossInstance(null);
2268
+ setBossConfig(null);
700
2269
  }
701
2270
  }
702
2271
  function shouldClearOnStart() {
703
- return bossConfig?.clearOnStart ?? false;
2272
+ return getBossConfig()?.clearOnStart ?? false;
2273
+ }
2274
+ function shouldSweepOrphanSchedules() {
2275
+ return getBossConfig()?.sweepOrphanSchedules ?? false;
704
2276
  }
705
2277
 
2278
+ // src/job/queue-policy.ts
2279
+ function resolveQueuePolicy(job2) {
2280
+ if (job2.options?.policy) {
2281
+ return job2.options.policy;
2282
+ }
2283
+ if (job2.runOnce || job2.options?.singletonKey) {
2284
+ return "exclusive";
2285
+ }
2286
+ return "standard";
2287
+ }
2288
+
2289
+ // src/job/job-builder.ts
2290
+ logger.child("@spfn/core:job");
2291
+
706
2292
  // src/job/job-router.ts
707
2293
  function isJobDef(value) {
708
2294
  return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
@@ -710,19 +2296,38 @@ function isJobDef(value) {
710
2296
  function isJobRouter(value) {
711
2297
  return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
712
2298
  }
713
- function collectJobs(router, prefix = "") {
714
- const jobs = [];
2299
+ function defineJobRouter(jobs) {
2300
+ return {
2301
+ jobs,
2302
+ _jobs: jobs
2303
+ };
2304
+ }
2305
+ function mergeJobRouters(base, extra) {
2306
+ for (const key of Object.keys(extra.jobs)) {
2307
+ if (key in base.jobs) {
2308
+ throw new Error(
2309
+ `jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
2310
+ );
2311
+ }
2312
+ }
2313
+ return defineJobRouter({ ...base.jobs, ...extra.jobs });
2314
+ }
2315
+ function collectJobEntries(router, prefix = "") {
2316
+ const entries = [];
715
2317
  for (const [key, value] of Object.entries(router.jobs)) {
716
- const name = prefix ? `${prefix}.${key}` : key;
2318
+ const path = prefix ? `${prefix}.${key}` : key;
717
2319
  if (isJobRouter(value)) {
718
- jobs.push(...collectJobs(value, name));
2320
+ entries.push(...collectJobEntries(value, path));
719
2321
  } else if (isJobDef(value)) {
720
- jobs.push(value);
2322
+ entries.push({ key: path, job: value });
721
2323
  }
722
2324
  }
723
- return jobs;
2325
+ return entries;
724
2326
  }
725
- var jobLogger2 = logger.child("@spfn/core:job");
2327
+ function collectJobs(router, prefix = "") {
2328
+ return collectJobEntries(router, prefix).map((entry) => entry.job);
2329
+ }
2330
+ var jobLogger3 = logger.child("@spfn/core:job");
726
2331
  function getEventQueueName(eventName) {
727
2332
  return `event:${eventName}`;
728
2333
  }
@@ -730,6 +2335,9 @@ function getDefaultJobOptions(options) {
730
2335
  return {
731
2336
  retryLimit: options?.retryLimit ?? 3,
732
2337
  retryDelay: options?.retryDelay ?? 1e3,
2338
+ // Exponential backoff by default — a failed cohort (e.g. a provider 429)
2339
+ // would otherwise all retry at the same fixed offset (thundering herd).
2340
+ retryBackoff: options?.retryBackoff ?? true,
733
2341
  expireInSeconds: options?.expireInSeconds ?? 300
734
2342
  };
735
2343
  }
@@ -740,58 +2348,147 @@ async function registerJobs(router) {
740
2348
  "pg-boss not initialized. Call initBoss() before registerJobs()"
741
2349
  );
742
2350
  }
2351
+ assertUniqueJobNames(router);
743
2352
  const jobs = collectJobs(router);
744
2353
  const clearOnStart = shouldClearOnStart();
745
- jobLogger2.info(`Registering ${jobs.length} job(s)...`);
2354
+ jobLogger3.info(`Registering ${jobs.length} job(s)...`);
746
2355
  if (clearOnStart) {
747
- jobLogger2.info("Clearing existing jobs before registration...");
2356
+ jobLogger3.info("Clearing existing jobs before registration...");
748
2357
  for (const job2 of jobs) {
749
2358
  await boss.deleteAllJobs(job2.name);
750
2359
  if (job2.subscribedEvent) {
751
2360
  const eventQueue = getEventQueueName(job2.subscribedEvent);
752
2361
  await boss.deleteAllJobs(eventQueue);
753
2362
  }
754
- }
755
- jobLogger2.info("Existing jobs cleared");
2363
+ }
2364
+ jobLogger3.info("Existing jobs cleared");
2365
+ }
2366
+ await Promise.all(jobs.map((job2) => registerJob(job2)));
2367
+ for (const job2 of jobs) {
2368
+ if (job2.cronExpression) {
2369
+ registeredCronNames.add(job2.name);
2370
+ }
2371
+ }
2372
+ if (shouldSweepOrphanSchedules()) {
2373
+ await sweepOrphanSchedules(boss);
2374
+ }
2375
+ jobLogger3.info("All jobs registered successfully");
2376
+ }
2377
+ function assertUniqueJobNames(router) {
2378
+ const keysByName = /* @__PURE__ */ new Map();
2379
+ for (const { key, job: job2 } of collectJobEntries(router)) {
2380
+ const existingKey = keysByName.get(job2.name);
2381
+ if (existingKey) {
2382
+ throw new Error(
2383
+ `registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
2384
+ );
2385
+ }
2386
+ keysByName.set(job2.name, key);
2387
+ }
2388
+ }
2389
+ var registeredCronNames = /* @__PURE__ */ new Set();
2390
+ async function sweepOrphanSchedules(boss) {
2391
+ if (registeredCronNames.size === 0) {
2392
+ jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
2393
+ return;
2394
+ }
2395
+ try {
2396
+ const schedules = await boss.getSchedules();
2397
+ const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
2398
+ if (orphans.length === 0) {
2399
+ jobLogger3.debug("No orphan schedules found");
2400
+ return;
2401
+ }
2402
+ await Promise.all(orphans.map(async (orphan) => {
2403
+ try {
2404
+ await boss.unschedule(orphan.name, orphan.key);
2405
+ jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
2406
+ } catch (error) {
2407
+ jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
2408
+ error: error instanceof Error ? error.message : String(error)
2409
+ });
2410
+ }
2411
+ }));
2412
+ } catch (error) {
2413
+ jobLogger3.error("Orphan schedule sweep failed", {
2414
+ error: error instanceof Error ? error.message : String(error)
2415
+ });
756
2416
  }
757
- for (const job2 of jobs) {
758
- await registerJob(job2);
2417
+ }
2418
+ async function ensureQueue(boss, queueName, policy) {
2419
+ await boss.createQueue(queueName, { policy });
2420
+ const existing = await boss.getQueue(queueName);
2421
+ if (!existing || existing.policy === policy) {
2422
+ return;
2423
+ }
2424
+ if (shouldClearOnStart()) {
2425
+ await boss.deleteQueue(queueName);
2426
+ await boss.createQueue(queueName, { policy });
2427
+ jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
2428
+ return;
759
2429
  }
760
- jobLogger2.info("All jobs registered successfully");
2430
+ jobLogger3.error(
2431
+ `[Queue:${queueName}] policy mismatch: the queue exists as "${existing.policy}" but its jobs require "${policy}". Delete the queue in a quiet window (getBoss().deleteQueue('${queueName}') drops its job rows) or set clearOnStart in development; until then singletonKey/runOnce on this queue do not deduplicate`
2432
+ );
2433
+ }
2434
+ function createQueueEnsurer(boss, policy) {
2435
+ const ensured = /* @__PURE__ */ new Set();
2436
+ return async (queueName) => {
2437
+ if (ensured.has(queueName)) {
2438
+ return;
2439
+ }
2440
+ ensured.add(queueName);
2441
+ await ensureQueue(boss, queueName, policy);
2442
+ };
761
2443
  }
762
- async function ensureQueue(boss, queueName) {
763
- await boss.createQueue(queueName);
2444
+ async function executeJobHandler(job2, pgBossJob) {
2445
+ jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
2446
+ const startTime = Date.now();
2447
+ try {
2448
+ if (job2.inputSchema) {
2449
+ await job2.handler(pgBossJob.data);
2450
+ } else {
2451
+ await job2.handler();
2452
+ }
2453
+ const duration = Date.now() - startTime;
2454
+ jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
2455
+ jobId: pgBossJob.id,
2456
+ duration
2457
+ });
2458
+ } catch (error) {
2459
+ const duration = Date.now() - startTime;
2460
+ jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
2461
+ jobId: pgBossJob.id,
2462
+ duration,
2463
+ error: error instanceof Error ? error.message : String(error)
2464
+ });
2465
+ throw error;
2466
+ }
764
2467
  }
765
- async function registerWorker(boss, job2, queueName) {
766
- await ensureQueue(boss, queueName);
2468
+ async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
2469
+ await ensureQueueOnce(queueName);
2470
+ const batchSize = job2.options?.batchSize ?? 1;
2471
+ const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
767
2472
  await boss.work(
768
2473
  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;
2474
+ { batchSize, pollingIntervalSeconds },
2475
+ async (pgBossJobs) => {
2476
+ if (batchSize <= 1) {
2477
+ await executeJobHandler(job2, pgBossJobs[0]);
2478
+ return;
2479
+ }
2480
+ const results = await Promise.allSettled(
2481
+ pgBossJobs.map((pgBossJob) => executeJobHandler(job2, pgBossJob))
2482
+ );
2483
+ const failedIds = [];
2484
+ for (let i = 0; i < results.length; i++) {
2485
+ if (results[i].status === "rejected") {
2486
+ failedIds.push(pgBossJobs[i].id);
793
2487
  }
794
2488
  }
2489
+ if (failedIds.length > 0) {
2490
+ await boss.fail(queueName, failedIds);
2491
+ }
795
2492
  }
796
2493
  );
797
2494
  }
@@ -803,28 +2500,28 @@ function connectEventToQueue(boss, job2, queueName) {
803
2500
  eventDef._registerJobQueue(queueName, async (queue, payload) => {
804
2501
  await boss.send(queue, payload, getDefaultJobOptions(job2.options));
805
2502
  });
806
- jobLogger2.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
2503
+ jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
807
2504
  }
808
- async function registerCronSchedule(boss, job2) {
2505
+ async function registerCronSchedule(boss, job2, ensureQueueOnce) {
809
2506
  if (!job2.cronExpression) {
810
2507
  return;
811
2508
  }
812
- jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
813
- await ensureQueue(boss, job2.name);
2509
+ jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
2510
+ await ensureQueueOnce(job2.name);
814
2511
  await boss.schedule(
815
2512
  job2.name,
816
2513
  job2.cronExpression,
817
2514
  {},
818
2515
  getDefaultJobOptions(job2.options)
819
2516
  );
820
- jobLogger2.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
2517
+ jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
821
2518
  }
822
- async function queueRunOnceJob(boss, job2) {
2519
+ async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
823
2520
  if (!job2.runOnce) {
824
2521
  return;
825
2522
  }
826
- jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
827
- await ensureQueue(boss, job2.name);
2523
+ jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
2524
+ await ensureQueueOnce(job2.name);
828
2525
  await boss.send(
829
2526
  job2.name,
830
2527
  {},
@@ -833,7 +2530,7 @@ async function queueRunOnceJob(boss, job2) {
833
2530
  singletonKey: `runOnce:${job2.name}`
834
2531
  }
835
2532
  );
836
- jobLogger2.info(`[Job:${job2.name}] runOnce job queued`);
2533
+ jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
837
2534
  }
838
2535
  async function registerJob(job2) {
839
2536
  const boss = getBoss();
@@ -841,15 +2538,271 @@ async function registerJob(job2) {
841
2538
  throw new Error("pg-boss not initialized");
842
2539
  }
843
2540
  const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
844
- jobLogger2.debug(`Registering job: ${job2.name}`, {
2541
+ jobLogger3.debug(`Registering job: ${job2.name}`, {
845
2542
  queueName,
846
2543
  subscribedEvent: job2.subscribedEvent
847
2544
  });
848
- await registerWorker(boss, job2, queueName);
2545
+ const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
2546
+ await registerWorker(boss, job2, queueName, ensureQueueOnce);
849
2547
  connectEventToQueue(boss, job2, queueName);
850
- await registerCronSchedule(boss, job2);
851
- await queueRunOnceJob(boss, job2);
852
- jobLogger2.debug(`Job registered: ${job2.name}`);
2548
+ await registerCronSchedule(boss, job2, ensureQueueOnce);
2549
+ await queueRunOnceJob(boss, job2, ensureQueueOnce);
2550
+ jobLogger3.debug(`Job registered: ${job2.name}`);
2551
+ }
2552
+ var wsLogger = logger.child("@spfn/core:ws");
2553
+ function isOriginAllowed(origin, allowList) {
2554
+ if (!allowList) {
2555
+ return true;
2556
+ }
2557
+ if (!origin) {
2558
+ return true;
2559
+ }
2560
+ return allowList.has(origin);
2561
+ }
2562
+ async function attachWSHandler(server, router, config = {}, tokenManager) {
2563
+ const WebSocketServer = await loadWSServer();
2564
+ const {
2565
+ pingInterval = 3e4,
2566
+ path = "/ws",
2567
+ maxPayload = 1048576,
2568
+ maxBufferedBytes = 1048576,
2569
+ maxConnections = 1e4,
2570
+ maxConnectionsPerSubject = 0,
2571
+ allowedOrigins,
2572
+ auth: authConfig
2573
+ } = config;
2574
+ const originAllowList = allowedOrigins && allowedOrigins.length > 0 ? new Set(allowedOrigins) : null;
2575
+ if (authConfig?.enabled && !tokenManager) {
2576
+ throw new Error(
2577
+ "WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
2578
+ );
2579
+ }
2580
+ const wss = new WebSocketServer({ server, path, maxPayload });
2581
+ const clients = /* @__PURE__ */ new Set();
2582
+ const subjectCounts = /* @__PURE__ */ new Map();
2583
+ wss.on("connection", (ws, req) => {
2584
+ if (!isOriginAllowed(req.headers?.origin, originAllowList)) {
2585
+ wsLogger.warn("WebSocket rejected: disallowed origin", { origin: req.headers?.origin });
2586
+ ws.close(1008, "Origin not allowed");
2587
+ return;
2588
+ }
2589
+ if (clients.size >= maxConnections) {
2590
+ ws.close(1013, "Server at capacity");
2591
+ return;
2592
+ }
2593
+ clients.add(ws);
2594
+ ws.on("close", () => clients.delete(ws));
2595
+ handleConnection(ws, req, router, authConfig, tokenManager, {
2596
+ pingInterval,
2597
+ maxBufferedBytes,
2598
+ maxConnectionsPerSubject,
2599
+ subjectCounts
2600
+ }).catch((err) => {
2601
+ wsLogger.error("WebSocket connection handler error", err);
2602
+ if (ws.readyState === 1) ws.close(1011, "Internal server error");
2603
+ });
2604
+ });
2605
+ wss.on("error", (err) => {
2606
+ wsLogger.error("WebSocket server error", err);
2607
+ });
2608
+ wsLogger.info(`\u2713 WebSocket endpoint registered at ${path}`, {
2609
+ events: router.eventNames,
2610
+ auth: !!authConfig?.enabled
2611
+ });
2612
+ return () => new Promise((resolve2, reject) => {
2613
+ for (const client of clients) {
2614
+ client.close(1001, "Server shutting down");
2615
+ }
2616
+ clients.clear();
2617
+ wss.close((err) => {
2618
+ if (err) reject(err);
2619
+ else resolve2();
2620
+ });
2621
+ });
2622
+ }
2623
+ async function handleConnection(ws, req, router, authConfig, tokenManager, opts) {
2624
+ const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;
2625
+ let pingTimer;
2626
+ let connectionUnsubscribes = [];
2627
+ let subscribedEvents = [];
2628
+ ws.on("close", () => {
2629
+ clearInterval(pingTimer);
2630
+ connectionUnsubscribes.forEach((fn) => fn());
2631
+ if (subscribedEvents.length > 0)
2632
+ wsLogger.info("WebSocket connection closed", { events: subscribedEvents });
2633
+ });
2634
+ const url = parseURL(req);
2635
+ if (!url) {
2636
+ ws.close(1002, "Invalid request URL");
2637
+ return;
2638
+ }
2639
+ const subject = await resolveSubject(url, authConfig?.enabled ? tokenManager : void 0);
2640
+ if (subject === false) {
2641
+ ws.close(4001, "Missing token");
2642
+ return;
2643
+ }
2644
+ if (subject === null) {
2645
+ ws.close(4001, "Invalid or expired token");
2646
+ return;
2647
+ }
2648
+ const requestedEvents = parseRequestedEvents2(url, router.eventNames);
2649
+ if (requestedEvents.length === 0) {
2650
+ ws.close(4e3, "No valid event names specified");
2651
+ return;
2652
+ }
2653
+ const allowedEvents = await resolveAllowedEvents(subject, requestedEvents, authConfig);
2654
+ if (allowedEvents === null) {
2655
+ ws.close(4003, "Not authorized for any requested events");
2656
+ return;
2657
+ }
2658
+ if (maxConnectionsPerSubject > 0 && typeof subject === "string") {
2659
+ const current = subjectCounts.get(subject) ?? 0;
2660
+ if (current >= maxConnectionsPerSubject) {
2661
+ ws.close(1013, "Too many connections for this subject");
2662
+ return;
2663
+ }
2664
+ subjectCounts.set(subject, current + 1);
2665
+ ws.on("close", () => {
2666
+ const remaining = (subjectCounts.get(subject) ?? 1) - 1;
2667
+ if (remaining <= 0) subjectCounts.delete(subject);
2668
+ else subjectCounts.set(subject, remaining);
2669
+ });
2670
+ }
2671
+ subscribedEvents = allowedEvents;
2672
+ wsLogger.info("WebSocket connection established", {
2673
+ events: allowedEvents,
2674
+ subject: subject ?? void 0
2675
+ });
2676
+ const connection = createConnection(ws, maxBufferedBytes);
2677
+ connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);
2678
+ if (ws.readyState !== 1) {
2679
+ connectionUnsubscribes.forEach((fn) => fn());
2680
+ connectionUnsubscribes = [];
2681
+ return;
2682
+ }
2683
+ ws.on("message", (data) => {
2684
+ onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
2685
+ });
2686
+ if (pingInterval > 0) {
2687
+ ws.isAlive = true;
2688
+ ws.on("pong", () => {
2689
+ ws.isAlive = true;
2690
+ });
2691
+ pingTimer = setInterval(() => {
2692
+ if (ws.readyState !== 1) return;
2693
+ if (ws.isAlive === false) {
2694
+ ws.terminate();
2695
+ return;
2696
+ }
2697
+ ws.isAlive = false;
2698
+ ws.ping();
2699
+ }, pingInterval);
2700
+ }
2701
+ connection.send("__connected", {
2702
+ subscribedEvents: allowedEvents,
2703
+ timestamp: Date.now()
2704
+ });
2705
+ }
2706
+ function parseURL(req) {
2707
+ try {
2708
+ return new URL(req.url ?? "/", "ws://localhost");
2709
+ } catch {
2710
+ return null;
2711
+ }
2712
+ }
2713
+ async function resolveSubject(url, tokenManager) {
2714
+ if (!tokenManager) {
2715
+ return void 0;
2716
+ }
2717
+ const token = url.searchParams.get("token");
2718
+ if (!token) {
2719
+ return false;
2720
+ }
2721
+ return await tokenManager.verify(token);
2722
+ }
2723
+ function parseRequestedEvents2(url, validEventNames) {
2724
+ const eventsParam = url.searchParams.get("events");
2725
+ if (!eventsParam) {
2726
+ return [];
2727
+ }
2728
+ return eventsParam.split(",").map((e) => e.trim()).filter((e) => validEventNames.includes(e));
2729
+ }
2730
+ async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
2731
+ if (!subject || !authConfig?.authorize) {
2732
+ return requestedEvents;
2733
+ }
2734
+ const allowed = await authConfig.authorize(subject, requestedEvents);
2735
+ return allowed.length === 0 ? null : allowed;
2736
+ }
2737
+ function safeSend(ws, frame, maxBufferedBytes) {
2738
+ if (ws.readyState !== 1) return;
2739
+ if (ws.bufferedAmount > maxBufferedBytes) {
2740
+ ws.close(1013, "Send buffer overflow");
2741
+ return;
2742
+ }
2743
+ try {
2744
+ ws.send(JSON.stringify(frame));
2745
+ } catch {
2746
+ }
2747
+ }
2748
+ function createConnection(ws, maxBufferedBytes) {
2749
+ return {
2750
+ send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),
2751
+ close: (code, reason) => ws.close(code, reason)
2752
+ };
2753
+ }
2754
+ function subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes) {
2755
+ const unsubscribes = [];
2756
+ for (const eventName of allowedEvents) {
2757
+ const eventDef = router.events[eventName];
2758
+ if (!eventDef) continue;
2759
+ const unsubscribe = eventDef.subscribe((payload) => {
2760
+ if (ws.readyState !== 1) return;
2761
+ if (subject && authConfig?.filter?.[eventName]) {
2762
+ if (!authConfig.filter[eventName](subject, payload)) return;
2763
+ }
2764
+ safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);
2765
+ });
2766
+ unsubscribes.push(unsubscribe);
2767
+ }
2768
+ return unsubscribes;
2769
+ }
2770
+ async function onClientMessage(data, router, connection, subject) {
2771
+ let message;
2772
+ try {
2773
+ message = JSON.parse(data.toString());
2774
+ } catch {
2775
+ return;
2776
+ }
2777
+ const { type, data: payload } = message;
2778
+ if (!type) return;
2779
+ const handler = router.messages[type];
2780
+ if (!handler) return;
2781
+ try {
2782
+ await handler({ payload, subject, ws: connection });
2783
+ } catch (err) {
2784
+ wsLogger.error(`WebSocket message handler error: ${type}`, err);
2785
+ }
2786
+ }
2787
+ function resolveWSServerCtor(mod) {
2788
+ return mod.WebSocketServer ?? mod.Server ?? mod.default?.WebSocketServer ?? mod.default?.Server;
2789
+ }
2790
+ async function loadWSServer() {
2791
+ let mod;
2792
+ try {
2793
+ mod = await import('ws');
2794
+ } catch {
2795
+ throw new Error(
2796
+ '@spfn/core WebSocket support requires the "ws" package.\nInstall it with: pnpm add ws'
2797
+ );
2798
+ }
2799
+ const WSS = resolveWSServerCtor(mod);
2800
+ if (typeof WSS !== "function") {
2801
+ throw new Error(
2802
+ "WebSocketServer not found in ws module. Ensure ws@^8 is installed: pnpm add ws"
2803
+ );
2804
+ }
2805
+ return WSS;
853
2806
  }
854
2807
  function getNetworkAddress() {
855
2808
  const nets = networkInterfaces();
@@ -886,18 +2839,57 @@ function printBanner(options) {
886
2839
  }
887
2840
  console.log("");
888
2841
  }
2842
+ var PORT_DEFAULTS = {
2843
+ server: 8790
2844
+ };
2845
+ var HOST_DEFAULT = "localhost";
2846
+ var CONFIG_FILE_NAMES = [
2847
+ "spfn.config.js",
2848
+ "spfn.config.mjs"
2849
+ ];
2850
+ async function loadAppConfig(cwd = process.cwd()) {
2851
+ for (const fileName of CONFIG_FILE_NAMES) {
2852
+ const fullPath = join(cwd, fileName);
2853
+ if (!existsSync(fullPath)) {
2854
+ continue;
2855
+ }
2856
+ try {
2857
+ const module = await import(pathToFileURL(fullPath).href);
2858
+ return module.default ?? {};
2859
+ } catch (error) {
2860
+ console.warn(
2861
+ `\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
2862
+ );
2863
+ return {};
2864
+ }
2865
+ }
2866
+ return {};
2867
+ }
2868
+ function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
2869
+ return {
2870
+ port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
2871
+ host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
2872
+ };
2873
+ }
2874
+ function readPort(value) {
2875
+ if (!value) {
2876
+ return void 0;
2877
+ }
2878
+ const port = Number(value);
2879
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
2880
+ }
889
2881
 
890
2882
  // 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) {
2883
+ function validateServerConfig(config) {
2884
+ if (config.port !== void 0) {
2885
+ if (!Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
894
2886
  throw new Error(
895
- `Invalid port: ${config2.port}. Port must be an integer between 0 and 65535.`
2887
+ `Invalid port: ${config.port}. Port must be an integer between 0 and 65535.`
896
2888
  );
897
2889
  }
898
2890
  }
899
- if (config2.timeout) {
900
- const { request, keepAlive, headers } = config2.timeout;
2891
+ if (config.timeout) {
2892
+ const { request, keepAlive, headers } = config.timeout;
901
2893
  if (request !== void 0 && (request < 0 || !Number.isFinite(request))) {
902
2894
  throw new Error(`Invalid timeout.request: ${request}. Must be a positive number.`);
903
2895
  }
@@ -913,16 +2905,16 @@ function validateServerConfig(config2) {
913
2905
  );
914
2906
  }
915
2907
  }
916
- if (config2.shutdown?.timeout !== void 0) {
917
- const timeout = config2.shutdown.timeout;
2908
+ if (config.shutdown?.timeout !== void 0) {
2909
+ const timeout = config.shutdown.timeout;
918
2910
  if (timeout < 0 || !Number.isFinite(timeout)) {
919
2911
  throw new Error(`Invalid shutdown.timeout: ${timeout}. Must be a positive number.`);
920
2912
  }
921
2913
  }
922
- if (config2.healthCheck?.path) {
923
- if (!config2.healthCheck.path.startsWith("/")) {
2914
+ if (config.healthCheck?.path) {
2915
+ if (!config.healthCheck.path.startsWith("/")) {
924
2916
  throw new Error(
925
- `Invalid healthCheck.path: "${config2.healthCheck.path}". Must start with "/".`
2917
+ `Invalid healthCheck.path: "${config.healthCheck.path}". Must start with "/".`
926
2918
  );
927
2919
  }
928
2920
  }
@@ -931,19 +2923,19 @@ var DEFAULT_MAX_LISTENERS = 15;
931
2923
  var TIMEOUTS = {
932
2924
  SERVER_CLOSE: 5e3,
933
2925
  DATABASE_CLOSE: 5e3,
934
- REDIS_CLOSE: 5e3,
935
- PRODUCTION_ERROR_SHUTDOWN: 1e4
936
- };
2926
+ REDIS_CLOSE: 5e3};
937
2927
  var CONFIG_FILE_PATHS = [
938
2928
  ".spfn/server/server.config.mjs",
2929
+ ".spfn/server/server.config.js",
939
2930
  ".spfn/server/server.config",
940
2931
  "src/server/server.config",
941
2932
  "src/server/server.config.ts"
942
2933
  ];
2934
+ var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
943
2935
  var processHandlersRegistered = false;
944
- async function startServer(config2) {
945
- loadEnvFiles();
946
- const finalConfig = await loadAndMergeConfig(config2);
2936
+ async function startServer(config) {
2937
+ loadEnv();
2938
+ const finalConfig = await loadAndMergeConfig(config);
947
2939
  const { host, port, debug } = finalConfig;
948
2940
  validateServerConfig(finalConfig);
949
2941
  if (!host || !port) {
@@ -957,10 +2949,17 @@ async function startServer(config2) {
957
2949
  };
958
2950
  try {
959
2951
  await initializeInfrastructure(finalConfig);
2952
+ await runMigrationBootGate(finalConfig);
960
2953
  const app = await createServer(finalConfig);
961
2954
  const server = startHttpServer(app, host, port);
2955
+ let wsCleanup;
2956
+ if (finalConfig.websockets) {
2957
+ wsCleanup = await initializeWebSocket(server, app, finalConfig);
2958
+ }
962
2959
  const timeouts = getTimeoutConfig(finalConfig.timeout);
963
2960
  applyServerTimeouts(server, timeouts);
2961
+ const fetchTimeouts = getFetchTimeoutConfig(finalConfig.fetchTimeout);
2962
+ applyGlobalFetchTimeouts(fetchTimeouts);
964
2963
  logServerTimeouts(timeouts);
965
2964
  printBanner({
966
2965
  mode: debug ? "Development" : "Production",
@@ -968,7 +2967,7 @@ async function startServer(config2) {
968
2967
  port
969
2968
  });
970
2969
  logServerStarted(debug, host, port, finalConfig, timeouts);
971
- const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState);
2970
+ const shutdownServer = createShutdownHandler(server, finalConfig, shutdownState, wsCleanup);
972
2971
  const shutdown = createGracefulShutdown(shutdownServer, finalConfig, shutdownState);
973
2972
  registerProcessHandlers(shutdown);
974
2973
  const serverInstance = {
@@ -977,11 +2976,6 @@ async function startServer(config2) {
977
2976
  config: finalConfig,
978
2977
  close: async () => {
979
2978
  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
2979
  await shutdownServer();
986
2980
  }
987
2981
  };
@@ -1001,8 +2995,7 @@ async function startServer(config2) {
1001
2995
  throw error;
1002
2996
  }
1003
2997
  }
1004
- async function loadAndMergeConfig(config2) {
1005
- const cwd = process.cwd();
2998
+ async function loadAndMergeConfig(config, cwd = process.cwd()) {
1006
2999
  let fileConfig = {};
1007
3000
  let loadedConfigPath = null;
1008
3001
  for (const configPath of CONFIG_FILE_PATHS) {
@@ -1020,31 +3013,52 @@ async function loadAndMergeConfig(config2) {
1020
3013
  }
1021
3014
  if (loadedConfigPath) {
1022
3015
  serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
3016
+ } else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
3017
+ serverLogger.warn(
3018
+ `\u26A0\uFE0F ${AUTHORED_CONFIG_PATH} exists but no configuration was loaded, so this server is running on defaults: no middlewares, no routes and no infrastructure settings from it. Run "spfn build" so the compiled config lands in .spfn/server/.`
3019
+ );
1023
3020
  } else {
1024
3021
  serverLogger.debug("No configuration file found, using defaults");
1025
3022
  }
3023
+ const appConfig = await loadAppConfig(cwd);
3024
+ warnOnDeprecatedAddress(config, fileConfig);
3025
+ const address = resolveServerAddress(appConfig, {
3026
+ port: config?.port ?? fileConfig?.port,
3027
+ host: config?.host ?? fileConfig?.host
3028
+ });
1026
3029
  return {
1027
3030
  ...fileConfig,
1028
- ...config2,
1029
- port: config2?.port ?? fileConfig?.port ?? env.PORT,
1030
- host: config2?.host ?? fileConfig?.host ?? env.HOST
3031
+ ...config,
3032
+ port: address.port,
3033
+ host: address.host
1031
3034
  };
1032
3035
  }
1033
- function getInfrastructureConfig(config2) {
3036
+ function warnOnDeprecatedAddress(config, fileConfig) {
3037
+ const used = [
3038
+ (config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
3039
+ (config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
3040
+ ].filter(Boolean);
3041
+ if (used.length > 0) {
3042
+ serverLogger.warn(
3043
+ `\u26A0\uFE0F ${used.join(" and ")} in server.config is deprecated and will be removed. Move the address to spfn.config.js \u2014 \`ports: { next, server }\` and \`host\` \u2014 so the Dockerfile, the compose file and next.config can read the same value.`
3044
+ );
3045
+ }
3046
+ }
3047
+ function getInfrastructureConfig(config) {
1034
3048
  return {
1035
- database: config2.infrastructure?.database !== false,
1036
- redis: config2.infrastructure?.redis !== false
3049
+ database: config.infrastructure?.database !== false,
3050
+ redis: config.infrastructure?.redis !== false
1037
3051
  };
1038
3052
  }
1039
- async function initializeInfrastructure(config2) {
1040
- if (config2.lifecycle?.beforeInfrastructure) {
3053
+ async function initializeInfrastructure(config) {
3054
+ if (config.lifecycle?.beforeInfrastructure) {
1041
3055
  serverLogger.debug("Executing beforeInfrastructure hook...");
1042
- await config2.lifecycle.beforeInfrastructure(config2);
3056
+ await config.lifecycle.beforeInfrastructure(config);
1043
3057
  }
1044
- const infraConfig = getInfrastructureConfig(config2);
3058
+ const infraConfig = getInfrastructureConfig(config);
1045
3059
  if (infraConfig.database) {
1046
3060
  serverLogger.debug("Initializing database...");
1047
- await initDatabase(config2.database);
3061
+ await initDatabase(config.database);
1048
3062
  } else {
1049
3063
  serverLogger.debug("Database initialization disabled");
1050
3064
  }
@@ -1054,11 +3068,11 @@ async function initializeInfrastructure(config2) {
1054
3068
  } else {
1055
3069
  serverLogger.debug("Redis initialization disabled");
1056
3070
  }
1057
- if (config2.lifecycle?.afterInfrastructure) {
3071
+ if (config.lifecycle?.afterInfrastructure) {
1058
3072
  serverLogger.debug("Executing afterInfrastructure hook...");
1059
- await config2.lifecycle.afterInfrastructure();
3073
+ await config.lifecycle.afterInfrastructure();
1060
3074
  }
1061
- if (config2.jobs) {
3075
+ if (config.jobs) {
1062
3076
  const dbUrl = env.DATABASE_URL;
1063
3077
  if (!dbUrl) {
1064
3078
  throw new Error(
@@ -1068,22 +3082,22 @@ async function initializeInfrastructure(config2) {
1068
3082
  serverLogger.debug("Initializing pg-boss...");
1069
3083
  await initBoss({
1070
3084
  connectionString: dbUrl,
1071
- ...config2.jobsConfig
3085
+ ...config.jobsConfig
1072
3086
  });
1073
3087
  serverLogger.debug("Registering jobs...");
1074
- await registerJobs(config2.jobs);
3088
+ await registerJobs(config.jobs);
1075
3089
  }
1076
- if (config2.workflows) {
1077
- const infraConfig2 = getInfrastructureConfig(config2);
3090
+ if (config.workflows) {
3091
+ const infraConfig2 = getInfrastructureConfig(config);
1078
3092
  if (!infraConfig2.database) {
1079
3093
  throw new Error(
1080
3094
  "Workflows require database connection. Ensure database is enabled in infrastructure config."
1081
3095
  );
1082
3096
  }
1083
3097
  serverLogger.debug("Initializing workflow engine...");
1084
- config2.workflows._init(
3098
+ config.workflows._init(
1085
3099
  getDatabase(),
1086
- config2.workflowsConfig
3100
+ config.workflowsConfig
1087
3101
  );
1088
3102
  serverLogger.info("Workflow engine initialized");
1089
3103
  }
@@ -1096,8 +3110,52 @@ function startHttpServer(app, host, port) {
1096
3110
  hostname: host
1097
3111
  });
1098
3112
  }
1099
- function logMiddlewareOrder(config2) {
1100
- const middlewareOrder = buildMiddlewareOrder(config2);
3113
+ async function initializeWebSocket(server, app, config) {
3114
+ const wsRouter = config.websockets;
3115
+ const wsConfig = config.websocketsConfig ?? {};
3116
+ const authConfig = wsConfig.auth;
3117
+ const wsPath = wsConfig.path ?? "/ws";
3118
+ const debug = config.debug ?? process.env.NODE_ENV === "development";
3119
+ let tokenManager;
3120
+ if (authConfig?.enabled) {
3121
+ let store = authConfig.store;
3122
+ if (!store) {
3123
+ try {
3124
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
3125
+ const cache2 = getCache2();
3126
+ if (cache2) {
3127
+ store = new CacheTokenStore(cache2);
3128
+ if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
3129
+ }
3130
+ } catch {
3131
+ }
3132
+ }
3133
+ const externalManager = typeof authConfig.tokenManager === "function" ? authConfig.tokenManager() : authConfig.tokenManager;
3134
+ tokenManager = externalManager ?? new SSETokenManager({
3135
+ ttl: authConfig.tokenTtl,
3136
+ store
3137
+ });
3138
+ const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
3139
+ const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
3140
+ const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
3141
+ app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
3142
+ const subject = getSubject(c);
3143
+ if (!subject) {
3144
+ return c.json({ error: "Unable to identify subject" }, 401);
3145
+ }
3146
+ const token = await tokenManager.issue(subject);
3147
+ return c.json({ token });
3148
+ });
3149
+ if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
3150
+ }
3151
+ await wireEventRouterCache(wsRouter, {
3152
+ multiInstance: wsConfig.multiInstance,
3153
+ channelPrefix: wsConfig.channelPrefix
3154
+ });
3155
+ return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
3156
+ }
3157
+ function logMiddlewareOrder(config) {
3158
+ const middlewareOrder = buildMiddlewareOrder(config);
1101
3159
  serverLogger.debug("Middleware execution order", {
1102
3160
  order: middlewareOrder
1103
3161
  });
@@ -1109,8 +3167,8 @@ function logServerTimeouts(timeouts) {
1109
3167
  headers: `${timeouts.headers}ms`
1110
3168
  });
1111
3169
  }
1112
- function logServerStarted(debug, host, port, config2, timeouts) {
1113
- const startupConfig = buildStartupConfig(config2, timeouts);
3170
+ function logServerStarted(debug, host, port, config, timeouts) {
3171
+ const startupConfig = buildStartupConfig(config, timeouts);
1114
3172
  serverLogger.info("Server started successfully", {
1115
3173
  mode: debug ? "development" : "production",
1116
3174
  host,
@@ -1118,65 +3176,84 @@ function logServerStarted(debug, host, port, config2, timeouts) {
1118
3176
  config: startupConfig
1119
3177
  });
1120
3178
  }
1121
- function createShutdownHandler(server, config2, shutdownState) {
3179
+ function createShutdownHandler(server, config, shutdownState, wsCleanup) {
1122
3180
  return async () => {
1123
3181
  if (shutdownState.isShuttingDown) {
1124
3182
  serverLogger.debug("Shutdown already in progress for this instance, skipping");
1125
3183
  return;
1126
3184
  }
1127
3185
  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...");
3186
+ const shutdownTimeout = getShutdownTimeout(config.shutdown);
3187
+ const shutdownManager = getShutdownManager();
3188
+ shutdownManager.beginShutdown();
3189
+ serverLogger.info("Phase 1: Closing HTTP server (stop accepting new connections)...");
3190
+ await closeHttpServer(server);
3191
+ if (wsCleanup) {
3192
+ serverLogger.info("Phase 1.5: Closing WebSocket server...");
3193
+ try {
3194
+ await wsCleanup();
3195
+ serverLogger.info("WebSocket server closed");
3196
+ } catch (error) {
3197
+ serverLogger.error("WebSocket server close failed", error);
3198
+ }
3199
+ }
3200
+ if (config.jobs) {
3201
+ serverLogger.info("Phase 2: Stopping pg-boss...");
1154
3202
  try {
1155
3203
  await stopBoss();
3204
+ serverLogger.info("pg-boss stopped");
1156
3205
  } catch (error) {
1157
3206
  serverLogger.error("pg-boss stop failed", error);
1158
3207
  }
1159
3208
  }
1160
- if (config2.lifecycle?.beforeShutdown) {
1161
- serverLogger.debug("Executing beforeShutdown hook...");
3209
+ const drainTimeout = Math.floor(shutdownTimeout * 0.8);
3210
+ serverLogger.info(`Phase 3: Draining tracked operations (timeout: ${drainTimeout}ms)...`);
3211
+ await shutdownManager.execute(drainTimeout);
3212
+ if (config.lifecycle?.beforeShutdown) {
3213
+ serverLogger.info("Phase 4: Executing beforeShutdown lifecycle hook...");
1162
3214
  try {
1163
- await config2.lifecycle.beforeShutdown();
3215
+ await config.lifecycle.beforeShutdown();
1164
3216
  } catch (error) {
1165
- serverLogger.error("beforeShutdown hook failed", error);
3217
+ serverLogger.error("beforeShutdown lifecycle hook failed", error);
1166
3218
  }
1167
3219
  }
1168
- const infraConfig = getInfrastructureConfig(config2);
3220
+ serverLogger.info("Phase 5: Closing infrastructure...");
3221
+ const infraConfig = getInfrastructureConfig(config);
1169
3222
  if (infraConfig.database) {
1170
- serverLogger.debug("Closing database connections...");
1171
3223
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1172
3224
  }
1173
3225
  if (infraConfig.redis) {
1174
- serverLogger.debug("Closing Redis connections...");
3226
+ await closeEventTransport();
1175
3227
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1176
3228
  }
1177
3229
  serverLogger.info("Server shutdown completed");
1178
3230
  };
1179
3231
  }
3232
+ async function closeHttpServer(server) {
3233
+ let timeoutId;
3234
+ await Promise.race([
3235
+ new Promise((resolve2, reject) => {
3236
+ server.close((err) => {
3237
+ if (timeoutId) clearTimeout(timeoutId);
3238
+ if (err) {
3239
+ serverLogger.error("HTTP server close error", err);
3240
+ reject(err);
3241
+ } else {
3242
+ serverLogger.info("HTTP server closed");
3243
+ resolve2();
3244
+ }
3245
+ });
3246
+ }),
3247
+ new Promise((_, reject) => {
3248
+ timeoutId = setTimeout(() => {
3249
+ reject(new Error(`HTTP server close timeout after ${TIMEOUTS.SERVER_CLOSE}ms`));
3250
+ }, TIMEOUTS.SERVER_CLOSE);
3251
+ })
3252
+ ]).catch((error) => {
3253
+ if (timeoutId) clearTimeout(timeoutId);
3254
+ serverLogger.warn("HTTP server close timeout, forcing shutdown", error);
3255
+ });
3256
+ }
1180
3257
  async function closeInfrastructure(closeFn, name, timeout) {
1181
3258
  let timeoutId;
1182
3259
  try {
@@ -1196,14 +3273,14 @@ async function closeInfrastructure(closeFn, name, timeout) {
1196
3273
  serverLogger.error(`${name} close failed or timed out`, error);
1197
3274
  }
1198
3275
  }
1199
- function createGracefulShutdown(shutdownServer, config2, shutdownState) {
3276
+ function createGracefulShutdown(shutdownServer, config, shutdownState) {
1200
3277
  return async (signal) => {
1201
3278
  if (shutdownState.isShuttingDown) {
1202
3279
  serverLogger.warn(`${signal} received but shutdown already in progress, ignoring`);
1203
3280
  return;
1204
3281
  }
1205
3282
  serverLogger.info(`${signal} received, starting graceful shutdown...`);
1206
- const shutdownTimeout = getShutdownTimeout(config2.shutdown);
3283
+ const shutdownTimeout = getShutdownTimeout(config.shutdown);
1207
3284
  let timeoutId;
1208
3285
  try {
1209
3286
  await Promise.race([
@@ -1231,31 +3308,8 @@ function createGracefulShutdown(shutdownServer, config2, shutdownState) {
1231
3308
  }
1232
3309
  };
1233
3310
  }
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
- }
3311
+ function handleProcessError(errorType) {
3312
+ serverLogger.warn(`${errorType} occurred - server continues running. Check logs above for details.`);
1259
3313
  }
1260
3314
  function registerProcessHandlers(shutdown) {
1261
3315
  if (processHandlersRegistered) {
@@ -1290,7 +3344,7 @@ function registerProcessHandlers(shutdown) {
1290
3344
  } else {
1291
3345
  serverLogger.error("Uncaught exception", error);
1292
3346
  }
1293
- handleProcessError("UNCAUGHT_EXCEPTION", shutdown);
3347
+ handleProcessError("UNCAUGHT_EXCEPTION");
1294
3348
  });
1295
3349
  process.on("unhandledRejection", (reason, promise) => {
1296
3350
  if (reason instanceof Error) {
@@ -1308,25 +3362,89 @@ function registerProcessHandlers(shutdown) {
1308
3362
  promise
1309
3363
  });
1310
3364
  }
1311
- handleProcessError("UNHANDLED_REJECTION", shutdown);
3365
+ handleProcessError("UNHANDLED_REJECTION");
1312
3366
  });
1313
3367
  serverLogger.debug("Process-level shutdown handlers registered successfully");
1314
3368
  }
1315
- async function cleanupOnFailure(config2) {
3369
+ async function cleanupOnFailure(config) {
1316
3370
  try {
1317
3371
  serverLogger.debug("Cleaning up after initialization failure...");
1318
- const infraConfig = getInfrastructureConfig(config2);
3372
+ const infraConfig = getInfrastructureConfig(config);
1319
3373
  if (infraConfig.database) {
1320
3374
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1321
3375
  }
1322
3376
  if (infraConfig.redis) {
3377
+ await closeEventTransport();
1323
3378
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1324
3379
  }
3380
+ resetShutdownManager();
1325
3381
  serverLogger.debug("Cleanup completed");
1326
3382
  } catch (cleanupError) {
1327
3383
  serverLogger.error("Cleanup failed", cleanupError);
1328
3384
  }
1329
3385
  }
3386
+ function getInfrastructure(config) {
3387
+ return {
3388
+ database: config?.infrastructure?.database !== false,
3389
+ redis: config?.infrastructure?.redis !== false
3390
+ };
3391
+ }
3392
+ function withServerlessDefaults(config) {
3393
+ if (!config) {
3394
+ return config;
3395
+ }
3396
+ return {
3397
+ ...config,
3398
+ database: {
3399
+ ...config.database,
3400
+ healthCheck: { enabled: false, ...config.database?.healthCheck }
3401
+ }
3402
+ };
3403
+ }
3404
+ function warnIfJobsConfigured(config) {
3405
+ if (config?.jobs) {
3406
+ serverLogger.warn(
3407
+ "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."
3408
+ );
3409
+ }
3410
+ }
3411
+ async function initInfrastructure(config) {
3412
+ const infra = getInfrastructure(config);
3413
+ if (infra.database) {
3414
+ await initDatabase(config?.database);
3415
+ }
3416
+ if (infra.redis) {
3417
+ await initCache();
3418
+ }
3419
+ }
3420
+ var appPromise;
3421
+ function createServerlessApp(config) {
3422
+ if (!appPromise) {
3423
+ appPromise = buildServerlessApp(config);
3424
+ }
3425
+ return appPromise;
3426
+ }
3427
+ async function buildServerlessApp(rawConfig) {
3428
+ loadEnv();
3429
+ const config = withServerlessDefaults(rawConfig);
3430
+ warnIfJobsConfigured(config);
3431
+ await initInfrastructure(config);
3432
+ return createServer(config);
3433
+ }
3434
+ function resetServerlessApp() {
3435
+ appPromise = void 0;
3436
+ }
3437
+ async function provisionInfrastructure(config) {
3438
+ loadEnv();
3439
+ if (config?.lifecycle?.beforeInfrastructure) {
3440
+ await config.lifecycle.beforeInfrastructure(config);
3441
+ }
3442
+ await initInfrastructure(config);
3443
+ if (config?.lifecycle?.afterInfrastructure) {
3444
+ await config.lifecycle.afterInfrastructure();
3445
+ }
3446
+ serverLogger.info("Provisioning complete");
3447
+ }
1330
3448
 
1331
3449
  // src/server/config-builder.ts
1332
3450
  function collectHooks(lifecycles, key) {
@@ -1387,12 +3505,56 @@ var ServerConfigBuilder = class {
1387
3505
  this.config.middlewares = middlewares;
1388
3506
  return this;
1389
3507
  }
3508
+ /**
3509
+ * Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
3510
+ */
3511
+ proxyGuard(proxyGuard) {
3512
+ this.config.proxyGuard = proxyGuard;
3513
+ return this;
3514
+ }
3515
+ /**
3516
+ * Configure rate limiting: an optional global default limiter plus the named
3517
+ * policies that `rateLimitPolicy(name, fallback)` tags resolve against.
3518
+ *
3519
+ * @example
3520
+ * ```typescript
3521
+ * .rateLimit({
3522
+ * mode: 'on',
3523
+ * default: { limit: 100, windowMs: 60_000 },
3524
+ * policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
3525
+ * })
3526
+ * ```
3527
+ */
3528
+ rateLimit(rateLimit2) {
3529
+ this.config.rateLimit = rateLimit2;
3530
+ return this;
3531
+ }
3532
+ /**
3533
+ * Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
3534
+ * callbacks). Private/reserved IPs are blocked by default.
3535
+ *
3536
+ * @example
3537
+ * ```typescript
3538
+ * .outboundFetch({ allowHosts: ['hooks.slack.com'] })
3539
+ * ```
3540
+ */
3541
+ outboundFetch(outboundFetch) {
3542
+ this.config.outboundFetch = outboundFetch;
3543
+ return this;
3544
+ }
1390
3545
  /**
1391
3546
  * Register define-route based router
1392
3547
  *
1393
- * Automatically applies:
1394
- * - Global middlewares from router._globalMiddlewares (via .use())
1395
- * - Package routers from router._packageRouters (via .packages())
3548
+ * Router-level middleware (`.use()`) and package routers (`.packages()`) travel
3549
+ * with the router itself and are applied by `registerRoutes` when the routes are
3550
+ * mounted — this method only records which router to mount.
3551
+ *
3552
+ * It deliberately does **not** copy `router._globalMiddlewares` into
3553
+ * `config.middlewares`: that copy used to make `registerRoutes` see the same
3554
+ * middleware twice (once from the config list, once from the router it was
3555
+ * handed) and attach both to every route. Middleware that verifies a JWT survives
3556
+ * running twice; middleware that consumes one-shot state — a nonce replay ledger —
3557
+ * rejects its own request the second time round.
1396
3558
  *
1397
3559
  * @example
1398
3560
  * ```typescript
@@ -1403,34 +3565,31 @@ var ServerConfigBuilder = class {
1403
3565
  * .use([authMiddleware]);
1404
3566
  *
1405
3567
  * export default defineServerConfig()
1406
- * .routes(appRouter) // middlewares auto-applied
3568
+ * .routes(appRouter) // .use() middleware applied once, at registration
1407
3569
  * .build();
1408
3570
  * ```
1409
3571
  */
1410
3572
  routes(router) {
1411
3573
  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
3574
  return this;
1430
3575
  }
1431
3576
  /**
1432
3577
  * Register background jobs router
1433
3578
  *
3579
+ * May be called once per domain router: repeated calls **merge** into one
3580
+ * router, the way `.routes()` composes. Calling it several times used to
3581
+ * keep only the last router and register nothing else, without warning.
3582
+ *
3583
+ * The pg-boss `config` is per server, so pass it on one call only.
3584
+ *
3585
+ * Two names can collide, and both fail loudly:
3586
+ * - a router **key** present in two calls throws here, at config time;
3587
+ * - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
3588
+ * `registerJobs()` at boot, before any queue or worker is created.
3589
+ *
3590
+ * @throws if a router key was already registered by an earlier call
3591
+ * @throws if `config` is given when an earlier call already gave one
3592
+ *
1434
3593
  * @example
1435
3594
  * ```typescript
1436
3595
  * import { job, defineJobRouter } from '@spfn/core/job';
@@ -1443,14 +3602,20 @@ var ServerConfigBuilder = class {
1443
3602
  *
1444
3603
  * export default defineServerConfig()
1445
3604
  * .routes(appRouter)
1446
- * .jobs(jobRouter)
3605
+ * .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
3606
+ * .jobs(billingJobRouter) // merged, not replaced
1447
3607
  * .build();
1448
3608
  * ```
1449
3609
  */
1450
- jobs(router, config2) {
1451
- this.config.jobs = router;
1452
- if (config2) {
1453
- this.config.jobsConfig = config2;
3610
+ jobs(router, config) {
3611
+ if (config && this.config.jobsConfig) {
3612
+ throw new Error(
3613
+ "jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
3614
+ );
3615
+ }
3616
+ this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
3617
+ if (config) {
3618
+ this.config.jobsConfig = config;
1454
3619
  }
1455
3620
  return this;
1456
3621
  }
@@ -1482,10 +3647,44 @@ var ServerConfigBuilder = class {
1482
3647
  * .events(eventRouter, { path: '/sse' })
1483
3648
  * ```
1484
3649
  */
1485
- events(router, config2) {
3650
+ events(router, config) {
1486
3651
  this.config.events = router;
1487
- if (config2) {
1488
- this.config.eventsConfig = config2;
3652
+ if (config) {
3653
+ this.config.eventsConfig = config;
3654
+ }
3655
+ return this;
3656
+ }
3657
+ /**
3658
+ * Register WebSocket router for bidirectional real-time communication
3659
+ *
3660
+ * Enables type-safe WebSocket connections with:
3661
+ * - Server→client event push (via defineEvent + emit)
3662
+ * - Client→server message handling (via messages in defineWSRouter)
3663
+ *
3664
+ * @example
3665
+ * ```typescript
3666
+ * // src/server/ws.ts
3667
+ * export const wsRouter = defineWSRouter({
3668
+ * events: { userUpdated, notification },
3669
+ * messages: {
3670
+ * ping: ({ ws }) => ws.send('pong', {}),
3671
+ * },
3672
+ * });
3673
+ *
3674
+ * // server.config.ts
3675
+ * export default defineServerConfig()
3676
+ * .websockets(wsRouter) // → WS /ws
3677
+ * .websockets(wsRouter, {
3678
+ * path: '/realtime', // custom path
3679
+ * auth: { enabled: true }, // token authentication
3680
+ * })
3681
+ * .build();
3682
+ * ```
3683
+ */
3684
+ websockets(router, config) {
3685
+ this.config.websockets = router;
3686
+ if (config) {
3687
+ this.config.websocketsConfig = config;
1489
3688
  }
1490
3689
  return this;
1491
3690
  }
@@ -1524,6 +3723,15 @@ var ServerConfigBuilder = class {
1524
3723
  this.config.healthCheck = healthCheck;
1525
3724
  return this;
1526
3725
  }
3726
+ /**
3727
+ * Supply the clock used by the built-in `GET /_core/time` capability.
3728
+ * Production servers normally keep the default `Date.now()` clock; this is
3729
+ * exposed so tests can assert an exact wire value without replacing globals.
3730
+ */
3731
+ serverTime(serverTime) {
3732
+ this.config.serverTime = serverTime;
3733
+ return this;
3734
+ }
1527
3735
  /**
1528
3736
  * Configure infrastructure initialization
1529
3737
  */
@@ -1531,6 +3739,21 @@ var ServerConfigBuilder = class {
1531
3739
  this.config.infrastructure = infrastructure;
1532
3740
  return this;
1533
3741
  }
3742
+ /**
3743
+ * Configure the migration boot gate
3744
+ *
3745
+ * @example
3746
+ * ```typescript
3747
+ * // A harness that applies migrations itself, after the server is up
3748
+ * export default defineServerConfig()
3749
+ * .migrations({ allowPending: true })
3750
+ * .build();
3751
+ * ```
3752
+ */
3753
+ migrations(migrations) {
3754
+ this.config.migrations = migrations;
3755
+ return this;
3756
+ }
1534
3757
  /**
1535
3758
  * Register workflow router for workflow orchestration
1536
3759
  *
@@ -1551,10 +3774,10 @@ var ServerConfigBuilder = class {
1551
3774
  * .build();
1552
3775
  * ```
1553
3776
  */
1554
- workflows(router, config2) {
3777
+ workflows(router, config) {
1555
3778
  this.config.workflows = router;
1556
- if (config2) {
1557
- this.config.workflowsConfig = config2;
3779
+ if (config) {
3780
+ this.config.workflowsConfig = config;
1558
3781
  }
1559
3782
  return this;
1560
3783
  }
@@ -1605,6 +3828,6 @@ function defineServerConfig() {
1605
3828
  return new ServerConfigBuilder();
1606
3829
  }
1607
3830
 
1608
- export { createServer, defineServerConfig, loadEnvFiles, startServer };
3831
+ export { CORE_HEALTH_PATH, CORE_NAMESPACE, CORE_TIME_OPERATION_ID, CORE_TIME_PATH, CORE_TIME_ROUTE, PendingMigrationsError, ServerTimeResponseSchema, createCoreTimeRoute, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
1609
3832
  //# sourceMappingURL=index.js.map
1610
3833
  //# sourceMappingURL=index.js.map