@spfn/core 0.3.0-beta.2 → 0.3.0-beta.20

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 (93) hide show
  1. package/README.md +331 -16
  2. package/dist/app-config/index.d.ts +134 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/codegen/index.d.ts +93 -15
  9. package/dist/codegen/index.js +626 -165
  10. package/dist/codegen/index.js.map +1 -1
  11. package/dist/config/index.d.ts +0 -63
  12. package/dist/config/index.js +15 -15
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/contract/index.d.ts +40 -17
  15. package/dist/contract/index.js +147 -23
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/db/index.d.ts +201 -34
  18. package/dist/db/index.js +294 -158
  19. package/dist/db/index.js.map +1 -1
  20. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  21. package/dist/env/index.d.ts +6 -442
  22. package/dist/env/index.js +13 -16
  23. package/dist/env/index.js.map +1 -1
  24. package/dist/env/loader.js +24 -1
  25. package/dist/env/loader.js.map +1 -1
  26. package/dist/error-BcdBVfRA.d.ts +150 -0
  27. package/dist/errors/index.js +1 -381
  28. package/dist/errors/index.js.map +1 -1
  29. package/dist/event/index.d.ts +5 -5
  30. package/dist/event/index.js +32 -1
  31. package/dist/event/index.js.map +1 -1
  32. package/dist/event/sse/client.d.ts +18 -4
  33. package/dist/event/sse/client.js +38 -2
  34. package/dist/event/sse/client.js.map +1 -1
  35. package/dist/event/sse/index.d.ts +5 -4
  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.d.ts +6 -2
  44. package/dist/middleware/index.js +7 -388
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +88 -8
  47. package/dist/nextjs/index.js +138 -62
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +45 -7
  50. package/dist/nextjs/server.js +14 -0
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/ops/index.d.ts +59 -43
  53. package/dist/ops/index.js +543 -97
  54. package/dist/ops/index.js.map +1 -1
  55. package/dist/registry-C2KQCh0m.d.ts +473 -0
  56. package/dist/route/index.d.ts +5 -3
  57. package/dist/route/index.js +48 -28
  58. package/dist/route/index.js.map +1 -1
  59. package/dist/{router-Qbssr11H.d.ts → route-builder-BHKgGkwI.d.ts} +3 -126
  60. package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
  61. package/dist/router-DaDrprJd.d.ts +172 -0
  62. package/dist/server/index.d.ts +185 -14
  63. package/dist/server/index.js +834 -78
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/surface/index.d.ts +109 -0
  66. package/dist/surface/index.js +908 -0
  67. package/dist/surface/index.js.map +1 -0
  68. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  69. package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
  70. package/dist/types-CxhPH4s_.d.ts +375 -0
  71. package/dist/{types-D1c57Ko-.d.ts → types-D52SokSM.d.ts} +43 -4
  72. package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
  73. package/docs/file-upload.md +195 -333
  74. package/package.json +20 -9
  75. package/src/cache/README.md +330 -0
  76. package/src/codegen/README.md +634 -0
  77. package/src/config/README.md +333 -0
  78. package/src/contract/README.md +440 -0
  79. package/src/db/README.md +589 -0
  80. package/src/db/manager/README.md +500 -0
  81. package/src/db/schema/README.md +354 -0
  82. package/src/db/transaction/README.md +822 -0
  83. package/src/env/README.md +787 -0
  84. package/src/errors/README.md +429 -0
  85. package/src/event/README.md +789 -0
  86. package/src/job/README.md +613 -0
  87. package/src/logger/README.md +321 -0
  88. package/src/middleware/README.md +634 -0
  89. package/src/nextjs/README.md +688 -0
  90. package/src/route/README.md +763 -0
  91. package/src/security/README.md +100 -0
  92. package/src/server/README.md +704 -0
  93. 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';
@@ -11,11 +11,13 @@ import { setDefaultSafeFetchPolicy } from '@spfn/core/security';
11
11
  import { streamSSE } from 'hono/streaming';
12
12
  import { randomBytes, createHash } from 'crypto';
13
13
  import { Agent, setGlobalDispatcher } from 'undici';
14
- import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, migrationTargets, closeDatabase } from '@spfn/core/db';
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';
20
+ import { pathToFileURL } from 'url';
19
21
 
20
22
  var __defProp = Object.defineProperty;
21
23
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -353,9 +355,10 @@ function parseEnvFile(filePath) {
353
355
  return parse(readFileSync(filePath, "utf-8"));
354
356
  }
355
357
  function loadEnv(options = {}) {
358
+ const nodeEnvAtEntry = process.env.NODE_ENV;
356
359
  const {
357
360
  cwd = process.cwd(),
358
- nodeEnv = process.env.NODE_ENV || "local",
361
+ nodeEnv = nodeEnvAtEntry || "local",
359
362
  server = true,
360
363
  debug = false,
361
364
  override = false
@@ -385,8 +388,30 @@ function loadEnv(options = {}) {
385
388
  envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
386
389
  envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
387
390
  }
391
+ warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, options.nodeEnv, nodeEnv);
388
392
  return { loadedFiles, loadedKeys };
389
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
+ }
390
415
 
391
416
  // src/server/dotenv-loader.ts
392
417
  var warned = false;
@@ -619,6 +644,12 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
619
644
  }
620
645
  return allowed;
621
646
  }
647
+
648
+ // src/event/sse/paths.ts
649
+ var DEFAULT_SSE_STREAM_PATH = "/events/stream";
650
+ function sseTokenPath(streamPath) {
651
+ return streamPath.replace(/\/[^/]+$/, "/token");
652
+ }
622
653
  function hashToken(token) {
623
654
  return createHash("sha256").update(token).digest("hex");
624
655
  }
@@ -1076,6 +1107,12 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
1076
1107
  );
1077
1108
  }
1078
1109
 
1110
+ // src/server/namespace.ts
1111
+ var CORE_NAMESPACE = "/_core";
1112
+ var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
1113
+ var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
1114
+ var LEGACY_HEALTH_PATH = "/health";
1115
+
1079
1116
  // src/server/shutdown-manager.ts
1080
1117
  var DEFAULT_HOOK_TIMEOUT = 1e4;
1081
1118
  var DEFAULT_HOOK_ORDER = 100;
@@ -1308,7 +1345,7 @@ async function readMigrationHealth() {
1308
1345
  targets: migrationTargets(snapshot.status)
1309
1346
  };
1310
1347
  }
