@spfn/core 0.3.0-beta.3 → 0.3.0-beta.30

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 (104) hide show
  1. package/README.md +301 -10
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +39 -9
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -1,4 +1,4 @@
1
- import { env } from '@spfn/core/config';
1
+ import { env, registry } from '@spfn/core/config';
2
2
  import { existsSync, readFileSync } from 'fs';
3
3
  import { resolve, join } from 'path';
4
4
  import { parse } from 'dotenv';
@@ -13,6 +13,7 @@ import { randomBytes, createHash } from 'crypto';
13
13
  import { Agent, setGlobalDispatcher } from 'undici';
14
14
  import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, closeDatabase, migrationTargets } from '@spfn/core/db';
15
15
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
16
+ import { FormatRegistry, Type } from '@sinclair/typebox';
16
17
  import { serve } from '@hono/node-server';
17
18
  import PgBoss from 'pg-boss';
18
19
  import { networkInterfaces } from 'os';
@@ -354,9 +355,10 @@ function parseEnvFile(filePath) {
354
355
  return parse(readFileSync(filePath, "utf-8"));
355
356
  }
356
357
  function loadEnv(options = {}) {
358
+ const nodeEnvAtEntry = process.env.NODE_ENV;
357
359
  const {
358
360
  cwd = process.cwd(),
359
- nodeEnv = process.env.NODE_ENV || "local",
361
+ nodeEnv = nodeEnvAtEntry || "local",
360
362
  server = true,
361
363
  debug = false,
362
364
  override = false
@@ -386,8 +388,30 @@ function loadEnv(options = {}) {
386
388
  envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
387
389
  envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
388
390
  }
391
+ warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, options.nodeEnv, nodeEnv);
389
392
  return { loadedFiles, loadedKeys };
390
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;
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
+ );
414
+ }
391
415
 
392
416
  // src/server/dotenv-loader.ts
393
417
  var warned = false;
