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

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.
@@ -398,11 +398,62 @@ function loadEnvFiles() {
398
398
  }
399
399
  loadEnv();
400
400
  }
401
+
402
+ // src/event/sse/bounded-writer.ts
403
+ function createBoundedWriter(stream, maxQueue, onClose) {
404
+ const pending = [];
405
+ let flushing = false;
406
+ let closed = false;
407
+ const flush = async () => {
408
+ if (flushing || closed) {
409
+ return;
410
+ }
411
+ flushing = true;
412
+ while (pending.length > 0 && !closed) {
413
+ const frame = pending.shift();
414
+ try {
415
+ await stream.writeSSE(frame);
416
+ } catch (err) {
417
+ flushing = false;
418
+ onClose(err instanceof Error ? err.message : String(err));
419
+ return;
420
+ }
421
+ }
422
+ flushing = false;
423
+ };
424
+ return {
425
+ enqueue(frame) {
426
+ if (closed) {
427
+ return;
428
+ }
429
+ pending.push(frame);
430
+ if (pending.length > maxQueue) {
431
+ onClose("outbound queue overflow (slow consumer)");
432
+ return;
433
+ }
434
+ void flush();
435
+ },
436
+ close() {
437
+ closed = true;
438
+ pending.length = 0;
439
+ },
440
+ get queued() {
441
+ return pending.length;
442
+ }
443
+ };
444
+ }
445
+
446
+ // src/event/sse/handler.ts
401
447
  var sseLogger = logger.child("@spfn/core:sse");
