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

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,12 @@ 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
16
  import { serve } from '@hono/node-server';
17
17
  import PgBoss from 'pg-boss';
18
18
  import { networkInterfaces } from 'os';
19
+ import { pathToFileURL } from 'url';
19
20
 
20
21
  var __defProp = Object.defineProperty;
21
22
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -1076,6 +1077,11 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
1076
1077
  );
1077
1078
  }
1078
1079
 
1080
+ // src/server/namespace.ts
1081
+ var CORE_NAMESPACE = "/_core";
1082
+ var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
1083
+ var LEGACY_HEALTH_PATH = "/health";
1084
+
1079
1085
  // src/server/shutdown-manager.ts
1080
1086
  var DEFAULT_HOOK_TIMEOUT = 1e4;
1081
1087
  var DEFAULT_HOOK_ORDER = 100;
@@ -1308,7 +1314,7 @@ async function readMigrationHealth() {
1308
1314
  targets: migrationTargets(snapshot.status)
1309
1315
  };
1310
1316
  }
1311
- function createHealthCheckHandler(detailed) {
1317
+ function createHealthCheckHandler(detailed, infrastructure) {
1312
1318
  return async (c) => {
1313
1319
  const shutdownManager = getShutdownManager();
1314
1320
  if (shutdownManager.isShuttingDown()) {
@@ -1322,23 +1328,29 @@ function createHealthCheckHandler(detailed) {
1322
1328
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1323
1329
  };
1324
1330
  if (detailed) {
1331
+ const databaseDisabled = infrastructure?.database === false;
1332
+ const redisDisabled = infrastructure?.redis === false;
1325
1333
  let dbStatus = "unknown";
1326
1334
  let dbError;
1327
- try {
1328
- const db = getDatabase();
1335
+ if (databaseDisabled) {
1336
+ dbStatus = "disabled";
1337
+ } else {
1329
1338
  try {
1330
- await db.execute("SELECT 1");
1331
- dbStatus = "connected";
1339
+ const db = getDatabase();
1340
+ try {
1341
+ await db.execute("SELECT 1");
1342
+ dbStatus = "connected";
1343
+ } catch (error) {
1344
+ dbStatus = "error";
1345
+ dbError = error instanceof Error ? error.message : String(error);
1346
+ }
1332
1347
  } catch (error) {
1333
- dbStatus = "error";
1334
- dbError = error instanceof Error ? error.message : String(error);
1348
+ dbStatus = "not_initialized";
1349
+ dbError = "Database not available";
1335
1350
  }
1336
- } catch (error) {
1337
- dbStatus = "not_initialized";
1338
- dbError = "Database not available";
1339
1351
  }
1340
- const redis = getCache();
1341
- let redisStatus = redis ? "unknown" : "not_initialized";
1352
+ const redis = redisDisabled ? null : getCache();
1353
+ let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
1342
1354
  let redisError;
1343
1355
  if (redis) {
1344
1356
  try {
@@ -1438,7 +1450,7 @@ function buildStartupConfig(config, timeouts) {
1438
1450
  const middlewareConfig = config.middleware ?? {};
1439
1451
  const healthCheckConfig = config.healthCheck ?? {};
1440
1452
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
1441
- const healthCheckPath = healthCheckConfig.path ?? "/health";
1453
+ const healthCheckPath = healthCheckConfig.path;
1442
1454
  const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
1443
1455
  return {
1444
1456
  middleware: {
@@ -1449,6 +1461,7 @@ function buildStartupConfig(config, timeouts) {
1449
1461
  },
1450
1462
  healthCheck: healthCheckEnabled ? {
1451
1463
  enabled: true,
1464
+ corePath: CORE_HEALTH_PATH,
1452
1465
  path: healthCheckPath,
1453
1466
  detailed: healthCheckDetailed
1454
1467
  } : { enabled: false },
@@ -1512,9 +1525,12 @@ async function createAutoConfiguredApp(config) {
1512
1525
  if (Array.isArray(config?.use)) {
1513
1526
  config.use.forEach((mw) => app.use("*", mw));
1514
1527
  }
1515
- registerHealthCheckEndpoint(app, config);
1528
+ registerCoreHealthEndpoint(app, config);
1516
1529
  await executeBeforeRoutesHook(app, config);
1517
- await loadAppRoutes(app, config);
1530
+ const appRoutes = await loadAppRoutes(app, config);
1531
+ registerMovedHealthSignpost(app, config, appRoutes);
1532
+ warnOnShadowedOptInPath(config, appRoutes);
1533
+ warnOnCoreNamespaceRoutes(appRoutes);
1518
1534
  await registerSSEEndpoint(app, config);
1519
1535
  await executeAfterRoutesHook(app, config);
1520
1536
  if (enableErrorHandler) {
@@ -1555,7 +1571,10 @@ async function applyProxyGuard(app, config) {
1555
1571
  serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
1556
1572
  }
1557
1573
  }
1558
- const autoSkip = [config?.healthCheck?.path ?? "/health"];
1574
+ const autoSkip = [CORE_HEALTH_PATH, LEGACY_HEALTH_PATH];
1575
+ if (config?.healthCheck?.path) {
1576
+ autoSkip.push(config.healthCheck.path);
1577
+ }
1559
1578
  if (config?.events) {
1560
1579
  autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
1561
1580
  }
@@ -1598,15 +1617,75 @@ function applyOutboundFetch(config) {
1598
1617
  const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
1599
1618
  setDefaultSafeFetchPolicy(policy);
1600
1619
  }
1601
- function registerHealthCheckEndpoint(app, config) {
1620
+ function resolveHealthCheck(config) {
1602
1621
  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}`);
1622
+ return {
1623
+ enabled: healthCheckConfig.enabled !== false,
1624
+ // No default. An unset `path` means the endpoint answers at
1625
+ // CORE_HEALTH_PATH and nowhere else — a default here is what used to put
1626
+ // it on /health without anyone asking.
1627
+ path: healthCheckConfig.path,
1628
+ detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
1629
+ };
1630
+ }
1631
+ function registerCoreHealthEndpoint(app, config) {
1632
+ const { enabled, path, detailed } = resolveHealthCheck(config);
1633
+ if (!enabled) {
1634
+ return;
1635
+ }
1636
+ const handler = createHealthCheckHandler(detailed, config?.infrastructure);
1637
+ app.get(CORE_HEALTH_PATH, handler);
1638
+ serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
1639
+ if (path && path !== CORE_HEALTH_PATH) {
1640
+ app.get(path, handler);
1641
+ serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
1642
+ }
1643
+ }
1644
+ function registerMovedHealthSignpost(app, config, appRoutes) {
1645
+ const { enabled, path } = resolveHealthCheck(config);
1646
+ if (!enabled || path === LEGACY_HEALTH_PATH) {
1647
+ return;
1648
+ }
1649
+ if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
1650
+ return;
1651
+ }
1652
+ let warned2 = false;
1653
+ app.get(LEGACY_HEALTH_PATH, (c) => {
1654
+ if (!warned2) {
1655
+ warned2 = true;
1656
+ serverLogger.warn(
1657
+ `\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.`
1658
+ );
1659
+ }
1660
+ return c.json({
1661
+ error: "health endpoint moved",
1662
+ movedTo: CORE_HEALTH_PATH,
1663
+ 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}' }).`
1664
+ }, 410);
1665
+ });
1666
+ }
1667
+ function warnOnShadowedOptInPath(config, appRoutes) {
1668
+ const { enabled, path } = resolveHealthCheck(config);
1669
+ if (!enabled || !path || path === CORE_HEALTH_PATH) {
1670
+ return;
1671
+ }
1672
+ const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
1673
+ if (shadowed.length === 0) {
1674
+ return;
1675
+ }
1676
+ serverLogger.warn(
1677
+ `\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.`
1678
+ );
1679
+ }
1680
+ function warnOnCoreNamespaceRoutes(appRoutes) {
1681
+ const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
1682
+ if (inside.length === 0) {
1683
+ return;
1609
1684
  }
1685
+ const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
1686
+ serverLogger.warn(
1687
+ `\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.`
1688
+ );
1610
1689
  }
1611
1690
  async function executeBeforeRoutesHook(app, config) {
1612
1691
  if (config?.lifecycle?.beforeRoutes) {
@@ -1618,9 +1697,12 @@ async function loadAppRoutes(app, config) {
1618
1697
  if (config?.routes) {
1619
1698
  const routes = registerRoutes(app, config.routes, config.middlewares);
1620
1699
  logRegisteredRoutes(routes, debug);
1621
- } else if (debug) {
1700
+ return routes;
1701
+ }
1702
+ if (debug) {
1622
1703
  serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
1623
1704
  }
1705
+ return [];
1624
1706
  }
1625
1707
  function logRegisteredRoutes(routes, debug) {
1626
1708
  if (routes.length === 0) {
@@ -2285,6 +2367,45 @@ function printBanner(options) {
2285
2367
  }
2286
2368
  console.log("");
2287
2369
  }
2370
+ var PORT_DEFAULTS = {
2371
+ server: 8790
2372
+ };
2373
+ var HOST_DEFAULT = "localhost";
2374
+ var CONFIG_FILE_NAMES = [
2375
+ "spfn.config.js",
2376
+ "spfn.config.mjs"
2377
+ ];
2378
+ async function loadAppConfig(cwd = process.cwd()) {
2379
+ for (const fileName of CONFIG_FILE_NAMES) {
2380
+ const fullPath = join(cwd, fileName);
2381
+ if (!existsSync(fullPath)) {
2382
+ continue;
2383
+ }
2384
+ try {
2385
+ const module = await import(pathToFileURL(fullPath).href);
2386
+ return module.default ?? {};
2387
+ } catch (error) {
2388
+ console.warn(
2389
+ `\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
2390
+ );
2391
+ return {};
2392
+ }
2393
+ }
2394
+ return {};
2395
+ }
2396
+ function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
2397
+ return {
2398
+ port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
2399
+ host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
2400
+ };
2401
+ }
2402
+ function readPort(value) {
2403
+ if (!value) {
2404
+ return void 0;
2405
+ }
2406
+ const port = Number(value);
2407
+ return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
2408
+ }
2288
2409
 
2289
2410
  // src/server/validation.ts
2290
2411
  function validateServerConfig(config) {
@@ -2333,10 +2454,12 @@ var TIMEOUTS = {
2333
2454
  REDIS_CLOSE: 5e3};
2334
2455
  var CONFIG_FILE_PATHS = [
2335
2456
  ".spfn/server/server.config.mjs",
2457
+ ".spfn/server/server.config.js",
2336
2458
  ".spfn/server/server.config",
2337
2459
  "src/server/server.config",
2338
2460
  "src/server/server.config.ts"
2339
2461
  ];
2462
+ var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
2340
2463
  var processHandlersRegistered = false;
2341
2464
  async function startServer(config) {
2342
2465
  loadEnv();
@@ -2400,8 +2523,7 @@ async function startServer(config) {
2400
2523
  throw error;
2401
2524
  }
2402
2525
  }
2403
- async function loadAndMergeConfig(config) {
2404
- const cwd = process.cwd();
2526
+ async function loadAndMergeConfig(config, cwd = process.cwd()) {
2405
2527
  let fileConfig = {};
2406
2528
  let loadedConfigPath = null;
2407
2529
  for (const configPath of CONFIG_FILE_PATHS) {
@@ -2419,16 +2541,37 @@ async function loadAndMergeConfig(config) {
2419
2541
  }
2420
2542
  if (loadedConfigPath) {
2421
2543
  serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
2544
+ } else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
2545
+ serverLogger.warn(
2546
+ `\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/.`
2547
+ );
2422
2548
  } else {
2423
2549
  serverLogger.debug("No configuration file found, using defaults");
2424
2550
  }
2551
+ const appConfig = await loadAppConfig(cwd);
2552
+ warnOnDeprecatedAddress(config, fileConfig);
2553
+ const address = resolveServerAddress(appConfig, {
2554
+ port: config?.port ?? fileConfig?.port,
2555
+ host: config?.host ?? fileConfig?.host
2556
+ });
2425
2557
  return {
2426
2558
  ...fileConfig,
2427
2559
  ...config,
2428
- port: config?.port ?? fileConfig?.port ?? env.PORT,
2429
- host: config?.host ?? fileConfig?.host ?? env.HOST
2560
+ port: address.port,
2561
+ host: address.host
2430
2562
  };
2431
2563
  }
2564
+ function warnOnDeprecatedAddress(config, fileConfig) {
2565
+ const used = [
2566
+ (config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
2567
+ (config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
2568
+ ].filter(Boolean);
2569
+ if (used.length > 0) {
2570
+ serverLogger.warn(
2571
+ `\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.`
2572
+ );
2573
+ }
2574
+ }
2432
2575
  function getInfrastructureConfig(config) {
2433
2576
  return {
2434
2577
  database: config.infrastructure?.database !== false,
@@ -3184,6 +3327,6 @@ function defineServerConfig() {
3184
3327
  return new ServerConfigBuilder();
3185
3328
  }
3186
3329
 
3187
- export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
3330
+ export { CORE_HEALTH_PATH, CORE_NAMESPACE, PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
3188
3331
  //# sourceMappingURL=index.js.map
3189
3332
  //# sourceMappingURL=index.js.map