1311
- function createHealthCheckHandler(detailed) {
1348
+ function createHealthCheckHandler(detailed, infrastructure) {
1312
1349
  return async (c) => {
1313
1350
  const shutdownManager = getShutdownManager();
1314
1351
  if (shutdownManager.isShuttingDown()) {
@@ -1322,23 +1359,29 @@ function createHealthCheckHandler(detailed) {
1322
1359
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1323
1360
  };
1324
1361
  if (detailed) {
1362
+ const databaseDisabled = infrastructure?.database === false;
1363
+ const redisDisabled = infrastructure?.redis === false;
1325
1364
  let dbStatus = "unknown";
1326
1365
  let dbError;
1327
- try {
1328
- const db = getDatabase();
1366
+ if (databaseDisabled) {
1367
+ dbStatus = "disabled";
1368
+ } else {
1329
1369
  try {
1330
- await db.execute("SELECT 1");
1331
- dbStatus = "connected";
1370
+ const db = getDatabase();
1371
+ try {
1372
+ await db.execute("SELECT 1");
1373
+ dbStatus = "connected";
1374
+ } catch (error) {
1375
+ dbStatus = "error";
1376
+ dbError = error instanceof Error ? error.message : String(error);
1377
+ }
1332
1378
  } catch (error) {
1333
- dbStatus = "error";
1334
- dbError = error instanceof Error ? error.message : String(error);
1379
+ dbStatus = "not_initialized";
1380
+ dbError = "Database not available";
1335
1381
  }
1336
- } catch (error) {
1337
- dbStatus = "not_initialized";
1338
- dbError = "Database not available";
1339
1382
  }
1340
- const redis = getCache();
1341
- let redisStatus = redis ? "unknown" : "not_initialized";
1383
+ const redis = redisDisabled ? null : getCache();
1384
+ let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
1342
1385
  let redisError;
1343
1386
  if (redis) {
1344
1387
  try {
@@ -1438,7 +1481,7 @@ function buildStartupConfig(config, timeouts) {
1438
1481
  const middlewareConfig = config.middleware ?? {};
1439
1482
  const healthCheckConfig = config.healthCheck ?? {};
1440
1483
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
1441
- const healthCheckPath = healthCheckConfig.path ?? "/health";
1484
+ const healthCheckPath = healthCheckConfig.path;
1442
1485
  const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
1443
1486
  return {
1444
1487
  middleware: {
@@ -1449,6 +1492,7 @@ function buildStartupConfig(config, timeouts) {
1449
1492
  },
1450
1493
  healthCheck: healthCheckEnabled ? {
1451
1494
  enabled: true,
1495
+ corePath: CORE_HEALTH_PATH,
1452
1496
  path: healthCheckPath,
1453
1497
  detailed: healthCheckDetailed
1454
1498
  } : { enabled: false },
@@ -1467,6 +1511,389 @@ function buildStartupConfig(config, timeouts) {
1467
1511
  };
1468
1512
  }
1469
1513
 
1514
+ // src/route/route-builder.ts
1515
+ var RouteBuilder = class _RouteBuilder {
1516
+ _method;
1517
+ _path;
1518
+ _input;
1519
+ _interceptor;
1520
+ _middlewares;
1521
+ _skipMiddlewares;
1522
+ _contract;
1523
+ /**
1524
+ * Create a new RouteBuilder with copied properties and optional overrides
1525
+ */
1526
+ clone(overrides) {
1527
+ const builder = new _RouteBuilder();
1528
+ builder._method = this._method;
1529
+ builder._path = this._path;
1530
+ builder._input = overrides?.input ?? this._input;
1531
+ builder._interceptor = overrides?.interceptor ?? this._interceptor;
1532
+ builder._middlewares = overrides?.middlewares ?? this._middlewares;
1533
+ builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
1534
+ builder._contract = overrides?.contract ?? this._contract;
1535
+ return builder;
1536
+ }
1537
+ /**
1538
+ * Define input schemas
1539
+ *
1540
+ * @example
1541
+ * ```ts
1542
+ * route.get('/users/:id')
1543
+ * .input({
1544
+ * params: Type.Object({ id: Type.String() }),
1545
+ * query: Type.Object({ page: Type.Number() }),
1546
+ * headers: Type.Object({ authorization: Type.String() })
1547
+ * })
1548
+ * .handler(async (c) => {
1549
+ * const { params, query, headers } = await c.data();
1550
+ * // params = { id: string }
1551
+ * // query = { page: number }
1552
+ * // headers = { authorization: string }
1553
+ * })
1554
+ * ```
1555
+ */
1556
+ input(input) {
1557
+ return this.clone({ input });
1558
+ }
1559
+ /**
1560
+ * Define fields injected by interceptors
1561
+ *
1562
+ * These fields are:
1563
+ * - Available in the handler (merged with input)
1564
+ * - Excluded from client types (codegen uses only input)
1565
+ * - Not validated by route input schema (injected by middleware)
1566
+ *
1567
+ * Use this when middleware/interceptors add fields to the request
1568
+ * before it reaches the handler.
1569
+ *
1570
+ * @example
1571
+ * ```ts
1572
+ * // Auth interceptor injects crypto key fields
1573
+ * route.post('/_auth/login')
1574
+ * .input({
1575
+ * body: Type.Object({
1576
+ * email: Type.String(),
1577
+ * password: Type.String()
1578
+ * })
1579
+ * })
1580
+ * .interceptor({
1581
+ * body: Type.Object({
1582
+ * publicKey: Type.String(),
1583
+ * keyId: Type.String(),
1584
+ * fingerprint: Type.String()
1585
+ * })
1586
+ * })
1587
+ * .handler(async (c) => {
1588
+ * const { body } = await c.data();
1589
+ * // body type: { email, password, publicKey, keyId, fingerprint }
1590
+ * // Client only sees: { email, password }
1591
+ * return loginService(body);
1592
+ * });
1593
+ * ```
1594
+ */
1595
+ interceptor(interceptor) {
1596
+ return this.clone({ interceptor });
1597
+ }
1598
+ /**
1599
+ * Add middlewares to the route
1600
+ *
1601
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1602
+ * Named middlewares that are already registered globally will be automatically
1603
+ * deduplicated to prevent double execution.
1604
+ *
1605
+ * @example
1606
+ * ```ts
1607
+ * import { authenticate } from '@spfn/auth/server/middleware';
1608
+ *
1609
+ * // With NamedMiddleware (auto-deduped if registered globally)
1610
+ * route.get('/users')
1611
+ * .use([authenticate, RateLimitMiddleware()])
1612
+ *
1613
+ * // With regular middleware handlers
1614
+ * route.get('/users')
1615
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1616
+ * ```
1617
+ */
1618
+ middleware(middlewares) {
1619
+ return this.clone({ middlewares });
1620
+ }
1621
+ /**
1622
+ * Add middlewares to the route (alias for `.middleware()`)
1623
+ *
1624
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1625
+ * Named middlewares that are already registered globally will be automatically
1626
+ * deduplicated to prevent double execution.
1627
+ *
1628
+ * @example
1629
+ * ```ts
1630
+ * import { authenticate } from '@spfn/auth/server/middleware';
1631
+ *
1632
+ * // With NamedMiddleware (auto-deduped if registered globally)
1633
+ * route.get('/users')
1634
+ * .use([authenticate, RateLimitMiddleware()])
1635
+ *
1636
+ * // With regular middleware handlers
1637
+ * route.get('/users')
1638
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1639
+ * ```
1640
+ */
1641
+ use(middlewares) {
1642
+ return this.middleware(middlewares);
1643
+ }
1644
+ /**
1645
+ * Skip server-level named middlewares
1646
+ *
1647
+ * Useful for public endpoints that should bypass auth or rate limiting
1648
+ *
1649
+ * @param middlewareNames - Array of middleware names to skip, or '*' to skip all
1650
+ *
1651
+ * @example
1652
+ * ```ts
1653
+ * // Skip specific middlewares
1654
+ * route.get('/status')
1655
+ * .skip(['auth', 'rateLimit'])
1656
+ * .handler(async (c) => c.json({ status: 'ok' }));
1657
+ *
1658
+ * // Skip only auth (still apply rate limiting)
1659
+ * route.get('/public-data')
1660
+ * .skip(['auth'])
1661
+ * .handler(async (c) => { ... });
1662
+ *
1663
+ * // Skip all middlewares
1664
+ * route.get('/public-health')
1665
+ * .skip('*')
1666
+ * .handler(async (c) => c.json({ status: 'ok' }));
1667
+ * ```
1668
+ */
1669
+ skip(middlewareNames) {
1670
+ return this.clone({ skipMiddlewares: middlewareNames });
1671
+ }
1672
+ /**
1673
+ * Publish this route as a versioned contract operation
1674
+ *
1675
+ * Marks the route as a promise to clients that are compiled and deployed
1676
+ * separately from the server — a mobile app, an external API consumer.
1677
+ * The `@spfn/core:contract` generator writes every contracted route into
1678
+ * `contracts/current.json`, and the build refuses a change that would break
1679
+ * an already-released client.
1680
+ *
1681
+ * Routes without `.contract()` are unaffected: they simply do not appear in
1682
+ * the contract. A web client needs nothing here — it derives its types from
1683
+ * the router in the same build.
1684
+ *
1685
+ * @example
1686
+ * ```ts
1687
+ * export const getUser = route.get('/users/:id')
1688
+ * .input({ params: Type.Object({ id: Type.String() }) })
1689
+ * .contract({
1690
+ * since: '1.2.0',
1691
+ * auth: 'clientProofV1',
1692
+ * requiresSession: true,
1693
+ * response: Type.Object({
1694
+ * id: Type.String(),
1695
+ * name: Type.String(),
1696
+ * email: Type.Optional(Type.String()),
1697
+ * }),
1698
+ * })
1699
+ * .handler(async (c) => { ... });
1700
+ * ```
1701
+ */
1702
+ contract(contract) {
1703
+ return this.clone({ contract });
1704
+ }
1705
+ /**
1706
+ * Define handler function
1707
+ *
1708
+ * Response type is automatically inferred from the return value.
1709
+ * Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
1710
+ *
1711
+ * @example
1712
+ * ```ts
1713
+ * // Direct return - type inferred from data
1714
+ * route.get('/users/:id')
1715
+ * .input({ params: Type.Object({ id: Type.String() }) })
1716
+ * .handler(async (c) => {
1717
+ * const { params } = await c.data();
1718
+ * return await getUser(params.id); // Type: User
1719
+ * })
1720
+ *
1721
+ * // Using c.created() - returns data with 201 status, type preserved
1722
+ * route.post('/users')
1723
+ * .input({ body: Type.Object({ name: Type.String() }) })
1724
+ * .handler(async (c) => {
1725
+ * const { body } = await c.data();
1726
+ * return c.created(await createUser(body)); // Type: User
1727
+ * })
1728
+ *
1729
+ * // Using c.paginated() - returns PaginatedResult<T>
1730
+ * route.get('/users')
1731
+ * .handler(async (c) => {
1732
+ * const users = await getUsers();
1733
+ * return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
1734
+ * })
1735
+ *
1736
+ * // Using c.noContent() - returns void
1737
+ * route.delete('/users/:id')
1738
+ * .handler(async (c) => {
1739
+ * await deleteUser(params.id);
1740
+ * return c.noContent(); // Type: void
1741
+ * })
1742
+ *
1743
+ * // Using c.json() - returns Response (type inference lost)
1744
+ * // Use only when you need custom status codes not covered by helpers
1745
+ * route.get('/custom')
1746
+ * .handler(async (c) => {
1747
+ * return c.json({ data }, 418); // Type: Response
1748
+ * })
1749
+ * ```
1750
+ */
1751
+ handler(fn) {
1752
+ return {
1753
+ method: this._method,
1754
+ path: this._path,
1755
+ input: this._input,
1756
+ interceptor: this._interceptor,
1757
+ middlewares: this._middlewares,
1758
+ skipMiddlewares: this._skipMiddlewares,
1759
+ contract: this._contract,
1760
+ handler: fn,
1761
+ _input: {},
1762
+ _interceptor: {},
1763
+ _response: {}
1764
+ };
1765
+ }
1766
+ };
1767
+ function createMethodRoute(method) {
1768
+ return (path) => {
1769
+ const builder = new RouteBuilder();
1770
+ builder._method = method;
1771
+ builder._path = path;
1772
+ return builder;
1773
+ };
1774
+ }
1775
+ var route = {
1776
+ get: createMethodRoute("GET"),
1777
+ post: createMethodRoute("POST"),
1778
+ put: createMethodRoute("PUT"),
1779
+ patch: createMethodRoute("PATCH"),
1780
+ delete: createMethodRoute("DELETE")
1781
+ };
1782
+
1783
+ // src/route/router.ts
1784
+ function createRouterInstance(state2) {
1785
+ return {
1786
+ routes: state2.routes,
1787
+ _routes: state2.routes,
1788
+ _packageRouters: state2.packageRouters,
1789
+ _globalMiddlewares: state2.globalMiddlewares,
1790
+ _contractVersion: state2.contractVersion,
1791
+ _publishesRouteMap: state2.publishesRouteMap,
1792
+ packages(routers) {
1793
+ const newPackageRouters = [...state2.packageRouters, ...routers];
1794
+ for (const pkgRouter of routers) {
1795
+ if (pkgRouter._packageRouters?.length > 0) {
1796
+ newPackageRouters.push(...pkgRouter._packageRouters);
1797
+ }
1798
+ }
1799
+ assertDistinctSurfaces(newPackageRouters);
1800
+ return createRouterInstance({ ...state2, packageRouters: newPackageRouters });
1801
+ },
1802
+ use(middlewares) {
1803
+ return createRouterInstance({
1804
+ ...state2,
1805
+ globalMiddlewares: [...state2.globalMiddlewares, ...middlewares]
1806
+ });
1807
+ },
1808
+ contractVersion(version) {
1809
+ assertContractVersion(version);
1810
+ return createRouterInstance({ ...state2, contractVersion: version });
1811
+ }
1812
+ };
1813
+ }
1814
+ function assertContractVersion(version) {
1815
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
1816
+ throw new Error(
1817
+ `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.`
1818
+ );
1819
+ }
1820
+ }
1821
+ function assertDistinctSurfaces(packageRouters) {
1822
+ const namespaces = /* @__PURE__ */ new Set();
1823
+ for (const router of packageRouters) {
1824
+ const namespace = router._surfaceNamespace;
1825
+ if (namespace === void 0) {
1826
+ continue;
1827
+ }
1828
+ if (namespaces.has(namespace)) {
1829
+ throw new Error(
1830
+ `.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.`
1831
+ );
1832
+ }
1833
+ namespaces.add(namespace);
1834
+ }
1835
+ }
1836
+ function defineRouter(routes) {
1837
+ return createRouterInstance({
1838
+ routes,
1839
+ packageRouters: [],
1840
+ globalMiddlewares: [],
1841
+ contractVersion: null,
1842
+ publishesRouteMap: true
1843
+ });
1844
+ }
1845
+
1846
+ // src/route/validation.ts
1847
+ FormatRegistry.Set(
1848
+ "email",
1849
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
1850
+ );
1851
+ FormatRegistry.Set(
1852
+ "uri",
1853
+ (value) => /^https?:\/\/.+/.test(value)
1854
+ );
1855
+ FormatRegistry.Set(
1856
+ "uuid",
1857
+ (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
1858
+ );
1859
+ FormatRegistry.Set(
1860
+ "date",
1861
+ (value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
1862
+ );
1863
+ FormatRegistry.Set(
1864
+ "date-time",
1865
+ (value) => !isNaN(Date.parse(value))
1866
+ );
1867
+
1868
+ // src/server/server-time.ts
1869
+ var CORE_TIME_OPERATION_ID = "core.time";
1870
+ var ServerTimeResponseSchema = Type.Object({
1871
+ serverTimeMillis: Type.Integer()
1872
+ }, { additionalProperties: false });
1873
+ var systemServerClock = {
1874
+ now: () => Date.now()
1875
+ };
1876
+ function createCoreTimeRoute(clock = systemServerClock) {
1877
+ return route.get(CORE_TIME_PATH).skip("*").contract({
1878
+ since: "0.3.0",
1879
+ auth: "none",
1880
+ requiresSession: false,
1881
+ response: ServerTimeResponseSchema
1882
+ }).handler(async (c) => {
1883
+ const response = {
1884
+ serverTimeMillis: clock.now()
1885
+ };
1886
+ c.raw.header("Cache-Control", "no-store");
1887
+ return response;
1888
+ });
1889
+ }
1890
+ var CORE_TIME_ROUTE = createCoreTimeRoute();
1891
+ function createCoreTimeRouter(clock) {
1892
+ return defineRouter({
1893
+ [CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
1894
+ });
1895
+ }
1896
+
1470
1897
  // src/server/create-server.ts
1471
1898
  var rateLimitApplied = /* @__PURE__ */ new WeakSet();
1472
1899
  async function createServer(config) {
@@ -1509,12 +1936,16 @@ async function createAutoConfiguredApp(config) {
1509
1936
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1510
1937
  await applyProxyGuard(app, config);
1511
1938
  applyRateLimit(config);
1939
+ registerCoreTimeEndpoint(app, config);
1512
1940
  if (Array.isArray(config?.use)) {
1513
1941
  config.use.forEach((mw) => app.use("*", mw));
1514
1942
  }
1515
- registerHealthCheckEndpoint(app, config);
1943
+ registerCoreHealthEndpoint(app, config);
1516
1944
  await executeBeforeRoutesHook(app, config);
1517
- await loadAppRoutes(app, config);
1945
+ const appRoutes = await loadAppRoutes(app, config);
1946
+ registerMovedHealthSignpost(app, config, appRoutes);
1947
+ warnOnShadowedOptInPath(config, appRoutes);
1948
+ warnOnCoreNamespaceRoutes(appRoutes);
1518
1949
  await registerSSEEndpoint(app, config);
1519
1950
  await executeAfterRoutesHook(app, config);
1520
1951
  if (enableErrorHandler) {
@@ -1555,7 +1986,10 @@ async function applyProxyGuard(app, config) {
1555
1986
  serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1556
1987
  }
1557
1988
  }
1558
- const autoSkip = [config?.healthCheck?.path ?? "/health"];
1989
+ const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
1990
+ if (config?.healthCheck?.path) {
1991
+ autoSkip.push(config.healthCheck.path);
1992
+ }
1559
1993
  if (config?.events) {
1560
1994
  autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
1561
1995
  }
@@ -1598,16 +2032,80 @@ function applyOutboundFetch(config) {
1598
2032
  const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
1599
2033
  setDefaultSafeFetchPolicy(policy);
1600
2034
  }
1601
- function registerHealthCheckEndpoint(app, config) {
2035
+ function resolveHealthCheck(config) {
1602
2036
  const healthCheckConfig = config?.healthCheck ?? {};
1603
- const healthCheckEnabled = healthCheckConfig.enabled !== false;
1604
- const healthCheckPath = healthCheckConfig.path ?? "/health";
1605
- const healthCheckDetailed = healthCheckConfig.detailed ?? process.env.NODE_ENV === "development";
1606
- if (healthCheckEnabled) {
1607
- app.get(healthCheckPath, createHealthCheckHandler(healthCheckDetailed));
1608
- serverLogger.debug(`Health check endpoint enabled at ${healthCheckPath}`);
2037
+ return {
2038
+ enabled: healthCheckConfig.enabled !== false,
2039
+ // No default. An unset `path` means the endpoint answers at
2040
+ // CORE_HEALTH_PATH and nowhere else — a default here is what used to put
2041
+ // it on /health without anyone asking.
2042
+ path: healthCheckConfig.path,
2043
+ detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
2044
+ };
2045
+ }
2046
+ function registerCoreTimeEndpoint(app, config) {
2047
+ registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
2048
+ serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
2049
+ }
2050
+ function registerCoreHealthEndpoint(app, config) {
2051
+ const { enabled, path, detailed } = resolveHealthCheck(config);
2052
+ if (!enabled) {
2053
+ return;
2054
+ }
2055
+ const handler = createHealthCheckHandler(detailed, config?.infrastructure);
2056
+ app.get(CORE_HEALTH_PATH, handler);
2057
+ serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
2058
+ if (path && path !== CORE_HEALTH_PATH) {
2059
+ app.get(path, handler);
2060
+ serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
1609
2061
  }
1610
2062
  }
2063
+ function registerMovedHealthSignpost(app, config, appRoutes) {
2064
+ const { enabled, path } = resolveHealthCheck(config);
2065
+ if (!enabled || path === LEGACY_HEALTH_PATH) {
2066
+ return;
2067
+ }
2068
+ if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
2069
+ return;
2070
+ }
2071
+ let warned2 = false;
2072
+ app.get(LEGACY_HEALTH_PATH, (c) => {
2073
+ if (!warned2) {
2074
+ warned2 = true;
2075
+ serverLogger.warn(
2076
+ `\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.`
2077
+ );
2078
+ }
2079
+ return c.json({
2080
+ error: "health endpoint moved",
2081
+ movedTo: CORE_HEALTH_PATH,
2082
+ 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}' }).`
2083
+ }, 410);
2084
+ });
2085
+ }
2086
+ function warnOnShadowedOptInPath(config, appRoutes) {
2087
+ const { enabled, path } = resolveHealthCheck(config);
2088
+ if (!enabled || !path || path === CORE_HEALTH_PATH) {
2089
+ return;
2090
+ }
2091
+ const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
2092
+ if (shadowed.length === 0) {
2093
+ return;
2094
+ }
2095
+ serverLogger.warn(
2096
+ `\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.`
2097
+ );
2098
+ }
2099
+ function warnOnCoreNamespaceRoutes(appRoutes) {
2100
+ const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
2101
+ if (inside.length === 0) {
2102
+ return;
2103
+ }
2104
+ const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
2105
+ serverLogger.warn(
2106
+ `\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.`
2107
+ );
2108
+ }
1611
2109
  async function executeBeforeRoutesHook(app, config) {
1612
2110
  if (config?.lifecycle?.beforeRoutes) {
1613
2111
  await config.lifecycle.beforeRoutes(app);
@@ -1618,9 +2116,12 @@ async function loadAppRoutes(app, config) {
1618
2116
  if (config?.routes) {
1619
2117
  const routes = registerRoutes(app, config.routes, config.middlewares);
1620
2118
  logRegisteredRoutes(routes, debug);
1621
- } else if (debug) {
2119
+ return routes;
2120
+ }
2121
+ if (debug) {
1622
2122
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
1623
2123
  }
2124
+ return [];
1624
2125
  }
1625
2126
  function logRegisteredRoutes(routes, debug) {
1626
2127
  if (routes.length === 0) {
@@ -1647,8 +2148,9 @@ async function registerSSEEndpoint(app, config) {
1647
2148
  return;
1648
2149
  }
1649
2150
  const eventsConfig = config.eventsConfig ?? {};
1650
- const streamPath = eventsConfig.path ?? "/events/stream";
2151
+ const streamPath = eventsConfig.path ?? DEFAULT_SSE_STREAM_PATH;
1651
2152
  const authConfig = eventsConfig.auth;
2153
+ assertEventContractServed(config.events._contract, streamPath, !!authConfig?.enabled);
1652
2154
  const debug = isDebugMode(config);
1653
2155
  let tokenManager;
1654
2156
  if (authConfig?.enabled) {
@@ -1671,7 +2173,7 @@ async function registerSSEEndpoint(app, config) {
1671
2173
  ttl: authConfig.tokenTtl,
1672
2174
  store
1673
2175
  });
1674
- const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
2176
+ const tokenPath = sseTokenPath(streamPath);
1675
2177
  const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
1676
2178
  const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
1677
2179
  app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
@@ -1700,6 +2202,23 @@ async function registerSSEEndpoint(app, config) {
1700
2202
  });
1701
2203
  }
1702
2204
  }
2205
+ function assertEventContractServed(contract, streamPath, authEnabled) {
2206
+ if (!contract) {
2207
+ return;
2208
+ }
2209
+ const contractPath = contract.streamPath ?? DEFAULT_SSE_STREAM_PATH;
2210
+ if (contractPath !== streamPath) {
2211
+ throw new Error(
2212
+ `SSE event router contract declares streamPath ${contractPath} but .events() registers ${streamPath}; the contract would describe a path the server does not serve`
2213
+ );
2214
+ }
2215
+ const servedAuth = authEnabled ? "tokenExchange" : "none";
2216
+ if (contract.auth !== servedAuth) {
2217
+ throw new Error(
2218
+ `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`
2219
+ );
2220
+ }
2221
+ }
1703
2222
  function isDebugMode(config) {
1704
2223
  return config?.debug ?? process.env.NODE_ENV === "development";
1705
2224
  }
@@ -1791,6 +2310,31 @@ function shouldSweepOrphanSchedules() {
1791
2310
  return getBossConfig()?.sweepOrphanSchedules ?? false;
1792
2311
  }
1793
2312
 
2313
+ // src/job/queue-policy.ts
2314
+ function resolveQueuePolicy(job2) {
2315
+ if (job2.options?.policy) {
2316
+ return job2.options.policy;
2317
+ }
2318
+ if (job2.runOnce || job2.options?.singletonKey) {
2319
+ return "exclusive";
2320
+ }
2321
+ return "standard";
2322
+ }
2323
+
2324
+ // src/job/retry-options.ts
2325
+ function toPgBossRetryOptions(options) {
2326
+ return {
2327
+ retryLimit: options?.retryLimit ?? 3,
2328
+ retryDelay: Math.ceil((options?.retryDelay ?? 1e3) / 1e3),
2329
+ // Exponential backoff by default — a failed cohort (e.g. a provider 429)
2330
+ // would otherwise all retry at the same fixed offset (thundering herd).
2331
+ retryBackoff: options?.retryBackoff ?? true
2332
+ };
2333
+ }
2334
+
2335
+ // src/job/job-builder.ts
2336
+ logger.child("@spfn/core:job");
2337
+
1794
2338
  // src/job/job-router.ts
1795
2339
  function isJobDef(value) {
1796
2340
  return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
@@ -1798,29 +2342,44 @@ function isJobDef(value) {
1798
2342
  function isJobRouter(value) {
1799
2343
  return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
1800
2344
  }
1801
- function collectJobs(router, prefix = "") {
1802
- const jobs = [];
2345
+ function defineJobRouter(jobs) {
2346
+ return {
2347
+ jobs,
2348
+ _jobs: jobs
2349
+ };
2350
+ }
2351
+ function mergeJobRouters(base, extra) {
2352
+ for (const key of Object.keys(extra.jobs)) {
2353
+ if (key in base.jobs) {
2354
+ throw new Error(
2355
+ `jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
2356
+ );
2357
+ }
2358
+ }
2359
+ return defineJobRouter({ ...base.jobs, ...extra.jobs });
2360
+ }
2361
+ function collectJobEntries(router, prefix = "") {
2362
+ const entries = [];
1803
2363
  for (const [key, value] of Object.entries(router.jobs)) {
1804
- const name = prefix ? `${prefix}.${key}` : key;
2364
+ const path = prefix ? `${prefix}.${key}` : key;
1805
2365
  if (isJobRouter(value)) {
1806
- jobs.push(...collectJobs(value, name));
2366
+ entries.push(...collectJobEntries(value, path));
1807
2367
  } else if (isJobDef(value)) {
1808
- jobs.push(value);
2368
+ entries.push({ key: path, job: value });
1809
2369
  }
1810
2370
  }
1811
- return jobs;
2371
+ return entries;
1812
2372
  }
1813
- var jobLogger2 = logger.child("@spfn/core:job");
2373
+ function collectJobs(router, prefix = "") {
2374
+ return collectJobEntries(router, prefix).map((entry) => entry.job);
2375
+ }
2376
+ var jobLogger3 = logger.child("@spfn/core:job");
1814
2377
  function getEventQueueName(eventName) {
1815
2378
  return `event:${eventName}`;
1816
2379
  }
1817
2380
  function getDefaultJobOptions(options) {
1818
2381
  return {
1819
- retryLimit: options?.retryLimit ?? 3,
1820
- retryDelay: options?.retryDelay ?? 1e3,
1821
- // Exponential backoff by default — a failed cohort (e.g. a provider 429)
1822
- // would otherwise all retry at the same fixed offset (thundering herd).
1823
- retryBackoff: options?.retryBackoff ?? true,
2382
+ ...toPgBossRetryOptions(options),
1824
2383
  expireInSeconds: options?.expireInSeconds ?? 300
1825
2384
  };
1826
2385
  }
@@ -1831,11 +2390,12 @@ async function registerJobs(router) {
1831
2390
  "pg-boss not initialized. Call initBoss() before registerJobs()"
1832
2391
  );
1833
2392
  }
2393
+ assertUniqueJobNames(router);
1834
2394
  const jobs = collectJobs(router);
1835
2395
  const clearOnStart = shouldClearOnStart();
1836
- jobLogger2.info(`Registering ${jobs.length} job(s)...`);
2396
+ jobLogger3.info(`Registering ${jobs.length} job(s)...`);
1837
2397
  if (clearOnStart) {
1838
- jobLogger2.info("Clearing existing jobs before registration...");
2398
+ jobLogger3.info("Clearing existing jobs before registration...");
1839
2399
  for (const job2 of jobs) {
1840
2400
  await boss.deleteAllJobs(job2.name);
1841
2401
  if (job2.subscribedEvent) {
@@ -1843,7 +2403,7 @@ async function registerJobs(router) {
1843
2403
  await boss.deleteAllJobs(eventQueue);
1844
2404
  }
1845
2405
  }
1846
- jobLogger2.info("Existing jobs cleared");
2406
+ jobLogger3.info("Existing jobs cleared");
1847
2407
  }
1848
2408
  await Promise.all(jobs.map((job2) => registerJob(job2)));
1849
2409
  for (const job2 of jobs) {
@@ -1854,42 +2414,77 @@ async function registerJobs(router) {
1854
2414
  if (shouldSweepOrphanSchedules()) {
1855
2415
  await sweepOrphanSchedules(boss);
1856
2416
  }
1857
- jobLogger2.info("All jobs registered successfully");
2417
+ jobLogger3.info("All jobs registered successfully");
2418
+ }
2419
+ function assertUniqueJobNames(router) {
2420
+ const keysByName = /* @__PURE__ */ new Map();
2421
+ for (const { key, job: job2 } of collectJobEntries(router)) {
2422
+ const existingKey = keysByName.get(job2.name);
2423
+ if (existingKey) {
2424
+ throw new Error(
2425
+ `registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
2426
+ );
2427
+ }
2428
+ keysByName.set(job2.name, key);
2429
+ }
1858
2430
  }
1859
2431
  var registeredCronNames = /* @__PURE__ */ new Set();
1860
2432
  async function sweepOrphanSchedules(boss) {
1861
2433
  if (registeredCronNames.size === 0) {
1862
- jobLogger2.debug("No cron jobs declared; skipping orphan schedule sweep");
2434
+ jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
1863
2435
  return;
1864
2436
  }
1865
2437
  try {
1866
2438
  const schedules = await boss.getSchedules();
1867
2439
  const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
1868
2440
  if (orphans.length === 0) {
1869
- jobLogger2.debug("No orphan schedules found");
2441
+ jobLogger3.debug("No orphan schedules found");
1870
2442
  return;
1871
2443
  }
1872
2444
  await Promise.all(orphans.map(async (orphan) => {
1873
2445
  try {
1874
2446
  await boss.unschedule(orphan.name, orphan.key);
1875
- jobLogger2.info(`Removed orphan schedule: ${orphan.name}`);
2447
+ jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
1876
2448
  } catch (error) {
1877
- jobLogger2.error(`Failed to remove orphan schedule: ${orphan.name}`, {
2449
+ jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
1878
2450
  error: error instanceof Error ? error.message : String(error)
1879
2451
  });
1880
2452
  }
1881
2453
  }));
1882
2454
  } catch (error) {
1883
- jobLogger2.error("Orphan schedule sweep failed", {
2455
+ jobLogger3.error("Orphan schedule sweep failed", {
1884
2456
  error: error instanceof Error ? error.message : String(error)
1885
2457
  });
1886
2458
  }
1887
2459
  }
1888
- async function ensureQueue(boss, queueName) {
1889
- await boss.createQueue(queueName);
2460
+ async function ensureQueue(boss, queueName, policy) {
2461
+ await boss.createQueue(queueName, { policy });
2462
+ const existing = await boss.getQueue(queueName);
2463
+ if (!existing || existing.policy === policy) {
2464
+ return;
2465
+ }
2466
+ if (shouldClearOnStart()) {
2467
+ await boss.deleteQueue(queueName);
2468
+ await boss.createQueue(queueName, { policy });
2469
+ jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
2470
+ return;
2471
+ }
2472
+ jobLogger3.error(
2473
+ `[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`
2474
+ );
2475
+ }
2476
+ function createQueueEnsurer(boss, policy) {
2477
+ const ensured = /* @__PURE__ */ new Set();
2478
+ return async (queueName) => {
2479
+ if (ensured.has(queueName)) {
2480
+ return;
2481
+ }
2482
+ ensured.add(queueName);
2483
+ await ensureQueue(boss, queueName, policy);
2484
+ };
1890
2485
  }
1891
2486
  async function executeJobHandler(job2, pgBossJob) {
1892
- jobLogger2.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
2487
+ jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
1893
2488
  const startTime = Date.now();
1894
2489
  try {
1895
2490
  if (job2.inputSchema) {
@@ -1898,13 +2493,13 @@ async function executeJobHandler(job2, pgBossJob) {
1898
2493
  await job2.handler();
1899
2494
  }
1900
2495
  const duration = Date.now() - startTime;
1901
- jobLogger2.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
2496
+ jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
1902
2497
  jobId: pgBossJob.id,
1903
2498
  duration
1904
2499
  });
1905
2500
  } catch (error) {
1906
2501
  const duration = Date.now() - startTime;
1907
- jobLogger2.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
2502
+ jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
1908
2503
  jobId: pgBossJob.id,
1909
2504
  duration,
1910
2505
  error: error instanceof Error ? error.message : String(error)
@@ -1912,8 +2507,8 @@ async function executeJobHandler(job2, pgBossJob) {
1912
2507
  throw error;
1913
2508
  }
1914
2509
  }
1915
- async function registerWorker(boss, job2, queueName) {
1916
- await ensureQueue(boss, queueName);
2510
+ async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
2511
+ await ensureQueueOnce(queueName);
1917
2512
  const batchSize = job2.options?.batchSize ?? 1;
1918
2513
  const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
1919
2514
  await boss.work(
@@ -1947,28 +2542,28 @@ function connectEventToQueue(boss, job2, queueName) {
1947
2542
  eventDef._registerJobQueue(queueName, async (queue, payload) => {
1948
2543
  await boss.send(queue, payload, getDefaultJobOptions(job2.options));
1949
2544
  });
1950
- jobLogger2.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
2545
+ jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
1951
2546
  }
1952
- async function registerCronSchedule(boss, job2) {
2547
+ async function registerCronSchedule(boss, job2, ensureQueueOnce) {
1953
2548
  if (!job2.cronExpression) {
1954
2549
  return;
1955
2550
  }
1956
- jobLogger2.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
1957
- await ensureQueue(boss, job2.name);
2551
+ jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
2552
+ await ensureQueueOnce(job2.name);
1958
2553
  await boss.schedule(
1959
2554
  job2.name,
1960
2555
  job2.cronExpression,
1961
2556
  {},
1962
2557
  getDefaultJobOptions(job2.options)
1963
2558
  );
1964
- jobLogger2.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
2559
+ jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
1965
2560
  }
1966
- async function queueRunOnceJob(boss, job2) {
2561
+ async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
1967
2562
  if (!job2.runOnce) {
1968
2563
  return;
1969
2564
  }
1970
- jobLogger2.debug(`[Job:${job2.name}] Queuing runOnce job`);
1971
- await ensureQueue(boss, job2.name);
2565
+ jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
2566
+ await ensureQueueOnce(job2.name);
1972
2567
  await boss.send(
1973
2568
  job2.name,
1974
2569
  {},
@@ -1977,7 +2572,7 @@ async function queueRunOnceJob(boss, job2) {
1977
2572
  singletonKey: `runOnce:${job2.name}`
1978
2573
  }
1979
2574
  );
1980
- jobLogger2.info(`[Job:${job2.name}] runOnce job queued`);
2575
+ jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
1981
2576
  }
1982
2577
  async function registerJob(job2) {
1983
2578
  const boss = getBoss();
@@ -1985,15 +2580,16 @@ async function registerJob(job2) {
1985
2580
  throw new Error("pg-boss not initialized");
1986
2581
  }
1987
2582
  const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
1988
- jobLogger2.debug(`Registering job: ${job2.name}`, {
2583
+ jobLogger3.debug(`Registering job: ${job2.name}`, {
1989
2584
  queueName,
1990
2585
  subscribedEvent: job2.subscribedEvent
1991
2586
  });
1992
- await registerWorker(boss, job2, queueName);
2587
+ const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
2588
+ await registerWorker(boss, job2, queueName, ensureQueueOnce);
1993
2589
  connectEventToQueue(boss, job2, queueName);
1994
- await registerCronSchedule(boss, job2);
1995
- await queueRunOnceJob(boss, job2);
1996
- jobLogger2.debug(`Job registered: ${job2.name}`);
2590
+ await registerCronSchedule(boss, job2, ensureQueueOnce);
2591
+ await queueRunOnceJob(boss, job2, ensureQueueOnce);
2592
+ jobLogger3.debug(`Job registered: ${job2.name}`);
1997
2593
  }
1998
2594
  var wsLogger = logger.child("@spfn/core:ws");
1999
2595
  function isOriginAllowed(origin, allowList) {
@@ -2285,6 +2881,89 @@ function printBanner(options) {
2285
2881
  }
2286
2882
  console.log("");
2287
2883
  }
2884
+ var PORT_DEFAULTS = {
2885
+ server: 8790
2886
+ };
2887
+ var HOST_DEFAULT = "localhost";
2888
+ var CONFIG_FILE_NAMES = [
2889
+ "spfn.config.js",
2890
+ "spfn.config.mjs"
2891
+ ];
2892
+ async function loadAppConfig(cwd = process.cwd()) {
2893
+ for (const fileName of CONFIG_FILE_NAMES) {
2894
+ const fullPath = join(cwd, fileName);
2895
+ if (!existsSync(fullPath)) {
2896
+ continue;
2897
+ }
2898
+ try {
2899
+ const module = await import(pathToFileURL(fullPath).href);
2900
+ return module.default ?? {};
2901
+ } catch (error) {
2902
+ console.warn(
2903
+ `\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
2904
+ );
2905
+ return {};
2906
+ }
2907
+ }
2908
+ return {};
2909
+ }
2910
+ function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
2911
+ return {
2912
+ port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
2913
+ host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
2914
+ };
2915
+ }
2916
+ function readPort(value) {
2917
+ if (!value) {
2918
+ return void 0;
2919
+ }
2920
+ const port = Number(value);
2921
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
2922
+ }
2923
+ logger.child("@spfn/core:env-registry");
2924
+ function validateAllEnv(registries) {
2925
+ const errors = [];
2926
+ const warnings = [];
2927
+ for (const registry of registries) {
2928
+ const result = registry.validateAll();
2929
+ errors.push(...result.errors);
2930
+ warnings.push(...result.warnings);
2931
+ }
2932
+ return {
2933
+ valid: errors.length === 0,
2934
+ errors,
2935
+ warnings
2936
+ };
2937
+ }
2938
+
2939
+ // src/server/env-boot-check.ts
2940
+ function runEnvBootCheck(config) {
2941
+ const result = validateAllEnv([registry, ...config.env?.registries ?? []]);
2942
+ const errors = uniqueByMessage(result.errors);
2943
+ const warnings = uniqueByMessage(result.warnings);
2944
+ for (const warning of warnings) {
2945
+ serverLogger.warn(`Environment: ${warning.message}`);
2946
+ }
2947
+ if (errors.length > 0) {
2948
+ serverLogger.error(
2949
+ `Environment validation failed (${errors.length}):
2950
+ ` + errors.map((error) => ` - ${error.message}`).join("\n")
2951
+ );
2952
+ }
2953
+ return { valid: errors.length === 0, errors, warnings };
2954
+ }
2955
+ function uniqueByMessage(entries) {
2956
+ const seen = /* @__PURE__ */ new Set();
2957
+ return entries.filter((entry) => {
2958
+ const id = `${entry.key}
2959
+ ${entry.message}`;
2960
+ if (seen.has(id)) {
2961
+ return false;
2962
+ }
2963
+ seen.add(id);
2964
+ return true;
2965
+ });
2966
+ }
2288
2967
 
2289
2968
  // src/server/validation.ts
2290
2969
  function validateServerConfig(config) {
@@ -2333,14 +3012,20 @@ var TIMEOUTS = {
2333
3012
  REDIS_CLOSE: 5e3};
2334
3013
  var CONFIG_FILE_PATHS = [
2335
3014
  ".spfn/server/server.config.mjs",
3015
+ ".spfn/server/server.config.js",
2336
3016
  ".spfn/server/server.config",
2337
3017
  "src/server/server.config",
2338
3018
  "src/server/server.config.ts"
2339
3019
  ];
3020
+ var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
2340
3021
  var processHandlersRegistered = false;
2341
3022
  async function startServer(config) {
2342
3023
  loadEnv();
2343
3024
  const finalConfig = await loadAndMergeConfig(config);
3025
+ if (!runEnvBootCheck(finalConfig).valid) {
3026
+ serverLogger.error("Refusing to start: fix the environment variables listed above.");
3027
+ process.exit(1);
3028
+ }
2344
3029
  const { host, port, debug } = finalConfig;
2345
3030
  validateServerConfig(finalConfig);
2346
3031
  if (!host || !port) {
@@ -2400,8 +3085,7 @@ async function startServer(config) {
2400
3085
  throw error;
2401
3086
  }
2402
3087
  }
2403
- async function loadAndMergeConfig(config) {
2404
- const cwd = process.cwd();
3088
+ async function loadAndMergeConfig(config, cwd = process.cwd()) {
2405
3089
  let fileConfig = {};
2406
3090
  let loadedConfigPath = null;
2407
3091
  for (const configPath of CONFIG_FILE_PATHS) {
@@ -2419,16 +3103,37 @@ async function loadAndMergeConfig(config) {
2419
3103
  }
2420
3104
  if (loadedConfigPath) {
2421
3105
  serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
3106
+ } else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
3107
+ serverLogger.warn(
3108
+ `\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/.`
3109
+ );
2422
3110
  } else {
2423
3111
  serverLogger.debug("No configuration file found, using defaults");
2424
3112
  }
3113
+ const appConfig = await loadAppConfig(cwd);
3114
+ warnOnDeprecatedAddress(config, fileConfig);
3115
+ const address = resolveServerAddress(appConfig, {
3116
+ port: config?.port ?? fileConfig?.port,
3117
+ host: config?.host ?? fileConfig?.host
3118
+ });
2425
3119
  return {
2426
3120
  ...fileConfig,
2427
3121
  ...config,
2428
- port: config?.port ?? fileConfig?.port ?? env.PORT,
2429
- host: config?.host ?? fileConfig?.host ?? env.HOST
3122
+ port: address.port,
3123
+ host: address.host
2430
3124
  };
2431
3125
  }
3126
+ function warnOnDeprecatedAddress(config, fileConfig) {
3127
+ const used = [
3128
+ (config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
3129
+ (config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
3130
+ ].filter(Boolean);
3131
+ if (used.length > 0) {
3132
+ serverLogger.warn(
3133
+ `\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.`
3134
+ );
3135
+ }
3136
+ }
2432
3137
  function getInfrastructureConfig(config) {
2433
3138
  return {
2434
3139
  database: config.infrastructure?.database !== false,
@@ -2811,6 +3516,9 @@ function createServerlessApp(config) {
2811
3516
  }
2812
3517
  async function buildServerlessApp(rawConfig) {
2813
3518
  loadEnv();
3519
+ if (!runEnvBootCheck(rawConfig ?? {}).valid) {
3520
+ throw new Error("Environment validation failed \u2014 the missing or invalid keys are logged above.");
3521
+ }
2814
3522
  const config = withServerlessDefaults(rawConfig);
2815
3523
  warnIfJobsConfigured(config);
2816
3524
  await initInfrastructure(config);
@@ -2961,6 +3669,20 @@ var ServerConfigBuilder = class {
2961
3669
  /**
2962
3670
  * Register background jobs router
2963
3671
  *
3672
+ * May be called once per domain router: repeated calls **merge** into one
3673
+ * router, the way `.routes()` composes. Calling it several times used to
3674
+ * keep only the last router and register nothing else, without warning.
3675
+ *
3676
+ * The pg-boss `config` is per server, so pass it on one call only.
3677
+ *
3678
+ * Two names can collide, and both fail loudly:
3679
+ * - a router **key** present in two calls throws here, at config time;
3680
+ * - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
3681
+ * `registerJobs()` at boot, before any queue or worker is created.
3682
+ *
3683
+ * @throws if a router key was already registered by an earlier call
3684
+ * @throws if `config` is given when an earlier call already gave one
3685
+ *
2964
3686
  * @example
2965
3687
  * ```typescript
2966
3688
  * import { job, defineJobRouter } from '@spfn/core/job';
@@ -2973,12 +3695,18 @@ var ServerConfigBuilder = class {
2973
3695
  *
2974
3696
  * export default defineServerConfig()
2975
3697
  * .routes(appRouter)
2976
- * .jobs(jobRouter)
3698
+ * .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
3699
+ * .jobs(billingJobRouter) // merged, not replaced
2977
3700
  * .build();
2978
3701
  * ```
2979
3702
  */
2980
3703
  jobs(router, config) {
2981
- this.config.jobs = router;
3704
+ if (config && this.config.jobsConfig) {
3705
+ throw new Error(
3706
+ "jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
3707
+ );
3708
+ }
3709
+ this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
2982
3710
  if (config) {
2983
3711
  this.config.jobsConfig = config;
2984
3712
  }
@@ -3088,6 +3816,15 @@ var ServerConfigBuilder = class {
3088
3816
  this.config.healthCheck = healthCheck;
3089
3817
  return this;
3090
3818
  }
3819
+ /**
3820
+ * Supply the clock used by the built-in `GET /_core/time` capability.
3821
+ * Production servers normally keep the default `Date.now()` clock; this is
3822
+ * exposed so tests can assert an exact wire value without replacing globals.
3823
+ */
3824
+ serverTime(serverTime) {
3825
+ this.config.serverTime = serverTime;
3826
+ return this;
3827
+ }
3091
3828
  /**
3092
3829
  * Configure infrastructure initialization
3093
3830
  */
@@ -3110,6 +3847,25 @@ var ServerConfigBuilder = class {
3110
3847
  this.config.migrations = migrations;
3111
3848
  return this;
3112
3849
  }
3850
+ /**
3851
+ * Hand the app's env registries to the boot check
3852
+ *
3853
+ * `@spfn/core`'s own registry is always checked; the registries given here
3854
+ * are checked with it, before the server listens.
3855
+ *
3856
+ * @example
3857
+ * ```typescript
3858
+ * import { envRegistry } from '@/server/config/env.config';
3859
+ *
3860
+ * export default defineServerConfig()
3861
+ * .env({ registries: [envRegistry] })
3862
+ * .build();
3863
+ * ```
3864
+ */
3865
+ env(env6) {
3866
+ this.config.env = env6;
3867
+ return this;
3868
+ }
3113
3869
  /**
3114
3870
  * Register workflow router for workflow orchestration
3115
3871
  *
@@ -3184,6 +3940,6 @@ function defineServerConfig() {
3184
3940
  return new ServerConfigBuilder();
3185
3941
  }
3186
3942
 
3187
- export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
3943
+ 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 };
3188
3944
  //# sourceMappingURL=index.js.map
3189
3945
  //# sourceMappingURL=index.js.map