@spfn/core 0.2.0-beta.50 → 0.2.0-beta.52

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(() => {
@@ -1150,12 +1453,17 @@ async function registerSSEEndpoint(app, config) {
1150
1453
  serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
1151
1454
  }
1152
1455
  }
1456
+ const transport = await wireEventRouterCache(config.events, {
1457
+ multiInstance: eventsConfig.multiInstance,
1458
+ channelPrefix: eventsConfig.channelPrefix
1459
+ });
1153
1460
  app.get(streamPath, createSSEHandler(config.events, eventsConfig, tokenManager));
1154
1461
  if (debug) {
1155
1462
  const eventNames = config.events.eventNames;
1156
1463
  serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
1157
1464
  events: eventNames,
1158
- auth: !!authConfig?.enabled
1465
+ auth: !!authConfig?.enabled,
1466
+ transport
1159
1467
  });
1160
1468
  }
1161
1469
  }
@@ -1890,6 +2198,10 @@ async function initializeWebSocket(server, app, config) {
1890
2198
  });
1891
2199
  if (debug) serverLogger.info(`\u2713 WS token endpoint registered at POST ${tokenPath}`);
1892
2200
  }
2201
+ await wireEventRouterCache(wsRouter, {
2202
+ multiInstance: wsConfig.multiInstance,
2203
+ channelPrefix: wsConfig.channelPrefix
2204
+ });
1893
2205
  return await attachWSHandler(server, wsRouter, wsConfig, tokenManager);
1894
2206
  }
1895
2207
  function logMiddlewareOrder(config) {
@@ -1961,6 +2273,7 @@ function createShutdownHandler(server, config, shutdownState, wsCleanup) {
1961
2273
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
1962
2274
  }
1963
2275
  if (infraConfig.redis) {
2276
+ await closeEventTransport();
1964
2277
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
1965
2278
  }
1966
2279
  serverLogger.info("Server shutdown completed");
@@ -2111,6 +2424,7 @@ async function cleanupOnFailure(config) {
2111
2424
  await closeInfrastructure(closeDatabase, "Database", TIMEOUTS.DATABASE_CLOSE);
2112
2425
  }
2113
2426
  if (infraConfig.redis) {
2427
+ await closeEventTransport();
2114
2428
  await closeInfrastructure(closeCache, "Redis", TIMEOUTS.REDIS_CLOSE);
2115
2429
  }
2116
2430
  resetShutdownManager();