@spfn/core 0.3.0-beta.2 → 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.
@@ -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;
@@ -1076,6 +1078,12 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
1076
1078
  );
1077
1079
  }
1078
1080
 
1081
+ // src/server/namespace.ts
1082
+ var CORE_NAMESPACE = "/_core";
1083
+ var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
1084
+ var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
1085
+ var LEGACY_HEALTH_PATH = "/health";
1086
+
1079
1087
  // src/server/shutdown-manager.ts
1080
1088
  var DEFAULT_HOOK_TIMEOUT = 1e4;
1081
1089
  var DEFAULT_HOOK_ORDER = 100;
@@ -1308,7 +1316,7 @@ async function readMigrationHealth() {
1308
1316
  targets: migrationTargets(snapshot.status)
1309
1317
  };
1310
1318
  }
1311
- function createHealthCheckHandler(detailed) {
1319
+ function createHealthCheckHandler(detailed, infrastructure) {
1312
1320
  return async (c) => {
1313
1321
  const shutdownManager = getShutdownManager();
1314
1322
  if (shutdownManager.isShuttingDown()) {
@@ -1322,23 +1330,29 @@ function createHealthCheckHandler(detailed) {
1322
1330
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1323
1331
  };
1324
1332
  if (detailed) {
1333
+ const databaseDisabled = infrastructure?.database === false;
1334
+ const redisDisabled = infrastructure?.redis === false;
1325
1335
  let dbStatus = "unknown";
1326
1336
  let dbError;
1327
- try {
1328
- const db = getDatabase();
1337
+ if (databaseDisabled) {
1338
+ dbStatus = "disabled";
1339
+ } else {
1329
1340
  try {
1330
- await db.execute("SELECT 1");
1331
- dbStatus = "connected";
1341
+ const db = getDatabase();
1342
+ try {
1343
+ await db.execute("SELECT 1");
1344
+ dbStatus = "connected";
1345
+ } catch (error) {
1346
+ dbStatus = "error";
1347
+ dbError = error instanceof Error ? error.message : String(error);
1348
+ }
1332
1349
  } catch (error) {
1333
- dbStatus = "error";
1334
- dbError = error instanceof Error ? error.message : String(error);
1350
+ dbStatus = "not_initialized";
1351
+ dbError = "Database not available";
1335
1352
  }
1336
- } catch (error) {
1337
- dbStatus = "not_initialized";
1338
- dbError = "Database not available";
1339
1353
  }
1340
- const redis = getCache();
1341
- let redisStatus = redis ? "unknown" : "not_initialized";
1354
+ const redis = redisDisabled ? null : getCache();
1355
+ let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
1342
1356
  let redisError;
1343
1357
  if (redis) {
1344
1358
  try {
@@ -1438,7 +1452,7 @@ function buildStartupConfig(config, timeouts) {
1438
1452
  const middlewareConfig = config.middleware ?? {};
1439
1453
  const healthCheckConfig = config.healthCheck ?? {};
1440
1454
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
1441
- const healthCheckPath = healthCheckConfig.path ?? "/health";
1455
+ const healthCheckPath = healthCheckConfig.path;
1442
1456
  const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
1443
1457
  return {
1444
1458
  middleware: {
@@ -1449,6 +1463,7 @@ function buildStartupConfig(config, timeouts) {
1449
1463
  },
1450
1464
  healthCheck: healthCheckEnabled ? {
1451
1465
  enabled: true,
1466
+ corePath: CORE_HEALTH_PATH,
1452
1467
  path: healthCheckPath,
1453
1468
  detailed: healthCheckDetailed
1454
1469
  } : { enabled: false },
@@ -1467,6 +1482,378 @@ function buildStartupConfig(config, timeouts) {
1467
1482
  };
1468
1483
  }
1469
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
+
1470
1857
  // src/server/create-server.ts
1471
1858
  var rateLimitApplied = /* @__PURE__ */ new WeakSet();
1472
1859
  async function createServer(config) {
@@ -1509,12 +1896,16 @@ async function createAutoConfiguredApp(config) {
1509
1896
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1510
1897
  await applyProxyGuard(app, config);
1511
1898
  applyRateLimit(config);
1899
+ registerCoreTimeEndpoint(app, config);
1512
1900
  if (Array.isArray(config?.use)) {
1513
1901
  config.use.forEach((mw) => app.use("*", mw));
1514
1902
  }
1515
- registerHealthCheckEndpoint(app, config);
1903
+ registerCoreHealthEndpoint(app, config);
1516
1904
  await executeBeforeRoutesHook(app, config);
1517
- await loadAppRoutes(app, config);
1905
+ const appRoutes = await loadAppRoutes(app, config);
1906
+ registerMovedHealthSignpost(app, config, appRoutes);
1907
+ warnOnShadowedOptInPath(config, appRoutes);
1908
+ warnOnCoreNamespaceRoutes(appRoutes);
1518
1909
  await registerSSEEndpoint(app, config);
1519
1910
  await executeAfterRoutesHook(app, config);
1520
1911
  if (enableErrorHandler) {
@@ -1555,7 +1946,10 @@ async function applyProxyGuard(app, config) {
1555
1946
  serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1556
1947
  }
1557
1948
  }
1558
- const autoSkip = [config?.healthCheck?.path ?? "/health"];
1949
+ const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
1950
+ if (config?.healthCheck?.path) {
1951
+ autoSkip.push(config.healthCheck.path);
1952
+ }
1559
1953
  if (config?.events) {
1560
1954
  autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
1561
1955
  }
@@ -1598,15 +1992,79 @@ function applyOutboundFetch(config) {
1598
1992
  const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
1599
1993
  setDefaultSafeFetchPolicy(policy);
1600
1994
  }
1601
- function registerHealthCheckEndpoint(app, config) {
1995
+ function resolveHealthCheck(config) {
1602
1996
  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}`);
1997
+ return {
1998
+ enabled: healthCheckConfig.enabled !== false,
1999
+ // No default. An unset `path` means the endpoint answers at
2000
+ // CORE_HEALTH_PATH and nowhere else — a default here is what used to put
2001
+ // it on /health without anyone asking.
2002
+ path: healthCheckConfig.path,
2003
+ detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
2004
+ };
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
+ }
2010
+ function registerCoreHealthEndpoint(app, config) {
2011
+ const { enabled, path, detailed } = resolveHealthCheck(config);
2012
+ if (!enabled) {
2013
+ return;
2014
+ }
2015
+ const handler = createHealthCheckHandler(detailed, config?.infrastructure);
2016
+ app.get(CORE_HEALTH_PATH, handler);
2017
+ serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
2018
+ if (path && path !== CORE_HEALTH_PATH) {
2019
+ app.get(path, handler);
2020
+ serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
2021
+ }
2022
+ }
2023
+ function registerMovedHealthSignpost(app, config, appRoutes) {
2024
+ const { enabled, path } = resolveHealthCheck(config);
2025
+ if (!enabled || path === LEGACY_HEALTH_PATH) {
2026
+ return;
2027
+ }
2028
+ if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
2029
+ return;
2030
+ }
2031
+ let warned2 = false;
2032
+ app.get(LEGACY_HEALTH_PATH, (c) => {
2033
+ if (!warned2) {
2034
+ warned2 = true;
2035
+ serverLogger.warn(
2036
+ `\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.`
2037
+ );
2038
+ }
2039
+ return c.json({
2040
+ error: "health endpoint moved",
2041
+ movedTo: CORE_HEALTH_PATH,
2042
+ 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}' }).`
2043
+ }, 410);
2044
+ });
2045
+ }
2046
+ function warnOnShadowedOptInPath(config, appRoutes) {
2047
+ const { enabled, path } = resolveHealthCheck(config);
2048
+ if (!enabled || !path || path === CORE_HEALTH_PATH) {
2049
+ return;
2050
+ }
2051
+ const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
2052
+ if (shadowed.length === 0) {
2053
+ return;
1609
2054
  }
2055
+ serverLogger.warn(
2056
+ `\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.`
2057
+ );
2058
+ }
2059
+ function warnOnCoreNamespaceRoutes(appRoutes) {
2060
+ const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
2061
+ if (inside.length === 0) {
2062
+ return;
2063
+ }
2064
+ const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
2065
+ serverLogger.warn(
2066
+ `\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.`
2067
+ );
1610
2068
  }
1611
2069
  async function executeBeforeRoutesHook(app, config) {
1612
2070
  if (config?.lifecycle?.beforeRoutes) {
@@ -1618,9 +2076,12 @@ async function loadAppRoutes(app, config) {
1618
2076
  if (config?.routes) {
1619
2077
  const routes = registerRoutes(app, config.routes, config.middlewares);
1620
2078
  logRegisteredRoutes(routes, debug);
1621
- } else if (debug) {
2079
+ return routes;
2080
+ }
2081
+ if (debug) {
1622
2082
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
1623
2083
  }
2084
+ return [];
1624
2085
  }
1625
2086
  function logRegisteredRoutes(routes, debug) {
1626
2087
  if (routes.length === 0) {
@@ -2285,6 +2746,45 @@ function printBanner(options) {
2285
2746
  }
2286
2747
  console.log("");
2287
2748
  }
2749
+ var PORT_DEFAULTS = {
2750
+ server: 8790
2751
+ };
2752
+ var HOST_DEFAULT = "localhost";
2753
+ var CONFIG_FILE_NAMES = [
2754
+ "spfn.config.js",
2755
+ "spfn.config.mjs"
2756
+ ];
2757
+ async function loadAppConfig(cwd = process.cwd()) {
2758
+ for (const fileName of CONFIG_FILE_NAMES) {
2759
+ const fullPath = join(cwd, fileName);
2760
+ if (!existsSync(fullPath)) {
2761
+ continue;
2762
+ }
2763
+ try {
2764
+ const module = await import(pathToFileURL(fullPath).href);
2765
+ return module.default ?? {};
2766
+ } catch (error) {
2767
+ console.warn(
2768
+ `\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
2769
+ );
2770
+ return {};
2771
+ }
2772
+ }
2773
+ return {};
2774
+ }
2775
+ function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
2776
+ return {
2777
+ port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
2778
+ host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
2779
+ };
2780
+ }
2781
+ function readPort(value) {
2782
+ if (!value) {
2783
+ return void 0;
2784
+ }
2785
+ const port = Number(value);
2786
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
2787
+ }
2288
2788
 
2289
2789
  // src/server/validation.ts
2290
2790
  function validateServerConfig(config) {
@@ -2333,10 +2833,12 @@ var TIMEOUTS = {
2333
2833
  REDIS_CLOSE: 5e3};
2334
2834
  var CONFIG_FILE_PATHS = [
2335
2835
  ".spfn/server/server.config.mjs",
2836
+ ".spfn/server/server.config.js",
2336
2837
  ".spfn/server/server.config",
2337
2838
  "src/server/server.config",
2338
2839
  "src/server/server.config.ts"
2339
2840
  ];
2841
+ var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
2340
2842
  var processHandlersRegistered = false;
2341
2843
  async function startServer(config) {
2342
2844
  loadEnv();
@@ -2400,8 +2902,7 @@ async function startServer(config) {
2400
2902
  throw error;
2401
2903
  }
2402
2904
  }
2403
- async function loadAndMergeConfig(config) {
2404
- const cwd = process.cwd();
2905
+ async function loadAndMergeConfig(config, cwd = process.cwd()) {
2405
2906
  let fileConfig = {};
2406
2907
  let loadedConfigPath = null;
2407
2908
  for (const configPath of CONFIG_FILE_PATHS) {
@@ -2419,16 +2920,37 @@ async function loadAndMergeConfig(config) {
2419
2920
  }
2420
2921
  if (loadedConfigPath) {
2421
2922
  serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
2923
+ } else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
2924
+ serverLogger.warn(
2925
+ `\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/.`
2926
+ );
2422
2927
  } else {
2423
2928
  serverLogger.debug("No configuration file found, using defaults");
2424
2929
  }
2930
+ const appConfig = await loadAppConfig(cwd);
2931
+ warnOnDeprecatedAddress(config, fileConfig);
2932
+ const address = resolveServerAddress(appConfig, {
2933
+ port: config?.port ?? fileConfig?.port,
2934
+ host: config?.host ?? fileConfig?.host
2935
+ });
2425
2936
  return {
2426
2937
  ...fileConfig,
2427
2938
  ...config,
2428
- port: config?.port ?? fileConfig?.port ?? env.PORT,
2429
- host: config?.host ?? fileConfig?.host ?? env.HOST
2939
+ port: address.port,
2940
+ host: address.host
2430
2941
  };
2431
2942
  }
2943
+ function warnOnDeprecatedAddress(config, fileConfig) {
2944
+ const used = [
2945
+ (config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
2946
+ (config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
2947
+ ].filter(Boolean);
2948
+ if (used.length > 0) {
2949
+ serverLogger.warn(
2950
+ `\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.`
2951
+ );
2952
+ }
2953
+ }
2432
2954
  function getInfrastructureConfig(config) {
2433
2955
  return {
2434
2956
  database: config.infrastructure?.database !== false,
@@ -3088,6 +3610,15 @@ var ServerConfigBuilder = class {
3088
3610
  this.config.healthCheck = healthCheck;
3089
3611
  return this;
3090
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
+ }
3091
3622
  /**
3092
3623
  * Configure infrastructure initialization
3093
3624
  */
@@ -3184,6 +3715,6 @@ function defineServerConfig() {
3184
3715
  return new ServerConfigBuilder();
3185
3716
  }
3186
3717
 
3187
- export { 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 };
3188
3719
  //# sourceMappingURL=index.js.map
3189
3720
  //# sourceMappingURL=index.js.map