@spfn/core 0.2.0-beta.53 → 0.2.0-beta.54
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/dist/{boss-Cxqc-Oiw.d.ts → boss-CEik0yq-.d.ts} +7 -0
- package/dist/cache/index.js +10 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/config/index.d.ts +213 -0
- package/dist/config/index.js +43 -1
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +57 -0
- package/dist/db/index.js +55 -8
- package/dist/db/index.js.map +1 -1
- package/dist/define-middleware-DuXD8Hvu.d.ts +167 -0
- package/dist/env/index.js +4 -3
- package/dist/env/index.js.map +1 -1
- package/dist/errors/index.d.ts +10 -0
- package/dist/errors/index.js +20 -2
- package/dist/errors/index.js.map +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/index.d.ts +4 -4
- package/dist/event/sse/index.js +41 -16
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/event/ws/index.js +75 -16
- package/dist/event/ws/index.js.map +1 -1
- package/dist/job/index.d.ts +2 -2
- package/dist/job/index.js +4 -4
- package/dist/job/index.js.map +1 -1
- package/dist/middleware/index.d.ts +64 -2
- package/dist/middleware/index.js +1030 -96
- package/dist/middleware/index.js.map +1 -1
- package/dist/nextjs/server.js +17 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/route/index.d.ts +3 -165
- package/dist/server/index.d.ts +4 -4
- package/dist/server/index.js +125 -39
- package/dist/server/index.js.map +1 -1
- package/dist/{token-manager-C2Ag5-s8.d.ts → token-manager-jKD_EsSE.d.ts} +0 -1
- package/dist/{types-VpVQIsyB.d.ts → types-BFB72jbM.d.ts} +1 -1
- package/dist/{types-CKsmzaB8.d.ts → types-DVjf37yO.d.ts} +37 -1
- package/package.json +6 -6
package/dist/server/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { cors } from 'hono/cors';
|
|
|
8
8
|
import { registerRoutes } from '@spfn/core/route';
|
|
9
9
|
import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
|
|
10
10
|
import { streamSSE } from 'hono/streaming';
|
|
11
|
-
import { randomBytes } from 'crypto';
|
|
11
|
+
import { randomBytes, createHash } from 'crypto';
|
|
12
12
|
import { Agent, setGlobalDispatcher } from 'undici';
|
|
13
13
|
import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
|
|
14
14
|
import { initCache, getCache, closeCache } from '@spfn/core/cache';
|
|
@@ -445,6 +445,23 @@ function createBoundedWriter(stream, maxQueue, onClose) {
|
|
|
445
445
|
|
|
446
446
|
// src/event/sse/handler.ts
|
|
447
447
|
var sseLogger = logger.child("@spfn/core:sse");
|
|
448
|
+
var frameCache = /* @__PURE__ */ new WeakMap();
|
|
449
|
+
function serializeFrame(eventName, payload) {
|
|
450
|
+
if (payload === null || typeof payload !== "object") {
|
|
451
|
+
return JSON.stringify({ event: eventName, data: payload });
|
|
452
|
+
}
|
|
453
|
+
let byEvent = frameCache.get(payload);
|
|
454
|
+
if (!byEvent) {
|
|
455
|
+
byEvent = /* @__PURE__ */ new Map();
|
|
456
|
+
frameCache.set(payload, byEvent);
|
|
457
|
+
}
|
|
458
|
+
let serialized = byEvent.get(eventName);
|
|
459
|
+
if (serialized === void 0) {
|
|
460
|
+
serialized = JSON.stringify({ event: eventName, data: payload });
|
|
461
|
+
byEvent.set(eventName, serialized);
|
|
462
|
+
}
|
|
463
|
+
return serialized;
|
|
464
|
+
}
|
|
448
465
|
var DEFAULT_MAX_QUEUE = 1e3;
|
|
449
466
|
function createSSEHandler(router, config = {}, tokenManager) {
|
|
450
467
|
const {
|
|
@@ -495,12 +512,14 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
495
512
|
let connectionDead = false;
|
|
496
513
|
let pingTimer;
|
|
497
514
|
let writer;
|
|
515
|
+
let onClosed;
|
|
498
516
|
const cleanup = (reason) => {
|
|
499
517
|
if (connectionDead) return;
|
|
500
518
|
connectionDead = true;
|
|
501
519
|
clearInterval(pingTimer);
|
|
502
520
|
unsubscribes.forEach((fn) => fn());
|
|
503
521
|
writer?.close();
|
|
522
|
+
onClosed?.();
|
|
504
523
|
sseLogger.info("SSE dead connection cleaned up", {
|
|
505
524
|
events: allowedEvents,
|
|
506
525
|
reason
|
|
@@ -530,10 +549,6 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
530
549
|
}
|
|
531
550
|
}
|
|
532
551
|
messageId++;
|
|
533
|
-
const message = {
|
|
534
|
-
event: eventName,
|
|
535
|
-
data: payload
|
|
536
|
-
};
|
|
537
552
|
sseLogger.debug("SSE sending event", {
|
|
538
553
|
event: eventName,
|
|
539
554
|
messageId
|
|
@@ -541,7 +556,7 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
541
556
|
writer.enqueue({
|
|
542
557
|
id: String(messageId),
|
|
543
558
|
event: eventName,
|
|
544
|
-
data:
|
|
559
|
+
data: serializeFrame(eventName, payload)
|
|
545
560
|
});
|
|
546
561
|
});
|
|
547
562
|
unsubscribes.push(unsubscribe);
|
|
@@ -558,8 +573,15 @@ function createSSEHandler(router, config = {}, tokenManager) {
|
|
|
558
573
|
});
|
|
559
574
|
}, pingInterval);
|
|
560
575
|
const abortSignal = c.req.raw.signal;
|
|
561
|
-
|
|
562
|
-
await
|
|
576
|
+
if (!abortSignal.aborted && !connectionDead) {
|
|
577
|
+
await new Promise((resolve2) => {
|
|
578
|
+
const onAbort = () => resolve2();
|
|
579
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
580
|
+
onClosed = () => {
|
|
581
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
582
|
+
resolve2();
|
|
583
|
+
};
|
|
584
|
+
});
|
|
563
585
|
}
|
|
564
586
|
cleanup();
|
|
565
587
|
}, async (err) => {
|
|
@@ -596,24 +618,28 @@ async function authorizeEvents(subject, requestedEvents, authConfig) {
|
|
|
596
618
|
}
|
|
597
619
|
return allowed;
|
|
598
620
|
}
|
|
621
|
+
function hashToken(token) {
|
|
622
|
+
return createHash("sha256").update(token).digest("hex");
|
|
623
|
+
}
|
|
599
624
|
var InMemoryTokenStore = class {
|
|
600
625
|
tokens = /* @__PURE__ */ new Map();
|
|
601
626
|
async set(token, data) {
|
|
602
|
-
this.tokens.set(token, data);
|
|
627
|
+
this.tokens.set(hashToken(token), data);
|
|
603
628
|
}
|
|
604
629
|
async consume(token) {
|
|
605
|
-
const
|
|
630
|
+
const key = hashToken(token);
|
|
631
|
+
const data = this.tokens.get(key);
|
|
606
632
|
if (!data) {
|
|
607
633
|
return null;
|
|
608
634
|
}
|
|
609
|
-
this.tokens.delete(
|
|
635
|
+
this.tokens.delete(key);
|
|
610
636
|
return data;
|
|
611
637
|
}
|
|
612
638
|
async cleanup() {
|
|
613
639
|
const now = Date.now();
|
|
614
|
-
for (const [
|
|
640
|
+
for (const [key, data] of this.tokens) {
|
|
615
641
|
if (data.expiresAt <= now) {
|
|
616
|
-
this.tokens.delete(
|
|
642
|
+
this.tokens.delete(key);
|
|
617
643
|
}
|
|
618
644
|
}
|
|
619
645
|
}
|
|
@@ -626,14 +652,14 @@ var CacheTokenStore = class {
|
|
|
626
652
|
async set(token, data) {
|
|
627
653
|
const ttlSeconds = Math.max(1, Math.ceil((data.expiresAt - Date.now()) / 1e3));
|
|
628
654
|
await this.cache.set(
|
|
629
|
-
this.prefix + token,
|
|
655
|
+
this.prefix + hashToken(token),
|
|
630
656
|
JSON.stringify(data),
|
|
631
657
|
"EX",
|
|
632
658
|
ttlSeconds
|
|
633
659
|
);
|
|
634
660
|
}
|
|
635
661
|
async consume(token) {
|
|
636
|
-
const key = this.prefix + token;
|
|
662
|
+
const key = this.prefix + hashToken(token);
|
|
637
663
|
let raw = null;
|
|
638
664
|
if (this.cache.getdel) {
|
|
639
665
|
raw = await this.cache.getdel(key);
|
|
@@ -668,7 +694,6 @@ var SSETokenManager = class {
|
|
|
668
694
|
async issue(subject) {
|
|
669
695
|
const token = randomBytes(32).toString("hex");
|
|
670
696
|
await this.store.set(token, {
|
|
671
|
-
token,
|
|
672
697
|
subject,
|
|
673
698
|
expiresAt: Date.now() + this.ttl
|
|
674
699
|
});
|
|
@@ -1372,7 +1397,7 @@ async function applyProxyGuard(app, config) {
|
|
|
1372
1397
|
if (mode === "off") {
|
|
1373
1398
|
return;
|
|
1374
1399
|
}
|
|
1375
|
-
const { createProxyGuard, createCacheNonceStore } = await import('@spfn/core/middleware');
|
|
1400
|
+
const { createProxyGuard, createCacheNonceStore, createInMemoryNonceStore } = await import('@spfn/core/middleware');
|
|
1376
1401
|
let nonceStore;
|
|
1377
1402
|
if (proxyGuardConfig?.nonce) {
|
|
1378
1403
|
try {
|
|
@@ -1382,10 +1407,12 @@ async function applyProxyGuard(app, config) {
|
|
|
1382
1407
|
nonceStore = createCacheNonceStore(cache);
|
|
1383
1408
|
serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
|
|
1384
1409
|
} else {
|
|
1385
|
-
|
|
1410
|
+
nonceStore = createInMemoryNonceStore();
|
|
1411
|
+
serverLogger.info("Proxy-guard nonce replay rejection: in-memory (single instance only \u2014 use a cache for multi-instance)");
|
|
1386
1412
|
}
|
|
1387
1413
|
} catch {
|
|
1388
|
-
|
|
1414
|
+
nonceStore = createInMemoryNonceStore();
|
|
1415
|
+
serverLogger.warn("Proxy-guard nonce: cache module unavailable \u2014 using in-memory store (single instance only)");
|
|
1389
1416
|
}
|
|
1390
1417
|
}
|
|
1391
1418
|
const autoSkip = [config?.healthCheck?.path ?? "/health"];
|
|
@@ -1404,6 +1431,7 @@ async function applyProxyGuard(app, config) {
|
|
|
1404
1431
|
allowedOrigins: proxyGuardConfig?.allowedOrigins,
|
|
1405
1432
|
maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
|
|
1406
1433
|
nonceStore,
|
|
1434
|
+
nonceFailClosed: proxyGuardConfig?.nonceFailClosed,
|
|
1407
1435
|
skipPaths
|
|
1408
1436
|
}));
|
|
1409
1437
|
serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
|
|
@@ -1649,9 +1677,7 @@ async function registerJobs(router) {
|
|
|
1649
1677
|
}
|
|
1650
1678
|
jobLogger2.info("Existing jobs cleared");
|
|
1651
1679
|
}
|
|
1652
|
-
|
|
1653
|
-
await registerJob(job2);
|
|
1654
|
-
}
|
|
1680
|
+
await Promise.all(jobs.map((job2) => registerJob(job2)));
|
|
1655
1681
|
jobLogger2.info("All jobs registered successfully");
|
|
1656
1682
|
}
|
|
1657
1683
|
async function ensureQueue(boss, queueName) {
|
|
@@ -1684,9 +1710,10 @@ async function executeJobHandler(job2, pgBossJob) {
|
|
|
1684
1710
|
async function registerWorker(boss, job2, queueName) {
|
|
1685
1711
|
await ensureQueue(boss, queueName);
|
|
1686
1712
|
const batchSize = job2.options?.batchSize ?? 1;
|
|
1713
|
+
const pollingIntervalSeconds = job2.options?.pollingIntervalSeconds ?? env.JOB_POLLING_INTERVAL_SECONDS;
|
|
1687
1714
|
await boss.work(
|
|
1688
1715
|
queueName,
|
|
1689
|
-
{ batchSize },
|
|
1716
|
+
{ batchSize, pollingIntervalSeconds },
|
|
1690
1717
|
async (pgBossJobs) => {
|
|
1691
1718
|
if (batchSize <= 1) {
|
|
1692
1719
|
await executeJobHandler(job2, pgBossJobs[0]);
|
|
@@ -1764,24 +1791,54 @@ async function registerJob(job2) {
|
|
|
1764
1791
|
jobLogger2.debug(`Job registered: ${job2.name}`);
|
|
1765
1792
|
}
|
|
1766
1793
|
var wsLogger = logger.child("@spfn/core:ws");
|
|
1794
|
+
function isOriginAllowed(origin, allowList) {
|
|
1795
|
+
if (!allowList) {
|
|
1796
|
+
return true;
|
|
1797
|
+
}
|
|
1798
|
+
if (!origin) {
|
|
1799
|
+
return true;
|
|
1800
|
+
}
|
|
1801
|
+
return allowList.has(origin);
|
|
1802
|
+
}
|
|
1767
1803
|
async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
1768
1804
|
const WebSocketServer = await loadWSServer();
|
|
1769
1805
|
const {
|
|
1770
1806
|
pingInterval = 3e4,
|
|
1771
1807
|
path = "/ws",
|
|
1808
|
+
maxPayload = 1048576,
|
|
1809
|
+
maxBufferedBytes = 1048576,
|
|
1810
|
+
maxConnections = 1e4,
|
|
1811
|
+
maxConnectionsPerSubject = 0,
|
|
1812
|
+
allowedOrigins,
|
|
1772
1813
|
auth: authConfig
|
|
1773
1814
|
} = config;
|
|
1815
|
+
const originAllowList = allowedOrigins && allowedOrigins.length > 0 ? new Set(allowedOrigins) : null;
|
|
1774
1816
|
if (authConfig?.enabled && !tokenManager) {
|
|
1775
1817
|
throw new Error(
|
|
1776
1818
|
"WebSocket auth.enabled=true requires a tokenManager. Pass tokenManager or use .websockets(router, { auth: { enabled: true } }) via startServer."
|
|
1777
1819
|
);
|
|
1778
1820
|
}
|
|
1779
|
-
const wss = new WebSocketServer({ server, path });
|
|
1821
|
+
const wss = new WebSocketServer({ server, path, maxPayload });
|
|
1780
1822
|
const clients = /* @__PURE__ */ new Set();
|
|
1823
|
+
const subjectCounts = /* @__PURE__ */ new Map();
|
|
1781
1824
|
wss.on("connection", (ws, req) => {
|
|
1825
|
+
if (!isOriginAllowed(req.headers?.origin, originAllowList)) {
|
|
1826
|
+
wsLogger.warn("WebSocket rejected: disallowed origin", { origin: req.headers?.origin });
|
|
1827
|
+
ws.close(1008, "Origin not allowed");
|
|
1828
|
+
return;
|
|
1829
|
+
}
|
|
1830
|
+
if (clients.size >= maxConnections) {
|
|
1831
|
+
ws.close(1013, "Server at capacity");
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1782
1834
|
clients.add(ws);
|
|
1783
1835
|
ws.on("close", () => clients.delete(ws));
|
|
1784
|
-
handleConnection(ws, req, router, authConfig, tokenManager,
|
|
1836
|
+
handleConnection(ws, req, router, authConfig, tokenManager, {
|
|
1837
|
+
pingInterval,
|
|
1838
|
+
maxBufferedBytes,
|
|
1839
|
+
maxConnectionsPerSubject,
|
|
1840
|
+
subjectCounts
|
|
1841
|
+
}).catch((err) => {
|
|
1785
1842
|
wsLogger.error("WebSocket connection handler error", err);
|
|
1786
1843
|
if (ws.readyState === 1) ws.close(1011, "Internal server error");
|
|
1787
1844
|
});
|
|
@@ -1804,7 +1861,8 @@ async function attachWSHandler(server, router, config = {}, tokenManager) {
|
|
|
1804
1861
|
});
|
|
1805
1862
|
});
|
|
1806
1863
|
}
|
|
1807
|
-
async function handleConnection(ws, req, router, authConfig, tokenManager,
|
|
1864
|
+
async function handleConnection(ws, req, router, authConfig, tokenManager, opts) {
|
|
1865
|
+
const { pingInterval, maxBufferedBytes, maxConnectionsPerSubject, subjectCounts } = opts;
|
|
1808
1866
|
let pingTimer;
|
|
1809
1867
|
let connectionUnsubscribes = [];
|
|
1810
1868
|
let subscribedEvents = [];
|
|
@@ -1838,13 +1896,26 @@ async function handleConnection(ws, req, router, authConfig, tokenManager, pingI
|
|
|
1838
1896
|
ws.close(4003, "Not authorized for any requested events");
|
|
1839
1897
|
return;
|
|
1840
1898
|
}
|
|
1899
|
+
if (maxConnectionsPerSubject > 0 && typeof subject === "string") {
|
|
1900
|
+
const current = subjectCounts.get(subject) ?? 0;
|
|
1901
|
+
if (current >= maxConnectionsPerSubject) {
|
|
1902
|
+
ws.close(1013, "Too many connections for this subject");
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
subjectCounts.set(subject, current + 1);
|
|
1906
|
+
ws.on("close", () => {
|
|
1907
|
+
const remaining = (subjectCounts.get(subject) ?? 1) - 1;
|
|
1908
|
+
if (remaining <= 0) subjectCounts.delete(subject);
|
|
1909
|
+
else subjectCounts.set(subject, remaining);
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1841
1912
|
subscribedEvents = allowedEvents;
|
|
1842
1913
|
wsLogger.info("WebSocket connection established", {
|
|
1843
1914
|
events: allowedEvents,
|
|
1844
1915
|
subject: subject ?? void 0
|
|
1845
1916
|
});
|
|
1846
|
-
const connection = createConnection(ws);
|
|
1847
|
-
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig);
|
|
1917
|
+
const connection = createConnection(ws, maxBufferedBytes);
|
|
1918
|
+
connectionUnsubscribes = subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes);
|
|
1848
1919
|
if (ws.readyState !== 1) {
|
|
1849
1920
|
connectionUnsubscribes.forEach((fn) => fn());
|
|
1850
1921
|
connectionUnsubscribes = [];
|
|
@@ -1854,8 +1925,18 @@ async function handleConnection(ws, req, router, authConfig, tokenManager, pingI
|
|
|
1854
1925
|
onClientMessage(data, router, connection, subject).catch((err) => wsLogger.error("Unhandled message error", err));
|
|
1855
1926
|
});
|
|
1856
1927
|
if (pingInterval > 0) {
|
|
1928
|
+
ws.isAlive = true;
|
|
1929
|
+
ws.on("pong", () => {
|
|
1930
|
+
ws.isAlive = true;
|
|
1931
|
+
});
|
|
1857
1932
|
pingTimer = setInterval(() => {
|
|
1858
|
-
if (ws.readyState
|
|
1933
|
+
if (ws.readyState !== 1) return;
|
|
1934
|
+
if (ws.isAlive === false) {
|
|
1935
|
+
ws.terminate();
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
ws.isAlive = false;
|
|
1939
|
+
ws.ping();
|
|
1859
1940
|
}, pingInterval);
|
|
1860
1941
|
}
|
|
1861
1942
|
connection.send("__connected", {
|
|
@@ -1894,16 +1975,24 @@ async function resolveAllowedEvents(subject, requestedEvents, authConfig) {
|
|
|
1894
1975
|
const allowed = await authConfig.authorize(subject, requestedEvents);
|
|
1895
1976
|
return allowed.length === 0 ? null : allowed;
|
|
1896
1977
|
}
|
|
1897
|
-
function
|
|
1978
|
+
function safeSend(ws, frame, maxBufferedBytes) {
|
|
1979
|
+
if (ws.readyState !== 1) return;
|
|
1980
|
+
if (ws.bufferedAmount > maxBufferedBytes) {
|
|
1981
|
+
ws.close(1013, "Send buffer overflow");
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
try {
|
|
1985
|
+
ws.send(JSON.stringify(frame));
|
|
1986
|
+
} catch {
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
function createConnection(ws, maxBufferedBytes) {
|
|
1898
1990
|
return {
|
|
1899
|
-
send: (type, payload) => {
|
|
1900
|
-
if (ws.readyState !== 1) return;
|
|
1901
|
-
ws.send(JSON.stringify({ type, data: payload }));
|
|
1902
|
-
},
|
|
1991
|
+
send: (type, payload) => safeSend(ws, { type, data: payload }, maxBufferedBytes),
|
|
1903
1992
|
close: (code, reason) => ws.close(code, reason)
|
|
1904
1993
|
};
|
|
1905
1994
|
}
|
|
1906
|
-
function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
|
|
1995
|
+
function subscribeEvents(ws, router, allowedEvents, subject, authConfig, maxBufferedBytes) {
|
|
1907
1996
|
const unsubscribes = [];
|
|
1908
1997
|
for (const eventName of allowedEvents) {
|
|
1909
1998
|
const eventDef = router.events[eventName];
|
|
@@ -1913,10 +2002,7 @@ function subscribeEvents(ws, router, allowedEvents, subject, authConfig) {
|
|
|
1913
2002
|
if (subject && authConfig?.filter?.[eventName]) {
|
|
1914
2003
|
if (!authConfig.filter[eventName](subject, payload)) return;
|
|
1915
2004
|
}
|
|
1916
|
-
|
|
1917
|
-
ws.send(JSON.stringify({ type: eventName, data: payload }));
|
|
1918
|
-
} catch {
|
|
1919
|
-
}
|
|
2005
|
+
safeSend(ws, { type: eventName, data: payload }, maxBufferedBytes);
|
|
1920
2006
|
});
|
|
1921
2007
|
unsubscribes.push(unsubscribe);
|
|
1922
2008
|
}
|