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

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.
@@ -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';
@@ -1080,6 +1081,7 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
1080
1081
  // src/server/namespace.ts
1081
1082
  var CORE_NAMESPACE = "/_core";
1082
1083
  var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
1084
+ var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
1083
1085
  var LEGACY_HEALTH_PATH = "/health";
1084
1086
 
1085
1087
  // src/server/shutdown-manager.ts
@@ -1480,6 +1482,378 @@ function buildStartupConfig(config, timeouts) {
1480
1482
  };
1481
1483
  }
1482
1484
 
1485
+ // src/route/route-builder.ts
1486
+ var RouteBuilder = class _RouteBuilder {
1487
+ _method;
1488
+ _path;
1489
+ _input;
1490
+ _interceptor;
1491
+ _middlewares;
1492
+ _skipMiddlewares;
1493
+ _contract;
1494
+ /**
1495
+ * Create a new RouteBuilder with copied properties and optional overrides
1496
+ */
1497
+ clone(overrides) {
1498
+ const builder = new _RouteBuilder();
1499
+ builder._method = this._method;
1500
+ builder._path = this._path;
1501
+ builder._input = overrides?.input ?? this._input;
1502
+ builder._interceptor = overrides?.interceptor ?? this._interceptor;
1503
+ builder._middlewares = overrides?.middlewares ?? this._middlewares;
1504
+ builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
1505
+ builder._contract = overrides?.contract ?? this._contract;
1506
+ return builder;
1507
+ }
1508
+ /**
1509
+ * Define input schemas
1510
+ *
1511
+ * @example
1512
+ * ```ts
1513
+ * route.get('/users/:id')
1514
+ * .input({
1515
+ * params: Type.Object({ id: Type.String() }),
1516
+ * query: Type.Object({ page: Type.Number() }),
1517
+ * headers: Type.Object({ authorization: Type.String() })
1518
+ * })
1519
+ * .handler(async (c) => {
1520
+ * const { params, query, headers } = await c.data();
1521
+ * // params = { id: string }
1522
+ * // query = { page: number }
1523
+ * // headers = { authorization: string }
1524
+ * })
1525
+ * ```
1526
+ */
1527
+ input(input) {
1528
+ return this.clone({ input });
1529
+ }
1530
+ /**
1531
+ * Define fields injected by interceptors
1532
+ *
1533
+ * These fields are:
1534
+ * - Available in the handler (merged with input)
1535
+ * - Excluded from client types (codegen uses only input)
1536
+ * - Not validated by route input schema (injected by middleware)
1537
+ *
1538
+ * Use this when middleware/interceptors add fields to the request
1539
+ * before it reaches the handler.
1540
+ *
1541
+ * @example
1542
+ * ```ts
1543
+ * // Auth interceptor injects crypto key fields
1544
+ * route.post('/_auth/login')
1545
+ * .input({
1546
+ * body: Type.Object({
1547
+ * email: Type.String(),
1548
+ * password: Type.String()
1549
+ * })
1550
+ * })
1551
+ * .interceptor({
1552
+ * body: Type.Object({
1553
+ * publicKey: Type.String(),
1554
+ * keyId: Type.String(),
1555
+ * fingerprint: Type.String()
1556
+ * })
1557
+ * })
1558
+ * .handler(async (c) => {
1559
+ * const { body } = await c.data();
1560
+ * // body type: { email, password, publicKey, keyId, fingerprint }
1561
+ * // Client only sees: { email, password }
1562
+ * return loginService(body);
1563
+ * });
1564
+ * ```
1565
+ */
1566
+ interceptor(interceptor) {
1567
+ return this.clone({ interceptor });
1568
+ }
1569
+ /**
1570
+ * Add middlewares to the route
1571
+ *
1572
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1573
+ * Named middlewares that are already registered globally will be automatically
1574
+ * deduplicated to prevent double execution.
1575
+ *
1576
+ * @example
1577
+ * ```ts
1578
+ * import { authenticate } from '@spfn/auth/server/middleware';
1579
+ *
1580
+ * // With NamedMiddleware (auto-deduped if registered globally)
1581
+ * route.get('/users')
1582
+ * .use([authenticate, RateLimitMiddleware()])
1583
+ *
1584
+ * // With regular middleware handlers
1585
+ * route.get('/users')
1586
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1587
+ * ```
1588
+ */
1589
+ middleware(middlewares) {
1590
+ return this.clone({ middlewares });
1591
+ }
1592
+ /**
1593
+ * Add middlewares to the route (alias for `.middleware()`)
1594
+ *
1595
+ * Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
1596
+ * Named middlewares that are already registered globally will be automatically
1597
+ * deduplicated to prevent double execution.
1598
+ *
1599
+ * @example
1600
+ * ```ts
1601
+ * import { authenticate } from '@spfn/auth/server/middleware';
1602
+ *
1603
+ * // With NamedMiddleware (auto-deduped if registered globally)
1604
+ * route.get('/users')
1605
+ * .use([authenticate, RateLimitMiddleware()])
1606
+ *
1607
+ * // With regular middleware handlers
1608
+ * route.get('/users')
1609
+ * .use([AuthMiddleware(), RateLimitMiddleware()])
1610
+ * ```
1611
+ */
1612
+ use(middlewares) {
1613
+ return this.middleware(middlewares);
1614
+ }
1615
+ /**
1616
+ * Skip server-level named middlewares
1617
+ *
1618
+ * Useful for public endpoints that should bypass auth or rate limiting
1619
+ *
1620
+ * @param middlewareNames - Array of middleware names to skip, or '*' to skip all
1621
+ *
1622
+ * @example
1623
+ * ```ts
1624
+ * // Skip specific middlewares
1625
+ * route.get('/status')
1626
+ * .skip(['auth', 'rateLimit'])
1627
+ * .handler(async (c) => c.json({ status: 'ok' }));
1628
+ *
1629
+ * // Skip only auth (still apply rate limiting)
1630
+ * route.get('/public-data')
1631
+ * .skip(['auth'])
1632
+ * .handler(async (c) => { ... });
1633
+ *
1634
+ * // Skip all middlewares
1635
+ * route.get('/public-health')
1636
+ * .skip('*')
1637
+ * .handler(async (c) => c.json({ status: 'ok' }));
1638
+ * ```
1639
+ */
1640
+ skip(middlewareNames) {
1641
+ return this.clone({ skipMiddlewares: middlewareNames });
1642
+ }
1643
+ /**
1644
+ * Publish this route as a versioned contract operation
1645
+ *
1646
+ * Marks the route as a promise to clients that are compiled and deployed
1647
+ * separately from the server — a mobile app, an external API consumer.
1648
+ * The `@spfn/core:contract` generator writes every contracted route into
1649
+ * `contracts/current.json`, and the build refuses a change that would break
1650
+ * an already-released client.
1651
+ *
1652
+ * Routes without `.contract()` are unaffected: they simply do not appear in
1653
+ * the contract. A web client needs nothing here — it derives its types from
1654
+ * the router in the same build.
1655
+ *
1656
+ * @example
1657
+ * ```ts
1658
+ * export const getUser = route.get('/users/:id')
1659
+ * .input({ params: Type.Object({ id: Type.String() }) })
1660
+ * .contract({
1661
+ * since: '1.2.0',
1662
+ * auth: 'clientProofV1',
1663
+ * requiresSession: true,
1664
+ * response: Type.Object({
1665
+ * id: Type.String(),
1666
+ * name: Type.String(),
1667
+ * email: Type.Optional(Type.String()),
1668
+ * }),
1669
+ * })
1670
+ * .handler(async (c) => { ... });
1671
+ * ```
1672
+ */
1673
+ contract(contract) {
1674
+ return this.clone({ contract });
1675
+ }
1676
+ /**
1677
+ * Define handler function
1678
+ *
1679
+ * Response type is automatically inferred from the return value.
1680
+ * Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
1681
+ *
1682
+ * @example
1683
+ * ```ts
1684
+ * // Direct return - type inferred from data
1685
+ * route.get('/users/:id')
1686
+ * .input({ params: Type.Object({ id: Type.String() }) })
1687
+ * .handler(async (c) => {
1688
+ * const { params } = await c.data();
1689
+ * return await getUser(params.id); // Type: User
1690
+ * })
1691
+ *
1692
+ * // Using c.created() - returns data with 201 status, type preserved
1693
+ * route.post('/users')
1694
+ * .input({ body: Type.Object({ name: Type.String() }) })
1695
+ * .handler(async (c) => {
1696
+ * const { body } = await c.data();
1697
+ * return c.created(await createUser(body)); // Type: User
1698
+ * })
1699
+ *
1700
+ * // Using c.paginated() - returns PaginatedResult<T>
1701
+ * route.get('/users')
1702
+ * .handler(async (c) => {
1703
+ * const users = await getUsers();
1704
+ * return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
1705
+ * })
1706
+ *
1707
+ * // Using c.noContent() - returns void
1708
+ * route.delete('/users/:id')
1709
+ * .handler(async (c) => {
1710
+ * await deleteUser(params.id);
1711
+ * return c.noContent(); // Type: void
1712
+ * })
1713
+ *
1714
+ * // Using c.json() - returns Response (type inference lost)
1715
+ * // Use only when you need custom status codes not covered by helpers
1716
+ * route.get('/custom')
1717
+ * .handler(async (c) => {
1718
+ * return c.json({ data }, 418); // Type: Response
1719
+ * })
1720
+ * ```
1721
+ */
1722
+ handler(fn) {
1723
+ return {
1724
+ method: this._method,
1725
+ path: this._path,
1726
+ input: this._input,
1727
+ interceptor: this._interceptor,
1728
+ middlewares: this._middlewares,
1729
+ skipMiddlewares: this._skipMiddlewares,
1730
+ contract: this._contract,
1731
+ handler: fn,
1732
+ _input: {},
1733
+ _interceptor: {},
1734
+ _response: {}
1735
+ };
1736
+ }
1737
+ };
1738
+ function createMethodRoute(method) {
1739
+ return (path) => {
1740
+ const builder = new RouteBuilder();
1741
+ builder._method = method;
1742
+ builder._path = path;
1743
+ return builder;
1744
+ };
1745
+ }
1746
+ var route = {
1747
+ get: createMethodRoute("GET"),
1748
+ post: createMethodRoute("POST"),
1749
+ put: createMethodRoute("PUT"),
1750
+ patch: createMethodRoute("PATCH"),
1751
+ delete: createMethodRoute("DELETE")
1752
+ };
1753
+
1754
+ // src/route/router.ts
1755
+ function createRouterInstance(routes, packageRouters = [], globalMiddlewares = [], contractVersion = null) {
1756
+ return {
1757
+ routes,
1758
+ _routes: routes,
1759
+ _packageRouters: packageRouters,
1760
+ _globalMiddlewares: globalMiddlewares,
1761
+ _contractVersion: contractVersion,
1762
+ packages(routers) {
1763
+ const newPackageRouters = [...this._packageRouters, ...routers];
1764
+ for (const pkgRouter of routers) {
1765
+ if (pkgRouter._packageRouters?.length > 0) {
1766
+ newPackageRouters.push(...pkgRouter._packageRouters);
1767
+ }
1768
+ }
1769
+ return createRouterInstance(
1770
+ this.routes,
1771
+ newPackageRouters,
1772
+ this._globalMiddlewares,
1773
+ this._contractVersion
1774
+ );
1775
+ },
1776
+ use(middlewares) {
1777
+ return createRouterInstance(
1778
+ this.routes,
1779
+ this._packageRouters,
1780
+ [...this._globalMiddlewares, ...middlewares],
1781
+ this._contractVersion
1782
+ );
1783
+ },
1784
+ contractVersion(version) {
1785
+ assertContractVersion(version);
1786
+ return createRouterInstance(
1787
+ this.routes,
1788
+ this._packageRouters,
1789
+ this._globalMiddlewares,
1790
+ version
1791
+ );
1792
+ }
1793
+ };
1794
+ }
1795
+ function assertContractVersion(version) {
1796
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
1797
+ throw new Error(
1798
+ `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.`
1799
+ );
1800
+ }
1801
+ }
1802
+ function defineRouter(routes) {
1803
+ return createRouterInstance(routes);
1804
+ }
1805
+
1806
+ // src/route/validation.ts
1807
+ FormatRegistry.Set(
1808
+ "email",
1809
+ (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
1810
+ );
1811
+ FormatRegistry.Set(
1812
+ "uri",
1813
+ (value) => /^https?:\/\/.+/.test(value)
1814
+ );
1815
+ FormatRegistry.Set(
1816
+ "uuid",
1817
+ (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
1818
+ );
1819
+ FormatRegistry.Set(
1820
+ "date",
1821
+ (value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
1822
+ );
1823
+ FormatRegistry.Set(
1824
+ "date-time",
1825
+ (value) => !isNaN(Date.parse(value))
1826
+ );
1827
+
1828
+ // src/server/server-time.ts
1829
+ var CORE_TIME_OPERATION_ID = "core.time";
1830
+ var ServerTimeResponseSchema = Type.Object({
1831
+ serverTimeMillis: Type.Integer()
1832
+ }, { additionalProperties: false });
1833
+ var systemServerClock = {
1834
+ now: () => Date.now()
1835
+ };
1836
+ function createCoreTimeRoute(clock = systemServerClock) {
1837
+ return route.get(CORE_TIME_PATH).skip("*").contract({
1838
+ since: "0.3.0",
1839
+ auth: "none",
1840
+ requiresSession: false,
1841
+ response: ServerTimeResponseSchema
1842
+ }).handler(async (c) => {
1843
+ const response = {
1844
+ serverTimeMillis: clock.now()
1845
+ };
1846
+ c.raw.header("Cache-Control", "no-store");
1847
+ return response;
1848
+ });
1849
+ }
1850
+ var CORE_TIME_ROUTE = createCoreTimeRoute();
1851
+ function createCoreTimeRouter(clock) {
1852
+ return defineRouter({
1853
+ [CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
1854
+ });
1855
+ }
1856
+
1483
1857
  // src/server/create-server.ts
1484
1858
  var rateLimitApplied = /* @__PURE__ */ new WeakSet();
1485
1859
  async function createServer(config) {
@@ -1522,6 +1896,7 @@ async function createAutoConfiguredApp(config) {
1522
1896
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1523
1897
  await applyProxyGuard(app, config);
1524
1898
  applyRateLimit(config);
1899
+ registerCoreTimeEndpoint(app, config);
1525
1900
  if (Array.isArray(config?.use)) {
1526
1901
  config.use.forEach((mw) => app.use("*", mw));
1527
1902
  }
@@ -1571,7 +1946,7 @@ async function applyProxyGuard(app, config) {
1571
1946
  serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1572
1947
  }
1573
1948
  }
1574
- const autoSkip = [CORE_HEALTH_PATH, LEGACY_HEALTH_PATH];
1949
+ const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
1575
1950
  if (config?.healthCheck?.path) {
1576
1951
  autoSkip.push(config.healthCheck.path);
1577
1952
  }
@@ -1628,6 +2003,10 @@ function resolveHealthCheck(config) {
1628
2003
  detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
1629
2004
  };
1630
2005
  }
2006
+ function registerCoreTimeEndpoint(app, config) {
2007
+ registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
2008
+ serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
2009
+ }
1631
2010
  function registerCoreHealthEndpoint(app, config) {
1632
2011
  const { enabled, path, detailed } = resolveHealthCheck(config);
1633
2012
  if (!enabled) {
@@ -3231,6 +3610,15 @@ var ServerConfigBuilder = class {
3231
3610
  this.config.healthCheck = healthCheck;
3232
3611
  return this;
3233
3612
  }
3613
+ /**
3614
+ * Supply the clock used by the built-in `GET /_core/time` capability.
3615
+ * Production servers normally keep the default `Date.now()` clock; this is
3616
+ * exposed so tests can assert an exact wire value without replacing globals.
3617
+ */
3618
+ serverTime(serverTime) {
3619
+ this.config.serverTime = serverTime;
3620
+ return this;
3621
+ }
3234
3622
  /**
3235
3623
  * Configure infrastructure initialization
3236
3624
  */
@@ -3327,6 +3715,6 @@ function defineServerConfig() {
3327
3715
  return new ServerConfigBuilder();
3328
3716
  }
3329
3717
 
3330
- export { CORE_HEALTH_PATH, CORE_NAMESPACE, PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
3718
+ 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
3719
  //# sourceMappingURL=index.js.map
3332
3720
  //# sourceMappingURL=index.js.map