@@ -405,7 +429,15 @@ function loadEnvFiles() {
405
429
  function createBoundedWriter(stream, maxQueue, onClose) {
406
430
  const pending = [];
407
431
  let flushing = false;
432
+ let reserved = 0;
408
433
  let closed = false;
434
+ const overflows = () => {
435
+ if (pending.length + reserved <= maxQueue) {
436
+ return false;
437
+ }
438
+ onClose("outbound queue overflow (slow consumer)");
439
+ return true;
440
+ };
409
441
  const flush = async () => {
410
442
  if (flushing || closed) {
411
443
  return;
@@ -429,11 +461,19 @@ function createBoundedWriter(stream, maxQueue, onClose) {
429
461
  return;
430
462
  }
431
463
  pending.push(frame);
432
- if (pending.length > maxQueue) {
433
- onClose("outbound queue overflow (slow consumer)");
434
- return;
464
+ if (!overflows()) {
465
+ void flush();
466
+ }
467
+ },
468
+ reserve() {
469
+ if (closed) {
470
+ return false;
435
471
  }
436
- void flush();
472
+ reserved++;
473
+ return !overflows();
474
+ },
475
+ clearReserved() {
476
+ reserved = 0;
437
477
  },
438
478
  close() {
439
479
  closed = true;
@@ -444,6 +484,124 @@ function createBoundedWriter(stream, maxQueue, onClose) {
444
484
  }
445
485
  };
446
486
  }
487
+ var registryLogger = logger.child("@spfn/core:sse");
488
+ var SUBSCRIPTION_GRANTED = "subscription.granted";
489
+ var SUBSCRIPTION_REVOKED = "subscription.revoked";
490
+ function createConnectionRegistry() {
491
+ const bySubject = /* @__PURE__ */ new Map();
492
+ const pendingBySubject = /* @__PURE__ */ new Map();
493
+ const beginAdmission = (subject, signal) => {
494
+ const changes = [];
495
+ addToSet(pendingBySubject, subject, changes);
496
+ const end = endOnce(signal, () => removeFromSet(pendingBySubject, subject, changes));
497
+ return {
498
+ cancel: end,
499
+ open: (connection) => {
500
+ if (!end()) {
501
+ return false;
502
+ }
503
+ applyRecorded(connection, changes);
504
+ addToSet(bySubject, subject, connection);
505
+ return true;
506
+ }
507
+ };
508
+ };
509
+ const change = (kind, subject, eventName, match) => {
510
+ const pending = pendingBySubject.get(subject);
511
+ const connections = bySubject.get(subject);
512
+ pending?.forEach((changes) => changes.push({ kind, eventName, match }));
513
+ connections?.forEach((connection) => {
514
+ if (matchesRequest(match, connection.request)) {
515
+ (kind === "grant" ? grantOn : revokeOn)(connection, eventName);
516
+ }
517
+ });
518
+ if (!pending && !connections) {
519
+ registryLogger.debug("SSE subscription change for a subject with no open connection ignored", {
520
+ event: eventName,
521
+ subject
522
+ });
523
+ }
524
+ };
525
+ return {
526
+ beginAdmission,
527
+ remove: (connection) => {
528
+ if (connection.subject) {
529
+ removeFromSet(bySubject, connection.subject, connection);
530
+ }
531
+ },
532
+ /** Open connections only — a pending admission is not counted. */
533
+ count: (subject) => bySubject.get(subject)?.size ?? 0,
534
+ pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
535
+ grant: (subject, eventName, match) => change("grant", subject, eventName, match),
536
+ revoke: (subject, eventName, match) => change("revoke", subject, eventName, match)
537
+ };
538
+ }
539
+ function endOnce(signal, remove) {
540
+ let ended = false;
541
+ const end = () => {
542
+ if (ended) {
543
+ return false;
544
+ }
545
+ ended = true;
546
+ signal.removeEventListener("abort", end);
547
+ remove();
548
+ return true;
549
+ };
550
+ signal.addEventListener("abort", end, { once: true });
551
+ if (signal.aborted) {
552
+ end();
553
+ }
554
+ return end;
555
+ }
556
+ function addToSet(map, key, value) {
557
+ const set = map.get(key) ?? /* @__PURE__ */ new Set();
558
+ set.add(value);
559
+ map.set(key, set);
560
+ }
561
+ function removeFromSet(map, key, value) {
562
+ const set = map.get(key);
563
+ set?.delete(value);
564
+ if (set?.size === 0) {
565
+ map.delete(key);
566
+ }
567
+ }
568
+ function matchesRequest(match, request) {
569
+ return match === void 0 || Object.entries(match.query).every(([field, value]) => Object.hasOwn(request.query, field) && request.query[field] === value);
570
+ }
571
+ function applyRecorded(connection, changes) {
572
+ changes.forEach(({ kind, eventName, match }) => {
573
+ if (!matchesRequest(match, connection.request)) {
574
+ return;
575
+ }
576
+ const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
577
+ if (from.delete(eventName)) {
578
+ to.add(eventName);
579
+ }
580
+ });
581
+ }
582
+ function grantOn(connection, eventName) {
583
+ if (!connection.denied.has(eventName)) {
584
+ return;
585
+ }
586
+ connection.unsubscribes.set(eventName, connection.subscribe(eventName));
587
+ connection.denied.delete(eventName);
588
+ connection.allowed.add(eventName);
589
+ connection.notify(SUBSCRIPTION_GRANTED, eventName);
590
+ }
591
+ function revokeOn(connection, eventName) {
592
+ if (!connection.allowed.has(eventName)) {
593
+ return;
594
+ }
595
+ connection.unsubscribes.get(eventName)?.();
596
+ connection.unsubscribes.delete(eventName);
597
+ connection.allowed.delete(eventName);
598
+ connection.denied.add(eventName);
599
+ connection.notify(SUBSCRIPTION_REVOKED, eventName);
600
+ }
601
+ function unsubscribeAll(connection) {
602
+ connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
603
+ connection.unsubscribes.clear();
604
+ }
447
605
 
448
606
  // src/event/sse/handler.ts
449
607
  var sseLogger = logger.child("@spfn/core:sse");
@@ -474,125 +632,329 @@ function createSSEHandler(router, config = {}, tokenManager) {
474
632
  auth: authConfig,
475
633
  maxQueue = DEFAULT_MAX_QUEUE
476
634
  } = config;
477
- return async (c) => {
478
- const subject = await authenticateToken(c, tokenManager);
479
- if (subject === false) {
480
- return c.json({ error: "Missing token parameter" }, 401);
635
+ const registry = createConnectionRegistry();
636
+ registerSubscriptionControls(router, registry);
637
+ const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
638
+ const handler = async (c) => {
639
+ const admission = await admitConnection(c, streamConfig, tokenManager);
640
+ if (admission instanceof Response) {
641
+ return admission;
481
642
  }
482
- if (subject === null) {
483
- return c.json({ error: "Invalid or expired token" }, 401);
643
+ if (c.req.raw.signal.aborted) {
644
+ admission.pending?.cancel();
645
+ return new Response(null, { status: 499 });
484
646
  }
485
- if (subject) {
486
- c.set("sseSubject", subject);
647
+ c.header("X-Accel-Buffering", "no");
648
+ return streamSSE(c, (stream) => runStream(c, stream, admission, streamConfig), async (err) => {
649
+ sseLogger.error("SSE stream error", {
650
+ error: err.message
651
+ });
652
+ });
653
+ };
654
+ return Object.assign(handler, {
655
+ /** @internal Open registered connections of a subject (tests). */
656
+ _openConnections: (subject) => registry.count(subject),
657
+ /** @internal Connects of a subject still in admission (tests). */
658
+ _pendingAdmissions: (subject) => registry.pendingCount(subject)
659
+ });
660
+ }
661
+ function registerSubscriptionControls(router, registry) {
662
+ for (const eventName of router.eventNames) {
663
+ router.events[eventName]._registerSubscriptionControl({
664
+ grant: (subject, match) => registry.grant(subject, eventName, match),
665
+ revoke: (subject, match) => registry.revoke(subject, eventName, match)
666
+ });
667
+ }
668
+ }
669
+ async function admitConnection(c, config, tokenManager) {
670
+ const { router, authConfig, registry } = config;
671
+ const subject = await authenticateToken(c, tokenManager);
672
+ if (subject === false) {
673
+ return c.json({ error: "Missing token parameter" }, 401);
674
+ }
675
+ if (subject === null) {
676
+ return c.json({ error: "Invalid or expired token" }, 401);
677
+ }
678
+ if (subject) {
679
+ c.set("sseSubject", subject);
680
+ }
681
+ const requested = parseRequestedEvents(c);
682
+ if (!requested) {
683
+ return c.json({ error: "Missing events parameter" }, 400);
684
+ }
685
+ const validEventNames = router.eventNames;
686
+ const invalidEvents = requested.filter((e) => !validEventNames.includes(e));
687
+ if (invalidEvents.length > 0) {
688
+ return c.json({
689
+ error: "Invalid event names",
690
+ invalidEvents,
691
+ validEvents: validEventNames
692
+ }, 400);
693
+ }
694
+ return whilePending(
695
+ subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
696
+ () => authorizeConnection(c, router, authConfig, subject, requested)
697
+ );
698
+ }
699
+ async function whilePending(pending, check) {
700
+ let outcome;
701
+ try {
702
+ outcome = await check();
703
+ return outcome instanceof Response ? outcome : { ...outcome, pending };
704
+ } finally {
705
+ if (!outcome || outcome instanceof Response) {
706
+ pending?.cancel();
487
707
  }
488
- const requestedEvents = parseRequestedEvents(c);
489
- if (!requestedEvents) {
490
- return c.json({ error: "Missing events parameter" }, 400);
708
+ }
709
+ }
710
+ async function authorizeConnection(c, router, authConfig, subject, requested) {
711
+ const authorized = await authorizeEvents(subject, requested, authConfig);
712
+ if (authorized === null) {
713
+ return c.json({ error: "Not authorized for any requested events" }, 403);
714
+ }
715
+ const request = { query: c.req.query(), headers: c.req.header() };
716
+ const resolved = await resolveConnectionPrincipal(subject, request, authConfig);
717
+ if (!resolved) {
718
+ return c.json({ error: "Principal could not be resolved" }, 403);
719
+ }
720
+ const allowed = subject ? await checkCanSubscribe(router, authorized, { subject, principal: resolved.principal, request }) : authorized;
721
+ const denied = requested.filter((e) => !allowed.includes(e));
722
+ sseLogger.debug("SSE connection requested", {
723
+ events: allowed,
724
+ deniedEvents: denied,
725
+ subject: subject || void 0,
726
+ clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
727
+ });
728
+ return { subject, requested, allowed, denied, request, principal: resolved.principal };
729
+ }
730
+ async function resolveConnectionPrincipal(subject, request, authConfig) {
731
+ if (!subject || !authConfig?.resolvePrincipal) {
732
+ return { principal: void 0 };
733
+ }
734
+ try {
735
+ return { principal: await authConfig.resolvePrincipal(subject, request) };
736
+ } catch {
737
+ sseLogger.warn("SSE principal could not be resolved \u2014 connection refused", { subject });
738
+ return null;
739
+ }
740
+ }
741
+ async function checkCanSubscribe(router, events, ctx) {
742
+ const verdicts = await Promise.all(events.map((eventName) => askCanSubscribe(eventName, router.events[eventName], ctx)));
743
+ return events.filter((_, i) => verdicts[i]);
744
+ }
745
+ async function askCanSubscribe(eventName, eventDef, ctx) {
746
+ if (!eventDef) {
747
+ return false;
748
+ }
749
+ if (!eventDef._canSubscribe) {
750
+ return true;
751
+ }
752
+ try {
753
+ return await eventDef._canSubscribe(ctx) === true;
754
+ } catch {
755
+ sseLogger.warn("SSE canSubscribe threw \u2014 event denied on this connection", {
756
+ event: eventName,
757
+ subject: ctx.subject
758
+ });
759
+ return false;
760
+ }
761
+ }
762
+ async function runStream(c, stream, admission, config) {
763
+ const { allowed, denied, subject, request } = admission;
764
+ let connectionDead = false;
765
+ let pingTimer;
766
+ let onClosed;
767
+ const cleanup = (reason) => {
768
+ if (connectionDead) return;
769
+ connectionDead = true;
770
+ clearInterval(pingTimer);
771
+ unsubscribeAll(connection);
772
+ config.registry.remove(connection);
773
+ writer.close();
774
+ onClosed?.();
775
+ sseLogger.info("SSE dead connection cleaned up", {
776
+ events: [...connection.allowed],
777
+ reason
778
+ });
779
+ };
780
+ const writer = createBoundedWriter(stream, config.maxQueue, (reason) => {
781
+ sseLogger.warn("SSE connection closed", { events: [...connection.allowed], reason });
782
+ cleanup(reason);
783
+ });
784
+ const hold = createFrameHold(writer);
785
+ const subscriberContext = {
786
+ router: config.router,
787
+ authConfig: config.authConfig,
788
+ subject,
789
+ request,
790
+ writeFrame: createFrameWriter(writer),
791
+ send: hold.send,
792
+ isDead: () => connectionDead
793
+ };
794
+ const connection = {
795
+ subject,
796
+ requested: admission.requested,
797
+ allowed: new Set(allowed),
798
+ denied: new Set(denied),
799
+ unsubscribes: /* @__PURE__ */ new Map(),
800
+ request,
801
+ subscribe: createEventSubscriber(subscriberContext),
802
+ notify: (frame, eventName) => {
803
+ if (connectionDead) return;
804
+ hold.send(() => writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) }));
491
805
  }
492
- const validEventNames = router.eventNames;
493
- const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
494
- if (invalidEvents.length > 0) {
495
- return c.json({
496
- error: "Invalid event names",
497
- invalidEvents,
498
- validEvents: validEventNames
499
- }, 400);
500
- }
501
- const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
502
- if (allowedEvents === null) {
503
- return c.json({ error: "Not authorized for any requested events" }, 403);
504
- }
505
- sseLogger.debug("SSE connection requested", {
506
- events: allowedEvents,
507
- subject: subject || void 0,
508
- clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
806
+ };
807
+ if (admission.pending?.open(connection) === false) {
808
+ cleanup("aborted during admission");
809
+ return;
810
+ }
811
+ void openSubscriptions(connection, writer, hold, {
812
+ ...subscriberContext,
813
+ principal: admission.principal
814
+ });
815
+ pingTimer = setInterval(() => {
816
+ if (connectionDead) return;
817
+ hold.send(() => writer.enqueue({
818
+ event: "ping",
819
+ data: JSON.stringify({ timestamp: Date.now() })
820
+ }));
821
+ }, config.pingInterval);
822
+ await waitForDisconnect(c.req.raw.signal, () => connectionDead, (resolve2) => {
823
+ onClosed = resolve2;
824
+ });
825
+ cleanup();
826
+ }
827
+ async function openSubscriptions(connection, writer, hold, ctx) {
828
+ const connected = { subscribedEvents: [...connection.allowed], deniedEvents: [...connection.denied] };
829
+ const producing = connected.subscribedEvents.filter((eventName) => ctx.router.events[eventName]._initial);
830
+ if (!ctx.subject || producing.length === 0) {
831
+ enqueueConnected(writer, { ...connected, initial: [] });
832
+ subscribeAllowed(connection);
833
+ return;
834
+ }
835
+ hold.hold();
836
+ subscribeAllowed(connection);
837
+ const served = await produceInitialFrames(producing, ctx.router, {
838
+ subject: ctx.subject,
839
+ principal: ctx.principal,
840
+ request: ctx.request
841
+ });
842
+ if (ctx.isDead()) {
843
+ return;
844
+ }
845
+ enqueueConnected(writer, { ...connected, initial: served.map(({ eventName }) => eventName) });
846
+ served.forEach(({ eventName, frames }) => frames.forEach((data) => ctx.writeFrame(eventName, data)));
847
+ hold.release();
848
+ }
849
+ function enqueueConnected(writer, connected) {
850
+ writer.enqueue({
851
+ event: "connected",
852
+ data: JSON.stringify({ ...connected, timestamp: Date.now() })
853
+ });
854
+ }
855
+ function subscribeAllowed(connection) {
856
+ for (const eventName of connection.allowed) {
857
+ connection.unsubscribes.set(eventName, connection.subscribe(eventName));
858
+ }
859
+ sseLogger.info("SSE connection established", {
860
+ events: [...connection.allowed],
861
+ deniedEvents: [...connection.denied],
862
+ subscriptionCount: connection.unsubscribes.size
863
+ });
864
+ }
865
+ async function produceInitialFrames(events, router, ctx) {
866
+ const results = await Promise.all(events.map((eventName) => askInitial(eventName, router.events[eventName], ctx)));
867
+ return results.filter((frames) => frames !== null);
868
+ }
869
+ async function askInitial(eventName, eventDef, ctx) {
870
+ try {
871
+ const payloads = await eventDef._initial(ctx);
872
+ return { eventName, frames: payloads.map((payload) => serializeFrame(eventName, payload)) };
873
+ } catch {
874
+ sseLogger.warn("SSE initial frames could not be produced \u2014 none sent for this event", {
875
+ event: eventName,
876
+ subject: ctx.subject
509
877
  });
510
- c.header("X-Accel-Buffering", "no");
511
- return streamSSE(c, async (stream) => {
512
- const unsubscribes = [];
513
- let messageId = 0;
514
- let connectionDead = false;
515
- let pingTimer;
516
- let writer;
517
- let onClosed;
518
- const cleanup = (reason) => {
519
- if (connectionDead) return;
520
- connectionDead = true;
521
- clearInterval(pingTimer);
522
- unsubscribes.forEach((fn) => fn());
523
- writer?.close();
524
- onClosed?.();
525
- sseLogger.info("SSE dead connection cleaned up", {
526
- events: allowedEvents,
527
- reason
528
- });
529
- };
530
- writer = createBoundedWriter(stream, maxQueue, (reason) => {
531
- sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
532
- cleanup(reason);
533
- });
534
- writer.enqueue({
535
- event: "connected",
536
- data: JSON.stringify({
537
- subscribedEvents: allowedEvents,
538
- timestamp: Date.now()
539
- })
540
- });
541
- for (const eventName of allowedEvents) {
542
- const eventDef = router.events[eventName];
543
- if (!eventDef) {
544
- continue;
545
- }
546
- const unsubscribe = eventDef.subscribe((payload) => {
547
- if (connectionDead) return;
548
- if (subject && authConfig?.filter?.[eventName]) {
549
- if (!authConfig.filter[eventName](subject, payload)) {
550
- return;
551
- }
552
- }
553
- messageId++;
554
- sseLogger.debug("SSE sending event", {
555
- event: eventName,
556
- messageId
557
- });
558
- writer.enqueue({
559
- id: String(messageId),
560
- event: eventName,
561
- data: serializeFrame(eventName, payload)
562
- });
563
- });
564
- unsubscribes.push(unsubscribe);
878
+ return null;
879
+ }
880
+ }
881
+ function createFrameHold(writer) {
882
+ let pending = null;
883
+ return {
884
+ send: (write) => {
885
+ if (!pending) {
886
+ write();
887
+ } else if (writer.reserve()) {
888
+ pending.push(write);
565
889
  }
566
- sseLogger.info("SSE connection established", {
567
- events: allowedEvents,
568
- subscriptionCount: unsubscribes.length
569
- });
570
- pingTimer = setInterval(() => {
571
- if (connectionDead) return;
572
- writer.enqueue({
573
- event: "ping",
574
- data: JSON.stringify({ timestamp: Date.now() })
575
- });
576
- }, pingInterval);
577
- const abortSignal = c.req.raw.signal;
578
- if (!abortSignal.aborted && !connectionDead) {
579
- await new Promise((resolve2) => {
580
- const onAbort = () => resolve2();
581
- abortSignal.addEventListener("abort", onAbort, { once: true });
582
- onClosed = () => {
583
- abortSignal.removeEventListener("abort", onAbort);
584
- resolve2();
585
- };
586
- });
890
+ },
891
+ hold: () => {
892
+ pending = [];
893
+ },
894
+ release: () => {
895
+ const held = pending ?? [];
896
+ pending = null;
897
+ writer.clearReserved();
898
+ held.forEach((write) => write());
899
+ }
900
+ };
901
+ }
902
+ function createFrameWriter(writer) {
903
+ let messageId = 0;
904
+ return (eventName, data) => {
905
+ messageId++;
906
+ sseLogger.debug("SSE sending event", {
907
+ event: eventName,
908
+ messageId
909
+ });
910
+ writer.enqueue({ id: String(messageId), event: eventName, data });
911
+ };
912
+ }
913
+ async function waitForDisconnect(abortSignal, isDead, onServerClose) {
914
+ if (abortSignal.aborted || isDead()) {
915
+ return;
916
+ }
917
+ await new Promise((resolve2) => {
918
+ const onAbort = () => resolve2();
919
+ abortSignal.addEventListener("abort", onAbort, { once: true });
920
+ onServerClose(() => {
921
+ abortSignal.removeEventListener("abort", onAbort);
922
+ resolve2();
923
+ });
924
+ });
925
+ }
926
+ function createEventSubscriber(ctx) {
927
+ return (eventName) => {
928
+ const eventDef = ctx.router.events[eventName];
929
+ let active = true;
930
+ const unsubscribe = eventDef.subscribe((payload) => {
931
+ if (!active || ctx.isDead() || !passesFrameFilters(eventName, eventDef, payload, ctx)) {
932
+ return;
587
933
  }
588
- cleanup();
589
- }, async (err) => {
590
- sseLogger.error("SSE stream error", {
591
- error: err.message
592
- });
934
+ const data = serializeFrame(eventName, payload);
935
+ ctx.send(() => ctx.writeFrame(eventName, data));
593
936
  });
937
+ return () => {
938
+ active = false;
939
+ unsubscribe();
940
+ };
594
941
  };
595
942
  }
943
+ function passesFrameFilters(eventName, eventDef, payload, ctx) {
944
+ const globalFilter = ctx.authConfig?.filter?.[eventName];
945
+ if (ctx.subject && globalFilter && !globalFilter(ctx.subject, payload)) {
946
+ return false;
947
+ }
948
+ if (!eventDef._filter) {
949
+ return true;
950
+ }
951
+ try {
952
+ return eventDef._filter(payload, { subject: ctx.subject, request: ctx.request }) === true;
953
+ } catch {
954
+ sseLogger.warn("SSE event filter threw \u2014 frame dropped", { event: eventName, subject: ctx.subject });
955
+ return false;
956
+ }
957
+ }
596
958
  async function authenticateToken(c, tokenManager) {
597
959
  if (!tokenManager) {
598
960
  return void 0;
@@ -608,7 +970,7 @@ function parseRequestedEvents(c) {
608
970
  if (!eventsParam) {
609
971
  return null;
610
972
  }
611
- return eventsParam.split(",").map((e) => e.trim());
973
+ return [...new Set(eventsParam.split(",").map((e) => e.trim()))];
612
974
  }
613
975
  async function authorizeEvents(subject, requestedEvents, authConfig) {
614
976
  if (!subject || !authConfig?.authorize) {
@@ -620,6 +982,12 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
620
982
  }
621
983
  return allowed;
622
984
  }
985
+
986
+ // src/event/sse/paths.ts
987
+ var DEFAULT_SSE_STREAM_PATH = "/events/stream";
988
+ function sseTokenPath(streamPath) {
989
+ return streamPath.replace(/\/[^/]+$/, "/token");
990
+ }
623
991
  function hashToken(token) {
624
992
  return createHash("sha256").update(token).digest("hex");
625
993
  }
@@ -1080,6 +1448,7 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
1080
1448
  // src/server/namespace.ts
1081
1449
  var CORE_NAMESPACE = "/_core";
1082
1450
  var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
1451
+ var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
1083
1452
  var LEGACY_HEALTH_PATH = "/health";
1084
1453
 
1085
1454
  // src/server/shutdown-manager.ts
@@ -1480,6 +1849,448 @@ function buildStartupConfig(config, timeouts) {
1480
1849
  };
1481
1850
  }
1482
1851
 
1852
+ // src/route/route-builder.ts
1853
+ var RouteBuilder = class _RouteBuilder {
1854
+ _method;
1855
+ _path;
1856
+ _input;
1857
+ _interceptor;
1858
+ _middlewares;
1859
+ _skipMiddlewares;
1860
+ _contract;
1861
+ _effect;
1862
+ _summary;
1863
+ /**
1864
+ * Create a new RouteBuilder with copied properties and optional overrides
1865
+ */
1866
+ clone(overrides) {
1867
+ const builder = new _RouteBuilder();
1868
+ builder._method = this._method;
1869
+ builder._path = this._path;
1870
+ builder._input = overrides?.input ?? this._input;
1871
+ builder._interceptor = overrides?.interceptor ?? this._interceptor;
1872
+ builder._middlewares = overrides?.middlewares ?? this._middlewares;
1873
+ builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
1874
+ builder._contract = overrides?.contract ?? this._contract;
1875
+ builder._effect = overrides?.effect ?? this._effect;
1876
+ builder._summary = overrides?.summary ?? this._summary;
1877
+ return builder;
1878
+ }
1879
+ /**
1880
+ * Define input schemas
1881
+ *
1882
+ * @example
1883
+ * ```ts
1884
+ * route.get('/users/:id')
1885
+ * .input({
1886
+ * params: Type.Object({ id: Type.String() }),
1887
+ * query: Type.Object({ page: Type.Number() }),
1888
+ * headers: Type.Object({ authorization: Type.String() })
1889
+ * })
1890
+ * .handler(async (c) => {
1891
+ * const { params, query, headers } = await c.data();
1892
+ * // params = { id: string }
1893
+ * // query = { page: number }
1894
+ * // headers = { authorization: string }
1895
+ * })
1896
+ * ```
1897
+ */
1898
+ input(input) {
1899
+ return this.clone({ input });
1900
+ }
1901
+ /**
1902
+ * Define fields injected by interceptors
1903
+ *
1904
+ * These fields are:
1905
+ * - Available in the handler (merged with input)
1906
+ * - Excluded from client types (codegen uses only input)
1907
+ * - Not validated by route input schema (injected by middleware)
1908
+ *
1909
+ * Use this when middleware/interceptors add fields to the request
1910
+ * before it reaches the handler.
1911
+ *
1912
+ * @example
1913
+ * ```ts
1914
+ * // Auth interceptor injects crypto key fields
1915
+ * route.post('/_auth/login')
1916
+ * .input({
1917
+ * body: Type.Object({
1918
+ * email: Type.String(),
1919
+ * password: Type.String()
1920
+ * })
1921
+ * })
1922
+ * .interceptor({
1923
+ * body: Type.Object({
1924
+ * publicKey: Type.String(),
1925
+ * keyId: Type.String(),
1926
+ * fingerprint: Type.String()
1927
+ * })
1928
+ * })
1929
+ * .handler(async (c) => {
1930
+ * const { body } = await c.data();
1931
+ * // body type: { email, password, publicKey, keyId, fingerprint }
1932
+ * // Client only sees: { email, password }
1933
+ * return loginService(body);
1934
+ * });
1935
+ * ```
1936
+ */
1937
+ interceptor(interceptor) {
1938
+ return this.clone({ interceptor });
1939
+ }
1940
+ /**
1941
+ * Add middlewares to the route
1942
+ *
1943
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1944
+ * Named middlewares that are already registered globally will be automatically
1945
+ * deduplicated to prevent double execution.
1946
+ *
1947
+ * @example
1948
+ * ```ts
1949
+ * import { authenticate } from '@spfn/auth/server/middleware';
1950
+ *
1951
+ * // With NamedMiddleware (auto-deduped if registered globally)
1952
+ * route.get('/users')
1953
+ * .use([authenticate, RateLimitMiddleware()])
1954
+ *
1955
+ * // With regular middleware handlers
1956
+ * route.get('/users')
1957
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1958
+ * ```
1959
+ */
1960
+ middleware(middlewares) {
1961
+ return this.clone({ middlewares });
1962
+ }
1963
+ /**
1964
+ * Add middlewares to the route (alias for `.middleware()`)
1965
+ *
1966
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1967
+ * Named middlewares that are already registered globally will be automatically
1968
+ * deduplicated to prevent double execution.
1969
+ *
1970
+ * @example
1971
+ * ```ts
1972
+ * import { authenticate } from '@spfn/auth/server/middleware';
1973
+ *
1974
+ * // With NamedMiddleware (auto-deduped if registered globally)
1975
+ * route.get('/users')
1976
+ * .use([authenticate, RateLimitMiddleware()])
1977
+ *
1978
+ * // With regular middleware handlers
1979
+ * route.get('/users')
1980
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1981
+ * ```
1982
+ */
1983
+ use(middlewares) {
1984
+ return this.middleware(middlewares);
1985
+ }
1986
+ /**
1987
+ * Skip server-level named middlewares
1988
+ *
1989
+ * Useful for public endpoints that should bypass auth or rate limiting
1990
+ *
1991
+ * @param middlewareNames - Array of middleware names to skip, or '*' to skip all
1992
+ *
1993
+ * @example
1994
+ * ```ts
1995
+ * // Skip specific middlewares
1996
+ * route.get('/status')
1997
+ * .skip(['auth', 'rateLimit'])
1998
+ * .handler(async (c) => c.json({ status: 'ok' }));
1999
+ *
2000
+ * // Skip only auth (still apply rate limiting)
2001
+ * route.get('/public-data')
2002
+ * .skip(['auth'])
2003
+ * .handler(async (c) => { ... });
2004
+ *
2005
+ * // Skip all middlewares
2006
+ * route.get('/public-health')
2007
+ * .skip('*')
2008
+ * .handler(async (c) => c.json({ status: 'ok' }));
2009
+ * ```
2010
+ */
2011
+ skip(middlewareNames) {
2012
+ return this.clone({ skipMiddlewares: middlewareNames });
2013
+ }
2014
+ /**
2015
+ * Publish this route as a versioned contract operation
2016
+ *
2017
+ * Marks the route as a promise to clients that are compiled and deployed
2018
+ * separately from the server — a mobile app, an external API consumer.
2019
+ * The `@spfn/core:contract` generator writes every contracted route into
2020
+ * `contracts/current.json`, and the build refuses a change that would break
2021
+ * an already-released client.
2022
+ *
2023
+ * Routes without `.contract()` are unaffected: they simply do not appear in
2024
+ * the contract. A web client needs nothing here — it derives its types from
2025
+ * the router in the same build.
2026
+ *
2027
+ * @example
2028
+ * ```ts
2029
+ * export const getUser = route.get('/users/:id')
2030
+ * .input({ params: Type.Object({ id: Type.String() }) })
2031
+ * .contract({
2032
+ * since: '1.2.0',
2033
+ * auth: 'clientProofV1',
2034
+ * requiresSession: true,
2035
+ * response: Type.Object({
2036
+ * id: Type.String(),
2037
+ * name: Type.String(),
2038
+ * email: Type.Optional(Type.String()),
2039
+ * }),
2040
+ * })
2041
+ * .handler(async (c) => { ... });
2042
+ * ```
2043
+ */
2044
+ contract(contract) {
2045
+ return this.clone({ contract });
2046
+ }
2047
+ /**
2048
+ * Declare what invoking this route does
2049
+ *
2050
+ * Read by a command surface (`@spfn/core/surface`, `@spfn/core/ops`),
2051
+ * which announces it in its manifest so a machine caller can tell a read
2052
+ * from a write before it calls — `spfn ops call` asks for `--yes` before a
2053
+ * `destructive` command. A surface built with `defineSurface` refuses an
2054
+ * app command that does not declare one; on `/_ops` it is optional.
2055
+ *
2056
+ * Nothing else reads it: codegen, the route map, and the contract are
2057
+ * unaffected, and a route outside any surface simply carries it.
2058
+ *
2059
+ * @example
2060
+ * ```ts
2061
+ * export const pageAdd = app.route.post('/pages')
2062
+ * .input({ body: PageAddBody })
2063
+ * .effect('write')
2064
+ * .summary('Add a page to the blueprint')
2065
+ * .handler(async (c) => { ... });
2066
+ * ```
2067
+ */
2068
+ effect(effect) {
2069
+ return this.clone({ effect });
2070
+ }
2071
+ /**
2072
+ * Describe this route in one line for a command surface's manifest
2073
+ *
2074
+ * A surface announces it beside the command in `spfn ops list` and
2075
+ * `--describe`, and refuses one that is empty or longer than 500
2076
+ * characters. Codegen, the route map, and the contract ignore it. On a
2077
+ * module command the module's own summary wins.
2078
+ *
2079
+ * @example
2080
+ * ```ts
2081
+ * export const pageAdd = app.route.post('/pages')
2082
+ * .effect('write')
2083
+ * .summary('Add a page to the blueprint')
2084
+ * .handler(async (c) => { ... });
2085
+ * ```
2086
+ */
2087
+ summary(summary) {
2088
+ return this.clone({ summary });
2089
+ }
2090
+ /**
2091
+ * Define handler function
2092
+ *
2093
+ * Response type is automatically inferred from the return value.
2094
+ * Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
2095
+ *
2096
+ * @example
2097
+ * ```ts
2098
+ * // Direct return - type inferred from data
2099
+ * route.get('/users/:id')
2100
+ * .input({ params: Type.Object({ id: Type.String() }) })
2101
+ * .handler(async (c) => {
2102
+ * const { params } = await c.data();
2103
+ * return await getUser(params.id); // Type: User
2104
+ * })
2105
+ *
2106
+ * // Using c.created() - returns data with 201 status, type preserved
2107
+ * route.post('/users')
2108
+ * .input({ body: Type.Object({ name: Type.String() }) })
2109
+ * .handler(async (c) => {
2110
+ * const { body } = await c.data();
2111
+ * return c.created(await createUser(body)); // Type: User
2112
+ * })
2113
+ *
2114
+ * // Using c.paginated() - returns PaginatedResult<T>
2115
+ * route.get('/users')
2116
+ * .handler(async (c) => {
2117
+ * const users = await getUsers();
2118
+ * return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
2119
+ * })
2120
+ *
2121
+ * // Using c.noContent() - returns void
2122
+ * route.delete('/users/:id')
2123
+ * .handler(async (c) => {
2124
+ * await deleteUser(params.id);
2125
+ * return c.noContent(); // Type: void
2126
+ * })
2127
+ *
2128
+ * // Using c.json() - returns Response (type inference lost)
2129
+ * // Use only when you need custom status codes not covered by helpers
2130
+ * route.get('/custom')
2131
+ * .handler(async (c) => {
2132
+ * return c.json({ data }, 418); // Type: Response
2133
+ * })
2134
+ * ```
2135
+ */
2136
+ handler(fn) {
2137
+ return {
2138
+ method: this._method,
2139
+ path: this._path,
2140
+ input: this._input,
2141
+ interceptor: this._interceptor,
2142
+ middlewares: this._middlewares,
2143
+ skipMiddlewares: this._skipMiddlewares,
2144
+ contract: this._contract,
2145
+ ...this._effect !== void 0 ? { effect: this._effect } : {},
2146
+ ...this._summary !== void 0 ? { summary: this._summary } : {},
2147
+ handler: fn,
2148
+ _input: {},
2149
+ _interceptor: {},
2150
+ _response: {}
2151
+ };
2152
+ }
2153
+ };
2154
+ function createMethodRoute(method) {
2155
+ return (path) => {
2156
+ const builder = new RouteBuilder();
2157
+ builder._method = method;
2158
+ builder._path = path;
2159
+ return builder;
2160
+ };
2161
+ }
2162
+ var route = {
2163
+ get: createMethodRoute("GET"),
2164
+ post: createMethodRoute("POST"),
2165
+ put: createMethodRoute("PUT"),
2166
+ patch: createMethodRoute("PATCH"),
2167
+ delete: createMethodRoute("DELETE")
2168
+ };
2169
+
2170
+ // src/route/router.ts
2171
+ function createRouterInstance(state2) {
2172
+ const router = {
2173
+ routes: state2.routes,
2174
+ _routes: state2.routes,
2175
+ _packageRouters: state2.packageRouters,
2176
+ _globalMiddlewares: state2.globalMiddlewares,
2177
+ _contractVersion: state2.contractVersion,
2178
+ _publishesRouteMap: state2.publishesRouteMap,
2179
+ packages(routers) {
2180
+ const newPackageRouters = [...state2.packageRouters, ...routers];
2181
+ for (const pkgRouter of routers) {
2182
+ if (pkgRouter._packageRouters?.length > 0) {
2183
+ newPackageRouters.push(...pkgRouter._packageRouters);
2184
+ }
2185
+ }
2186
+ assertDistinctSurfaces(newPackageRouters);
2187
+ return carryMarkers(router, createRouterInstance({ ...state2, packageRouters: newPackageRouters }));
2188
+ },
2189
+ use(middlewares) {
2190
+ return carryMarkers(router, createRouterInstance({
2191
+ ...state2,
2192
+ globalMiddlewares: [...state2.globalMiddlewares, ...middlewares]
2193
+ }));
2194
+ },
2195
+ contractVersion(version) {
2196
+ assertContractVersion(version);
2197
+ return carryMarkers(router, createRouterInstance({ ...state2, contractVersion: version }));
2198
+ }
2199
+ };
2200
+ return router;
2201
+ }
2202
+ var MARKERS = ["_surfaceNamespace", "_stateManifest"];
2203
+ function carryMarkers(from, to) {
2204
+ for (const marker of MARKERS) {
2205
+ if (from[marker] !== void 0) {
2206
+ Object.defineProperty(to, marker, { value: from[marker], enumerable: false });
2207
+ }
2208
+ }
2209
+ return to;
2210
+ }
2211
+ function assertContractVersion(version) {
2212
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
2213
+ throw new Error(
2214
+ `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.`
2215
+ );
2216
+ }
2217
+ }
2218
+ function assertDistinctSurfaces(packageRouters) {
2219
+ const namespaces = /* @__PURE__ */ new Set();
2220
+ for (const router of packageRouters) {
2221
+ const namespace = router._surfaceNamespace;
2222
+ if (namespace === void 0) {
2223
+ continue;
2224
+ }
2225
+ if (namespaces.has(namespace)) {
2226
+ throw new Error(
2227
+ `.packages() mounts two command surfaces on "${namespace}". Both serve ${namespace}/_manifest, so the second one's commands would answer while no manifest lists them. Mount one router per namespace.`
2228
+ );
2229
+ }
2230
+ namespaces.add(namespace);
2231
+ }
2232
+ }
2233
+ function defineRouter(routes) {
2234
+ return createRouterInstance({
2235
+ routes,
2236
+ packageRouters: [],
2237
+ globalMiddlewares: [],
2238
+ contractVersion: null,
2239
+ publishesRouteMap: true
2240
+ });
2241
+ }
2242
+
2243
+ // src/route/validation.ts
2244
+ FormatRegistry.Set(
2245
+ "email",
2246
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
2247
+ );
2248
+ FormatRegistry.Set(
2249
+ "uri",
2250
+ (value) => /^https?:\/\/.+/.test(value)
2251
+ );
2252
+ FormatRegistry.Set(
2253
+ "uuid",
2254
+ (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
2255
+ );
2256
+ FormatRegistry.Set(
2257
+ "date",
2258
+ (value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
2259
+ );
2260
+ FormatRegistry.Set(
2261
+ "date-time",
2262
+ (value) => !isNaN(Date.parse(value))
2263
+ );
2264
+
2265
+ // src/server/server-time.ts
2266
+ var CORE_TIME_OPERATION_ID = "core.time";
2267
+ var ServerTimeResponseSchema = Type.Object({
2268
+ serverTimeMillis: Type.Integer()
2269
+ }, { additionalProperties: false });
2270
+ var systemServerClock = {
2271
+ now: () => Date.now()
2272
+ };
2273
+ function createCoreTimeRoute(clock = systemServerClock) {
2274
+ return route.get(CORE_TIME_PATH).skip("*").contract({
2275
+ since: "0.3.0",
2276
+ auth: "none",
2277
+ requiresSession: false,
2278
+ response: ServerTimeResponseSchema
2279
+ }).handler(async (c) => {
2280
+ const response = {
2281
+ serverTimeMillis: clock.now()
2282
+ };
2283
+ c.raw.header("Cache-Control", "no-store");
2284
+ return response;
2285
+ });
2286
+ }
2287
+ var CORE_TIME_ROUTE = createCoreTimeRoute();
2288
+ function createCoreTimeRouter(clock) {
2289
+ return defineRouter({
2290
+ [CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
2291
+ });
2292
+ }
2293
+
1483
2294
  // src/server/create-server.ts
1484
2295
  var rateLimitApplied = /* @__PURE__ */ new WeakSet();
1485
2296
  async function createServer(config) {
@@ -1522,6 +2333,7 @@ async function createAutoConfiguredApp(config) {
1522
2333
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1523
2334
  await applyProxyGuard(app, config);
1524
2335
  applyRateLimit(config);
2336
+ registerCoreTimeEndpoint(app, config);
1525
2337
  if (Array.isArray(config?.use)) {
1526
2338
  config.use.forEach((mw) => app.use("*", mw));
1527
2339
  }
@@ -1571,7 +2383,7 @@ async function applyProxyGuard(app, config) {
1571
2383
  serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1572
2384
  }
1573
2385
  }
1574
- const autoSkip = [CORE_HEALTH_PATH, LEGACY_HEALTH_PATH];
2386
+ const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
1575
2387
  if (config?.healthCheck?.path) {
1576
2388
  autoSkip.push(config.healthCheck.path);
1577
2389
  }
@@ -1628,6 +2440,10 @@ function resolveHealthCheck(config) {
1628
2440
  detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
1629
2441
  };
1630
2442
  }
2443
+ function registerCoreTimeEndpoint(app, config) {
2444
+ registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
2445
+ serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
2446
+ }
1631
2447
  function registerCoreHealthEndpoint(app, config) {
1632
2448
  const { enabled, path, detailed } = resolveHealthCheck(config);
1633
2449
  if (!enabled) {
@@ -1729,8 +2545,9 @@ async function registerSSEEndpoint(app, config) {
1729
2545
  return;
1730
2546
  }
1731
2547
  const eventsConfig = config.eventsConfig ?? {};
1732
- const streamPath = eventsConfig.path ?? "/events/stream";
2548
+ const streamPath = eventsConfig.path ?? DEFAULT_SSE_STREAM_PATH;
1733
2549
  const authConfig = eventsConfig.auth;
2550
+ assertEventContractServed(config.events._contract, streamPath, !!authConfig?.enabled);
1734
2551
  const debug = isDebugMode(config);
1735
2552
  let tokenManager;
1736
2553
  if (authConfig?.enabled) {
@@ -1753,7 +2570,7 @@ async function registerSSEEndpoint(app, config) {
1753
2570
  ttl: authConfig.tokenTtl,
1754
2571
  store
1755
2572
  });
1756
- const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
2573
+ const tokenPath = sseTokenPath(streamPath);
1757
2574
  const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
1758
2575
  const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
1759
2576
  app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
@@ -1782,6 +2599,23 @@ async function registerSSEEndpoint(app, config) {
1782
2599
  });
1783
2600
  }
1784
2601
  }
2602
+ function assertEventContractServed(contract, streamPath, authEnabled) {
2603
+ if (!contract) {
2604
+ return;
2605
+ }
2606
+ const contractPath = contract.streamPath ?? DEFAULT_SSE_STREAM_PATH;
2607
+ if (contractPath !== streamPath) {
2608
+ throw new Error(
2609
+ `SSE event router contract declares streamPath ${contractPath} but .events() registers ${streamPath}; the contract would describe a path the server does not serve`
2610
+ );
2611
+ }
2612
+ const servedAuth = authEnabled ? "tokenExchange" : "none";
2613
+ if (contract.auth !== servedAuth) {
2614
+ throw new Error(
2615
+ `SSE event router contract declares auth ${contract.auth} but .events() serves auth ${servedAuth}; the contract would describe an auth mode the server does not serve`
2616
+ );
2617
+ }
2618
+ }
1785
2619
  function isDebugMode(config) {
1786
2620
  return config?.debug ?? process.env.NODE_ENV === "development";
1787
2621
  }
@@ -1873,6 +2707,31 @@ function shouldSweepOrphanSchedules() {
1873
2707
  return getBossConfig()?.sweepOrphanSchedules ?? false;
1874
2708
  }
1875
2709
 
2710
+ // src/job/queue-policy.ts
2711
+ function resolveQueuePolicy(job2) {
2712
+ if (job2.options?.policy) {
2713
+ return job2.options.policy;
2714
+ }
2715
+ if (job2.runOnce || job2.options?.singletonKey) {
2716
+ return "exclusive";
2717
+ }
2718
+ return "standard";
2719
+ }
2720
+
2721
+ // src/job/retry-options.ts
2722
+ function toPgBossRetryOptions(options) {
2723
+ return {
2724
+ retryLimit: options?.retryLimit ?? 3,
2725
+ retryDelay: Math.ceil((options?.retryDelay ?? 1e3) / 1e3),
2726
+ // Exponential backoff by default — a failed cohort (e.g. a provider 429)
2727
+ // would otherwise all retry at the same fixed offset (thundering herd).
2728
+ retryBackoff: options?.retryBackoff ?? true
2729
+ };
2730
+ }
2731
+
2732
+ // src/job/job-builder.ts
2733
+ logger.child("@spfn/core:job");
2734
+
1876
2735
  // src/job/job-router.ts
1877
2736
  function isJobDef(value) {
1878
2737
  return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
@@ -1880,29 +2739,44 @@ function isJobDef(value) {
1880
2739
  function isJobRouter(value) {
1881
2740
  return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
1882
2741
  }
1883
- function collectJobs(router, prefix = "") {
1884
- const jobs = [];
2742
+ function defineJobRouter(jobs) {
2743
+ return {
2744
+ jobs,
2745
+ _jobs: jobs
2746
+ };
2747
+ }
2748
+ function mergeJobRouters(base, extra) {
2749
+ for (const key of Object.keys(extra.jobs)) {
2750
+ if (key in base.jobs) {
2751
+ throw new Error(
2752
+ `jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
2753
+ );
2754
+ }
2755
+ }
2756
+ return defineJobRouter({ ...base.jobs, ...extra.jobs });
2757
+ }
2758
+ function collectJobEntries(router, prefix = "") {
2759
+ const entries = [];
1885
2760
  for (const [key, value] of Object.entries(router.jobs)) {
1886
- const name = prefix ? `${prefix}.${key}` : key;
2761
+ const path = prefix ? `${prefix}.${key}` : key;
1887
2762
  if (isJobRouter(value)) {
1888
- jobs.push(...collectJobs(value, name));
2763
+ entries.push(...collectJobEntries(value, path));
1889
2764
  } else if (isJobDef(value)) {
1890
- jobs.push(value);
2765
+ entries.push({ key: path, job: value });
1891
2766
  }
1892
2767
  }
1893
- return jobs;
2768
+ return entries;
2769
+ }
2770
+ function collectJobs(router, prefix = "") {
2771
+ return collectJobEntries(router, prefix).map((entry) => entry.job);
1894
2772
  }
1895
- var jobLogger2 = logger.child("@spfn/core:job");
2773
+ var jobLogger3 = logger.child("@spfn/core:job");
1896
2774
  function getEventQueueName(eventName) {
1897
2775
  return `event:${eventName}`;
1898
2776
  }
1899
2777
  function getDefaultJobOptions(options) {
1900
2778
  return {
1901
- retryLimit: options?.retryLimit ?? 3,
1902
- retryDelay: options?.retryDelay ?? 1e3,
1903
- // Exponential backoff by default — a failed cohort (e.g. a provider 429)
1904
- // would otherwise all retry at the same fixed offset (thundering herd).
1905
- retryBackoff: options?.retryBackoff ?? true,
2779
+ ...toPgBossRetryOptions(options),
1906
2780
  expireInSeconds: options?.expireInSeconds ?? 300
1907
2781
  };
1908
2782
  }
@@ -1913,11 +2787,12 @@ async function registerJobs(router) {
1913
2787
  "pg-boss not initialized. Call initBoss() before registerJobs()"
1914
2788
  );
1915
2789
  }
2790
+ assertUniqueJobNames(router);
1916
2791
  const jobs = collectJobs(router);
1917
2792
  const clearOnStart = shouldClearOnStart();
1918
- jobLogger2.info(`Registering ${jobs.length} job(s)...`);
2793
+ jobLogger3.info(`Registering ${jobs.length} job(s)...`);
1919
2794
  if (clearOnStart) {
1920
- jobLogger2.info("Clearing existing jobs before registration...");
2795
+ jobLogger3.info("Clearing existing jobs before registration...");
1921
2796
  for (const job2 of jobs) {
1922
2797
  await boss.deleteAllJobs(job2.name);
1923
2798
  if (job2.subscribedEvent) {
@@ -1925,7 +2800,7 @@ async function registerJobs(router) {
1925
2800
  await boss.deleteAllJobs(eventQueue);
1926
2801
  }
1927
2802
  }
1928
- jobLogger2.info("Existing jobs cleared");
2803
+ jobLogger3.info("Existing jobs cleared");
1929
2804
  }
1930
2805
  await Promise.all(jobs.map((job2) => registerJob(job2)));
1931
2806
  for (const job2 of jobs) {
@@ -1936,42 +2811,77 @@ async function registerJobs(router) {
1936
2811
  if (shouldSweepOrphanSchedules()) {
1937
2812
  await sweepOrphanSchedules(boss);
1938
2813
  }
1939
- jobLogger2.info("All jobs registered successfully");
2814
+ jobLogger3.info("All jobs registered successfully");
2815
+ }
2816
+ function assertUniqueJobNames(router) {
2817
+ const keysByName = /* @__PURE__ */ new Map();
2818
+ for (const { key, job: job2 } of collectJobEntries(router)) {
2819
+ const existingKey = keysByName.get(job2.name);
2820
+ if (existingKey) {
2821
+ throw new Error(
2822
+ `registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
2823
+ );
2824
+ }
2825
+ keysByName.set(job2.name, key);
2826
+ }
1940
2827
  }
1941
2828
  var registeredCronNames = /* @__PURE__ */ new Set();
1942
2829
  async function sweepOrphanSchedules(boss) {
1943
2830
  if (registeredCronNames.size === 0) {
1944
- jobLogger2.debug("No cron jobs declared; skipping orphan schedule sweep");
2831
+ jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
1945
2832
  return;
1946
2833
  }
1947
2834
  try {
1948
2835
  const schedules = await boss.getSchedules();
1949
2836
  const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
1950
2837
  if (orphans.length === 0) {
1951
- jobLogger2.debug("No orphan schedules found");
2838
+ jobLogger3.debug("No orphan schedules found");
1952
2839
  return;
1953
2840
  }
1954
2841
  await Promise.all(orphans.map(async (orphan) => {
1955
2842
  try {
1956
2843
  await boss.unschedule(orphan.name, orphan.key);
1957
- jobLogger2.info(`Removed orphan schedule: ${orphan.name}`);
2844
+ jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
1958
2845
  } catch (error) {
1959
- jobLogger2.error(`Failed to remove orphan schedule: ${orphan.name}`, {
2846
+ jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
1960
2847
  error: error instanceof Error ? error.message : String(error)
1961
2848
  });
1962
2849
  }
1963
2850
  }));
1964
2851
  } catch (error) {
1965
- jobLogger2.error("Orphan schedule sweep failed", {
2852
+ jobLogger3.error("Orphan schedule sweep failed", {
1966
2853
  error: error instanceof Error ? error.message : String(error)
1967
2854
  });
1968
2855
  }
1969
2856
  }
1970
- async function ensureQueue(boss, queueName) {
1971
- await boss.createQueue(queueName);
2857
+ async function ensureQueue(boss, queueName, policy) {
2858
+ await boss.createQueue(queueName, { policy });
2859
+ const existing = await boss.getQueue(queueName);
2860
+ if (!existing || existing.policy === policy) {
2861
+ return;
2862
+ }
2863
+ if (shouldClearOnStart()) {
2864
+ await boss.deleteQueue(queueName);
2865
+ await boss.createQueue(queueName, { policy });
2866
+ jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
2867
+ return;
2868
+ }
2869
+ jobLogger3.error(
2870
+ `[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`
2871
+ );
2872
+ }
2873
+ function createQueueEnsurer(boss, policy) {
2874
+ const ensured = /* @__PURE__ */ new Set();
2875
+ return async (queueName) => {
2876
+ if (ensured.has(queueName)) {
2877
+ return;
2878
+ }
2879
+ ensured.add(queueName);
2880
+ await ensureQueue(boss, queueName, policy);
2881
+ };
1972
2882
  }
1973
2883
  async function executeJobHandler(job2, pgBossJob) {
1974
- jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
2884
+ jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
1975
2885
  const startTime = Date.now();
1976
2886
  try {
1977
2887
  if (job2.inputSchema) {
@@ -1980,13 +2890,13 @@ async function executeJobHandler(job2, pgBossJob) {
1980
2890
  await job2.handler();
1981
2891
  }
1982
2892
  const duration = Date.now() - startTime;
1983
- jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
2893
+ jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
1984
2894
  jobId: pgBossJob.id,
1985
2895
  duration
1986
2896
  });
1987
2897
  } catch (error) {
1988
2898
  const duration = Date.now() - startTime;
1989
- jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
2899
+ jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
1990
2900
  jobId: pgBossJob.id,
1991
2901
  duration,
1992
2902
  error: error instanceof Error ? error.message : String(error)
@@ -1994,8 +2904,8 @@ async function executeJobHandler(job2, pgBossJob) {
1994
2904
  throw error;
1995
2905
  }
1996
2906
  }
1997
- async function registerWorker(boss, job2, queueName) {
1998
- await ensureQueue(boss, queueName);
2907
+ async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
2908
+ await ensureQueueOnce(queueName);
1999
2909
  const batchSize = job2.options?.batchSize ?? 1;
2000
2910
  const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
2001
2911
  await boss.work(
@@ -2029,28 +2939,28 @@ function connectEventToQueue(boss, job2, queueName) {
2029
2939
  eventDef._registerJobQueue(queueName, async (queue, payload) => {
2030
2940
  await boss.send(queue, payload, getDefaultJobOptions(job2.options));
2031
2941
  });
2032
- jobLogger2.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
2942
+ jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
2033
2943
  }
2034
- async function registerCronSchedule(boss, job2) {
2944
+ async function registerCronSchedule(boss, job2, ensureQueueOnce) {
2035
2945
  if (!job2.cronExpression) {
2036
2946
  return;
2037
2947
  }
2038
- jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
2039
- await ensureQueue(boss, job2.name);
2948
+ jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
2949
+ await ensureQueueOnce(job2.name);
2040
2950
  await boss.schedule(
2041
2951
  job2.name,
2042
2952
  job2.cronExpression,
2043
2953
  {},
2044
2954
  getDefaultJobOptions(job2.options)
2045
2955
  );
2046
- jobLogger2.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
2956
+ jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
2047
2957
  }
2048
- async function queueRunOnceJob(boss, job2) {
2958
+ async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
2049
2959
  if (!job2.runOnce) {
2050
2960
  return;
2051
2961
  }
2052
- jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
2053
- await ensureQueue(boss, job2.name);
2962
+ jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
2963
+ await ensureQueueOnce(job2.name);
2054
2964
  await boss.send(
2055
2965
  job2.name,
2056
2966
  {},
@@ -2059,7 +2969,7 @@ async function queueRunOnceJob(boss, job2) {
2059
2969
  singletonKey: `runOnce:${job2.name}`
2060
2970
  }
2061
2971
  );
2062
- jobLogger2.info(`[Job:${job2.name}] runOnce job queued`);
2972
+ jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
2063
2973
  }
2064
2974
  async function registerJob(job2) {
2065
2975
  const boss = getBoss();
@@ -2067,15 +2977,16 @@ async function registerJob(job2) {
2067
2977
  throw new Error("pg-boss not initialized");
2068
2978
  }
2069
2979
  const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
2070
- jobLogger2.debug(`Registering job: ${job2.name}`, {
2980
+ jobLogger3.debug(`Registering job: ${job2.name}`, {
2071
2981
  queueName,
2072
2982
  subscribedEvent: job2.subscribedEvent
2073
2983
  });
2074
- await registerWorker(boss, job2, queueName);
2984
+ const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
2985
+ await registerWorker(boss, job2, queueName, ensureQueueOnce);
2075
2986
  connectEventToQueue(boss, job2, queueName);
2076
- await registerCronSchedule(boss, job2);
2077
- await queueRunOnceJob(boss, job2);
2078
- jobLogger2.debug(`Job registered: ${job2.name}`);
2987
+ await registerCronSchedule(boss, job2, ensureQueueOnce);
2988
+ await queueRunOnceJob(boss, job2, ensureQueueOnce);
2989
+ jobLogger3.debug(`Job registered: ${job2.name}`);
2079
2990
  }
2080
2991
  var wsLogger = logger.child("@spfn/core:ws");
2081
2992
  function isOriginAllowed(origin, allowList) {
@@ -2406,6 +3317,50 @@ function readPort(value) {
2406
3317
  const port = Number(value);
2407
3318
  return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
2408
3319
  }
3320
+ logger.child("@spfn/core:env-registry");
3321
+ function validateAllEnv(registries) {
3322
+ const errors = [];
3323
+ const warnings = [];
3324
+ for (const registry of registries) {
3325
+ const result = registry.validateAll();
3326
+ errors.push(...result.errors);
3327
+ warnings.push(...result.warnings);
3328
+ }
3329
+ return {
3330
+ valid: errors.length === 0,
3331
+ errors,
3332
+ warnings
3333
+ };
3334
+ }
3335
+
3336
+ // src/server/env-boot-check.ts
3337
+ function runEnvBootCheck(config) {
3338
+ const result = validateAllEnv([registry, ...config.env?.registries ?? []]);
3339
+ const errors = uniqueByMessage(result.errors);
3340
+ const warnings = uniqueByMessage(result.warnings);
3341
+ for (const warning of warnings) {
3342
+ serverLogger.warn(`Environment: ${warning.message}`);
3343
+ }
3344
+ if (errors.length > 0) {
3345
+ serverLogger.error(
3346
+ `Environment validation failed (${errors.length}):
3347
+ ` + errors.map((error) => ` - ${error.message}`).join("\n")
3348
+ );
3349
+ }
3350
+ return { valid: errors.length === 0, errors, warnings };
3351
+ }
3352
+ function uniqueByMessage(entries) {
3353
+ const seen = /* @__PURE__ */ new Set();
3354
+ return entries.filter((entry) => {
3355
+ const id = `${entry.key}
3356
+ ${entry.message}`;
3357
+ if (seen.has(id)) {
3358
+ return false;
3359
+ }
3360
+ seen.add(id);
3361
+ return true;
3362
+ });
3363
+ }
2409
3364
 
2410
3365
  // src/server/validation.ts
2411
3366
  function validateServerConfig(config) {
@@ -2464,6 +3419,10 @@ var processHandlersRegistered = false;
2464
3419
  async function startServer(config) {
2465
3420
  loadEnv();
2466
3421
  const finalConfig = await loadAndMergeConfig(config);
3422
+ if (!runEnvBootCheck(finalConfig).valid) {
3423
+ serverLogger.error("Refusing to start: fix the environment variables listed above.");
3424
+ process.exit(1);
3425
+ }
2467
3426
  const { host, port, debug } = finalConfig;
2468
3427
  validateServerConfig(finalConfig);
2469
3428
  if (!host || !port) {
@@ -2954,6 +3913,9 @@ function createServerlessApp(config) {
2954
3913
  }
2955
3914
  async function buildServerlessApp(rawConfig) {
2956
3915
  loadEnv();
3916
+ if (!runEnvBootCheck(rawConfig ?? {}).valid) {
3917
+ throw new Error("Environment validation failed \u2014 the missing or invalid keys are logged above.");
3918
+ }
2957
3919
  const config = withServerlessDefaults(rawConfig);
2958
3920
  warnIfJobsConfigured(config);
2959
3921
  await initInfrastructure(config);
@@ -3104,6 +4066,20 @@ var ServerConfigBuilder = class {
3104
4066
  /**
3105
4067
  * Register background jobs router
3106
4068
  *
4069
+ * May be called once per domain router: repeated calls **merge** into one
4070
+ * router, the way `.routes()` composes. Calling it several times used to
4071
+ * keep only the last router and register nothing else, without warning.
4072
+ *
4073
+ * The pg-boss `config` is per server, so pass it on one call only.
4074
+ *
4075
+ * Two names can collide, and both fail loudly:
4076
+ * - a router **key** present in two calls throws here, at config time;
4077
+ * - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
4078
+ * `registerJobs()` at boot, before any queue or worker is created.
4079
+ *
4080
+ * @throws if a router key was already registered by an earlier call
4081
+ * @throws if `config` is given when an earlier call already gave one
4082
+ *
3107
4083
  * @example
3108
4084
  * ```typescript
3109
4085
  * import { job, defineJobRouter } from '@spfn/core/job';
@@ -3116,12 +4092,18 @@ var ServerConfigBuilder = class {
3116
4092
  *
3117
4093
  * export default defineServerConfig()
3118
4094
  * .routes(appRouter)
3119
- * .jobs(jobRouter)
4095
+ * .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
4096
+ * .jobs(billingJobRouter) // merged, not replaced
3120
4097
  * .build();
3121
4098
  * ```
3122
4099
  */
3123
4100
  jobs(router, config) {
3124
- this.config.jobs = router;
4101
+ if (config && this.config.jobsConfig) {
4102
+ throw new Error(
4103
+ "jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
4104
+ );
4105
+ }
4106
+ this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
3125
4107
  if (config) {
3126
4108
  this.config.jobsConfig = config;
3127
4109
  }
@@ -3231,6 +4213,15 @@ var ServerConfigBuilder = class {
3231
4213
  this.config.healthCheck = healthCheck;
3232
4214
  return this;
3233
4215
  }
4216
+ /**
4217
+ * Supply the clock used by the built-in `GET /_core/time` capability.
4218
+ * Production servers normally keep the default `Date.now()` clock; this is
4219
+ * exposed so tests can assert an exact wire value without replacing globals.
4220
+ */
4221
+ serverTime(serverTime) {
4222
+ this.config.serverTime = serverTime;
4223
+ return this;
4224
+ }
3234
4225
  /**
3235
4226
  * Configure infrastructure initialization
3236
4227
  */
@@ -3253,6 +4244,25 @@ var ServerConfigBuilder = class {
3253
4244
  this.config.migrations = migrations;
3254
4245
  return this;
3255
4246
  }
4247
+ /**
4248
+ * Hand the app's env registries to the boot check
4249
+ *
4250
+ * `@spfn/core`'s own registry is always checked; the registries given here
4251
+ * are checked with it, before the server listens.
4252
+ *
4253
+ * @example
4254
+ * ```typescript
4255
+ * import { envRegistry } from '@/server/config/env.config';
4256
+ *
4257
+ * export default defineServerConfig()
4258
+ * .env({ registries: [envRegistry] })
4259
+ * .build();
4260
+ * ```
4261
+ */
4262
+ env(env6) {
4263
+ this.config.env = env6;
4264
+ return this;
4265
+ }
3256
4266
  /**
3257
4267
  * Register workflow router for workflow orchestration
3258
4268
  *
@@ -3327,6 +4337,6 @@ function defineServerConfig() {
3327
4337
  return new ServerConfigBuilder();
3328
4338
  }
3329
4339
 
3330
- export { CORE_HEALTH_PATH, CORE_NAMESPACE, PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
4340
+ 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 };
3331
4341
  //# sourceMappingURL=index.js.map
3332
4342
  //# sourceMappingURL=index.js.map