@kyneta/sse-transport 1.3.0 → 1.4.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.
- package/README.md +1 -1
- package/dist/client.d.ts +80 -79
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +410 -487
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts +50 -47
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +29741 -23014
- package/dist/express.js.map +1 -1
- package/dist/server-transport-C0bHmtVV.d.ts +183 -0
- package/dist/server-transport-C0bHmtVV.d.ts.map +1 -0
- package/dist/server-transport-Dq0kBllJ.js +247 -0
- package/dist/server-transport-Dq0kBllJ.js.map +1 -0
- package/dist/server.d.ts +3 -4
- package/dist/server.js +2 -12
- package/dist/types-Bg1SdZ2w.d.ts +84 -0
- package/dist/types-Bg1SdZ2w.d.ts.map +1 -0
- package/package.json +11 -11
- package/src/__tests__/connection.test.ts +5 -4
- package/src/__tests__/sse-handler.test.ts +3 -2
- package/src/client-transport.ts +2 -2
- package/src/express-router.ts +1 -1
- package/src/server-transport.ts +3 -3
- package/dist/chunk-7D4SUZUM.js +0 -38
- package/dist/chunk-7D4SUZUM.js.map +0 -1
- package/dist/chunk-ZBE5AMNA.js +0 -255
- package/dist/chunk-ZBE5AMNA.js.map +0 -1
- package/dist/server-transport-D9l5s5PV.d.ts +0 -180
- package/dist/server.js.map +0 -1
- package/dist/types-DaVaqfwF.d.ts +0 -82
package/dist/client.js
CHANGED
|
@@ -1,496 +1,419 @@
|
|
|
1
|
-
import "./chunk-7D4SUZUM.js";
|
|
2
|
-
|
|
3
|
-
// src/client-transport.ts
|
|
4
1
|
import { createObservableProgram } from "@kyneta/machine";
|
|
5
|
-
import { Transport } from "@kyneta/transport";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
2
|
+
import { DEFAULT_RECONNECT, Transport, computeBackoffDelay } from "@kyneta/transport";
|
|
3
|
+
import { TextReassembler, encodeTextComplete, fragmentTextPayload, textCodec } from "@kyneta/wire";
|
|
4
|
+
//#region src/client-program.ts
|
|
5
|
+
/**
|
|
6
|
+
* Create the SSE client connection lifecycle program — a pure Mealy machine.
|
|
7
|
+
*
|
|
8
|
+
* The returned `Program<SseClientMsg, SseClientState, SseClientEffect>`
|
|
9
|
+
* encodes every state transition and effect as inspectable data. The imperative
|
|
10
|
+
* shell interprets `SseClientEffect` as actual I/O.
|
|
11
|
+
*/
|
|
15
12
|
function createSseClientProgram(options) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return [
|
|
104
|
-
{ status: "connecting", attempt: model.attempt },
|
|
105
|
-
{ type: "create-event-source", url, attempt: model.attempt }
|
|
106
|
-
];
|
|
107
|
-
}
|
|
108
|
-
// -----------------------------------------------------------------
|
|
109
|
-
// stop
|
|
110
|
-
// -----------------------------------------------------------------
|
|
111
|
-
case "stop": {
|
|
112
|
-
if (model.status === "disconnected") return [model];
|
|
113
|
-
const effects = [
|
|
114
|
-
{ type: "cancel-reconnect-timer" }
|
|
115
|
-
];
|
|
116
|
-
if (model.status === "connecting") {
|
|
117
|
-
effects.push({ type: "close-event-source" });
|
|
118
|
-
}
|
|
119
|
-
if (model.status === "connected") {
|
|
120
|
-
effects.push(
|
|
121
|
-
{ type: "close-event-source" },
|
|
122
|
-
{ type: "remove-channel" },
|
|
123
|
-
{ type: "abort-pending-posts" }
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
return [
|
|
127
|
-
{ status: "disconnected", reason: { type: "intentional" } },
|
|
128
|
-
...effects
|
|
129
|
-
];
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
};
|
|
13
|
+
const { url, jitterFn = () => Math.random() * 1e3 } = options;
|
|
14
|
+
const reconnect = {
|
|
15
|
+
...DEFAULT_RECONNECT,
|
|
16
|
+
...options.reconnect
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Attempt to transition into reconnecting, or give up and disconnect.
|
|
20
|
+
*
|
|
21
|
+
* Pure — computes the next state and effects from the current attempt
|
|
22
|
+
* count and reconnect configuration. Returns a tuple suitable for
|
|
23
|
+
* spreading into an `update` return.
|
|
24
|
+
*/
|
|
25
|
+
function tryReconnect(currentAttempt, reason, ...extraEffects) {
|
|
26
|
+
if (!reconnect.enabled) return [{
|
|
27
|
+
status: "disconnected",
|
|
28
|
+
reason
|
|
29
|
+
}, ...extraEffects];
|
|
30
|
+
if (currentAttempt >= reconnect.maxAttempts) return [{
|
|
31
|
+
status: "disconnected",
|
|
32
|
+
reason: {
|
|
33
|
+
type: "max-retries-exceeded",
|
|
34
|
+
attempts: currentAttempt
|
|
35
|
+
}
|
|
36
|
+
}, ...extraEffects];
|
|
37
|
+
const delay = computeBackoffDelay(currentAttempt + 1, reconnect.baseDelay, reconnect.maxDelay, jitterFn());
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
status: "reconnecting",
|
|
41
|
+
attempt: currentAttempt + 1,
|
|
42
|
+
nextAttemptMs: delay
|
|
43
|
+
},
|
|
44
|
+
...extraEffects,
|
|
45
|
+
{
|
|
46
|
+
type: "start-reconnect-timer",
|
|
47
|
+
delayMs: delay
|
|
48
|
+
}
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
init: [{ status: "disconnected" }],
|
|
53
|
+
update(msg, model) {
|
|
54
|
+
switch (msg.type) {
|
|
55
|
+
case "start":
|
|
56
|
+
if (model.status !== "disconnected") return [model];
|
|
57
|
+
return [{
|
|
58
|
+
status: "connecting",
|
|
59
|
+
attempt: 1
|
|
60
|
+
}, {
|
|
61
|
+
type: "create-event-source",
|
|
62
|
+
url,
|
|
63
|
+
attempt: 1
|
|
64
|
+
}];
|
|
65
|
+
case "event-source-opened":
|
|
66
|
+
if (model.status !== "connecting") return [model];
|
|
67
|
+
return [{ status: "connected" }, { type: "add-channel-and-establish" }];
|
|
68
|
+
case "event-source-error": {
|
|
69
|
+
const reason = {
|
|
70
|
+
type: "error",
|
|
71
|
+
error: /* @__PURE__ */ new Error("EventSource connection error")
|
|
72
|
+
};
|
|
73
|
+
if (model.status === "connecting") return tryReconnect(model.attempt, reason, { type: "close-event-source" });
|
|
74
|
+
if (model.status === "connected") return tryReconnect(0, reason, { type: "remove-channel" }, { type: "close-event-source" }, { type: "abort-pending-posts" });
|
|
75
|
+
return [model];
|
|
76
|
+
}
|
|
77
|
+
case "reconnect-timer-fired":
|
|
78
|
+
if (model.status !== "reconnecting") return [model];
|
|
79
|
+
return [{
|
|
80
|
+
status: "connecting",
|
|
81
|
+
attempt: model.attempt
|
|
82
|
+
}, {
|
|
83
|
+
type: "create-event-source",
|
|
84
|
+
url,
|
|
85
|
+
attempt: model.attempt
|
|
86
|
+
}];
|
|
87
|
+
case "stop": {
|
|
88
|
+
if (model.status === "disconnected") return [model];
|
|
89
|
+
const effects = [{ type: "cancel-reconnect-timer" }];
|
|
90
|
+
if (model.status === "connecting") effects.push({ type: "close-event-source" });
|
|
91
|
+
if (model.status === "connected") effects.push({ type: "close-event-source" }, { type: "remove-channel" }, { type: "abort-pending-posts" });
|
|
92
|
+
return [{
|
|
93
|
+
status: "disconnected",
|
|
94
|
+
reason: { type: "intentional" }
|
|
95
|
+
}, ...effects];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
134
100
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/client-transport.ts
|
|
103
|
+
/**
|
|
104
|
+
* Default fragment threshold in characters.
|
|
105
|
+
* 60K chars provides a safety margin below typical 100KB body-parser limits,
|
|
106
|
+
* accounting for JSON overhead and potential base64 expansion.
|
|
107
|
+
*/
|
|
108
|
+
const DEFAULT_FRAGMENT_THRESHOLD = 6e4;
|
|
109
|
+
/**
|
|
110
|
+
* Default POST retry options.
|
|
111
|
+
*/
|
|
112
|
+
const DEFAULT_POST_RETRY = {
|
|
113
|
+
maxAttempts: 3,
|
|
114
|
+
baseDelay: 1e3,
|
|
115
|
+
maxDelay: 1e4
|
|
142
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* SSE client network adapter for @kyneta/exchange.
|
|
119
|
+
*
|
|
120
|
+
* Uses two HTTP channels:
|
|
121
|
+
* - **EventSource** (GET, long-lived) for server→client messages
|
|
122
|
+
* - **fetch POST** for client→server messages
|
|
123
|
+
*
|
|
124
|
+
* Both directions use the text wire format (`textCodec` + text framing).
|
|
125
|
+
*
|
|
126
|
+
* Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —
|
|
127
|
+
* a pure Mealy machine whose transitions are deterministically testable.
|
|
128
|
+
* This class is the imperative shell that interprets data effects as I/O.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```typescript
|
|
132
|
+
* import { createSseClient } from "@kyneta/sse-transport/client"
|
|
133
|
+
*
|
|
134
|
+
* const exchange = new Exchange({
|
|
135
|
+
* id: "browser-client",
|
|
136
|
+
* transports: [createSseClient({
|
|
137
|
+
* postUrl: "/sync",
|
|
138
|
+
* eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
|
|
139
|
+
* reconnect: { enabled: true },
|
|
140
|
+
* })],
|
|
141
|
+
* })
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
143
144
|
var SseClientTransport = class extends Transport {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// ==========================================================================
|
|
398
|
-
// POST sending with retry
|
|
399
|
-
// ==========================================================================
|
|
400
|
-
/**
|
|
401
|
-
* Send a text frame via POST with retry logic.
|
|
402
|
-
*/
|
|
403
|
-
async #sendTextWithRetry(url, textFrame) {
|
|
404
|
-
let attempt = 0;
|
|
405
|
-
const postRetryOpts = {
|
|
406
|
-
...DEFAULT_POST_RETRY,
|
|
407
|
-
...this.#options.postRetry
|
|
408
|
-
};
|
|
409
|
-
const { maxAttempts, baseDelay, maxDelay } = postRetryOpts;
|
|
410
|
-
while (attempt < maxAttempts) {
|
|
411
|
-
try {
|
|
412
|
-
if (!this.#currentRetryAbortController) {
|
|
413
|
-
this.#currentRetryAbortController = new AbortController();
|
|
414
|
-
}
|
|
415
|
-
if (!this.#peerId) {
|
|
416
|
-
throw new Error("PeerId not available for retry");
|
|
417
|
-
}
|
|
418
|
-
const response = await fetch(url, {
|
|
419
|
-
method: "POST",
|
|
420
|
-
headers: {
|
|
421
|
-
"Content-Type": "text/plain",
|
|
422
|
-
"X-Peer-Id": this.#peerId
|
|
423
|
-
},
|
|
424
|
-
body: textFrame,
|
|
425
|
-
signal: this.#currentRetryAbortController.signal
|
|
426
|
-
});
|
|
427
|
-
if (!response.ok) {
|
|
428
|
-
if (response.status >= 400 && response.status < 500) {
|
|
429
|
-
throw new Error(`Failed to send message: ${response.statusText}`);
|
|
430
|
-
}
|
|
431
|
-
throw new Error(`Server error: ${response.statusText}`);
|
|
432
|
-
}
|
|
433
|
-
this.#currentRetryAbortController = void 0;
|
|
434
|
-
return;
|
|
435
|
-
} catch (error) {
|
|
436
|
-
attempt++;
|
|
437
|
-
const err = error;
|
|
438
|
-
if (err.name === "AbortError") {
|
|
439
|
-
throw error;
|
|
440
|
-
}
|
|
441
|
-
if (!this.#currentRetryAbortController) {
|
|
442
|
-
const abortError = new Error("Retry aborted by connection reset");
|
|
443
|
-
abortError.name = "AbortError";
|
|
444
|
-
throw abortError;
|
|
445
|
-
}
|
|
446
|
-
if (attempt >= maxAttempts) {
|
|
447
|
-
this.#currentRetryAbortController = void 0;
|
|
448
|
-
throw error;
|
|
449
|
-
}
|
|
450
|
-
const delay = Math.min(
|
|
451
|
-
baseDelay * 2 ** (attempt - 1) + Math.random() * 100,
|
|
452
|
-
maxDelay
|
|
453
|
-
);
|
|
454
|
-
await new Promise((resolve, reject) => {
|
|
455
|
-
if (this.#currentRetryAbortController?.signal.aborted) {
|
|
456
|
-
const error2 = new Error("Retry aborted");
|
|
457
|
-
error2.name = "AbortError";
|
|
458
|
-
reject(error2);
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
const timer = setTimeout(() => {
|
|
462
|
-
cleanup();
|
|
463
|
-
resolve();
|
|
464
|
-
}, delay);
|
|
465
|
-
const onAbort = () => {
|
|
466
|
-
clearTimeout(timer);
|
|
467
|
-
cleanup();
|
|
468
|
-
const error2 = new Error("Retry aborted");
|
|
469
|
-
error2.name = "AbortError";
|
|
470
|
-
reject(error2);
|
|
471
|
-
};
|
|
472
|
-
const cleanup = () => {
|
|
473
|
-
this.#currentRetryAbortController?.signal.removeEventListener(
|
|
474
|
-
"abort",
|
|
475
|
-
onAbort
|
|
476
|
-
);
|
|
477
|
-
};
|
|
478
|
-
this.#currentRetryAbortController?.signal.addEventListener(
|
|
479
|
-
"abort",
|
|
480
|
-
onAbort
|
|
481
|
-
);
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
145
|
+
#peerId;
|
|
146
|
+
#options;
|
|
147
|
+
#handle;
|
|
148
|
+
#eventSource;
|
|
149
|
+
#serverChannel;
|
|
150
|
+
#reconnectTimer;
|
|
151
|
+
#fragmentThreshold;
|
|
152
|
+
#reassembler;
|
|
153
|
+
#currentRetryAbortController;
|
|
154
|
+
constructor(options) {
|
|
155
|
+
super({ transportType: "sse-client" });
|
|
156
|
+
this.#options = options;
|
|
157
|
+
this.#fragmentThreshold = options.fragmentThreshold ?? 6e4;
|
|
158
|
+
this.#reassembler = new TextReassembler({ timeoutMs: 1e4 });
|
|
159
|
+
const program = createSseClientProgram({
|
|
160
|
+
url: "__deferred__",
|
|
161
|
+
reconnect: options.reconnect
|
|
162
|
+
});
|
|
163
|
+
this.#handle = createObservableProgram(program, (effect, dispatch) => {
|
|
164
|
+
this.#executeEffect(effect, dispatch);
|
|
165
|
+
});
|
|
166
|
+
this.#setupLifecycleEvents();
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Subscribe to the observable handle's transitions and forward them to
|
|
170
|
+
* the lifecycle callbacks. `wasConnectedBefore` is observer-local state,
|
|
171
|
+
* not in the program model.
|
|
172
|
+
*/
|
|
173
|
+
#setupLifecycleEvents() {
|
|
174
|
+
let wasConnectedBefore = false;
|
|
175
|
+
this.#handle.subscribeToTransitions((transition) => {
|
|
176
|
+
this.#options.lifecycle?.onStateChange?.(transition);
|
|
177
|
+
const { from, to } = transition;
|
|
178
|
+
if (to.status === "disconnected" && to.reason) this.#options.lifecycle?.onDisconnect?.(to.reason);
|
|
179
|
+
if (to.status === "reconnecting") this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs);
|
|
180
|
+
if (wasConnectedBefore && (from.status === "reconnecting" || from.status === "connecting") && to.status === "connected") this.#options.lifecycle?.onReconnected?.();
|
|
181
|
+
if (to.status === "connected") wasConnectedBefore = true;
|
|
182
|
+
if (to.status === "disconnected" && to.reason?.type === "intentional") wasConnectedBefore = false;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
#executeEffect(effect, dispatch) {
|
|
186
|
+
switch (effect.type) {
|
|
187
|
+
case "create-event-source":
|
|
188
|
+
this.#doCreateEventSource(dispatch);
|
|
189
|
+
break;
|
|
190
|
+
case "close-event-source":
|
|
191
|
+
if (this.#eventSource) {
|
|
192
|
+
this.#eventSource.onopen = null;
|
|
193
|
+
this.#eventSource.onmessage = null;
|
|
194
|
+
this.#eventSource.onerror = null;
|
|
195
|
+
this.#eventSource.close();
|
|
196
|
+
this.#eventSource = void 0;
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
case "add-channel-and-establish":
|
|
200
|
+
if (this.#serverChannel) {
|
|
201
|
+
this.removeChannel(this.#serverChannel.channelId);
|
|
202
|
+
this.#serverChannel = void 0;
|
|
203
|
+
}
|
|
204
|
+
this.#serverChannel = this.addChannel();
|
|
205
|
+
this.establishChannel(this.#serverChannel.channelId);
|
|
206
|
+
break;
|
|
207
|
+
case "remove-channel":
|
|
208
|
+
if (this.#serverChannel) {
|
|
209
|
+
this.removeChannel(this.#serverChannel.channelId);
|
|
210
|
+
this.#serverChannel = void 0;
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
case "start-reconnect-timer":
|
|
214
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
215
|
+
this.#reconnectTimer = void 0;
|
|
216
|
+
dispatch({ type: "reconnect-timer-fired" });
|
|
217
|
+
}, effect.delayMs);
|
|
218
|
+
break;
|
|
219
|
+
case "cancel-reconnect-timer":
|
|
220
|
+
if (this.#reconnectTimer !== void 0) {
|
|
221
|
+
clearTimeout(this.#reconnectTimer);
|
|
222
|
+
this.#reconnectTimer = void 0;
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
case "abort-pending-posts":
|
|
226
|
+
if (this.#currentRetryAbortController) {
|
|
227
|
+
this.#currentRetryAbortController.abort();
|
|
228
|
+
this.#currentRetryAbortController = void 0;
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Create an EventSource and wire up event handlers.
|
|
235
|
+
* The URL is resolved here (may be a function of peerId).
|
|
236
|
+
*/
|
|
237
|
+
#doCreateEventSource(dispatch) {
|
|
238
|
+
if (!this.#peerId) throw new Error("Cannot connect: peerId not set");
|
|
239
|
+
const url = typeof this.#options.eventSourceUrl === "function" ? this.#options.eventSourceUrl(this.#peerId) : this.#options.eventSourceUrl;
|
|
240
|
+
try {
|
|
241
|
+
this.#eventSource = new EventSource(url);
|
|
242
|
+
this.#eventSource.onopen = () => {
|
|
243
|
+
dispatch({ type: "event-source-opened" });
|
|
244
|
+
};
|
|
245
|
+
this.#eventSource.onmessage = (event) => {
|
|
246
|
+
this.#handleMessage(event);
|
|
247
|
+
};
|
|
248
|
+
this.#eventSource.onerror = () => {
|
|
249
|
+
dispatch({ type: "event-source-error" });
|
|
250
|
+
};
|
|
251
|
+
} catch (_error) {
|
|
252
|
+
dispatch({ type: "event-source-error" });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Get the current connection state.
|
|
257
|
+
*/
|
|
258
|
+
getState() {
|
|
259
|
+
return this.#handle.getState();
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Subscribe to state transitions.
|
|
263
|
+
*/
|
|
264
|
+
subscribeToTransitions(listener) {
|
|
265
|
+
return this.#handle.subscribeToTransitions(listener);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Wait for a specific state.
|
|
269
|
+
*/
|
|
270
|
+
waitForState(predicate, options) {
|
|
271
|
+
return this.#handle.waitForState(predicate, options);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Wait for a specific status.
|
|
275
|
+
*/
|
|
276
|
+
waitForStatus(status, options) {
|
|
277
|
+
return this.#handle.waitForStatus(status, options);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Whether the client is connected and ready to send/receive.
|
|
281
|
+
*/
|
|
282
|
+
get isConnected() {
|
|
283
|
+
return this.#handle.getState().status === "connected";
|
|
284
|
+
}
|
|
285
|
+
generate() {
|
|
286
|
+
return {
|
|
287
|
+
transportType: this.transportType,
|
|
288
|
+
send: (msg) => {
|
|
289
|
+
if (!this.#peerId) return;
|
|
290
|
+
if (!this.#eventSource || this.#eventSource.readyState === 2) return;
|
|
291
|
+
const resolvedPostUrl = typeof this.#options.postUrl === "function" ? this.#options.postUrl(this.#peerId) : this.#options.postUrl;
|
|
292
|
+
const textFrame = encodeTextComplete(textCodec, msg);
|
|
293
|
+
if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
|
|
294
|
+
const fragments = fragmentTextPayload(JSON.stringify(textCodec.encode(msg)), this.#fragmentThreshold);
|
|
295
|
+
for (const fragment of fragments) this.#sendTextWithRetry(resolvedPostUrl, fragment);
|
|
296
|
+
} else this.#sendTextWithRetry(resolvedPostUrl, textFrame);
|
|
297
|
+
},
|
|
298
|
+
stop: () => {}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
async onStart() {
|
|
302
|
+
if (!this.identity) throw new Error("Adapter not properly initialized — identity not available");
|
|
303
|
+
this.#peerId = this.identity.peerId;
|
|
304
|
+
this.#handle.dispatch({ type: "start" });
|
|
305
|
+
}
|
|
306
|
+
async onStop() {
|
|
307
|
+
this.#reassembler.dispose();
|
|
308
|
+
this.#handle.dispatch({ type: "stop" });
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Handle incoming SSE message.
|
|
312
|
+
*
|
|
313
|
+
* Each SSE `data:` event contains a text wire frame string.
|
|
314
|
+
* Feed it through the TextReassembler to handle both complete
|
|
315
|
+
* and fragmented frames.
|
|
316
|
+
*/
|
|
317
|
+
#handleMessage(event) {
|
|
318
|
+
if (!this.#serverChannel) return;
|
|
319
|
+
const data = event.data;
|
|
320
|
+
if (typeof data !== "string") return;
|
|
321
|
+
const result = this.#reassembler.receive(data);
|
|
322
|
+
if (result.status === "complete") try {
|
|
323
|
+
const parsed = JSON.parse(result.frame.content.payload);
|
|
324
|
+
const messages = textCodec.decode(parsed);
|
|
325
|
+
for (const msg of messages) this.#serverChannel.onReceive(msg);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
console.error("Failed to decode SSE message:", error);
|
|
328
|
+
}
|
|
329
|
+
else if (result.status === "error") console.error("SSE message reassembly error:", result.error);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Send a text frame via POST with retry logic.
|
|
333
|
+
*/
|
|
334
|
+
async #sendTextWithRetry(url, textFrame) {
|
|
335
|
+
let attempt = 0;
|
|
336
|
+
const { maxAttempts, baseDelay, maxDelay } = {
|
|
337
|
+
...DEFAULT_POST_RETRY,
|
|
338
|
+
...this.#options.postRetry
|
|
339
|
+
};
|
|
340
|
+
while (attempt < maxAttempts) try {
|
|
341
|
+
if (!this.#currentRetryAbortController) this.#currentRetryAbortController = new AbortController();
|
|
342
|
+
if (!this.#peerId) throw new Error("PeerId not available for retry");
|
|
343
|
+
const response = await fetch(url, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: {
|
|
346
|
+
"Content-Type": "text/plain",
|
|
347
|
+
"X-Peer-Id": this.#peerId
|
|
348
|
+
},
|
|
349
|
+
body: textFrame,
|
|
350
|
+
signal: this.#currentRetryAbortController.signal
|
|
351
|
+
});
|
|
352
|
+
if (!response.ok) {
|
|
353
|
+
if (response.status >= 400 && response.status < 500) throw new Error(`Failed to send message: ${response.statusText}`);
|
|
354
|
+
throw new Error(`Server error: ${response.statusText}`);
|
|
355
|
+
}
|
|
356
|
+
this.#currentRetryAbortController = void 0;
|
|
357
|
+
return;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
attempt++;
|
|
360
|
+
if (error.name === "AbortError") throw error;
|
|
361
|
+
if (!this.#currentRetryAbortController) {
|
|
362
|
+
const abortError = /* @__PURE__ */ new Error("Retry aborted by connection reset");
|
|
363
|
+
abortError.name = "AbortError";
|
|
364
|
+
throw abortError;
|
|
365
|
+
}
|
|
366
|
+
if (attempt >= maxAttempts) {
|
|
367
|
+
this.#currentRetryAbortController = void 0;
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
const delay = Math.min(baseDelay * 2 ** (attempt - 1) + Math.random() * 100, maxDelay);
|
|
371
|
+
await new Promise((resolve, reject) => {
|
|
372
|
+
if (this.#currentRetryAbortController?.signal.aborted) {
|
|
373
|
+
const error = /* @__PURE__ */ new Error("Retry aborted");
|
|
374
|
+
error.name = "AbortError";
|
|
375
|
+
reject(error);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const timer = setTimeout(() => {
|
|
379
|
+
cleanup();
|
|
380
|
+
resolve();
|
|
381
|
+
}, delay);
|
|
382
|
+
const onAbort = () => {
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
cleanup();
|
|
385
|
+
const error = /* @__PURE__ */ new Error("Retry aborted");
|
|
386
|
+
error.name = "AbortError";
|
|
387
|
+
reject(error);
|
|
388
|
+
};
|
|
389
|
+
const cleanup = () => {
|
|
390
|
+
this.#currentRetryAbortController?.signal.removeEventListener("abort", onAbort);
|
|
391
|
+
};
|
|
392
|
+
this.#currentRetryAbortController?.signal.addEventListener("abort", onAbort);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
486
396
|
};
|
|
397
|
+
/**
|
|
398
|
+
* Create an SSE client adapter for browser-to-server connections.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```typescript
|
|
402
|
+
* import { createSseClient } from "@kyneta/sse-transport/client"
|
|
403
|
+
*
|
|
404
|
+
* const exchange = new Exchange({
|
|
405
|
+
* transports: [createSseClient({
|
|
406
|
+
* postUrl: "/sync",
|
|
407
|
+
* eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
|
|
408
|
+
* reconnect: { enabled: true },
|
|
409
|
+
* })],
|
|
410
|
+
* })
|
|
411
|
+
* ```
|
|
412
|
+
*/
|
|
487
413
|
function createSseClient(options) {
|
|
488
|
-
|
|
414
|
+
return () => new SseClientTransport(options);
|
|
489
415
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
createSseClient,
|
|
494
|
-
createSseClientProgram
|
|
495
|
-
};
|
|
416
|
+
//#endregion
|
|
417
|
+
export { DEFAULT_FRAGMENT_THRESHOLD, SseClientTransport, createSseClient, createSseClientProgram };
|
|
418
|
+
|
|
496
419
|
//# sourceMappingURL=client.js.map
|