448
+ var DEFAULT_MAX_QUEUE = 1e3;
402
449
  function createSSEHandler(router, config = {}, tokenManager) {
403
450
  const {
404
- pingInterval = 3e4,
405
- auth: authConfig
451
+ // 10s — keep-alive가 중간 프록시(GCP LB/nginx)의 idle 타임아웃(흔히 ~15-30s)보다 자주 와야
452
+ // 첫 ping 전 침묵 구간에 연결이 끊기지 않는다. 30s 기본은 idle 타임아웃보다 길어 SSE가
453
+ // 데이터 없는 동안 끊기던 문제가 있었다.
454
+ pingInterval = 1e4,
455
+ auth: authConfig,
456
+ maxQueue = DEFAULT_MAX_QUEUE
406
457
  } = config;
407
458
  return async (c) => {
408
459
  const subject = await authenticateToken(c, tokenManager);
@@ -443,15 +494,29 @@ function createSSEHandler(router, config = {}, tokenManager) {
443
494
  let messageId = 0;
444
495
  let connectionDead = false;
445
496
  let pingTimer;
446
- const cleanup = () => {
497
+ let writer;
498
+ const cleanup = (reason) => {
447
499
  if (connectionDead) return;
448
500
  connectionDead = true;
449
501
  clearInterval(pingTimer);
450
502
  unsubscribes.forEach((fn) => fn());
503
+ writer?.close();
451
504
  sseLogger.info("SSE dead connection cleaned up", {
452
- events: allowedEvents
505
+ events: allowedEvents,
506
+ reason
453
507
  });
454
508
  };
509
+ writer = createBoundedWriter(stream, maxQueue, (reason) => {
510
+ sseLogger.warn("SSE connection closed", { events: allowedEvents, reason });
511
+ cleanup(reason);
512
+ });
513
+ writer.enqueue({
514
+ event: "connected",
515
+ data: JSON.stringify({
516
+ subscribedEvents: allowedEvents,
517
+ timestamp: Date.now()
518
+ })
519
+ });
455
520
  for (const eventName of allowedEvents) {
456
521
  const eventDef = router.events[eventName];
457
522
  if (!eventDef) {
@@ -473,17 +538,10 @@ function createSSEHandler(router, config = {}, tokenManager) {
473
538
  event: eventName,
474
539
  messageId
475
540
  });
476
- stream.writeSSE({
541
+ writer.enqueue({
477
542
  id: String(messageId),
478
543
  event: eventName,
479
544
  data: JSON.stringify(message)
480
- }).catch((err) => {
481
- sseLogger.warn("SSE write failed", {
482
- event: eventName,
483
- messageId,
484
- error: err.message
485
- });
486
- cleanup();
487
545
  });
488
546
  });
489
547
  unsubscribes.push(unsubscribe);
@@ -492,23 +550,11 @@ function createSSEHandler(router, config = {}, tokenManager) {
492
550
  events: allowedEvents,
493
551
  subscriptionCount: unsubscribes.length
494
552
  });
495
- await stream.writeSSE({
496
- event: "connected",
497
- data: JSON.stringify({
498
- subscribedEvents: allowedEvents,
499
- timestamp: Date.now()
500
- })
501
- });
502
553
  pingTimer = setInterval(() => {
503
554
  if (connectionDead) return;
504
- stream.writeSSE({
555
+ writer.enqueue({
505
556
  event: "ping",
506
557
  data: JSON.stringify({ timestamp: Date.now() })
507
- }).catch((err) => {
508
- sseLogger.warn("SSE ping failed", {
509
- error: err.message
510
- });
511
- cleanup();
512
558
  });
513
559
  }, pingInterval);
514
560
  const abortSignal = c.req.raw.signal;
@@ -649,6 +695,263 @@ var SSETokenManager = class {
649
695
  }
650
696
  }
651
697
  };
698
+ var transportLogger = logger.child("@spfn/core:event-transport");
699
+ var DEFAULT_CHANNEL_PREFIX = "spfn:sse:";
700
+ var SUBSCRIBER_QUIT_TIMEOUT_MS = 5e3;
701
+ var PUBLISH_TIMEOUT_MS = 5e3;
702
+ var SUBSCRIBE_TIMEOUT_MS = 5e3;
703
+ var WARN_THROTTLE_MS = 3e4;
704
+ var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
705
+ function withTimeout(promise, ms, label) {
706
+ return new Promise((resolve2, reject) => {
707
+ const timer = setTimeout(() => {
708
+ reject(new Error(`${label} timed out after ${ms}ms`));
709
+ }, ms);
710
+ timer.unref?.();
711
+ promise.then(
712
+ (value) => {
713
+ clearTimeout(timer);
714
+ resolve2(value);
715
+ },
716
+ (err) => {
717
+ clearTimeout(timer);
718
+ reject(err);
719
+ }
720
+ );
721
+ });
722
+ }
723
+ var warnThrottle = /* @__PURE__ */ new Map();
724
+ function throttledWarn(key, message, fields) {
725
+ const now = Date.now();
726
+ const entry = warnThrottle.get(key);
727
+ if (entry && now - entry.last < WARN_THROTTLE_MS) {
728
+ entry.suppressed++;
729
+ return;
730
+ }
731
+ transportLogger.warn(message, entry && entry.suppressed > 0 ? { ...fields, suppressedSinceLast: entry.suppressed } : fields);
732
+ warnThrottle.set(key, { last: now, suppressed: 0 });
733
+ }
734
+ var STATE_KEY = Symbol.for("@spfn/core:event-transport");
735
+ var state = globalThis[STATE_KEY] ??= {
736
+ pubSubCache: void 0,
737
+ subscriber: void 0,
738
+ channelPrefix: void 0,
739
+ wired: /* @__PURE__ */ new Map()
740
+ };
741
+ function resolveChannelPrefix(override) {
742
+ return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
743
+ }
744
+ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
745
+ let subscriber;
746
+ const handlers = /* @__PURE__ */ new Map();
747
+ let degradedUntil = 0;
748
+ const ensureSubscriber = () => {
749
+ if (subscriber) {
750
+ return subscriber;
751
+ }
752
+ subscriber = client.duplicate();
753
+ onSubscriber?.(subscriber);
754
+ subscriber.on("message", (channel, raw) => {
755
+ const handler = handlers.get(channel);
756
+ if (!handler) {
757
+ return;
758
+ }
759
+ let payload;
760
+ try {
761
+ payload = JSON.parse(raw);
762
+ } catch {
763
+ transportLogger.warn("Pub/sub message parse failed \u2014 dropped", { channel });
764
+ return;
765
+ }
766
+ void handler(payload);
767
+ });
768
+ subscriber.on("error", (err) => {
769
+ throttledWarn("subscriber-error", "Pub/sub subscriber error", { error: err.message });
770
+ });
771
+ return subscriber;
772
+ };
773
+ const subscriberHealthy = () => !!subscriber && (subscriber.status === void 0 || subscriber.status === "ready");
774
+ const deliverLocal = (channel, message) => {
775
+ const handler = handlers.get(channel);
776
+ if (handler) {
777
+ void handler(message);
778
+ }
779
+ };
780
+ return {
781
+ publish: async (name, message) => {
782
+ const channel = channelPrefix + name;
783
+ let serialized;
784
+ try {
785
+ serialized = JSON.stringify(message ?? null);
786
+ } catch (err) {
787
+ transportLogger.warn("Pub/sub payload serialization failed \u2014 local delivery only", {
788
+ channel,
789
+ error: err instanceof Error ? err.message : String(err)
790
+ });
791
+ deliverLocal(channel, message ?? null);
792
+ return;
793
+ }
794
+ if (serialized === void 0) {
795
+ transportLogger.warn("Pub/sub payload not serializable (function/symbol) \u2014 local delivery only", {
796
+ channel
797
+ });
798
+ deliverLocal(channel, message ?? null);
799
+ return;
800
+ }
801
+ const ready = client.status === void 0 || client.status === "ready";
802
+ const now = Date.now();
803
+ if (!ready || degradedUntil > 0 && now < degradedUntil) {
804
+ deliverLocal(channel, JSON.parse(serialized));
805
+ return;
806
+ }
807
+ if (degradedUntil > 0) {
808
+ degradedUntil = now + PUBLISH_TIMEOUT_MS;
809
+ }
810
+ try {
811
+ await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
812
+ degradedUntil = 0;
813
+ if (!subscriberHealthy()) {
814
+ throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
815
+ channel
816
+ });
817
+ deliverLocal(channel, JSON.parse(serialized));
818
+ }
819
+ } catch (err) {
820
+ degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
821
+ throttledWarn("publish-failed", "Pub/sub publish failed \u2014 local delivery only", {
822
+ channel,
823
+ error: err instanceof Error ? err.message : String(err)
824
+ });
825
+ deliverLocal(channel, JSON.parse(serialized));
826
+ }
827
+ },
828
+ subscribe: async (name, handler) => {
829
+ const channel = channelPrefix + name;
830
+ const sub = ensureSubscriber();
831
+ try {
832
+ await withTimeout(sub.subscribe(channel), SUBSCRIBE_TIMEOUT_MS, "pub/sub subscribe");
833
+ } catch (err) {
834
+ if (handlers.size === 0 && subscriber === sub) {
835
+ subscriber = void 0;
836
+ void sub.quit?.().catch(() => void 0);
837
+ }
838
+ throw err;
839
+ }
840
+ handlers.set(channel, handler);
841
+ }
842
+ };
843
+ }
844
+ async function wireEventRouterCache(router, options = {}) {
845
+ if (options.multiInstance === false) {
846
+ const events2 = router.events;
847
+ const stillRedis = Object.keys(events2).filter((k) => state.wired.has(events2[k].name)).length;
848
+ if (stillRedis > 0) {
849
+ transportLogger.warn("multiInstance:false has no effect on events already wired to redis by another router", {
850
+ redisWired: stillRedis
851
+ });
852
+ } else {
853
+ transportLogger.info("Event transport: in-process (multiInstance disabled)");
854
+ }
855
+ return "in-process";
856
+ }
857
+ const pubSubCache = await resolvePubSubCache(options.channelPrefix);
858
+ if (!pubSubCache) {
859
+ transportLogger.info("Event transport: in-process (no cache configured)");
860
+ return "in-process";
861
+ }
862
+ const events = router.events;
863
+ let wired = 0;
864
+ let degraded = 0;
865
+ let alreadyWired = 0;
866
+ for (const key of Object.keys(events)) {
867
+ const event = events[key];
868
+ if (state.wired.has(event.name)) {
869
+ alreadyWired++;
870
+ continue;
871
+ }
872
+ try {
873
+ await event.useCache(pubSubCache);
874
+ state.wired.set(event.name, event);
875
+ wired++;
876
+ } catch (err) {
877
+ degraded++;
878
+ transportLogger.warn("Event cache wiring failed \u2014 staying in-process for this event", {
879
+ event: event.name,
880
+ error: err instanceof Error ? err.message : String(err)
881
+ });
882
+ }
883
+ }
884
+ if (wired + alreadyWired === 0) {
885
+ transportLogger.warn("Event transport: redis configured but no event wired \u2014 running in-process", {
886
+ total: Object.keys(events).length,
887
+ degraded
888
+ });
889
+ return "in-process";
890
+ }
891
+ transportLogger.info("Event transport: redis (cross-pod fan-out)", {
892
+ total: Object.keys(events).length,
893
+ wired,
894
+ degraded,
895
+ alreadyWired
896
+ });
897
+ return "redis";
898
+ }
899
+ async function resolvePubSubCache(channelPrefix) {
900
+ const resolvedPrefix = resolveChannelPrefix(channelPrefix);
901
+ if (state.pubSubCache) {
902
+ if (state.channelPrefix !== resolvedPrefix) {
903
+ transportLogger.warn("Conflicting channelPrefix ignored \u2014 the transport prefix is process-global (first wiring wins).", {
904
+ active: state.channelPrefix,
905
+ ignored: resolvedPrefix
906
+ });
907
+ }
908
+ return state.pubSubCache;
909
+ }
910
+ let client;
911
+ try {
912
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
913
+ client = getCache2();
914
+ } catch {
915
+ return void 0;
916
+ }
917
+ if (!client) {
918
+ return void 0;
919
+ }
920
+ if (!channelPrefix && !process.env.SPFN_SSE_CHANNEL_PREFIX) {
921
+ transportLogger.warn(
922
+ "Event fan-out is on the default channel prefix (spfn:sse:). Apps sharing one Redis must set channelPrefix or SPFN_SSE_CHANNEL_PREFIX to avoid cross-app leakage."
923
+ );
924
+ }
925
+ state.channelPrefix = resolvedPrefix;
926
+ state.pubSubCache = createRedisPubSubCache(
927
+ client,
928
+ resolvedPrefix,
929
+ (connection) => {
930
+ state.subscriber = connection;
931
+ }
932
+ );
933
+ return state.pubSubCache;
934
+ }
935
+ async function closeEventTransport() {
936
+ const subscriber = state.subscriber;
937
+ for (const event of state.wired.values()) {
938
+ event._resetCache?.();
939
+ }
940
+ state.subscriber = void 0;
941
+ state.pubSubCache = void 0;
942
+ state.channelPrefix = void 0;
943
+ state.wired.clear();
944
+ if (!subscriber?.quit) {
945
+ return;
946
+ }
947
+ await Promise.race([
948
+ subscriber.quit().catch(() => void 0),
949
+ new Promise((resolve2) => {
950
+ const timer = setTimeout(resolve2, SUBSCRIBER_QUIT_TIMEOUT_MS);
951
+ timer.unref?.();
952
+ })
953
+ ]);
954
+ }
652
955
  var serverLogger = logger.child("@spfn/core:server");
653
956
 
654
957
  // src/server/shutdown-manager.ts
@@ -822,7 +1125,7 @@ var ShutdownManager = class {
822
1125
  for (const hook of this.hooks) {
823
1126
  serverLogger.debug(`Shutdown hook [${hook.name}] starting (timeout: ${hook.timeout}ms)`);
824
1127
  try {
825
- await withTimeout(
1128
+ await withTimeout2(
826
1129
  hook.handler(),
827
1130
  hook.timeout,
828
1131
  `Shutdown hook '${hook.name}' timeout after ${hook.timeout}ms`
@@ -850,7 +1153,7 @@ function resetShutdownManager() {
850
1153
  function sleep(ms) {
851
1154
  return new Promise((resolve2) => setTimeout(resolve2, ms));
852
1155
  }
853
- async function withTimeout(promise, timeout, message) {
1156
+ async function withTimeout2(promise, timeout, message) {
854
1157
  let timeoutId;
855
1158
  return Promise.race([
856
1159
  promise.finally(() => {
@@ -1040,6 +1343,7 @@ async function createAutoConfiguredApp(config) {
1040
1343
  });
1041
1344
  }
1042
1345
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1346
+ await applyProxyGuard(app, config);
1043
1347
  if (Array.isArray(config?.use)) {
1044
1348
  config.use.forEach((mw) => app.use("*", mw));
1045
1349
  }
@@ -1062,6 +1366,48 @@ function applyDefaultMiddleware(app, config, enableLogger, enableCors) {
1062
1366
  app.use("*", cors(corsOptions));
1063
1367
  }
1064
1368
  }
1369
+ async function applyProxyGuard(app, config) {
1370
+ const proxyGuardConfig = config?.proxyGuard;
1371
+ const mode = proxyGuardConfig?.mode ?? "off";
1372
+ if (mode === "off") {
1373
+ return;
1374
+ }
1375
+ const { createProxyGuard, createCacheNonceStore } = await import('@spfn/core/middleware');
1376
+ let nonceStore;
1377
+ if (proxyGuardConfig?.nonce) {
1378
+ try {
1379
+ const { getCache: getCache2 } = await import('@spfn/core/cache');
1380
+ const cache = getCache2();
1381
+ if (cache) {
1382
+ nonceStore = createCacheNonceStore(cache);
1383
+ serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
1384
+ } else {
1385
+ serverLogger.warn("Proxy-guard nonce enabled but no cache available \u2014 using timestamp window only");
1386
+ }
1387
+ } catch {
1388
+ serverLogger.warn("Proxy-guard nonce enabled but cache module unavailable \u2014 using timestamp window only");
1389
+ }
1390
+ }
1391
+ const autoSkip = [config?.healthCheck?.path ?? "/health"];
1392
+ if (config?.events) {
1393
+ autoSkip.push(config.eventsConfig?.path ?? "/events/stream");
1394
+ }
1395
+ if (config?.websockets) {
1396
+ autoSkip.push(config.websocketsConfig?.path ?? "/ws");
1397
+ }
1398
+ const skipPaths = [...proxyGuardConfig?.skipPaths ?? [], ...autoSkip];
1399
+ app.use("*", createProxyGuard({
1400
+ mode,
1401
+ secret: proxyGuardConfig?.secret,
1402
+ previousSecrets: proxyGuardConfig?.previousSecrets,
1403
+ windowMs: proxyGuardConfig?.windowMs,
1404
+ allowedOrigins: proxyGuardConfig?.allowedOrigins,
1405
+ maxBodyBytes: proxyGuardConfig?.maxBodyBytes,
1406
+ nonceStore,
1407
+ skipPaths
1408
+ }));
1409
+ serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
1410
+ }
1065
1411
  function registerHealthCheckEndpoint(app, config) {
1066
1412
  const healthCheckConfig = config?.healthCheck ?? {};
1067
1413
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
@@ -1150,12 +1496,17 @@ async function registerSSEEndpoint(app, config) {
1150
1496
  serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
1151
1497
  }
1152
1498
  }
1499
+ const transport = await wireEventRouterCache(config.events, {
1500
+ multiInstance: eventsConfig.multiInstance,
1501
+ channelPrefix: eventsConfig.channelPrefix
1502
+ });
1153
1503
  app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
1154
1504
  if (debug) {
1155
1505
  const eventNames = config.events.eventNames;
1156
1506
  serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
1157
1507
  events: eventNames,
1158
- auth: !!authConfig?.enabled
1508
+ auth: !!authConfig?.enabled,
1509
+ transport
1159
1510
  });
1160
1511
  }
1161
1512
  }
@@ -1890,6 +2241,10 @@ async function initializeWebSocket(server, app, config) {
1890
2241
  });
1891
2242
  if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
1892
2243
  }
2244
+ await wireEventRouterCache(wsRouter, {
2245
+ multiInstance: wsConfig.multiInstance,
2246
+ channelPrefix: wsConfig.channelPrefix
2247
+ });
1893
2248
  return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
1894
2249
  }
1895
2250
  function logMiddlewareOrder(config) {
@@ -1961,6 +2316,7 @@ function createShutdownHandler(server, config, shutdownState, wsCleanup) {
1961
2316
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1962
2317
  }
1963
2318
  if (infraConfig.redis) {
2319
+ await closeEventTransport();
1964
2320
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1965
2321
  }
1966
2322
  serverLogger.info("Server shutdown completed");
@@ -2111,6 +2467,7 @@ async function cleanupOnFailure(config) {
2111
2467
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
2112
2468
  }
2113
2469
  if (infraConfig.redis) {
2470
+ await closeEventTransport();
2114
2471
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
2115
2472
  }
2116
2473
  resetShutdownManager();
@@ -2179,6 +2536,13 @@ var ServerConfigBuilder = class {
2179
2536
  this.config.middlewares = middlewares;
2180
2537
  return this;
2181
2538
  }
2539
+ /**
2540
+ * Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
2541
+ */
2542
+ proxyGuard(proxyGuard) {
2543
+ this.config.proxyGuard = proxyGuard;
2544
+ return this;
2545
+ }
2182
2546
  /**
2183
2547
  * Register define-route based router
2184
2548
  *