@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.
- package/README.md +79 -6
- package/dist/app-config/index.d.ts +96 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/config/index.d.ts +0 -63
- package/dist/config/index.js +11 -11
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +5 -4
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
- package/dist/middleware/index.d.ts +6 -2
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/index.js.map +1 -1
- package/dist/ops/index.d.ts +4 -3
- package/dist/ops/index.js +1 -1
- package/dist/ops/index.js.map +1 -1
- package/dist/route/index.d.ts +5 -3
- package/dist/route/index.js +1 -1
- package/dist/route/index.js.map +1 -1
- package/dist/{router-Qbssr11H.d.ts → route-builder-2ani2jEI.d.ts} +3 -126
- package/dist/router-DJdpwuB6.d.ts +127 -0
- package/dist/server/index.d.ts +111 -9
- package/dist/server/index.js +561 -30
- package/dist/server/index.js.map +1 -1
- package/dist/{types-D1c57Ko-.d.ts → types-ClQVomgV.d.ts} +1 -1
- package/package.json +6 -1
package/dist/server/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
1328
|
-
|
|
1337
|
+
if (databaseDisabled) {
|
|
1338
|
+
dbStatus = "disabled";
|
|
1339
|
+
} else {
|
|
1329
1340
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
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 = "
|
|
1334
|
-
dbError =
|
|
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
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
1995
|
+
function resolveHealthCheck(config) {
|
|
1602
1996
|
const healthCheckConfig = config?.healthCheck ?? {};
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
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:
|
|
2429
|
-
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
|