@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.
|
|
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.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
83
|
-
"@cosmicdrift/kumiko-testing": "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",
|