@spfn/core 0.2.0-beta.53 → 0.2.0-beta.55

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.
Files changed (41) hide show
  1. package/dist/{boss-Cxqc-Oiw.d.ts → boss-CEik0yq-.d.ts} +7 -0
  2. package/dist/cache/index.js +10 -1
  3. package/dist/cache/index.js.map +1 -1
  4. package/dist/config/index.d.ts +213 -0
  5. package/dist/config/index.js +43 -1
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/db/index.d.ts +57 -0
  8. package/dist/db/index.js +55 -8
  9. package/dist/db/index.js.map +1 -1
  10. package/dist/define-middleware-DuXD8Hvu.d.ts +167 -0
  11. package/dist/env/index.js +4 -3
  12. package/dist/env/index.js.map +1 -1
  13. package/dist/errors/index.d.ts +10 -0
  14. package/dist/errors/index.js +20 -2
  15. package/dist/errors/index.js.map +1 -1
  16. package/dist/event/index.d.ts +3 -3
  17. package/dist/event/index.js.map +1 -1
  18. package/dist/event/sse/client.d.ts +2 -2
  19. package/dist/event/sse/index.d.ts +4 -4
  20. package/dist/event/sse/index.js +41 -16
  21. package/dist/event/sse/index.js.map +1 -1
  22. package/dist/event/ws/client.d.ts +2 -2
  23. package/dist/event/ws/index.d.ts +3 -3
  24. package/dist/event/ws/index.js +75 -16
  25. package/dist/event/ws/index.js.map +1 -1
  26. package/dist/job/index.d.ts +2 -2
  27. package/dist/job/index.js +4 -4
  28. package/dist/job/index.js.map +1 -1
  29. package/dist/middleware/index.d.ts +64 -2
  30. package/dist/middleware/index.js +1030 -96
  31. package/dist/middleware/index.js.map +1 -1
  32. package/dist/nextjs/server.js +17 -0
  33. package/dist/nextjs/server.js.map +1 -1
  34. package/dist/route/index.d.ts +3 -165
  35. package/dist/server/index.d.ts +4 -4
  36. package/dist/server/index.js +125 -39
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/{token-manager-C2Ag5-s8.d.ts → token-manager-jKD_EsSE.d.ts} +0 -1
  39. package/dist/{types-VpVQIsyB.d.ts → types-BFB72jbM.d.ts} +1 -1
  40. package/dist/{types-CKsmzaB8.d.ts → types-DVjf37yO.d.ts} +37 -1
  41. package/package.json +6 -6
@@ -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: JSON.stringify(message)
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
- while (!abortSignal.aborted && !connectionDead) {
562
- await stream.sleep(pingInterval);
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 data = this.tokens.get(token);
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(token);
635
+ this.tokens.delete(key);
610
636
  return data;
611
637
  }
612
638
  async cleanup() {
613
639
  const now = Date.now();
614
- for (const [token, data] of this.tokens) {
640
+ for (const [key, data] of this.tokens) {
615
641
  if (data.expiresAt <= now) {
616
- this.tokens.delete(token);
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
- serverLogger.warn("Proxy-guard nonce enabled but no cache available \u2014 using timestamp window only");
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
- serverLogger.warn("Proxy-guard nonce enabled but cache module unavailable \u2014 using timestamp window only");
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
- for (const job2 of jobs) {
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, pingInterval).catch((err) => {
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, pingInterval) {
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 === 1) ws.ping();
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 createConnection(ws) {
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
- try {
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
  }