@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21
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 +343 -16
- package/dist/app-config/index.d.ts +134 -0
- package/dist/app-config/index.js +58 -0
- package/dist/app-config/index.js.map +1 -0
- package/dist/authz/index.js +1 -381
- package/dist/authz/index.js.map +1 -1
- package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
- package/dist/codegen/index.d.ts +93 -15
- package/dist/codegen/index.js +626 -165
- package/dist/codegen/index.js.map +1 -1
- package/dist/config/index.d.ts +0 -63
- package/dist/config/index.js +15 -15
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +40 -17
- package/dist/contract/index.js +147 -23
- package/dist/contract/index.js.map +1 -1
- package/dist/db/index.d.ts +201 -34
- package/dist/db/index.js +294 -158
- package/dist/db/index.js.map +1 -1
- package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
- package/dist/env/index.d.ts +6 -442
- package/dist/env/index.js +13 -16
- package/dist/env/index.js.map +1 -1
- package/dist/env/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/error-TsUIjp_x.d.ts +157 -0
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +5 -5
- package/dist/event/index.js +32 -1
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +18 -4
- package/dist/event/sse/client.js +38 -2
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +5 -4
- package/dist/event/ws/client.d.ts +3 -2
- package/dist/event/ws/index.d.ts +4 -3
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +131 -52
- package/dist/job/index.js.map +1 -1
- package/dist/logger/index.js +0 -12
- package/dist/logger/index.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -2
- package/dist/middleware/index.js +7 -388
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +88 -8
- package/dist/nextjs/index.js +138 -62
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +45 -7
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +62 -43
- package/dist/ops/index.js +638 -97
- package/dist/ops/index.js.map +1 -1
- package/dist/registry-C2KQCh0m.d.ts +473 -0
- package/dist/route/index.d.ts +6 -3
- package/dist/route/index.js +97 -28
- package/dist/route/index.js.map +1 -1
- package/dist/route/types.d.ts +7 -1
- package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
- package/dist/router-4YoIy5fN.d.ts +172 -0
- package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
- package/dist/server/index.d.ts +185 -14
- package/dist/server/index.js +883 -78
- package/dist/server/index.js.map +1 -1
- package/dist/surface/index.d.ts +115 -0
- package/dist/surface/index.js +1003 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
- package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
- package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
- package/dist/types-CxhPH4s_.d.ts +375 -0
- package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
- package/docs/file-upload.md +195 -333
- package/package.json +20 -9
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +634 -0
- package/src/config/README.md +333 -0
- package/src/contract/README.md +440 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +354 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +787 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +789 -0
- package/src/job/README.md +613 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +688 -0
- package/src/route/README.md +763 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
- package/dist/types-9oszaJqp.d.ts +0 -250
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { env } from '@spfn/core/config';
|
|
1
|
+
import { env, registry } from '@spfn/core/config';
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { resolve, join } from 'path';
|
|
4
4
|
import { parse } from 'dotenv';
|
|
@@ -11,11 +11,13 @@ import { setDefaultSafeFetchPolicy } from '@spfn/core/security';
|
|
|
11
11
|
import { streamSSE } from 'hono/streaming';
|
|
12
12
|
import { randomBytes, createHash } from 'crypto';
|
|
13
13
|
import { Agent, setGlobalDispatcher } from 'undici';
|
|
14
|
-
import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT,
|
|
14
|
+
import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, closeDatabase, migrationTargets } from '@spfn/core/db';
|
|
15
15
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
16
|
+
import { FormatRegistry, Type } from '@sinclair/typebox';
|
|
16
17
|
import { serve } from '@hono/node-server';
|
|
17
18
|
import PgBoss from 'pg-boss';
|
|
18
19
|
import { networkInterfaces } from 'os';
|
|
20
|
+
import { pathToFileURL } from 'url';
|
|
19
21
|
|
|
20
22
|
var __defProp = Object.defineProperty;
|
|
21
23
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -353,9 +355,10 @@ function parseEnvFile(filePath) {
|
|
|
353
355
|
return parse(readFileSync(filePath, "utf-8"));
|
|
354
356
|
}
|
|
355
357
|
function loadEnv(options = {}) {
|
|
358
|
+
const nodeEnvAtEntry = process.env.NODE_ENV;
|
|
356
359
|
const {
|
|
357
360
|
cwd = process.cwd(),
|
|
358
|
-
nodeEnv =
|
|
361
|
+
nodeEnv = nodeEnvAtEntry || "local",
|
|
359
362
|
server = true,
|
|
360
363
|
debug = false,
|
|
361
364
|
override = false
|
|
@@ -385,8 +388,30 @@ function loadEnv(options = {}) {
|
|
|
385
388
|
envLogger.debug(`Loaded env files: ${loadedFiles.join(", ")}`);
|
|
386
389
|
envLogger.debug(`Loaded ${loadedKeys.length} environment variables`);
|
|
387
390
|
}
|
|
391
|
+
warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, options.nodeEnv, nodeEnv);
|
|
388
392
|
return { loadedFiles, loadedKeys };
|
|
389
393
|
}
|
|
394
|
+
function warnIfEnvGuessCostSomething(cwd, nodeEnvAtEntry, explicitNodeEnv, resolvedNodeEnv) {
|
|
395
|
+
if (nodeEnvAtEntry || explicitNodeEnv) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const declared = process.env.NODE_ENV;
|
|
399
|
+
if (!declared) {
|
|
400
|
+
envLogger.warn(
|
|
401
|
+
`NODE_ENV is not set anywhere; env files were resolved for "${resolvedNodeEnv}". Set NODE_ENV in the process environment to choose the file set.`
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (declared === resolvedNodeEnv) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (!existsSync(resolve(cwd, `.env.${declared}`))) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
envLogger.warn(
|
|
412
|
+
`.env.${declared} was NOT loaded: NODE_ENV was not set when the process started, so env files were resolved for "${resolvedNodeEnv}" before a file declared NODE_ENV=${declared}. Set NODE_ENV in the process environment, not in a .env file.`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
390
415
|
|
|
391
416
|
// src/server/dotenv-loader.ts
|
|
392
417
|
var warned = false;
|
|
@@ -619,6 +644,12 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
|
619
644
|
}
|
|
620
645
|
return allowed;
|
|
621
646
|
}
|
|
647
|
+
|
|
648
|
+
// src/event/sse/paths.ts
|
|
649
|
+
var DEFAULT_SSE_STREAM_PATH = "/events/stream";
|
|
650
|
+
function sseTokenPath(streamPath) {
|
|
651
|
+
return streamPath.replace(/\/[^/]+$/, "/token");
|
|
652
|
+
}
|
|
622
653
|
function hashToken(token) {
|
|
623
654
|
return createHash("sha256").update(token).digest("hex");
|
|
624
655
|
}
|
|
@@ -1076,6 +1107,12 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
|
|
|
1076
1107
|
);
|
|
1077
1108
|
}
|
|
1078
1109
|
|
|
1110
|
+
// src/server/namespace.ts
|
|
1111
|
+
var CORE_NAMESPACE = "/_core";
|
|
1112
|
+
var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
|
|
1113
|
+
var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
|
|
1114
|
+
var LEGACY_HEALTH_PATH = "/health";
|
|
1115
|
+
|
|
1079
1116
|
// src/server/shutdown-manager.ts
|
|
1080
1117
|
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
1081
1118
|
var DEFAULT_HOOK_ORDER = 100;
|
|
@@ -1308,7 +1345,7 @@ async function readMigrationHealth() {
|
|
|
1308
1345
|
targets: migrationTargets(snapshot.status)
|
|
1309
1346
|
};
|
|
1310
1347
|
}
|
|
1311
|
-
function createHealthCheckHandler(detailed) {
|
|
1348
|
+
function createHealthCheckHandler(detailed, infrastructure) {
|
|
1312
1349
|
return async (c) => {
|
|
1313
1350
|
const shutdownManager = getShutdownManager();
|
|
1314
1351
|
if (shutdownManager.isShuttingDown()) {
|
|
@@ -1322,23 +1359,29 @@ function createHealthCheckHandler(detailed) {
|
|
|
1322
1359
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1323
1360
|
};
|
|
1324
1361
|
if (detailed) {
|
|
1362
|
+
const databaseDisabled = infrastructure?.database === false;
|
|
1363
|
+
const redisDisabled = infrastructure?.redis === false;
|
|
1325
1364
|
let dbStatus = "unknown";
|
|
1326
1365
|
let dbError;
|
|
1327
|
-
|
|
1328
|
-
|
|
1366
|
+
if (databaseDisabled) {
|
|
1367
|
+
dbStatus = "disabled";
|
|
1368
|
+
} else {
|
|
1329
1369
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1370
|
+
const db = getDatabase();
|
|
1371
|
+
try {
|
|
1372
|
+
await db.execute("SELECT 1");
|
|
1373
|
+
dbStatus = "connected";
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
dbStatus = "error";
|
|
1376
|
+
dbError = error instanceof Error ? error.message : String(error);
|
|
1377
|
+
}
|
|
1332
1378
|
} catch (error) {
|
|
1333
|
-
dbStatus = "
|
|
1334
|
-
dbError =
|
|
1379
|
+
dbStatus = "not_initialized";
|
|
1380
|
+
dbError = "Database not available";
|
|
1335
1381
|
}
|
|
1336
|
-
} catch (error) {
|
|
1337
|
-
dbStatus = "not_initialized";
|
|
1338
|
-
dbError = "Database not available";
|
|
1339
1382
|
}
|
|
1340
|
-
const redis = getCache();
|
|
1341
|
-
let redisStatus = redis ? "unknown" : "not_initialized";
|
|
1383
|
+
const redis = redisDisabled ? null : getCache();
|
|
1384
|
+
let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
|
|
1342
1385
|
let redisError;
|
|
1343
1386
|
if (redis) {
|
|
1344
1387
|
try {
|
|
@@ -1438,7 +1481,7 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1438
1481
|
const middlewareConfig = config.middleware ?? {};
|
|
1439
1482
|
const healthCheckConfig = config.healthCheck ?? {};
|
|
1440
1483
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
1441
|
-
const healthCheckPath = healthCheckConfig.path
|
|
1484
|
+
const healthCheckPath = healthCheckConfig.path;
|
|
1442
1485
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
1443
1486
|
return {
|
|
1444
1487
|
middleware: {
|
|
@@ -1449,6 +1492,7 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1449
1492
|
},
|
|
1450
1493
|
healthCheck: healthCheckEnabled ? {
|
|
1451
1494
|
enabled: true,
|
|
1495
|
+
corePath: CORE_HEALTH_PATH,
|
|
1452
1496
|
path: healthCheckPath,
|
|
1453
1497
|
detailed: healthCheckDetailed
|
|
1454
1498
|
} : { enabled: false },
|
|
@@ -1467,6 +1511,438 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1467
1511
|
};
|
|
1468
1512
|
}
|
|
1469
1513
|
|
|
1514
|
+
// src/route/route-builder.ts
|
|
1515
|
+
var RouteBuilder = class _RouteBuilder {
|
|
1516
|
+
_method;
|
|
1517
|
+
_path;
|
|
1518
|
+
_input;
|
|
1519
|
+
_interceptor;
|
|
1520
|
+
_middlewares;
|
|
1521
|
+
_skipMiddlewares;
|
|
1522
|
+
_contract;
|
|
1523
|
+
_effect;
|
|
1524
|
+
_summary;
|
|
1525
|
+
/**
|
|
1526
|
+
* Create a new RouteBuilder with copied properties and optional overrides
|
|
1527
|
+
*/
|
|
1528
|
+
clone(overrides) {
|
|
1529
|
+
const builder = new _RouteBuilder();
|
|
1530
|
+
builder._method = this._method;
|
|
1531
|
+
builder._path = this._path;
|
|
1532
|
+
builder._input = overrides?.input ?? this._input;
|
|
1533
|
+
builder._interceptor = overrides?.interceptor ?? this._interceptor;
|
|
1534
|
+
builder._middlewares = overrides?.middlewares ?? this._middlewares;
|
|
1535
|
+
builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
|
|
1536
|
+
builder._contract = overrides?.contract ?? this._contract;
|
|
1537
|
+
builder._effect = overrides?.effect ?? this._effect;
|
|
1538
|
+
builder._summary = overrides?.summary ?? this._summary;
|
|
1539
|
+
return builder;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Define input schemas
|
|
1543
|
+
*
|
|
1544
|
+
* @example
|
|
1545
|
+
* ```ts
|
|
1546
|
+
* route.get('/users/:id')
|
|
1547
|
+
* .input({
|
|
1548
|
+
* params: Type.Object({ id: Type.String() }),
|
|
1549
|
+
* query: Type.Object({ page: Type.Number() }),
|
|
1550
|
+
* headers: Type.Object({ authorization: Type.String() })
|
|
1551
|
+
* })
|
|
1552
|
+
* .handler(async (c) => {
|
|
1553
|
+
* const { params, query, headers } = await c.data();
|
|
1554
|
+
* // params = { id: string }
|
|
1555
|
+
* // query = { page: number }
|
|
1556
|
+
* // headers = { authorization: string }
|
|
1557
|
+
* })
|
|
1558
|
+
* ```
|
|
1559
|
+
*/
|
|
1560
|
+
input(input) {
|
|
1561
|
+
return this.clone({ input });
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Define fields injected by interceptors
|
|
1565
|
+
*
|
|
1566
|
+
* These fields are:
|
|
1567
|
+
* - Available in the handler (merged with input)
|
|
1568
|
+
* - Excluded from client types (codegen uses only input)
|
|
1569
|
+
* - Not validated by route input schema (injected by middleware)
|
|
1570
|
+
*
|
|
1571
|
+
* Use this when middleware/interceptors add fields to the request
|
|
1572
|
+
* before it reaches the handler.
|
|
1573
|
+
*
|
|
1574
|
+
* @example
|
|
1575
|
+
* ```ts
|
|
1576
|
+
* // Auth interceptor injects crypto key fields
|
|
1577
|
+
* route.post('/_auth/login')
|
|
1578
|
+
* .input({
|
|
1579
|
+
* body: Type.Object({
|
|
1580
|
+
* email: Type.String(),
|
|
1581
|
+
* password: Type.String()
|
|
1582
|
+
* })
|
|
1583
|
+
* })
|
|
1584
|
+
* .interceptor({
|
|
1585
|
+
* body: Type.Object({
|
|
1586
|
+
* publicKey: Type.String(),
|
|
1587
|
+
* keyId: Type.String(),
|
|
1588
|
+
* fingerprint: Type.String()
|
|
1589
|
+
* })
|
|
1590
|
+
* })
|
|
1591
|
+
* .handler(async (c) => {
|
|
1592
|
+
* const { body } = await c.data();
|
|
1593
|
+
* // body type: { email, password, publicKey, keyId, fingerprint }
|
|
1594
|
+
* // Client only sees: { email, password }
|
|
1595
|
+
* return loginService(body);
|
|
1596
|
+
* });
|
|
1597
|
+
* ```
|
|
1598
|
+
*/
|
|
1599
|
+
interceptor(interceptor) {
|
|
1600
|
+
return this.clone({ interceptor });
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Add middlewares to the route
|
|
1604
|
+
*
|
|
1605
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1606
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1607
|
+
* deduplicated to prevent double execution.
|
|
1608
|
+
*
|
|
1609
|
+
* @example
|
|
1610
|
+
* ```ts
|
|
1611
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1612
|
+
*
|
|
1613
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1614
|
+
* route.get('/users')
|
|
1615
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1616
|
+
*
|
|
1617
|
+
* // With regular middleware handlers
|
|
1618
|
+
* route.get('/users')
|
|
1619
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1620
|
+
* ```
|
|
1621
|
+
*/
|
|
1622
|
+
middleware(middlewares) {
|
|
1623
|
+
return this.clone({ middlewares });
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Add middlewares to the route (alias for `.middleware()`)
|
|
1627
|
+
*
|
|
1628
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1629
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1630
|
+
* deduplicated to prevent double execution.
|
|
1631
|
+
*
|
|
1632
|
+
* @example
|
|
1633
|
+
* ```ts
|
|
1634
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1635
|
+
*
|
|
1636
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1637
|
+
* route.get('/users')
|
|
1638
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1639
|
+
*
|
|
1640
|
+
* // With regular middleware handlers
|
|
1641
|
+
* route.get('/users')
|
|
1642
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1643
|
+
* ```
|
|
1644
|
+
*/
|
|
1645
|
+
use(middlewares) {
|
|
1646
|
+
return this.middleware(middlewares);
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* Skip server-level named middlewares
|
|
1650
|
+
*
|
|
1651
|
+
* Useful for public endpoints that should bypass auth or rate limiting
|
|
1652
|
+
*
|
|
1653
|
+
* @param middlewareNames - Array of middleware names to skip, or '*' to skip all
|
|
1654
|
+
*
|
|
1655
|
+
* @example
|
|
1656
|
+
* ```ts
|
|
1657
|
+
* // Skip specific middlewares
|
|
1658
|
+
* route.get('/status')
|
|
1659
|
+
* .skip(['auth', 'rateLimit'])
|
|
1660
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1661
|
+
*
|
|
1662
|
+
* // Skip only auth (still apply rate limiting)
|
|
1663
|
+
* route.get('/public-data')
|
|
1664
|
+
* .skip(['auth'])
|
|
1665
|
+
* .handler(async (c) => { ... });
|
|
1666
|
+
*
|
|
1667
|
+
* // Skip all middlewares
|
|
1668
|
+
* route.get('/public-health')
|
|
1669
|
+
* .skip('*')
|
|
1670
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1671
|
+
* ```
|
|
1672
|
+
*/
|
|
1673
|
+
skip(middlewareNames) {
|
|
1674
|
+
return this.clone({ skipMiddlewares: middlewareNames });
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Publish this route as a versioned contract operation
|
|
1678
|
+
*
|
|
1679
|
+
* Marks the route as a promise to clients that are compiled and deployed
|
|
1680
|
+
* separately from the server — a mobile app, an external API consumer.
|
|
1681
|
+
* The `@spfn/core:contract` generator writes every contracted route into
|
|
1682
|
+
* `contracts/current.json`, and the build refuses a change that would break
|
|
1683
|
+
* an already-released client.
|
|
1684
|
+
*
|
|
1685
|
+
* Routes without `.contract()` are unaffected: they simply do not appear in
|
|
1686
|
+
* the contract. A web client needs nothing here — it derives its types from
|
|
1687
|
+
* the router in the same build.
|
|
1688
|
+
*
|
|
1689
|
+
* @example
|
|
1690
|
+
* ```ts
|
|
1691
|
+
* export const getUser = route.get('/users/:id')
|
|
1692
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
1693
|
+
* .contract({
|
|
1694
|
+
* since: '1.2.0',
|
|
1695
|
+
* auth: 'clientProofV1',
|
|
1696
|
+
* requiresSession: true,
|
|
1697
|
+
* response: Type.Object({
|
|
1698
|
+
* id: Type.String(),
|
|
1699
|
+
* name: Type.String(),
|
|
1700
|
+
* email: Type.Optional(Type.String()),
|
|
1701
|
+
* }),
|
|
1702
|
+
* })
|
|
1703
|
+
* .handler(async (c) => { ... });
|
|
1704
|
+
* ```
|
|
1705
|
+
*/
|
|
1706
|
+
contract(contract) {
|
|
1707
|
+
return this.clone({ contract });
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Declare what invoking this route does
|
|
1711
|
+
*
|
|
1712
|
+
* Read by a command surface (`@spfn/core/surface`, `@spfn/core/ops`),
|
|
1713
|
+
* which announces it in its manifest so a machine caller can tell a read
|
|
1714
|
+
* from a write before it calls — `spfn ops call` asks for `--yes` before a
|
|
1715
|
+
* `destructive` command. A surface built with `defineSurface` refuses an
|
|
1716
|
+
* app command that does not declare one; on `/_ops` it is optional.
|
|
1717
|
+
*
|
|
1718
|
+
* Nothing else reads it: codegen, the route map, and the contract are
|
|
1719
|
+
* unaffected, and a route outside any surface simply carries it.
|
|
1720
|
+
*
|
|
1721
|
+
* @example
|
|
1722
|
+
* ```ts
|
|
1723
|
+
* export const pageAdd = app.route.post('/pages')
|
|
1724
|
+
* .input({ body: PageAddBody })
|
|
1725
|
+
* .effect('write')
|
|
1726
|
+
* .summary('Add a page to the blueprint')
|
|
1727
|
+
* .handler(async (c) => { ... });
|
|
1728
|
+
* ```
|
|
1729
|
+
*/
|
|
1730
|
+
effect(effect) {
|
|
1731
|
+
return this.clone({ effect });
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Describe this route in one line for a command surface's manifest
|
|
1735
|
+
*
|
|
1736
|
+
* A surface announces it beside the command in `spfn ops list` and
|
|
1737
|
+
* `--describe`, and refuses one that is empty or longer than 500
|
|
1738
|
+
* characters. Codegen, the route map, and the contract ignore it. On a
|
|
1739
|
+
* module command the module's own summary wins.
|
|
1740
|
+
*
|
|
1741
|
+
* @example
|
|
1742
|
+
* ```ts
|
|
1743
|
+
* export const pageAdd = app.route.post('/pages')
|
|
1744
|
+
* .effect('write')
|
|
1745
|
+
* .summary('Add a page to the blueprint')
|
|
1746
|
+
* .handler(async (c) => { ... });
|
|
1747
|
+
* ```
|
|
1748
|
+
*/
|
|
1749
|
+
summary(summary) {
|
|
1750
|
+
return this.clone({ summary });
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* Define handler function
|
|
1754
|
+
*
|
|
1755
|
+
* Response type is automatically inferred from the return value.
|
|
1756
|
+
* Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
|
|
1757
|
+
*
|
|
1758
|
+
* @example
|
|
1759
|
+
* ```ts
|
|
1760
|
+
* // Direct return - type inferred from data
|
|
1761
|
+
* route.get('/users/:id')
|
|
1762
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
1763
|
+
* .handler(async (c) => {
|
|
1764
|
+
* const { params } = await c.data();
|
|
1765
|
+
* return await getUser(params.id); // Type: User
|
|
1766
|
+
* })
|
|
1767
|
+
*
|
|
1768
|
+
* // Using c.created() - returns data with 201 status, type preserved
|
|
1769
|
+
* route.post('/users')
|
|
1770
|
+
* .input({ body: Type.Object({ name: Type.String() }) })
|
|
1771
|
+
* .handler(async (c) => {
|
|
1772
|
+
* const { body } = await c.data();
|
|
1773
|
+
* return c.created(await createUser(body)); // Type: User
|
|
1774
|
+
* })
|
|
1775
|
+
*
|
|
1776
|
+
* // Using c.paginated() - returns PaginatedResult<T>
|
|
1777
|
+
* route.get('/users')
|
|
1778
|
+
* .handler(async (c) => {
|
|
1779
|
+
* const users = await getUsers();
|
|
1780
|
+
* return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
|
|
1781
|
+
* })
|
|
1782
|
+
*
|
|
1783
|
+
* // Using c.noContent() - returns void
|
|
1784
|
+
* route.delete('/users/:id')
|
|
1785
|
+
* .handler(async (c) => {
|
|
1786
|
+
* await deleteUser(params.id);
|
|
1787
|
+
* return c.noContent(); // Type: void
|
|
1788
|
+
* })
|
|
1789
|
+
*
|
|
1790
|
+
* // Using c.json() - returns Response (type inference lost)
|
|
1791
|
+
* // Use only when you need custom status codes not covered by helpers
|
|
1792
|
+
* route.get('/custom')
|
|
1793
|
+
* .handler(async (c) => {
|
|
1794
|
+
* return c.json({ data }, 418); // Type: Response
|
|
1795
|
+
* })
|
|
1796
|
+
* ```
|
|
1797
|
+
*/
|
|
1798
|
+
handler(fn) {
|
|
1799
|
+
return {
|
|
1800
|
+
method: this._method,
|
|
1801
|
+
path: this._path,
|
|
1802
|
+
input: this._input,
|
|
1803
|
+
interceptor: this._interceptor,
|
|
1804
|
+
middlewares: this._middlewares,
|
|
1805
|
+
skipMiddlewares: this._skipMiddlewares,
|
|
1806
|
+
contract: this._contract,
|
|
1807
|
+
...this._effect !== void 0 ? { effect: this._effect } : {},
|
|
1808
|
+
...this._summary !== void 0 ? { summary: this._summary } : {},
|
|
1809
|
+
handler: fn,
|
|
1810
|
+
_input: {},
|
|
1811
|
+
_interceptor: {},
|
|
1812
|
+
_response: {}
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
function createMethodRoute(method) {
|
|
1817
|
+
return (path) => {
|
|
1818
|
+
const builder = new RouteBuilder();
|
|
1819
|
+
builder._method = method;
|
|
1820
|
+
builder._path = path;
|
|
1821
|
+
return builder;
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
var route = {
|
|
1825
|
+
get: createMethodRoute("GET"),
|
|
1826
|
+
post: createMethodRoute("POST"),
|
|
1827
|
+
put: createMethodRoute("PUT"),
|
|
1828
|
+
patch: createMethodRoute("PATCH"),
|
|
1829
|
+
delete: createMethodRoute("DELETE")
|
|
1830
|
+
};
|
|
1831
|
+
|
|
1832
|
+
// src/route/router.ts
|
|
1833
|
+
function createRouterInstance(state2) {
|
|
1834
|
+
return {
|
|
1835
|
+
routes: state2.routes,
|
|
1836
|
+
_routes: state2.routes,
|
|
1837
|
+
_packageRouters: state2.packageRouters,
|
|
1838
|
+
_globalMiddlewares: state2.globalMiddlewares,
|
|
1839
|
+
_contractVersion: state2.contractVersion,
|
|
1840
|
+
_publishesRouteMap: state2.publishesRouteMap,
|
|
1841
|
+
packages(routers) {
|
|
1842
|
+
const newPackageRouters = [...state2.packageRouters, ...routers];
|
|
1843
|
+
for (const pkgRouter of routers) {
|
|
1844
|
+
if (pkgRouter._packageRouters?.length > 0) {
|
|
1845
|
+
newPackageRouters.push(...pkgRouter._packageRouters);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
assertDistinctSurfaces(newPackageRouters);
|
|
1849
|
+
return createRouterInstance({ ...state2, packageRouters: newPackageRouters });
|
|
1850
|
+
},
|
|
1851
|
+
use(middlewares) {
|
|
1852
|
+
return createRouterInstance({
|
|
1853
|
+
...state2,
|
|
1854
|
+
globalMiddlewares: [...state2.globalMiddlewares, ...middlewares]
|
|
1855
|
+
});
|
|
1856
|
+
},
|
|
1857
|
+
contractVersion(version) {
|
|
1858
|
+
assertContractVersion(version);
|
|
1859
|
+
return createRouterInstance({ ...state2, contractVersion: version });
|
|
1860
|
+
}
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
function assertContractVersion(version) {
|
|
1864
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
|
|
1865
|
+
throw new Error(
|
|
1866
|
+
`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.`
|
|
1867
|
+
);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
function assertDistinctSurfaces(packageRouters) {
|
|
1871
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
1872
|
+
for (const router of packageRouters) {
|
|
1873
|
+
const namespace = router._surfaceNamespace;
|
|
1874
|
+
if (namespace === void 0) {
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
if (namespaces.has(namespace)) {
|
|
1878
|
+
throw new Error(
|
|
1879
|
+
`.packages() mounts two command surfaces on "${namespace}". Both serve ${namespace}/_manifest, so the second one's commands would answer while no manifest lists them. Mount one router per namespace.`
|
|
1880
|
+
);
|
|
1881
|
+
}
|
|
1882
|
+
namespaces.add(namespace);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
function defineRouter(routes) {
|
|
1886
|
+
return createRouterInstance({
|
|
1887
|
+
routes,
|
|
1888
|
+
packageRouters: [],
|
|
1889
|
+
globalMiddlewares: [],
|
|
1890
|
+
contractVersion: null,
|
|
1891
|
+
publishesRouteMap: true
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
// src/route/validation.ts
|
|
1896
|
+
FormatRegistry.Set(
|
|
1897
|
+
"email",
|
|
1898
|
+
(value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
1899
|
+
);
|
|
1900
|
+
FormatRegistry.Set(
|
|
1901
|
+
"uri",
|
|
1902
|
+
(value) => /^https?:\/\/.+/.test(value)
|
|
1903
|
+
);
|
|
1904
|
+
FormatRegistry.Set(
|
|
1905
|
+
"uuid",
|
|
1906
|
+
(value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
1907
|
+
);
|
|
1908
|
+
FormatRegistry.Set(
|
|
1909
|
+
"date",
|
|
1910
|
+
(value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
1911
|
+
);
|
|
1912
|
+
FormatRegistry.Set(
|
|
1913
|
+
"date-time",
|
|
1914
|
+
(value) => !isNaN(Date.parse(value))
|
|
1915
|
+
);
|
|
1916
|
+
|
|
1917
|
+
// src/server/server-time.ts
|
|
1918
|
+
var CORE_TIME_OPERATION_ID = "core.time";
|
|
1919
|
+
var ServerTimeResponseSchema = Type.Object({
|
|
1920
|
+
serverTimeMillis: Type.Integer()
|
|
1921
|
+
}, { additionalProperties: false });
|
|
1922
|
+
var systemServerClock = {
|
|
1923
|
+
now: () => Date.now()
|
|
1924
|
+
};
|
|
1925
|
+
function createCoreTimeRoute(clock = systemServerClock) {
|
|
1926
|
+
return route.get(CORE_TIME_PATH).skip("*").contract({
|
|
1927
|
+
since: "0.3.0",
|
|
1928
|
+
auth: "none",
|
|
1929
|
+
requiresSession: false,
|
|
1930
|
+
response: ServerTimeResponseSchema
|
|
1931
|
+
}).handler(async (c) => {
|
|
1932
|
+
const response = {
|
|
1933
|
+
serverTimeMillis: clock.now()
|
|
1934
|
+
};
|
|
1935
|
+
c.raw.header("Cache-Control", "no-store");
|
|
1936
|
+
return response;
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
var CORE_TIME_ROUTE = createCoreTimeRoute();
|
|
1940
|
+
function createCoreTimeRouter(clock) {
|
|
1941
|
+
return defineRouter({
|
|
1942
|
+
[CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1470
1946
|
// src/server/create-server.ts
|
|
1471
1947
|
var rateLimitApplied = /* @__PURE__ */ new WeakSet();
|
|
1472
1948
|
async function createServer(config) {
|
|
@@ -1509,12 +1985,16 @@ async function createAutoConfiguredApp(config) {
|
|
|
1509
1985
|
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1510
1986
|
await applyProxyGuard(app, config);
|
|
1511
1987
|
applyRateLimit(config);
|
|
1988
|
+
registerCoreTimeEndpoint(app, config);
|
|
1512
1989
|
if (Array.isArray(config?.use)) {
|
|
1513
1990
|
config.use.forEach((mw) => app.use("*", mw));
|
|
1514
1991
|
}
|
|
1515
|
-
|
|
1992
|
+
registerCoreHealthEndpoint(app, config);
|
|
1516
1993
|
await executeBeforeRoutesHook(app, config);
|
|
1517
|
-
await loadAppRoutes(app, config);
|
|
1994
|
+
const appRoutes = await loadAppRoutes(app, config);
|
|
1995
|
+
registerMovedHealthSignpost(app, config, appRoutes);
|
|
1996
|
+
warnOnShadowedOptInPath(config, appRoutes);
|
|
1997
|
+
warnOnCoreNamespaceRoutes(appRoutes);
|
|
1518
1998
|
await registerSSEEndpoint(app, config);
|
|
1519
1999
|
await executeAfterRoutesHook(app, config);
|
|
1520
2000
|
if (enableErrorHandler) {
|
|
@@ -1555,7 +2035,10 @@ async function applyProxyGuard(app, config) {
|
|
|
1555
2035
|
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1556
2036
|
}
|
|
1557
2037
|
}
|
|
1558
|
-
const autoSkip = [
|
|
2038
|
+
const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
|
|
2039
|
+
if (config?.healthCheck?.path) {
|
|
2040
|
+
autoSkip.push(config.healthCheck.path);
|
|
2041
|
+
}
|
|
1559
2042
|
if (config?.events) {
|
|
1560
2043
|
autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
|
|
1561
2044
|
}
|
|
@@ -1598,15 +2081,79 @@ function applyOutboundFetch(config) {
|
|
|
1598
2081
|
const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
|
|
1599
2082
|
setDefaultSafeFetchPolicy(policy);
|
|
1600
2083
|
}
|
|
1601
|
-
function
|
|
2084
|
+
function resolveHealthCheck(config) {
|
|
1602
2085
|
const healthCheckConfig = config?.healthCheck ?? {};
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
2086
|
+
return {
|
|
2087
|
+
enabled: healthCheckConfig.enabled !== false,
|
|
2088
|
+
// No default. An unset `path` means the endpoint answers at
|
|
2089
|
+
// CORE_HEALTH_PATH and nowhere else — a default here is what used to put
|
|
2090
|
+
// it on /health without anyone asking.
|
|
2091
|
+
path: healthCheckConfig.path,
|
|
2092
|
+
detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
function registerCoreTimeEndpoint(app, config) {
|
|
2096
|
+
registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
|
|
2097
|
+
serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
|
|
2098
|
+
}
|
|
2099
|
+
function registerCoreHealthEndpoint(app, config) {
|
|
2100
|
+
const { enabled, path, detailed } = resolveHealthCheck(config);
|
|
2101
|
+
if (!enabled) {
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
const handler = createHealthCheckHandler(detailed, config?.infrastructure);
|
|
2105
|
+
app.get(CORE_HEALTH_PATH, handler);
|
|
2106
|
+
serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
|
|
2107
|
+
if (path && path !== CORE_HEALTH_PATH) {
|
|
2108
|
+
app.get(path, handler);
|
|
2109
|
+
serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
function registerMovedHealthSignpost(app, config, appRoutes) {
|
|
2113
|
+
const { enabled, path } = resolveHealthCheck(config);
|
|
2114
|
+
if (!enabled || path === LEGACY_HEALTH_PATH) {
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
|
|
2118
|
+
return;
|
|
1609
2119
|
}
|
|
2120
|
+
let warned2 = false;
|
|
2121
|
+
app.get(LEGACY_HEALTH_PATH, (c) => {
|
|
2122
|
+
if (!warned2) {
|
|
2123
|
+
warned2 = true;
|
|
2124
|
+
serverLogger.warn(
|
|
2125
|
+
`\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.`
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
return c.json({
|
|
2129
|
+
error: "health endpoint moved",
|
|
2130
|
+
movedTo: CORE_HEALTH_PATH,
|
|
2131
|
+
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}' }).`
|
|
2132
|
+
}, 410);
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
function warnOnShadowedOptInPath(config, appRoutes) {
|
|
2136
|
+
const { enabled, path } = resolveHealthCheck(config);
|
|
2137
|
+
if (!enabled || !path || path === CORE_HEALTH_PATH) {
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
|
|
2141
|
+
if (shadowed.length === 0) {
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
serverLogger.warn(
|
|
2145
|
+
`\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.`
|
|
2146
|
+
);
|
|
2147
|
+
}
|
|
2148
|
+
function warnOnCoreNamespaceRoutes(appRoutes) {
|
|
2149
|
+
const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
|
|
2150
|
+
if (inside.length === 0) {
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
|
|
2154
|
+
serverLogger.warn(
|
|
2155
|
+
`\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.`
|
|
2156
|
+
);
|
|
1610
2157
|
}
|
|
1611
2158
|
async function executeBeforeRoutesHook(app, config) {
|
|
1612
2159
|
if (config?.lifecycle?.beforeRoutes) {
|
|
@@ -1618,9 +2165,12 @@ async function loadAppRoutes(app, config) {
|
|
|
1618
2165
|
if (config?.routes) {
|
|
1619
2166
|
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1620
2167
|
logRegisteredRoutes(routes, debug);
|
|
1621
|
-
|
|
2168
|
+
return routes;
|
|
2169
|
+
}
|
|
2170
|
+
if (debug) {
|
|
1622
2171
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
1623
2172
|
}
|
|
2173
|
+
return [];
|
|
1624
2174
|
}
|
|
1625
2175
|
function logRegisteredRoutes(routes, debug) {
|
|
1626
2176
|
if (routes.length === 0) {
|
|
@@ -1647,8 +2197,9 @@ async function registerSSEEndpoint(app, config) {
|
|
|
1647
2197
|
return;
|
|
1648
2198
|
}
|
|
1649
2199
|
const eventsConfig = config.eventsConfig ?? {};
|
|
1650
|
-
const streamPath = eventsConfig.path ??
|
|
2200
|
+
const streamPath = eventsConfig.path ?? DEFAULT_SSE_STREAM_PATH;
|
|
1651
2201
|
const authConfig = eventsConfig.auth;
|
|
2202
|
+
assertEventContractServed(config.events._contract, streamPath, !!authConfig?.enabled);
|
|
1652
2203
|
const debug = isDebugMode(config);
|
|
1653
2204
|
let tokenManager;
|
|
1654
2205
|
if (authConfig?.enabled) {
|
|
@@ -1671,7 +2222,7 @@ async function registerSSEEndpoint(app, config) {
|
|
|
1671
2222
|
ttl: authConfig.tokenTtl,
|
|
1672
2223
|
store
|
|
1673
2224
|
});
|
|
1674
|
-
const tokenPath = streamPath
|
|
2225
|
+
const tokenPath = sseTokenPath(streamPath);
|
|
1675
2226
|
const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
|
|
1676
2227
|
const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
|
|
1677
2228
|
app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
|
|
@@ -1700,6 +2251,23 @@ async function registerSSEEndpoint(app, config) {
|
|
|
1700
2251
|
});
|
|
1701
2252
|
}
|
|
1702
2253
|
}
|
|
2254
|
+
function assertEventContractServed(contract, streamPath, authEnabled) {
|
|
2255
|
+
if (!contract) {
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
const contractPath = contract.streamPath ?? DEFAULT_SSE_STREAM_PATH;
|
|
2259
|
+
if (contractPath !== streamPath) {
|
|
2260
|
+
throw new Error(
|
|
2261
|
+
`SSE event router contract declares streamPath ${contractPath} but .events() registers ${streamPath}; the contract would describe a path the server does not serve`
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
const servedAuth = authEnabled ? "tokenExchange" : "none";
|
|
2265
|
+
if (contract.auth !== servedAuth) {
|
|
2266
|
+
throw new Error(
|
|
2267
|
+
`SSE event router contract declares auth ${contract.auth} but .events() serves auth ${servedAuth}; the contract would describe an auth mode the server does not serve`
|
|
2268
|
+
);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
1703
2271
|
function isDebugMode(config) {
|
|
1704
2272
|
return config?.debug ?? process.env.NODE_ENV === "development";
|
|
1705
2273
|
}
|
|
@@ -1791,6 +2359,31 @@ function shouldSweepOrphanSchedules() {
|
|
|
1791
2359
|
return getBossConfig()?.sweepOrphanSchedules ?? false;
|
|
1792
2360
|
}
|
|
1793
2361
|
|
|
2362
|
+
// src/job/queue-policy.ts
|
|
2363
|
+
function resolveQueuePolicy(job2) {
|
|
2364
|
+
if (job2.options?.policy) {
|
|
2365
|
+
return job2.options.policy;
|
|
2366
|
+
}
|
|
2367
|
+
if (job2.runOnce || job2.options?.singletonKey) {
|
|
2368
|
+
return "exclusive";
|
|
2369
|
+
}
|
|
2370
|
+
return "standard";
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// src/job/retry-options.ts
|
|
2374
|
+
function toPgBossRetryOptions(options) {
|
|
2375
|
+
return {
|
|
2376
|
+
retryLimit: options?.retryLimit ?? 3,
|
|
2377
|
+
retryDelay: Math.ceil((options?.retryDelay ?? 1e3) / 1e3),
|
|
2378
|
+
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
2379
|
+
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
2380
|
+
retryBackoff: options?.retryBackoff ?? true
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// src/job/job-builder.ts
|
|
2385
|
+
logger.child("@spfn/core:job");
|
|
2386
|
+
|
|
1794
2387
|
// src/job/job-router.ts
|
|
1795
2388
|
function isJobDef(value) {
|
|
1796
2389
|
return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
|
|
@@ -1798,29 +2391,44 @@ function isJobDef(value) {
|
|
|
1798
2391
|
function isJobRouter(value) {
|
|
1799
2392
|
return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
|
|
1800
2393
|
}
|
|
1801
|
-
function
|
|
1802
|
-
|
|
2394
|
+
function defineJobRouter(jobs) {
|
|
2395
|
+
return {
|
|
2396
|
+
jobs,
|
|
2397
|
+
_jobs: jobs
|
|
2398
|
+
};
|
|
2399
|
+
}
|
|
2400
|
+
function mergeJobRouters(base, extra) {
|
|
2401
|
+
for (const key of Object.keys(extra.jobs)) {
|
|
2402
|
+
if (key in base.jobs) {
|
|
2403
|
+
throw new Error(
|
|
2404
|
+
`jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
return defineJobRouter({ ...base.jobs, ...extra.jobs });
|
|
2409
|
+
}
|
|
2410
|
+
function collectJobEntries(router, prefix = "") {
|
|
2411
|
+
const entries = [];
|
|
1803
2412
|
for (const [key, value] of Object.entries(router.jobs)) {
|
|
1804
|
-
const
|
|
2413
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
1805
2414
|
if (isJobRouter(value)) {
|
|
1806
|
-
|
|
2415
|
+
entries.push(...collectJobEntries(value, path));
|
|
1807
2416
|
} else if (isJobDef(value)) {
|
|
1808
|
-
|
|
2417
|
+
entries.push({ key: path, job: value });
|
|
1809
2418
|
}
|
|
1810
2419
|
}
|
|
1811
|
-
return
|
|
2420
|
+
return entries;
|
|
1812
2421
|
}
|
|
1813
|
-
|
|
2422
|
+
function collectJobs(router, prefix = "") {
|
|
2423
|
+
return collectJobEntries(router, prefix).map((entry) => entry.job);
|
|
2424
|
+
}
|
|
2425
|
+
var jobLogger3 = logger.child("@spfn/core:job");
|
|
1814
2426
|
function getEventQueueName(eventName) {
|
|
1815
2427
|
return `event:${eventName}`;
|
|
1816
2428
|
}
|
|
1817
2429
|
function getDefaultJobOptions(options) {
|
|
1818
2430
|
return {
|
|
1819
|
-
|
|
1820
|
-
retryDelay: options?.retryDelay ?? 1e3,
|
|
1821
|
-
// Exponential backoff by default — a failed cohort (e.g. a provider 429)
|
|
1822
|
-
// would otherwise all retry at the same fixed offset (thundering herd).
|
|
1823
|
-
retryBackoff: options?.retryBackoff ?? true,
|
|
2431
|
+
...toPgBossRetryOptions(options),
|
|
1824
2432
|
expireInSeconds: options?.expireInSeconds ?? 300
|
|
1825
2433
|
};
|
|
1826
2434
|
}
|
|
@@ -1831,11 +2439,12 @@ async function registerJobs(router) {
|
|
|
1831
2439
|
"pg-boss not initialized. Call initBoss() before registerJobs()"
|
|
1832
2440
|
);
|
|
1833
2441
|
}
|
|
2442
|
+
assertUniqueJobNames(router);
|
|
1834
2443
|
const jobs = collectJobs(router);
|
|
1835
2444
|
const clearOnStart = shouldClearOnStart();
|
|
1836
|
-
|
|
2445
|
+
jobLogger3.info(`Registering ${jobs.length} job(s)...`);
|
|
1837
2446
|
if (clearOnStart) {
|
|
1838
|
-
|
|
2447
|
+
jobLogger3.info("Clearing existing jobs before registration...");
|
|
1839
2448
|
for (const job2 of jobs) {
|
|
1840
2449
|
await boss.deleteAllJobs(job2.name);
|
|
1841
2450
|
if (job2.subscribedEvent) {
|
|
@@ -1843,7 +2452,7 @@ async function registerJobs(router) {
|
|
|
1843
2452
|
await boss.deleteAllJobs(eventQueue);
|
|
1844
2453
|
}
|
|
1845
2454
|
}
|
|
1846
|
-
|
|
2455
|
+
jobLogger3.info("Existing jobs cleared");
|
|
1847
2456
|
}
|
|
1848
2457
|
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
1849
2458
|
for (const job2 of jobs) {
|
|
@@ -1854,42 +2463,77 @@ async function registerJobs(router) {
|
|
|
1854
2463
|
if (shouldSweepOrphanSchedules()) {
|
|
1855
2464
|
await sweepOrphanSchedules(boss);
|
|
1856
2465
|
}
|
|
1857
|
-
|
|
2466
|
+
jobLogger3.info("All jobs registered successfully");
|
|
2467
|
+
}
|
|
2468
|
+
function assertUniqueJobNames(router) {
|
|
2469
|
+
const keysByName = /* @__PURE__ */ new Map();
|
|
2470
|
+
for (const { key, job: job2 } of collectJobEntries(router)) {
|
|
2471
|
+
const existingKey = keysByName.get(job2.name);
|
|
2472
|
+
if (existingKey) {
|
|
2473
|
+
throw new Error(
|
|
2474
|
+
`registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
keysByName.set(job2.name, key);
|
|
2478
|
+
}
|
|
1858
2479
|
}
|
|
1859
2480
|
var registeredCronNames = /* @__PURE__ */ new Set();
|
|
1860
2481
|
async function sweepOrphanSchedules(boss) {
|
|
1861
2482
|
if (registeredCronNames.size === 0) {
|
|
1862
|
-
|
|
2483
|
+
jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
|
|
1863
2484
|
return;
|
|
1864
2485
|
}
|
|
1865
2486
|
try {
|
|
1866
2487
|
const schedules = await boss.getSchedules();
|
|
1867
2488
|
const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
|
|
1868
2489
|
if (orphans.length === 0) {
|
|
1869
|
-
|
|
2490
|
+
jobLogger3.debug("No orphan schedules found");
|
|
1870
2491
|
return;
|
|
1871
2492
|
}
|
|
1872
2493
|
await Promise.all(orphans.map(async (orphan) => {
|
|
1873
2494
|
try {
|
|
1874
2495
|
await boss.unschedule(orphan.name, orphan.key);
|
|
1875
|
-
|
|
2496
|
+
jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
|
|
1876
2497
|
} catch (error) {
|
|
1877
|
-
|
|
2498
|
+
jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
|
|
1878
2499
|
error: error instanceof Error ? error.message : String(error)
|
|
1879
2500
|
});
|
|
1880
2501
|
}
|
|
1881
2502
|
}));
|
|
1882
2503
|
} catch (error) {
|
|
1883
|
-
|
|
2504
|
+
jobLogger3.error("Orphan schedule sweep failed", {
|
|
1884
2505
|
error: error instanceof Error ? error.message : String(error)
|
|
1885
2506
|
});
|
|
1886
2507
|
}
|
|
1887
2508
|
}
|
|
1888
|
-
async function ensureQueue(boss, queueName) {
|
|
1889
|
-
await boss.createQueue(queueName);
|
|
2509
|
+
async function ensureQueue(boss, queueName, policy) {
|
|
2510
|
+
await boss.createQueue(queueName, { policy });
|
|
2511
|
+
const existing = await boss.getQueue(queueName);
|
|
2512
|
+
if (!existing || existing.policy === policy) {
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
if (shouldClearOnStart()) {
|
|
2516
|
+
await boss.deleteQueue(queueName);
|
|
2517
|
+
await boss.createQueue(queueName, { policy });
|
|
2518
|
+
jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
jobLogger3.error(
|
|
2522
|
+
`[Queue:${queueName}] policy mismatch: the queue exists as "${existing.policy}" but its jobs require "${policy}". Delete the queue in a quiet window (getBoss().deleteQueue('${queueName}') drops its job rows) or set clearOnStart in development; until then singletonKey/runOnce on this queue do not deduplicate`
|
|
2523
|
+
);
|
|
2524
|
+
}
|
|
2525
|
+
function createQueueEnsurer(boss, policy) {
|
|
2526
|
+
const ensured = /* @__PURE__ */ new Set();
|
|
2527
|
+
return async (queueName) => {
|
|
2528
|
+
if (ensured.has(queueName)) {
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
ensured.add(queueName);
|
|
2532
|
+
await ensureQueue(boss, queueName, policy);
|
|
2533
|
+
};
|
|
1890
2534
|
}
|
|
1891
2535
|
async function executeJobHandler(job2, pgBossJob) {
|
|
1892
|
-
|
|
2536
|
+
jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
1893
2537
|
const startTime = Date.now();
|
|
1894
2538
|
try {
|
|
1895
2539
|
if (job2.inputSchema) {
|
|
@@ -1898,13 +2542,13 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1898
2542
|
await job2.handler();
|
|
1899
2543
|
}
|
|
1900
2544
|
const duration = Date.now() - startTime;
|
|
1901
|
-
|
|
2545
|
+
jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
1902
2546
|
jobId: pgBossJob.id,
|
|
1903
2547
|
duration
|
|
1904
2548
|
});
|
|
1905
2549
|
} catch (error) {
|
|
1906
2550
|
const duration = Date.now() - startTime;
|
|
1907
|
-
|
|
2551
|
+
jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
1908
2552
|
jobId: pgBossJob.id,
|
|
1909
2553
|
duration,
|
|
1910
2554
|
error: error instanceof Error ? error.message : String(error)
|
|
@@ -1912,8 +2556,8 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1912
2556
|
throw error;
|
|
1913
2557
|
}
|
|
1914
2558
|
}
|
|
1915
|
-
async function registerWorker(boss, job2, queueName) {
|
|
1916
|
-
await
|
|
2559
|
+
async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
|
|
2560
|
+
await ensureQueueOnce(queueName);
|
|
1917
2561
|
const batchSize = job2.options?.batchSize ?? 1;
|
|
1918
2562
|
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
1919
2563
|
await boss.work(
|
|
@@ -1947,28 +2591,28 @@ function connectEventToQueue(boss, job2, queueName) {
|
|
|
1947
2591
|
eventDef._registerJobQueue(queueName, async (queue, payload) => {
|
|
1948
2592
|
await boss.send(queue, payload, getDefaultJobOptions(job2.options));
|
|
1949
2593
|
});
|
|
1950
|
-
|
|
2594
|
+
jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
|
|
1951
2595
|
}
|
|
1952
|
-
async function registerCronSchedule(boss, job2) {
|
|
2596
|
+
async function registerCronSchedule(boss, job2, ensureQueueOnce) {
|
|
1953
2597
|
if (!job2.cronExpression) {
|
|
1954
2598
|
return;
|
|
1955
2599
|
}
|
|
1956
|
-
|
|
1957
|
-
await
|
|
2600
|
+
jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
|
|
2601
|
+
await ensureQueueOnce(job2.name);
|
|
1958
2602
|
await boss.schedule(
|
|
1959
2603
|
job2.name,
|
|
1960
2604
|
job2.cronExpression,
|
|
1961
2605
|
{},
|
|
1962
2606
|
getDefaultJobOptions(job2.options)
|
|
1963
2607
|
);
|
|
1964
|
-
|
|
2608
|
+
jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
|
|
1965
2609
|
}
|
|
1966
|
-
async function queueRunOnceJob(boss, job2) {
|
|
2610
|
+
async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
|
|
1967
2611
|
if (!job2.runOnce) {
|
|
1968
2612
|
return;
|
|
1969
2613
|
}
|
|
1970
|
-
|
|
1971
|
-
await
|
|
2614
|
+
jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
|
|
2615
|
+
await ensureQueueOnce(job2.name);
|
|
1972
2616
|
await boss.send(
|
|
1973
2617
|
job2.name,
|
|
1974
2618
|
{},
|
|
@@ -1977,7 +2621,7 @@ async function queueRunOnceJob(boss, job2) {
|
|
|
1977
2621
|
singletonKey: `runOnce:${job2.name}`
|
|
1978
2622
|
}
|
|
1979
2623
|
);
|
|
1980
|
-
|
|
2624
|
+
jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
|
|
1981
2625
|
}
|
|
1982
2626
|
async function registerJob(job2) {
|
|
1983
2627
|
const boss = getBoss();
|
|
@@ -1985,15 +2629,16 @@ async function registerJob(job2) {
|
|
|
1985
2629
|
throw new Error("pg-boss not initialized");
|
|
1986
2630
|
}
|
|
1987
2631
|
const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
|
|
1988
|
-
|
|
2632
|
+
jobLogger3.debug(`Registering job: ${job2.name}`, {
|
|
1989
2633
|
queueName,
|
|
1990
2634
|
subscribedEvent: job2.subscribedEvent
|
|
1991
2635
|
});
|
|
1992
|
-
|
|
2636
|
+
const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
|
|
2637
|
+
await registerWorker(boss, job2, queueName, ensureQueueOnce);
|
|
1993
2638
|
connectEventToQueue(boss, job2, queueName);
|
|
1994
|
-
await registerCronSchedule(boss, job2);
|
|
1995
|
-
await queueRunOnceJob(boss, job2);
|
|
1996
|
-
|
|
2639
|
+
await registerCronSchedule(boss, job2, ensureQueueOnce);
|
|
2640
|
+
await queueRunOnceJob(boss, job2, ensureQueueOnce);
|
|
2641
|
+
jobLogger3.debug(`Job registered: ${job2.name}`);
|
|
1997
2642
|
}
|
|
1998
2643
|
var wsLogger = logger.child("@spfn/core:ws");
|
|
1999
2644
|
function isOriginAllowed(origin, allowList) {
|
|
@@ -2285,6 +2930,89 @@ function printBanner(options) {
|
|
|
2285
2930
|
}
|
|
2286
2931
|
console.log("");
|
|
2287
2932
|
}
|
|
2933
|
+
var PORT_DEFAULTS = {
|
|
2934
|
+
server: 8790
|
|
2935
|
+
};
|
|
2936
|
+
var HOST_DEFAULT = "localhost";
|
|
2937
|
+
var CONFIG_FILE_NAMES = [
|
|
2938
|
+
"spfn.config.js",
|
|
2939
|
+
"spfn.config.mjs"
|
|
2940
|
+
];
|
|
2941
|
+
async function loadAppConfig(cwd = process.cwd()) {
|
|
2942
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
2943
|
+
const fullPath = join(cwd, fileName);
|
|
2944
|
+
if (!existsSync(fullPath)) {
|
|
2945
|
+
continue;
|
|
2946
|
+
}
|
|
2947
|
+
try {
|
|
2948
|
+
const module = await import(pathToFileURL(fullPath).href);
|
|
2949
|
+
return module.default ?? {};
|
|
2950
|
+
} catch (error) {
|
|
2951
|
+
console.warn(
|
|
2952
|
+
`\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
|
|
2953
|
+
);
|
|
2954
|
+
return {};
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
return {};
|
|
2958
|
+
}
|
|
2959
|
+
function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
|
|
2960
|
+
return {
|
|
2961
|
+
port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
|
|
2962
|
+
host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
function readPort(value) {
|
|
2966
|
+
if (!value) {
|
|
2967
|
+
return void 0;
|
|
2968
|
+
}
|
|
2969
|
+
const port = Number(value);
|
|
2970
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
2971
|
+
}
|
|
2972
|
+
logger.child("@spfn/core:env-registry");
|
|
2973
|
+
function validateAllEnv(registries) {
|
|
2974
|
+
const errors = [];
|
|
2975
|
+
const warnings = [];
|
|
2976
|
+
for (const registry of registries) {
|
|
2977
|
+
const result = registry.validateAll();
|
|
2978
|
+
errors.push(...result.errors);
|
|
2979
|
+
warnings.push(...result.warnings);
|
|
2980
|
+
}
|
|
2981
|
+
return {
|
|
2982
|
+
valid: errors.length === 0,
|
|
2983
|
+
errors,
|
|
2984
|
+
warnings
|
|
2985
|
+
};
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
// src/server/env-boot-check.ts
|
|
2989
|
+
function runEnvBootCheck(config) {
|
|
2990
|
+
const result = validateAllEnv([registry, ...config.env?.registries ?? []]);
|
|
2991
|
+
const errors = uniqueByMessage(result.errors);
|
|
2992
|
+
const warnings = uniqueByMessage(result.warnings);
|
|
2993
|
+
for (const warning of warnings) {
|
|
2994
|
+
serverLogger.warn(`Environment: ${warning.message}`);
|
|
2995
|
+
}
|
|
2996
|
+
if (errors.length > 0) {
|
|
2997
|
+
serverLogger.error(
|
|
2998
|
+
`Environment validation failed (${errors.length}):
|
|
2999
|
+
` + errors.map((error) => ` - ${error.message}`).join("\n")
|
|
3000
|
+
);
|
|
3001
|
+
}
|
|
3002
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
3003
|
+
}
|
|
3004
|
+
function uniqueByMessage(entries) {
|
|
3005
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3006
|
+
return entries.filter((entry) => {
|
|
3007
|
+
const id = `${entry.key}
|
|
3008
|
+
${entry.message}`;
|
|
3009
|
+
if (seen.has(id)) {
|
|
3010
|
+
return false;
|
|
3011
|
+
}
|
|
3012
|
+
seen.add(id);
|
|
3013
|
+
return true;
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
2288
3016
|
|
|
2289
3017
|
// src/server/validation.ts
|
|
2290
3018
|
function validateServerConfig(config) {
|
|
@@ -2333,14 +3061,20 @@ var TIMEOUTS = {
|
|
|
2333
3061
|
REDIS_CLOSE: 5e3};
|
|
2334
3062
|
var CONFIG_FILE_PATHS = [
|
|
2335
3063
|
".spfn/server/server.config.mjs",
|
|
3064
|
+
".spfn/server/server.config.js",
|
|
2336
3065
|
".spfn/server/server.config",
|
|
2337
3066
|
"src/server/server.config",
|
|
2338
3067
|
"src/server/server.config.ts"
|
|
2339
3068
|
];
|
|
3069
|
+
var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
|
|
2340
3070
|
var processHandlersRegistered = false;
|
|
2341
3071
|
async function startServer(config) {
|
|
2342
3072
|
loadEnv();
|
|
2343
3073
|
const finalConfig = await loadAndMergeConfig(config);
|
|
3074
|
+
if (!runEnvBootCheck(finalConfig).valid) {
|
|
3075
|
+
serverLogger.error("Refusing to start: fix the environment variables listed above.");
|
|
3076
|
+
process.exit(1);
|
|
3077
|
+
}
|
|
2344
3078
|
const { host, port, debug } = finalConfig;
|
|
2345
3079
|
validateServerConfig(finalConfig);
|
|
2346
3080
|
if (!host || !port) {
|
|
@@ -2400,8 +3134,7 @@ async function startServer(config) {
|
|
|
2400
3134
|
throw error;
|
|
2401
3135
|
}
|
|
2402
3136
|
}
|
|
2403
|
-
async function loadAndMergeConfig(config) {
|
|
2404
|
-
const cwd = process.cwd();
|
|
3137
|
+
async function loadAndMergeConfig(config, cwd = process.cwd()) {
|
|
2405
3138
|
let fileConfig = {};
|
|
2406
3139
|
let loadedConfigPath = null;
|
|
2407
3140
|
for (const configPath of CONFIG_FILE_PATHS) {
|
|
@@ -2419,16 +3152,37 @@ async function loadAndMergeConfig(config) {
|
|
|
2419
3152
|
}
|
|
2420
3153
|
if (loadedConfigPath) {
|
|
2421
3154
|
serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
|
|
3155
|
+
} else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
|
|
3156
|
+
serverLogger.warn(
|
|
3157
|
+
`\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/.`
|
|
3158
|
+
);
|
|
2422
3159
|
} else {
|
|
2423
3160
|
serverLogger.debug("No configuration file found, using defaults");
|
|
2424
3161
|
}
|
|
3162
|
+
const appConfig = await loadAppConfig(cwd);
|
|
3163
|
+
warnOnDeprecatedAddress(config, fileConfig);
|
|
3164
|
+
const address = resolveServerAddress(appConfig, {
|
|
3165
|
+
port: config?.port ?? fileConfig?.port,
|
|
3166
|
+
host: config?.host ?? fileConfig?.host
|
|
3167
|
+
});
|
|
2425
3168
|
return {
|
|
2426
3169
|
...fileConfig,
|
|
2427
3170
|
...config,
|
|
2428
|
-
port:
|
|
2429
|
-
host:
|
|
3171
|
+
port: address.port,
|
|
3172
|
+
host: address.host
|
|
2430
3173
|
};
|
|
2431
3174
|
}
|
|
3175
|
+
function warnOnDeprecatedAddress(config, fileConfig) {
|
|
3176
|
+
const used = [
|
|
3177
|
+
(config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
|
|
3178
|
+
(config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
|
|
3179
|
+
].filter(Boolean);
|
|
3180
|
+
if (used.length > 0) {
|
|
3181
|
+
serverLogger.warn(
|
|
3182
|
+
`\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.`
|
|
3183
|
+
);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
2432
3186
|
function getInfrastructureConfig(config) {
|
|
2433
3187
|
return {
|
|
2434
3188
|
database: config.infrastructure?.database !== false,
|
|
@@ -2811,6 +3565,9 @@ function createServerlessApp(config) {
|
|
|
2811
3565
|
}
|
|
2812
3566
|
async function buildServerlessApp(rawConfig) {
|
|
2813
3567
|
loadEnv();
|
|
3568
|
+
if (!runEnvBootCheck(rawConfig ?? {}).valid) {
|
|
3569
|
+
throw new Error("Environment validation failed \u2014 the missing or invalid keys are logged above.");
|
|
3570
|
+
}
|
|
2814
3571
|
const config = withServerlessDefaults(rawConfig);
|
|
2815
3572
|
warnIfJobsConfigured(config);
|
|
2816
3573
|
await initInfrastructure(config);
|
|
@@ -2961,6 +3718,20 @@ var ServerConfigBuilder = class {
|
|
|
2961
3718
|
/**
|
|
2962
3719
|
* Register background jobs router
|
|
2963
3720
|
*
|
|
3721
|
+
* May be called once per domain router: repeated calls **merge** into one
|
|
3722
|
+
* router, the way `.routes()` composes. Calling it several times used to
|
|
3723
|
+
* keep only the last router and register nothing else, without warning.
|
|
3724
|
+
*
|
|
3725
|
+
* The pg-boss `config` is per server, so pass it on one call only.
|
|
3726
|
+
*
|
|
3727
|
+
* Two names can collide, and both fail loudly:
|
|
3728
|
+
* - a router **key** present in two calls throws here, at config time;
|
|
3729
|
+
* - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
|
|
3730
|
+
* `registerJobs()` at boot, before any queue or worker is created.
|
|
3731
|
+
*
|
|
3732
|
+
* @throws if a router key was already registered by an earlier call
|
|
3733
|
+
* @throws if `config` is given when an earlier call already gave one
|
|
3734
|
+
*
|
|
2964
3735
|
* @example
|
|
2965
3736
|
* ```typescript
|
|
2966
3737
|
* import { job, defineJobRouter } from '@spfn/core/job';
|
|
@@ -2973,12 +3744,18 @@ var ServerConfigBuilder = class {
|
|
|
2973
3744
|
*
|
|
2974
3745
|
* export default defineServerConfig()
|
|
2975
3746
|
* .routes(appRouter)
|
|
2976
|
-
* .jobs(jobRouter)
|
|
3747
|
+
* .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
|
|
3748
|
+
* .jobs(billingJobRouter) // merged, not replaced
|
|
2977
3749
|
* .build();
|
|
2978
3750
|
* ```
|
|
2979
3751
|
*/
|
|
2980
3752
|
jobs(router, config) {
|
|
2981
|
-
this.config.
|
|
3753
|
+
if (config && this.config.jobsConfig) {
|
|
3754
|
+
throw new Error(
|
|
3755
|
+
"jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
|
|
3756
|
+
);
|
|
3757
|
+
}
|
|
3758
|
+
this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
|
|
2982
3759
|
if (config) {
|
|
2983
3760
|
this.config.jobsConfig = config;
|
|
2984
3761
|
}
|
|
@@ -3088,6 +3865,15 @@ var ServerConfigBuilder = class {
|
|
|
3088
3865
|
this.config.healthCheck = healthCheck;
|
|
3089
3866
|
return this;
|
|
3090
3867
|
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Supply the clock used by the built-in `GET /_core/time` capability.
|
|
3870
|
+
* Production servers normally keep the default `Date.now()` clock; this is
|
|
3871
|
+
* exposed so tests can assert an exact wire value without replacing globals.
|
|
3872
|
+
*/
|
|
3873
|
+
serverTime(serverTime) {
|
|
3874
|
+
this.config.serverTime = serverTime;
|
|
3875
|
+
return this;
|
|
3876
|
+
}
|
|
3091
3877
|
/**
|
|
3092
3878
|
* Configure infrastructure initialization
|
|
3093
3879
|
*/
|
|
@@ -3110,6 +3896,25 @@ var ServerConfigBuilder = class {
|
|
|
3110
3896
|
this.config.migrations = migrations;
|
|
3111
3897
|
return this;
|
|
3112
3898
|
}
|
|
3899
|
+
/**
|
|
3900
|
+
* Hand the app's env registries to the boot check
|
|
3901
|
+
*
|
|
3902
|
+
* `@spfn/core`'s own registry is always checked; the registries given here
|
|
3903
|
+
* are checked with it, before the server listens.
|
|
3904
|
+
*
|
|
3905
|
+
* @example
|
|
3906
|
+
* ```typescript
|
|
3907
|
+
* import { envRegistry } from '@/server/config/env.config';
|
|
3908
|
+
*
|
|
3909
|
+
* export default defineServerConfig()
|
|
3910
|
+
* .env({ registries: [envRegistry] })
|
|
3911
|
+
* .build();
|
|
3912
|
+
* ```
|
|
3913
|
+
*/
|
|
3914
|
+
env(env6) {
|
|
3915
|
+
this.config.env = env6;
|
|
3916
|
+
return this;
|
|
3917
|
+
}
|
|
3113
3918
|
/**
|
|
3114
3919
|
* Register workflow router for workflow orchestration
|
|
3115
3920
|
*
|
|
@@ -3184,6 +3989,6 @@ function defineServerConfig() {
|
|
|
3184
3989
|
return new ServerConfigBuilder();
|
|
3185
3990
|
}
|
|
3186
3991
|
|
|
3187
|
-
export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
|
|
3992
|
+
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
3993
|
//# sourceMappingURL=index.js.map
|
|
3189
3994
|
//# sourceMappingURL=index.js.map
|