@cosmicdrift/kumiko-renderer 0.403.0 → 0.404.0

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.
@@ -10,6 +10,8 @@ export type CreateEventSourceLiveEventsOptions = {
10
10
  readonly onSessionEnded?: () => void;
11
11
  /** Reconnect-timer seam; defaults to the global setTimeout/clearTimeout. */
12
12
  readonly timers?: LiveEventTimers;
13
+ /** Stall-watchdog timer seam; defaults to the global setTimeout/clearTimeout. */
14
+ readonly stallTimers?: LiveEventTimers;
13
15
  /** Jitter source in [0, 1); defaults to Math.random. */
14
16
  readonly random?: () => number;
15
17
  /** Clock for synthetic resync events; defaults to Date.now. */
@@ -21,6 +23,7 @@ export type LiveEventTimers = {
21
23
  };
22
24
  export declare const RECONNECT_BASE_DELAY_MS = 1000;
23
25
  export declare const RECONNECT_MAX_DELAY_MS = 30000;
26
+ export declare const SSE_STALL_TIMEOUT_MS = 30000;
24
27
  export declare function reconnectDelayMs(attempt: number, random: number): number;
25
28
  /** Returns an EventSource-backed `LiveEventSubscriber`.
26
29
  * Typically called once during app bootstrap and passed as a value to
@@ -3,6 +3,9 @@ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import { isSessionEndedError, LIVE_RESYNC_EVENT_TYPE, } from "../../index.js";
4
4
  export const RECONNECT_BASE_DELAY_MS = 1_000;
5
5
  export const RECONNECT_MAX_DELAY_MS = 30_000;
6
+ // Two missed server heartbeats (15 s each, SSE_HEARTBEAT_INTERVAL_MS in framework sse-route.ts).
7
+ // A KEDA interceptor can leave the stream hanging without an `error` event (kedacore/http-add-on#1803).
8
+ export const SSE_STALL_TIMEOUT_MS = 30_000;
6
9
  const defaultTimers = {
7
10
  set: (callback, delayMs) => {
8
11
  const handle = setTimeout(callback, delayMs);
@@ -67,6 +70,7 @@ async function isSessionEndedRejection(url) {
67
70
  export function createEventSourceLiveEvents(options = {}) {
68
71
  const url = options.url ?? "/api/sse";
69
72
  const timers = options.timers ?? defaultTimers;
73
+ const stallTimers = options.stallTimers ?? defaultTimers;
70
74
  const random = options.random ?? Math.random;
71
75
  const now = options.now ?? Date.now;
72
76
  const subscribers = new Set();
@@ -74,6 +78,25 @@ export function createEventSourceLiveEvents(options = {}) {
74
78
  const wiredEntities = new Set();
75
79
  let cancelReconnect;
76
80
  let reconnectAttempt = 0;
81
+ let cancelStallWatchdog;
82
+ const disarmStallWatchdog = () => {
83
+ cancelStallWatchdog?.();
84
+ cancelStallWatchdog = undefined;
85
+ };
86
+ const armStallWatchdog = (connection) => {
87
+ // skip: a frame from a connection that is no longer the current one
88
+ if (source !== connection)
89
+ return;
90
+ disarmStallWatchdog();
91
+ cancelStallWatchdog = stallTimers.set(() => {
92
+ cancelStallWatchdog = undefined;
93
+ // skip: a late timer of a connection that was already replaced
94
+ if (source !== connection)
95
+ return;
96
+ connection.close();
97
+ scheduleReconnect();
98
+ }, SSE_STALL_TIMEOUT_MS);
99
+ };
77
100
  // `subscribers` is a flat Set across all entities — the browser-side
78
101
  // addEventListener(entityName, ...) gate below only filters which frames
79
102
  // arrive at all, not which subscriber a given frame is for. Without this
@@ -119,6 +142,7 @@ export function createEventSourceLiveEvents(options = {}) {
119
142
  return;
120
143
  connection.addEventListener(entityName, (e) => {
121
144
  const data = propertyOf(e, "data");
145
+ armStallWatchdog(connection);
122
146
  if (typeof data === "string")
123
147
  handleEvent(data, entityName);
124
148
  });
@@ -142,14 +166,22 @@ export function createEventSourceLiveEvents(options = {}) {
142
166
  sessionEndedSignalled = true;
143
167
  onSessionEnded?.();
144
168
  };
169
+ armStallWatchdog(connection);
170
+ connection.addEventListener("ping", () => armStallWatchdog(connection));
145
171
  connection.addEventListener("open", () => {
172
+ armStallWatchdog(connection);
146
173
  reconnectAttempt = 0;
147
174
  emitResync();
148
175
  });
149
176
  connection.addEventListener(SESSION_ENDED_EVENT, (e) => {
150
177
  const code = sessionEndedFrameCode(e);
151
- if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 }))
178
+ if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 })) {
179
+ disarmStallWatchdog();
152
180
  signalSessionEnded();
181
+ // skip: a reconnect after a session end would only be refused again
182
+ return;
183
+ }
184
+ armStallWatchdog(connection);
153
185
  });
154
186
  connection.addEventListener("error", () => {
155
187
  // skip: CONNECTING means a dropped stream the browser retries itself;
@@ -161,10 +193,12 @@ export function createEventSourceLiveEvents(options = {}) {
161
193
  if (source !== connection)
162
194
  return;
163
195
  if (sessionEnded) {
196
+ disarmStallWatchdog();
164
197
  signalSessionEnded();
165
198
  // skip: the session ended, reconnecting would only be refused again
166
199
  return;
167
200
  }
201
+ disarmStallWatchdog();
168
202
  scheduleReconnect();
169
203
  });
170
204
  });
@@ -196,6 +230,7 @@ export function createEventSourceLiveEvents(options = {}) {
196
230
  cancelReconnect = undefined;
197
231
  }
198
232
  reconnectAttempt = 0;
233
+ disarmStallWatchdog();
199
234
  // skip: already closed, nothing to close
200
235
  if (source === undefined)
201
236
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.403.0",
3
+ "version": "0.404.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.403.0",
52
- "@cosmicdrift/kumiko-types": "0.403.0",
51
+ "@cosmicdrift/kumiko-framework": "0.404.0",
52
+ "@cosmicdrift/kumiko-types": "0.404.0",
53
53
  "@types/qrcode": "^1.5.5",
54
54
  "class-variance-authority": "^0.7.1",
55
55
  "clsx": "^2.1.1",
@@ -79,8 +79,8 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@cosmicdrift/kumiko-locale-de": "0.403.0",
83
- "@cosmicdrift/kumiko-testing": "0.403.0",
82
+ "@cosmicdrift/kumiko-locale-de": "0.404.0",
83
+ "@cosmicdrift/kumiko-testing": "0.404.0",
84
84
  "@tailwindcss/cli": "^4.3.3",
85
85
  "@testing-library/react": "^16.3.3",
86
86
  "@testing-library/user-event": "^14.6.8",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.404.0",
4
+ "type": "fix",
5
+ "title": "SSE client reconnects after a stalled stream",
6
+ "detail": "createEventSourceLiveEvents arms a 30 s watchdog (two missed server heartbeats) that is reset by open, ping and every entity or session-ended frame. On expiry it closes the connection and reconnects through the existing backoff path, which resyncs all listeners. The timer is cleared on close and error. The seam is the new stallTimers option."
7
+ },
2
8
  {
3
9
  "version": "0.403.0",
4
10
  "type": "improvement",