@spfn/core 0.3.0-beta.1 → 0.3.0-beta.11
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 +301 -26
- 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/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/config/index.d.ts +0 -63
- package/dist/config/index.js +12 -12
- package/dist/config/index.js.map +1 -1
- package/dist/contract/index.d.ts +5 -4
- 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/loader.js +24 -1
- package/dist/env/loader.js.map +1 -1
- package/dist/errors/index.js +1 -381
- package/dist/errors/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +15 -2
- package/dist/event/sse/client.js +38 -2
- package/dist/event/sse/client.js.map +1 -1
- package/dist/job/index.d.ts +48 -3
- package/dist/job/index.js +118 -40
- 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 +73 -6
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +35 -2
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/ops/index.d.ts +115 -14
- package/dist/ops/index.js +339 -15
- 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-ukNdAZcN.d.ts → route-builder-2ani2jEI.d.ts} +3 -126
- package/dist/router-DJdpwuB6.d.ts +127 -0
- package/dist/server/index.d.ts +130 -11
- package/dist/server/index.js +714 -70
- package/dist/server/index.js.map +1 -1
- package/dist/{types-Bvvig_tT.d.ts → types-ClQVomgV.d.ts} +1 -1
- package/dist/{types-9oszaJqp.d.ts → types-yv6z_mK7.d.ts} +122 -3
- package/docs/file-upload.md +195 -333
- package/package.json +14 -8
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -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 +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +741 -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 +649 -0
- package/src/route/README.md +744 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
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;
|
|
@@ -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;
|
|
@@ -1076,6 +1101,12 @@ async function runMigrationBootGate(config, cwd = process.cwd()) {
|
|
|
1076
1101
|
);
|
|
1077
1102
|
}
|
|
1078
1103
|
|
|
1104
|
+
// src/server/namespace.ts
|
|
1105
|
+
var CORE_NAMESPACE = "/_core";
|
|
1106
|
+
var CORE_HEALTH_PATH = `${CORE_NAMESPACE}/health`;
|
|
1107
|
+
var CORE_TIME_PATH = `${CORE_NAMESPACE}/time`;
|
|
1108
|
+
var LEGACY_HEALTH_PATH = "/health";
|
|
1109
|
+
|
|
1079
1110
|
// src/server/shutdown-manager.ts
|
|
1080
1111
|
var DEFAULT_HOOK_TIMEOUT = 1e4;
|
|
1081
1112
|
var DEFAULT_HOOK_ORDER = 100;
|
|
@@ -1308,7 +1339,7 @@ async function readMigrationHealth() {
|
|
|
1308
1339
|
targets: migrationTargets(snapshot.status)
|
|
1309
1340
|
};
|
|
1310
1341
|
}
|
|
1311
|
-
function createHealthCheckHandler(detailed) {
|
|
1342
|
+
function createHealthCheckHandler(detailed, infrastructure) {
|
|
1312
1343
|
return async (c) => {
|
|
1313
1344
|
const shutdownManager = getShutdownManager();
|
|
1314
1345
|
if (shutdownManager.isShuttingDown()) {
|
|
@@ -1322,23 +1353,29 @@ function createHealthCheckHandler(detailed) {
|
|
|
1322
1353
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1323
1354
|
};
|
|
1324
1355
|
if (detailed) {
|
|
1356
|
+
const databaseDisabled = infrastructure?.database === false;
|
|
1357
|
+
const redisDisabled = infrastructure?.redis === false;
|
|
1325
1358
|
let dbStatus = "unknown";
|
|
1326
1359
|
let dbError;
|
|
1327
|
-
|
|
1328
|
-
|
|
1360
|
+
if (databaseDisabled) {
|
|
1361
|
+
dbStatus = "disabled";
|
|
1362
|
+
} else {
|
|
1329
1363
|
try {
|
|
1330
|
-
|
|
1331
|
-
|
|
1364
|
+
const db = getDatabase();
|
|
1365
|
+
try {
|
|
1366
|
+
await db.execute("SELECT 1");
|
|
1367
|
+
dbStatus = "connected";
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
dbStatus = "error";
|
|
1370
|
+
dbError = error instanceof Error ? error.message : String(error);
|
|
1371
|
+
}
|
|
1332
1372
|
} catch (error) {
|
|
1333
|
-
dbStatus = "
|
|
1334
|
-
dbError =
|
|
1373
|
+
dbStatus = "not_initialized";
|
|
1374
|
+
dbError = "Database not available";
|
|
1335
1375
|
}
|
|
1336
|
-
} catch (error) {
|
|
1337
|
-
dbStatus = "not_initialized";
|
|
1338
|
-
dbError = "Database not available";
|
|
1339
1376
|
}
|
|
1340
|
-
const redis = getCache();
|
|
1341
|
-
let redisStatus = redis ? "unknown" : "not_initialized";
|
|
1377
|
+
const redis = redisDisabled ? null : getCache();
|
|
1378
|
+
let redisStatus = redisDisabled ? "disabled" : redis ? "unknown" : "not_initialized";
|
|
1342
1379
|
let redisError;
|
|
1343
1380
|
if (redis) {
|
|
1344
1381
|
try {
|
|
@@ -1438,7 +1475,7 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1438
1475
|
const middlewareConfig = config.middleware ?? {};
|
|
1439
1476
|
const healthCheckConfig = config.healthCheck ?? {};
|
|
1440
1477
|
const healthCheckEnabled = healthCheckConfig.enabled !== false;
|
|
1441
|
-
const healthCheckPath = healthCheckConfig.path
|
|
1478
|
+
const healthCheckPath = healthCheckConfig.path;
|
|
1442
1479
|
const healthCheckDetailed = healthCheckConfig.detailed ?? env.NODE_ENV === "development";
|
|
1443
1480
|
return {
|
|
1444
1481
|
middleware: {
|
|
@@ -1449,6 +1486,7 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1449
1486
|
},
|
|
1450
1487
|
healthCheck: healthCheckEnabled ? {
|
|
1451
1488
|
enabled: true,
|
|
1489
|
+
corePath: CORE_HEALTH_PATH,
|
|
1452
1490
|
path: healthCheckPath,
|
|
1453
1491
|
detailed: healthCheckDetailed
|
|
1454
1492
|
} : { enabled: false },
|
|
@@ -1467,6 +1505,378 @@ function buildStartupConfig(config, timeouts) {
|
|
|
1467
1505
|
};
|
|
1468
1506
|
}
|
|
1469
1507
|
|
|
1508
|
+
// src/route/route-builder.ts
|
|
1509
|
+
var RouteBuilder = class _RouteBuilder {
|
|
1510
|
+
_method;
|
|
1511
|
+
_path;
|
|
1512
|
+
_input;
|
|
1513
|
+
_interceptor;
|
|
1514
|
+
_middlewares;
|
|
1515
|
+
_skipMiddlewares;
|
|
1516
|
+
_contract;
|
|
1517
|
+
/**
|
|
1518
|
+
* Create a new RouteBuilder with copied properties and optional overrides
|
|
1519
|
+
*/
|
|
1520
|
+
clone(overrides) {
|
|
1521
|
+
const builder = new _RouteBuilder();
|
|
1522
|
+
builder._method = this._method;
|
|
1523
|
+
builder._path = this._path;
|
|
1524
|
+
builder._input = overrides?.input ?? this._input;
|
|
1525
|
+
builder._interceptor = overrides?.interceptor ?? this._interceptor;
|
|
1526
|
+
builder._middlewares = overrides?.middlewares ?? this._middlewares;
|
|
1527
|
+
builder._skipMiddlewares = overrides?.skipMiddlewares ?? this._skipMiddlewares;
|
|
1528
|
+
builder._contract = overrides?.contract ?? this._contract;
|
|
1529
|
+
return builder;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Define input schemas
|
|
1533
|
+
*
|
|
1534
|
+
* @example
|
|
1535
|
+
* ```ts
|
|
1536
|
+
* route.get('/users/:id')
|
|
1537
|
+
* .input({
|
|
1538
|
+
* params: Type.Object({ id: Type.String() }),
|
|
1539
|
+
* query: Type.Object({ page: Type.Number() }),
|
|
1540
|
+
* headers: Type.Object({ authorization: Type.String() })
|
|
1541
|
+
* })
|
|
1542
|
+
* .handler(async (c) => {
|
|
1543
|
+
* const { params, query, headers } = await c.data();
|
|
1544
|
+
* // params = { id: string }
|
|
1545
|
+
* // query = { page: number }
|
|
1546
|
+
* // headers = { authorization: string }
|
|
1547
|
+
* })
|
|
1548
|
+
* ```
|
|
1549
|
+
*/
|
|
1550
|
+
input(input) {
|
|
1551
|
+
return this.clone({ input });
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Define fields injected by interceptors
|
|
1555
|
+
*
|
|
1556
|
+
* These fields are:
|
|
1557
|
+
* - Available in the handler (merged with input)
|
|
1558
|
+
* - Excluded from client types (codegen uses only input)
|
|
1559
|
+
* - Not validated by route input schema (injected by middleware)
|
|
1560
|
+
*
|
|
1561
|
+
* Use this when middleware/interceptors add fields to the request
|
|
1562
|
+
* before it reaches the handler.
|
|
1563
|
+
*
|
|
1564
|
+
* @example
|
|
1565
|
+
* ```ts
|
|
1566
|
+
* // Auth interceptor injects crypto key fields
|
|
1567
|
+
* route.post('/_auth/login')
|
|
1568
|
+
* .input({
|
|
1569
|
+
* body: Type.Object({
|
|
1570
|
+
* email: Type.String(),
|
|
1571
|
+
* password: Type.String()
|
|
1572
|
+
* })
|
|
1573
|
+
* })
|
|
1574
|
+
* .interceptor({
|
|
1575
|
+
* body: Type.Object({
|
|
1576
|
+
* publicKey: Type.String(),
|
|
1577
|
+
* keyId: Type.String(),
|
|
1578
|
+
* fingerprint: Type.String()
|
|
1579
|
+
* })
|
|
1580
|
+
* })
|
|
1581
|
+
* .handler(async (c) => {
|
|
1582
|
+
* const { body } = await c.data();
|
|
1583
|
+
* // body type: { email, password, publicKey, keyId, fingerprint }
|
|
1584
|
+
* // Client only sees: { email, password }
|
|
1585
|
+
* return loginService(body);
|
|
1586
|
+
* });
|
|
1587
|
+
* ```
|
|
1588
|
+
*/
|
|
1589
|
+
interceptor(interceptor) {
|
|
1590
|
+
return this.clone({ interceptor });
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Add middlewares to the route
|
|
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
|
+
middleware(middlewares) {
|
|
1613
|
+
return this.clone({ middlewares });
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Add middlewares to the route (alias for `.middleware()`)
|
|
1617
|
+
*
|
|
1618
|
+
* Accepts both regular middleware handlers and named middlewares (NamedMiddleware).
|
|
1619
|
+
* Named middlewares that are already registered globally will be automatically
|
|
1620
|
+
* deduplicated to prevent double execution.
|
|
1621
|
+
*
|
|
1622
|
+
* @example
|
|
1623
|
+
* ```ts
|
|
1624
|
+
* import { authenticate } from '@spfn/auth/server/middleware';
|
|
1625
|
+
*
|
|
1626
|
+
* // With NamedMiddleware (auto-deduped if registered globally)
|
|
1627
|
+
* route.get('/users')
|
|
1628
|
+
* .use([authenticate, RateLimitMiddleware()])
|
|
1629
|
+
*
|
|
1630
|
+
* // With regular middleware handlers
|
|
1631
|
+
* route.get('/users')
|
|
1632
|
+
* .use([AuthMiddleware(), RateLimitMiddleware()])
|
|
1633
|
+
* ```
|
|
1634
|
+
*/
|
|
1635
|
+
use(middlewares) {
|
|
1636
|
+
return this.middleware(middlewares);
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* Skip server-level named middlewares
|
|
1640
|
+
*
|
|
1641
|
+
* Useful for public endpoints that should bypass auth or rate limiting
|
|
1642
|
+
*
|
|
1643
|
+
* @param middlewareNames - Array of middleware names to skip, or '*' to skip all
|
|
1644
|
+
*
|
|
1645
|
+
* @example
|
|
1646
|
+
* ```ts
|
|
1647
|
+
* // Skip specific middlewares
|
|
1648
|
+
* route.get('/status')
|
|
1649
|
+
* .skip(['auth', 'rateLimit'])
|
|
1650
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1651
|
+
*
|
|
1652
|
+
* // Skip only auth (still apply rate limiting)
|
|
1653
|
+
* route.get('/public-data')
|
|
1654
|
+
* .skip(['auth'])
|
|
1655
|
+
* .handler(async (c) => { ... });
|
|
1656
|
+
*
|
|
1657
|
+
* // Skip all middlewares
|
|
1658
|
+
* route.get('/public-health')
|
|
1659
|
+
* .skip('*')
|
|
1660
|
+
* .handler(async (c) => c.json({ status: 'ok' }));
|
|
1661
|
+
* ```
|
|
1662
|
+
*/
|
|
1663
|
+
skip(middlewareNames) {
|
|
1664
|
+
return this.clone({ skipMiddlewares: middlewareNames });
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Publish this route as a versioned contract operation
|
|
1668
|
+
*
|
|
1669
|
+
* Marks the route as a promise to clients that are compiled and deployed
|
|
1670
|
+
* separately from the server — a mobile app, an external API consumer.
|
|
1671
|
+
* The `@spfn/core:contract` generator writes every contracted route into
|
|
1672
|
+
* `contracts/current.json`, and the build refuses a change that would break
|
|
1673
|
+
* an already-released client.
|
|
1674
|
+
*
|
|
1675
|
+
* Routes without `.contract()` are unaffected: they simply do not appear in
|
|
1676
|
+
* the contract. A web client needs nothing here — it derives its types from
|
|
1677
|
+
* the router in the same build.
|
|
1678
|
+
*
|
|
1679
|
+
* @example
|
|
1680
|
+
* ```ts
|
|
1681
|
+
* export const getUser = route.get('/users/:id')
|
|
1682
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
1683
|
+
* .contract({
|
|
1684
|
+
* since: '1.2.0',
|
|
1685
|
+
* auth: 'clientProofV1',
|
|
1686
|
+
* requiresSession: true,
|
|
1687
|
+
* response: Type.Object({
|
|
1688
|
+
* id: Type.String(),
|
|
1689
|
+
* name: Type.String(),
|
|
1690
|
+
* email: Type.Optional(Type.String()),
|
|
1691
|
+
* }),
|
|
1692
|
+
* })
|
|
1693
|
+
* .handler(async (c) => { ... });
|
|
1694
|
+
* ```
|
|
1695
|
+
*/
|
|
1696
|
+
contract(contract) {
|
|
1697
|
+
return this.clone({ contract });
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Define handler function
|
|
1701
|
+
*
|
|
1702
|
+
* Response type is automatically inferred from the return value.
|
|
1703
|
+
* Use helper methods like `c.created()`, `c.paginated()` for proper type inference.
|
|
1704
|
+
*
|
|
1705
|
+
* @example
|
|
1706
|
+
* ```ts
|
|
1707
|
+
* // Direct return - type inferred from data
|
|
1708
|
+
* route.get('/users/:id')
|
|
1709
|
+
* .input({ params: Type.Object({ id: Type.String() }) })
|
|
1710
|
+
* .handler(async (c) => {
|
|
1711
|
+
* const { params } = await c.data();
|
|
1712
|
+
* return await getUser(params.id); // Type: User
|
|
1713
|
+
* })
|
|
1714
|
+
*
|
|
1715
|
+
* // Using c.created() - returns data with 201 status, type preserved
|
|
1716
|
+
* route.post('/users')
|
|
1717
|
+
* .input({ body: Type.Object({ name: Type.String() }) })
|
|
1718
|
+
* .handler(async (c) => {
|
|
1719
|
+
* const { body } = await c.data();
|
|
1720
|
+
* return c.created(await createUser(body)); // Type: User
|
|
1721
|
+
* })
|
|
1722
|
+
*
|
|
1723
|
+
* // Using c.paginated() - returns PaginatedResult<T>
|
|
1724
|
+
* route.get('/users')
|
|
1725
|
+
* .handler(async (c) => {
|
|
1726
|
+
* const users = await getUsers();
|
|
1727
|
+
* return c.paginated(users, 1, 20, 100); // Type: PaginatedResult<User>
|
|
1728
|
+
* })
|
|
1729
|
+
*
|
|
1730
|
+
* // Using c.noContent() - returns void
|
|
1731
|
+
* route.delete('/users/:id')
|
|
1732
|
+
* .handler(async (c) => {
|
|
1733
|
+
* await deleteUser(params.id);
|
|
1734
|
+
* return c.noContent(); // Type: void
|
|
1735
|
+
* })
|
|
1736
|
+
*
|
|
1737
|
+
* // Using c.json() - returns Response (type inference lost)
|
|
1738
|
+
* // Use only when you need custom status codes not covered by helpers
|
|
1739
|
+
* route.get('/custom')
|
|
1740
|
+
* .handler(async (c) => {
|
|
1741
|
+
* return c.json({ data }, 418); // Type: Response
|
|
1742
|
+
* })
|
|
1743
|
+
* ```
|
|
1744
|
+
*/
|
|
1745
|
+
handler(fn) {
|
|
1746
|
+
return {
|
|
1747
|
+
method: this._method,
|
|
1748
|
+
path: this._path,
|
|
1749
|
+
input: this._input,
|
|
1750
|
+
interceptor: this._interceptor,
|
|
1751
|
+
middlewares: this._middlewares,
|
|
1752
|
+
skipMiddlewares: this._skipMiddlewares,
|
|
1753
|
+
contract: this._contract,
|
|
1754
|
+
handler: fn,
|
|
1755
|
+
_input: {},
|
|
1756
|
+
_interceptor: {},
|
|
1757
|
+
_response: {}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
function createMethodRoute(method) {
|
|
1762
|
+
return (path) => {
|
|
1763
|
+
const builder = new RouteBuilder();
|
|
1764
|
+
builder._method = method;
|
|
1765
|
+
builder._path = path;
|
|
1766
|
+
return builder;
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
var route = {
|
|
1770
|
+
get: createMethodRoute("GET"),
|
|
1771
|
+
post: createMethodRoute("POST"),
|
|
1772
|
+
put: createMethodRoute("PUT"),
|
|
1773
|
+
patch: createMethodRoute("PATCH"),
|
|
1774
|
+
delete: createMethodRoute("DELETE")
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
// src/route/router.ts
|
|
1778
|
+
function createRouterInstance(routes, packageRouters = [], globalMiddlewares = [], contractVersion = null) {
|
|
1779
|
+
return {
|
|
1780
|
+
routes,
|
|
1781
|
+
_routes: routes,
|
|
1782
|
+
_packageRouters: packageRouters,
|
|
1783
|
+
_globalMiddlewares: globalMiddlewares,
|
|
1784
|
+
_contractVersion: contractVersion,
|
|
1785
|
+
packages(routers) {
|
|
1786
|
+
const newPackageRouters = [...this._packageRouters, ...routers];
|
|
1787
|
+
for (const pkgRouter of routers) {
|
|
1788
|
+
if (pkgRouter._packageRouters?.length > 0) {
|
|
1789
|
+
newPackageRouters.push(...pkgRouter._packageRouters);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return createRouterInstance(
|
|
1793
|
+
this.routes,
|
|
1794
|
+
newPackageRouters,
|
|
1795
|
+
this._globalMiddlewares,
|
|
1796
|
+
this._contractVersion
|
|
1797
|
+
);
|
|
1798
|
+
},
|
|
1799
|
+
use(middlewares) {
|
|
1800
|
+
return createRouterInstance(
|
|
1801
|
+
this.routes,
|
|
1802
|
+
this._packageRouters,
|
|
1803
|
+
[...this._globalMiddlewares, ...middlewares],
|
|
1804
|
+
this._contractVersion
|
|
1805
|
+
);
|
|
1806
|
+
},
|
|
1807
|
+
contractVersion(version) {
|
|
1808
|
+
assertContractVersion(version);
|
|
1809
|
+
return createRouterInstance(
|
|
1810
|
+
this.routes,
|
|
1811
|
+
this._packageRouters,
|
|
1812
|
+
this._globalMiddlewares,
|
|
1813
|
+
version
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
function assertContractVersion(version) {
|
|
1819
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/.test(version)) {
|
|
1820
|
+
throw new Error(
|
|
1821
|
+
`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.`
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
function defineRouter(routes) {
|
|
1826
|
+
return createRouterInstance(routes);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// src/route/validation.ts
|
|
1830
|
+
FormatRegistry.Set(
|
|
1831
|
+
"email",
|
|
1832
|
+
(value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
1833
|
+
);
|
|
1834
|
+
FormatRegistry.Set(
|
|
1835
|
+
"uri",
|
|
1836
|
+
(value) => /^https?:\/\/.+/.test(value)
|
|
1837
|
+
);
|
|
1838
|
+
FormatRegistry.Set(
|
|
1839
|
+
"uuid",
|
|
1840
|
+
(value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
|
1841
|
+
);
|
|
1842
|
+
FormatRegistry.Set(
|
|
1843
|
+
"date",
|
|
1844
|
+
(value) => /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
1845
|
+
);
|
|
1846
|
+
FormatRegistry.Set(
|
|
1847
|
+
"date-time",
|
|
1848
|
+
(value) => !isNaN(Date.parse(value))
|
|
1849
|
+
);
|
|
1850
|
+
|
|
1851
|
+
// src/server/server-time.ts
|
|
1852
|
+
var CORE_TIME_OPERATION_ID = "core.time";
|
|
1853
|
+
var ServerTimeResponseSchema = Type.Object({
|
|
1854
|
+
serverTimeMillis: Type.Integer()
|
|
1855
|
+
}, { additionalProperties: false });
|
|
1856
|
+
var systemServerClock = {
|
|
1857
|
+
now: () => Date.now()
|
|
1858
|
+
};
|
|
1859
|
+
function createCoreTimeRoute(clock = systemServerClock) {
|
|
1860
|
+
return route.get(CORE_TIME_PATH).skip("*").contract({
|
|
1861
|
+
since: "0.3.0",
|
|
1862
|
+
auth: "none",
|
|
1863
|
+
requiresSession: false,
|
|
1864
|
+
response: ServerTimeResponseSchema
|
|
1865
|
+
}).handler(async (c) => {
|
|
1866
|
+
const response = {
|
|
1867
|
+
serverTimeMillis: clock.now()
|
|
1868
|
+
};
|
|
1869
|
+
c.raw.header("Cache-Control", "no-store");
|
|
1870
|
+
return response;
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
var CORE_TIME_ROUTE = createCoreTimeRoute();
|
|
1874
|
+
function createCoreTimeRouter(clock) {
|
|
1875
|
+
return defineRouter({
|
|
1876
|
+
[CORE_TIME_OPERATION_ID]: createCoreTimeRoute(clock)
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1470
1880
|
// src/server/create-server.ts
|
|
1471
1881
|
var rateLimitApplied = /* @__PURE__ */ new WeakSet();
|
|
1472
1882
|
async function createServer(config) {
|
|
@@ -1509,12 +1919,16 @@ async function createAutoConfiguredApp(config) {
|
|
|
1509
1919
|
applyDefaultMiddleware(app, config, enableLogger, enableCors);
|
|
1510
1920
|
await applyProxyGuard(app, config);
|
|
1511
1921
|
applyRateLimit(config);
|
|
1922
|
+
registerCoreTimeEndpoint(app, config);
|
|
1512
1923
|
if (Array.isArray(config?.use)) {
|
|
1513
1924
|
config.use.forEach((mw) => app.use("*", mw));
|
|
1514
1925
|
}
|
|
1515
|
-
|
|
1926
|
+
registerCoreHealthEndpoint(app, config);
|
|
1516
1927
|
await executeBeforeRoutesHook(app, config);
|
|
1517
|
-
await loadAppRoutes(app, config);
|
|
1928
|
+
const appRoutes = await loadAppRoutes(app, config);
|
|
1929
|
+
registerMovedHealthSignpost(app, config, appRoutes);
|
|
1930
|
+
warnOnShadowedOptInPath(config, appRoutes);
|
|
1931
|
+
warnOnCoreNamespaceRoutes(appRoutes);
|
|
1518
1932
|
await registerSSEEndpoint(app, config);
|
|
1519
1933
|
await executeAfterRoutesHook(app, config);
|
|
1520
1934
|
if (enableErrorHandler) {
|
|
@@ -1555,7 +1969,10 @@ async function applyProxyGuard(app, config) {
|
|
|
1555
1969
|
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1556
1970
|
}
|
|
1557
1971
|
}
|
|
1558
|
-
const autoSkip = [
|
|
1972
|
+
const autoSkip = [CORE_HEALTH_PATH, CORE_TIME_PATH, LEGACY_HEALTH_PATH];
|
|
1973
|
+
if (config?.healthCheck?.path) {
|
|
1974
|
+
autoSkip.push(config.healthCheck.path);
|
|
1975
|
+
}
|
|
1559
1976
|
if (config?.events) {
|
|
1560
1977
|
autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
|
|
1561
1978
|
}
|
|
@@ -1598,15 +2015,79 @@ function applyOutboundFetch(config) {
|
|
|
1598
2015
|
const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
|
|
1599
2016
|
setDefaultSafeFetchPolicy(policy);
|
|
1600
2017
|
}
|
|
1601
|
-
function
|
|
2018
|
+
function resolveHealthCheck(config) {
|
|
1602
2019
|
const healthCheckConfig = config?.healthCheck ?? {};
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
2020
|
+
return {
|
|
2021
|
+
enabled: healthCheckConfig.enabled !== false,
|
|
2022
|
+
// No default. An unset `path` means the endpoint answers at
|
|
2023
|
+
// CORE_HEALTH_PATH and nowhere else — a default here is what used to put
|
|
2024
|
+
// it on /health without anyone asking.
|
|
2025
|
+
path: healthCheckConfig.path,
|
|
2026
|
+
detailed: healthCheckConfig.detailed ?? process.env.NODE_ENV === "development"
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
function registerCoreTimeEndpoint(app, config) {
|
|
2030
|
+
registerRoutes(app, createCoreTimeRouter(config?.serverTime?.clock));
|
|
2031
|
+
serverLogger.debug(`Server time endpoint enabled at ${CORE_TIME_PATH}`);
|
|
2032
|
+
}
|
|
2033
|
+
function registerCoreHealthEndpoint(app, config) {
|
|
2034
|
+
const { enabled, path, detailed } = resolveHealthCheck(config);
|
|
2035
|
+
if (!enabled) {
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
const handler = createHealthCheckHandler(detailed, config?.infrastructure);
|
|
2039
|
+
app.get(CORE_HEALTH_PATH, handler);
|
|
2040
|
+
serverLogger.debug(`Health check endpoint enabled at ${CORE_HEALTH_PATH}`);
|
|
2041
|
+
if (path && path !== CORE_HEALTH_PATH) {
|
|
2042
|
+
app.get(path, handler);
|
|
2043
|
+
serverLogger.debug(`Health check endpoint also answering at ${path}, as configured`);
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
function registerMovedHealthSignpost(app, config, appRoutes) {
|
|
2047
|
+
const { enabled, path } = resolveHealthCheck(config);
|
|
2048
|
+
if (!enabled || path === LEGACY_HEALTH_PATH) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (appRoutes.some((r) => r.method === "GET" && r.path === LEGACY_HEALTH_PATH)) {
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
let warned2 = false;
|
|
2055
|
+
app.get(LEGACY_HEALTH_PATH, (c) => {
|
|
2056
|
+
if (!warned2) {
|
|
2057
|
+
warned2 = true;
|
|
2058
|
+
serverLogger.warn(
|
|
2059
|
+
`\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.`
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
return c.json({
|
|
2063
|
+
error: "health endpoint moved",
|
|
2064
|
+
movedTo: CORE_HEALTH_PATH,
|
|
2065
|
+
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}' }).`
|
|
2066
|
+
}, 410);
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
function warnOnShadowedOptInPath(config, appRoutes) {
|
|
2070
|
+
const { enabled, path } = resolveHealthCheck(config);
|
|
2071
|
+
if (!enabled || !path || path === CORE_HEALTH_PATH) {
|
|
2072
|
+
return;
|
|
1609
2073
|
}
|
|
2074
|
+
const shadowed = appRoutes.filter((r) => r.method === "GET" && r.path === path);
|
|
2075
|
+
if (shadowed.length === 0) {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
serverLogger.warn(
|
|
2079
|
+
`\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.`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
function warnOnCoreNamespaceRoutes(appRoutes) {
|
|
2083
|
+
const inside = appRoutes.filter((r) => r.path === CORE_NAMESPACE || r.path.startsWith(`${CORE_NAMESPACE}/`));
|
|
2084
|
+
if (inside.length === 0) {
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
const names = inside.map((r) => `${r.name} (${r.method} ${r.path})`).join(", ");
|
|
2088
|
+
serverLogger.warn(
|
|
2089
|
+
`\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.`
|
|
2090
|
+
);
|
|
1610
2091
|
}
|
|
1611
2092
|
async function executeBeforeRoutesHook(app, config) {
|
|
1612
2093
|
if (config?.lifecycle?.beforeRoutes) {
|
|
@@ -1618,9 +2099,12 @@ async function loadAppRoutes(app, config) {
|
|
|
1618
2099
|
if (config?.routes) {
|
|
1619
2100
|
const routes = registerRoutes(app, config.routes, config.middlewares);
|
|
1620
2101
|
logRegisteredRoutes(routes, debug);
|
|
1621
|
-
|
|
2102
|
+
return routes;
|
|
2103
|
+
}
|
|
2104
|
+
if (debug) {
|
|
1622
2105
|
serverLogger.warn("\u26A0\uFE0F No routes configured. Use defineServerConfig().routes() to register routes.");
|
|
1623
2106
|
}
|
|
2107
|
+
return [];
|
|
1624
2108
|
}
|
|
1625
2109
|
function logRegisteredRoutes(routes, debug) {
|
|
1626
2110
|
if (routes.length === 0) {
|
|
@@ -1791,6 +2275,20 @@ function shouldSweepOrphanSchedules() {
|
|
|
1791
2275
|
return getBossConfig()?.sweepOrphanSchedules ?? false;
|
|
1792
2276
|
}
|
|
1793
2277
|
|
|
2278
|
+
// src/job/queue-policy.ts
|
|
2279
|
+
function resolveQueuePolicy(job2) {
|
|
2280
|
+
if (job2.options?.policy) {
|
|
2281
|
+
return job2.options.policy;
|
|
2282
|
+
}
|
|
2283
|
+
if (job2.runOnce || job2.options?.singletonKey) {
|
|
2284
|
+
return "exclusive";
|
|
2285
|
+
}
|
|
2286
|
+
return "standard";
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// src/job/job-builder.ts
|
|
2290
|
+
logger.child("@spfn/core:job");
|
|
2291
|
+
|
|
1794
2292
|
// src/job/job-router.ts
|
|
1795
2293
|
function isJobDef(value) {
|
|
1796
2294
|
return value !== null && typeof value === "object" && "name" in value && "handler" in value && "send" in value && "run" in value;
|
|
@@ -1798,19 +2296,38 @@ function isJobDef(value) {
|
|
|
1798
2296
|
function isJobRouter(value) {
|
|
1799
2297
|
return value !== null && typeof value === "object" && "jobs" in value && "_jobs" in value;
|
|
1800
2298
|
}
|
|
1801
|
-
function
|
|
1802
|
-
|
|
2299
|
+
function defineJobRouter(jobs) {
|
|
2300
|
+
return {
|
|
2301
|
+
jobs,
|
|
2302
|
+
_jobs: jobs
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
function mergeJobRouters(base, extra) {
|
|
2306
|
+
for (const key of Object.keys(extra.jobs)) {
|
|
2307
|
+
if (key in base.jobs) {
|
|
2308
|
+
throw new Error(
|
|
2309
|
+
`jobs(): key "${key}" is already registered by an earlier .jobs() call; rename it or nest it under a domain router`
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
return defineJobRouter({ ...base.jobs, ...extra.jobs });
|
|
2314
|
+
}
|
|
2315
|
+
function collectJobEntries(router, prefix = "") {
|
|
2316
|
+
const entries = [];
|
|
1803
2317
|
for (const [key, value] of Object.entries(router.jobs)) {
|
|
1804
|
-
const
|
|
2318
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
1805
2319
|
if (isJobRouter(value)) {
|
|
1806
|
-
|
|
2320
|
+
entries.push(...collectJobEntries(value, path));
|
|
1807
2321
|
} else if (isJobDef(value)) {
|
|
1808
|
-
|
|
2322
|
+
entries.push({ key: path, job: value });
|
|
1809
2323
|
}
|
|
1810
2324
|
}
|
|
1811
|
-
return
|
|
2325
|
+
return entries;
|
|
2326
|
+
}
|
|
2327
|
+
function collectJobs(router, prefix = "") {
|
|
2328
|
+
return collectJobEntries(router, prefix).map((entry) => entry.job);
|
|
1812
2329
|
}
|
|
1813
|
-
var
|
|
2330
|
+
var jobLogger3 = logger.child("@spfn/core:job");
|
|
1814
2331
|
function getEventQueueName(eventName) {
|
|
1815
2332
|
return `event:${eventName}`;
|
|
1816
2333
|
}
|
|
@@ -1831,11 +2348,12 @@ async function registerJobs(router) {
|
|
|
1831
2348
|
"pg-boss not initialized. Call initBoss() before registerJobs()"
|
|
1832
2349
|
);
|
|
1833
2350
|
}
|
|
2351
|
+
assertUniqueJobNames(router);
|
|
1834
2352
|
const jobs = collectJobs(router);
|
|
1835
2353
|
const clearOnStart = shouldClearOnStart();
|
|
1836
|
-
|
|
2354
|
+
jobLogger3.info(`Registering ${jobs.length} job(s)...`);
|
|
1837
2355
|
if (clearOnStart) {
|
|
1838
|
-
|
|
2356
|
+
jobLogger3.info("Clearing existing jobs before registration...");
|
|
1839
2357
|
for (const job2 of jobs) {
|
|
1840
2358
|
await boss.deleteAllJobs(job2.name);
|
|
1841
2359
|
if (job2.subscribedEvent) {
|
|
@@ -1843,7 +2361,7 @@ async function registerJobs(router) {
|
|
|
1843
2361
|
await boss.deleteAllJobs(eventQueue);
|
|
1844
2362
|
}
|
|
1845
2363
|
}
|
|
1846
|
-
|
|
2364
|
+
jobLogger3.info("Existing jobs cleared");
|
|
1847
2365
|
}
|
|
1848
2366
|
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
1849
2367
|
for (const job2 of jobs) {
|
|
@@ -1854,42 +2372,77 @@ async function registerJobs(router) {
|
|
|
1854
2372
|
if (shouldSweepOrphanSchedules()) {
|
|
1855
2373
|
await sweepOrphanSchedules(boss);
|
|
1856
2374
|
}
|
|
1857
|
-
|
|
2375
|
+
jobLogger3.info("All jobs registered successfully");
|
|
2376
|
+
}
|
|
2377
|
+
function assertUniqueJobNames(router) {
|
|
2378
|
+
const keysByName = /* @__PURE__ */ new Map();
|
|
2379
|
+
for (const { key, job: job2 } of collectJobEntries(router)) {
|
|
2380
|
+
const existingKey = keysByName.get(job2.name);
|
|
2381
|
+
if (existingKey) {
|
|
2382
|
+
throw new Error(
|
|
2383
|
+
`registerJobs(): job name "${job2.name}" is defined twice (keys "${existingKey}" and "${key}"); job names are pg-boss queue names and must be unique`
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
keysByName.set(job2.name, key);
|
|
2387
|
+
}
|
|
1858
2388
|
}
|
|
1859
2389
|
var registeredCronNames = /* @__PURE__ */ new Set();
|
|
1860
2390
|
async function sweepOrphanSchedules(boss) {
|
|
1861
2391
|
if (registeredCronNames.size === 0) {
|
|
1862
|
-
|
|
2392
|
+
jobLogger3.debug("No cron jobs declared; skipping orphan schedule sweep");
|
|
1863
2393
|
return;
|
|
1864
2394
|
}
|
|
1865
2395
|
try {
|
|
1866
2396
|
const schedules = await boss.getSchedules();
|
|
1867
2397
|
const orphans = schedules.filter((schedule) => !registeredCronNames.has(schedule.name));
|
|
1868
2398
|
if (orphans.length === 0) {
|
|
1869
|
-
|
|
2399
|
+
jobLogger3.debug("No orphan schedules found");
|
|
1870
2400
|
return;
|
|
1871
2401
|
}
|
|
1872
2402
|
await Promise.all(orphans.map(async (orphan) => {
|
|
1873
2403
|
try {
|
|
1874
2404
|
await boss.unschedule(orphan.name, orphan.key);
|
|
1875
|
-
|
|
2405
|
+
jobLogger3.info(`Removed orphan schedule: ${orphan.name}`);
|
|
1876
2406
|
} catch (error) {
|
|
1877
|
-
|
|
2407
|
+
jobLogger3.error(`Failed to remove orphan schedule: ${orphan.name}`, {
|
|
1878
2408
|
error: error instanceof Error ? error.message : String(error)
|
|
1879
2409
|
});
|
|
1880
2410
|
}
|
|
1881
2411
|
}));
|
|
1882
2412
|
} catch (error) {
|
|
1883
|
-
|
|
2413
|
+
jobLogger3.error("Orphan schedule sweep failed", {
|
|
1884
2414
|
error: error instanceof Error ? error.message : String(error)
|
|
1885
2415
|
});
|
|
1886
2416
|
}
|
|
1887
2417
|
}
|
|
1888
|
-
async function ensureQueue(boss, queueName) {
|
|
1889
|
-
await boss.createQueue(queueName);
|
|
2418
|
+
async function ensureQueue(boss, queueName, policy) {
|
|
2419
|
+
await boss.createQueue(queueName, { policy });
|
|
2420
|
+
const existing = await boss.getQueue(queueName);
|
|
2421
|
+
if (!existing || existing.policy === policy) {
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
if (shouldClearOnStart()) {
|
|
2425
|
+
await boss.deleteQueue(queueName);
|
|
2426
|
+
await boss.createQueue(queueName, { policy });
|
|
2427
|
+
jobLogger3.info(`[Queue:${queueName}] policy ${existing.policy} \u2192 ${policy} (recreated: clearOnStart)`);
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
jobLogger3.error(
|
|
2431
|
+
`[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`
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
function createQueueEnsurer(boss, policy) {
|
|
2435
|
+
const ensured = /* @__PURE__ */ new Set();
|
|
2436
|
+
return async (queueName) => {
|
|
2437
|
+
if (ensured.has(queueName)) {
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
ensured.add(queueName);
|
|
2441
|
+
await ensureQueue(boss, queueName, policy);
|
|
2442
|
+
};
|
|
1890
2443
|
}
|
|
1891
2444
|
async function executeJobHandler(job2, pgBossJob) {
|
|
1892
|
-
|
|
2445
|
+
jobLogger3.debug(`[Job:${job2.name}] Executing...`, { jobId: pgBossJob.id });
|
|
1893
2446
|
const startTime = Date.now();
|
|
1894
2447
|
try {
|
|
1895
2448
|
if (job2.inputSchema) {
|
|
@@ -1898,13 +2451,13 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1898
2451
|
await job2.handler();
|
|
1899
2452
|
}
|
|
1900
2453
|
const duration = Date.now() - startTime;
|
|
1901
|
-
|
|
2454
|
+
jobLogger3.info(`[Job:${job2.name}] Completed in ${duration}ms`, {
|
|
1902
2455
|
jobId: pgBossJob.id,
|
|
1903
2456
|
duration
|
|
1904
2457
|
});
|
|
1905
2458
|
} catch (error) {
|
|
1906
2459
|
const duration = Date.now() - startTime;
|
|
1907
|
-
|
|
2460
|
+
jobLogger3.error(`[Job:${job2.name}] Failed after ${duration}ms`, {
|
|
1908
2461
|
jobId: pgBossJob.id,
|
|
1909
2462
|
duration,
|
|
1910
2463
|
error: error instanceof Error ? error.message : String(error)
|
|
@@ -1912,8 +2465,8 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1912
2465
|
throw error;
|
|
1913
2466
|
}
|
|
1914
2467
|
}
|
|
1915
|
-
async function registerWorker(boss, job2, queueName) {
|
|
1916
|
-
await
|
|
2468
|
+
async function registerWorker(boss, job2, queueName, ensureQueueOnce) {
|
|
2469
|
+
await ensureQueueOnce(queueName);
|
|
1917
2470
|
const batchSize = job2.options?.batchSize ?? 1;
|
|
1918
2471
|
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
1919
2472
|
await boss.work(
|
|
@@ -1947,28 +2500,28 @@ function connectEventToQueue(boss, job2, queueName) {
|
|
|
1947
2500
|
eventDef._registerJobQueue(queueName, async (queue, payload) => {
|
|
1948
2501
|
await boss.send(queue, payload, getDefaultJobOptions(job2.options));
|
|
1949
2502
|
});
|
|
1950
|
-
|
|
2503
|
+
jobLogger3.debug(`[Job:${job2.name}] Connected to event: ${job2.subscribedEvent}`);
|
|
1951
2504
|
}
|
|
1952
|
-
async function registerCronSchedule(boss, job2) {
|
|
2505
|
+
async function registerCronSchedule(boss, job2, ensureQueueOnce) {
|
|
1953
2506
|
if (!job2.cronExpression) {
|
|
1954
2507
|
return;
|
|
1955
2508
|
}
|
|
1956
|
-
|
|
1957
|
-
await
|
|
2509
|
+
jobLogger3.debug(`[Job:${job2.name}] Scheduling cron: ${job2.cronExpression}`);
|
|
2510
|
+
await ensureQueueOnce(job2.name);
|
|
1958
2511
|
await boss.schedule(
|
|
1959
2512
|
job2.name,
|
|
1960
2513
|
job2.cronExpression,
|
|
1961
2514
|
{},
|
|
1962
2515
|
getDefaultJobOptions(job2.options)
|
|
1963
2516
|
);
|
|
1964
|
-
|
|
2517
|
+
jobLogger3.info(`[Job:${job2.name}] Cron scheduled: ${job2.cronExpression}`);
|
|
1965
2518
|
}
|
|
1966
|
-
async function queueRunOnceJob(boss, job2) {
|
|
2519
|
+
async function queueRunOnceJob(boss, job2, ensureQueueOnce) {
|
|
1967
2520
|
if (!job2.runOnce) {
|
|
1968
2521
|
return;
|
|
1969
2522
|
}
|
|
1970
|
-
|
|
1971
|
-
await
|
|
2523
|
+
jobLogger3.debug(`[Job:${job2.name}] Queuing runOnce job`);
|
|
2524
|
+
await ensureQueueOnce(job2.name);
|
|
1972
2525
|
await boss.send(
|
|
1973
2526
|
job2.name,
|
|
1974
2527
|
{},
|
|
@@ -1977,7 +2530,7 @@ async function queueRunOnceJob(boss, job2) {
|
|
|
1977
2530
|
singletonKey: `runOnce:${job2.name}`
|
|
1978
2531
|
}
|
|
1979
2532
|
);
|
|
1980
|
-
|
|
2533
|
+
jobLogger3.info(`[Job:${job2.name}] runOnce job queued`);
|
|
1981
2534
|
}
|
|
1982
2535
|
async function registerJob(job2) {
|
|
1983
2536
|
const boss = getBoss();
|
|
@@ -1985,15 +2538,16 @@ async function registerJob(job2) {
|
|
|
1985
2538
|
throw new Error("pg-boss not initialized");
|
|
1986
2539
|
}
|
|
1987
2540
|
const queueName = job2.subscribedEvent ? getEventQueueName(job2.subscribedEvent) : job2.name;
|
|
1988
|
-
|
|
2541
|
+
jobLogger3.debug(`Registering job: ${job2.name}`, {
|
|
1989
2542
|
queueName,
|
|
1990
2543
|
subscribedEvent: job2.subscribedEvent
|
|
1991
2544
|
});
|
|
1992
|
-
|
|
2545
|
+
const ensureQueueOnce = createQueueEnsurer(boss, resolveQueuePolicy(job2));
|
|
2546
|
+
await registerWorker(boss, job2, queueName, ensureQueueOnce);
|
|
1993
2547
|
connectEventToQueue(boss, job2, queueName);
|
|
1994
|
-
await registerCronSchedule(boss, job2);
|
|
1995
|
-
await queueRunOnceJob(boss, job2);
|
|
1996
|
-
|
|
2548
|
+
await registerCronSchedule(boss, job2, ensureQueueOnce);
|
|
2549
|
+
await queueRunOnceJob(boss, job2, ensureQueueOnce);
|
|
2550
|
+
jobLogger3.debug(`Job registered: ${job2.name}`);
|
|
1997
2551
|
}
|
|
1998
2552
|
var wsLogger = logger.child("@spfn/core:ws");
|
|
1999
2553
|
function isOriginAllowed(origin, allowList) {
|
|
@@ -2285,6 +2839,45 @@ function printBanner(options) {
|
|
|
2285
2839
|
}
|
|
2286
2840
|
console.log("");
|
|
2287
2841
|
}
|
|
2842
|
+
var PORT_DEFAULTS = {
|
|
2843
|
+
server: 8790
|
|
2844
|
+
};
|
|
2845
|
+
var HOST_DEFAULT = "localhost";
|
|
2846
|
+
var CONFIG_FILE_NAMES = [
|
|
2847
|
+
"spfn.config.js",
|
|
2848
|
+
"spfn.config.mjs"
|
|
2849
|
+
];
|
|
2850
|
+
async function loadAppConfig(cwd = process.cwd()) {
|
|
2851
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
2852
|
+
const fullPath = join(cwd, fileName);
|
|
2853
|
+
if (!existsSync(fullPath)) {
|
|
2854
|
+
continue;
|
|
2855
|
+
}
|
|
2856
|
+
try {
|
|
2857
|
+
const module = await import(pathToFileURL(fullPath).href);
|
|
2858
|
+
return module.default ?? {};
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
console.warn(
|
|
2861
|
+
`\u26A0\uFE0F ${fileName} could not be imported \u2014 falling back to defaults for ports and host. ${error instanceof Error ? error.message : String(error)}`
|
|
2862
|
+
);
|
|
2863
|
+
return {};
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
return {};
|
|
2867
|
+
}
|
|
2868
|
+
function resolveServerAddress(config, deprecated = {}, env6 = process.env) {
|
|
2869
|
+
return {
|
|
2870
|
+
port: readPort(env6.SPFN_PORT) ?? config.ports?.server ?? deprecated.port ?? PORT_DEFAULTS.server,
|
|
2871
|
+
host: env6.SPFN_HOST || config.host || deprecated.host || HOST_DEFAULT
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
function readPort(value) {
|
|
2875
|
+
if (!value) {
|
|
2876
|
+
return void 0;
|
|
2877
|
+
}
|
|
2878
|
+
const port = Number(value);
|
|
2879
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
|
|
2880
|
+
}
|
|
2288
2881
|
|
|
2289
2882
|
// src/server/validation.ts
|
|
2290
2883
|
function validateServerConfig(config) {
|
|
@@ -2333,10 +2926,12 @@ var TIMEOUTS = {
|
|
|
2333
2926
|
REDIS_CLOSE: 5e3};
|
|
2334
2927
|
var CONFIG_FILE_PATHS = [
|
|
2335
2928
|
".spfn/server/server.config.mjs",
|
|
2929
|
+
".spfn/server/server.config.js",
|
|
2336
2930
|
".spfn/server/server.config",
|
|
2337
2931
|
"src/server/server.config",
|
|
2338
2932
|
"src/server/server.config.ts"
|
|
2339
2933
|
];
|
|
2934
|
+
var AUTHORED_CONFIG_PATH = "src/server/server.config.ts";
|
|
2340
2935
|
var processHandlersRegistered = false;
|
|
2341
2936
|
async function startServer(config) {
|
|
2342
2937
|
loadEnv();
|
|
@@ -2400,8 +2995,7 @@ async function startServer(config) {
|
|
|
2400
2995
|
throw error;
|
|
2401
2996
|
}
|
|
2402
2997
|
}
|
|
2403
|
-
async function loadAndMergeConfig(config) {
|
|
2404
|
-
const cwd = process.cwd();
|
|
2998
|
+
async function loadAndMergeConfig(config, cwd = process.cwd()) {
|
|
2405
2999
|
let fileConfig = {};
|
|
2406
3000
|
let loadedConfigPath = null;
|
|
2407
3001
|
for (const configPath of CONFIG_FILE_PATHS) {
|
|
@@ -2419,16 +3013,37 @@ async function loadAndMergeConfig(config) {
|
|
|
2419
3013
|
}
|
|
2420
3014
|
if (loadedConfigPath) {
|
|
2421
3015
|
serverLogger.debug(`Loaded configuration from ${loadedConfigPath}`);
|
|
3016
|
+
} else if (existsSync(join(cwd, AUTHORED_CONFIG_PATH))) {
|
|
3017
|
+
serverLogger.warn(
|
|
3018
|
+
`\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/.`
|
|
3019
|
+
);
|
|
2422
3020
|
} else {
|
|
2423
3021
|
serverLogger.debug("No configuration file found, using defaults");
|
|
2424
3022
|
}
|
|
3023
|
+
const appConfig = await loadAppConfig(cwd);
|
|
3024
|
+
warnOnDeprecatedAddress(config, fileConfig);
|
|
3025
|
+
const address = resolveServerAddress(appConfig, {
|
|
3026
|
+
port: config?.port ?? fileConfig?.port,
|
|
3027
|
+
host: config?.host ?? fileConfig?.host
|
|
3028
|
+
});
|
|
2425
3029
|
return {
|
|
2426
3030
|
...fileConfig,
|
|
2427
3031
|
...config,
|
|
2428
|
-
port:
|
|
2429
|
-
host:
|
|
3032
|
+
port: address.port,
|
|
3033
|
+
host: address.host
|
|
2430
3034
|
};
|
|
2431
3035
|
}
|
|
3036
|
+
function warnOnDeprecatedAddress(config, fileConfig) {
|
|
3037
|
+
const used = [
|
|
3038
|
+
(config?.port ?? fileConfig?.port) !== void 0 ? ".port()" : null,
|
|
3039
|
+
(config?.host ?? fileConfig?.host) !== void 0 ? ".host()" : null
|
|
3040
|
+
].filter(Boolean);
|
|
3041
|
+
if (used.length > 0) {
|
|
3042
|
+
serverLogger.warn(
|
|
3043
|
+
`\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.`
|
|
3044
|
+
);
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2432
3047
|
function getInfrastructureConfig(config) {
|
|
2433
3048
|
return {
|
|
2434
3049
|
database: config.infrastructure?.database !== false,
|
|
@@ -2961,6 +3576,20 @@ var ServerConfigBuilder = class {
|
|
|
2961
3576
|
/**
|
|
2962
3577
|
* Register background jobs router
|
|
2963
3578
|
*
|
|
3579
|
+
* May be called once per domain router: repeated calls **merge** into one
|
|
3580
|
+
* router, the way `.routes()` composes. Calling it several times used to
|
|
3581
|
+
* keep only the last router and register nothing else, without warning.
|
|
3582
|
+
*
|
|
3583
|
+
* The pg-boss `config` is per server, so pass it on one call only.
|
|
3584
|
+
*
|
|
3585
|
+
* Two names can collide, and both fail loudly:
|
|
3586
|
+
* - a router **key** present in two calls throws here, at config time;
|
|
3587
|
+
* - two jobs sharing a **`job.name`** (the pg-boss queue name) throw in
|
|
3588
|
+
* `registerJobs()` at boot, before any queue or worker is created.
|
|
3589
|
+
*
|
|
3590
|
+
* @throws if a router key was already registered by an earlier call
|
|
3591
|
+
* @throws if `config` is given when an earlier call already gave one
|
|
3592
|
+
*
|
|
2964
3593
|
* @example
|
|
2965
3594
|
* ```typescript
|
|
2966
3595
|
* import { job, defineJobRouter } from '@spfn/core/job';
|
|
@@ -2973,12 +3602,18 @@ var ServerConfigBuilder = class {
|
|
|
2973
3602
|
*
|
|
2974
3603
|
* export default defineServerConfig()
|
|
2975
3604
|
* .routes(appRouter)
|
|
2976
|
-
* .jobs(jobRouter)
|
|
3605
|
+
* .jobs(jobRouter, { clearOnStart: isDev }) // config on one call only
|
|
3606
|
+
* .jobs(billingJobRouter) // merged, not replaced
|
|
2977
3607
|
* .build();
|
|
2978
3608
|
* ```
|
|
2979
3609
|
*/
|
|
2980
3610
|
jobs(router, config) {
|
|
2981
|
-
this.config.
|
|
3611
|
+
if (config && this.config.jobsConfig) {
|
|
3612
|
+
throw new Error(
|
|
3613
|
+
"jobs(): pg-boss config was already given by an earlier .jobs() call; pass it once"
|
|
3614
|
+
);
|
|
3615
|
+
}
|
|
3616
|
+
this.config.jobs = this.config.jobs ? mergeJobRouters(this.config.jobs, router) : router;
|
|
2982
3617
|
if (config) {
|
|
2983
3618
|
this.config.jobsConfig = config;
|
|
2984
3619
|
}
|
|
@@ -3088,6 +3723,15 @@ var ServerConfigBuilder = class {
|
|
|
3088
3723
|
this.config.healthCheck = healthCheck;
|
|
3089
3724
|
return this;
|
|
3090
3725
|
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Supply the clock used by the built-in `GET /_core/time` capability.
|
|
3728
|
+
* Production servers normally keep the default `Date.now()` clock; this is
|
|
3729
|
+
* exposed so tests can assert an exact wire value without replacing globals.
|
|
3730
|
+
*/
|
|
3731
|
+
serverTime(serverTime) {
|
|
3732
|
+
this.config.serverTime = serverTime;
|
|
3733
|
+
return this;
|
|
3734
|
+
}
|
|
3091
3735
|
/**
|
|
3092
3736
|
* Configure infrastructure initialization
|
|
3093
3737
|
*/
|
|
@@ -3184,6 +3828,6 @@ function defineServerConfig() {
|
|
|
3184
3828
|
return new ServerConfigBuilder();
|
|
3185
3829
|
}
|
|
3186
3830
|
|
|
3187
|
-
export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
|
|
3831
|
+
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
3832
|
//# sourceMappingURL=index.js.map
|
|
3189
3833
|
//# sourceMappingURL=index.js.map
|