@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/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
- encodeTextComplete,
8
- fragmentTextPayload,
9
- TextReassembler,
10
- textCodec
11
- } from "@kyneta/wire";
12
-
13
- // src/client-program.ts
14
- import { computeBackoffDelay, DEFAULT_RECONNECT } from "@kyneta/transport";
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
- const { url, jitterFn = () => Math.random() * 1e3 } = options;
17
- const reconnect = {
18
- ...DEFAULT_RECONNECT,
19
- ...options.reconnect
20
- };
21
- function tryReconnect(currentAttempt, reason, ...extraEffects) {
22
- if (!reconnect.enabled) {
23
- return [{ status: "disconnected", reason }, ...extraEffects];
24
- }
25
- if (currentAttempt >= reconnect.maxAttempts) {
26
- return [
27
- {
28
- status: "disconnected",
29
- reason: { type: "max-retries-exceeded", attempts: currentAttempt }
30
- },
31
- ...extraEffects
32
- ];
33
- }
34
- const delay = computeBackoffDelay(
35
- currentAttempt + 1,
36
- reconnect.baseDelay,
37
- reconnect.maxDelay,
38
- jitterFn()
39
- );
40
- return [
41
- {
42
- status: "reconnecting",
43
- attempt: currentAttempt + 1,
44
- nextAttemptMs: delay
45
- },
46
- ...extraEffects,
47
- { type: "start-reconnect-timer", delayMs: delay }
48
- ];
49
- }
50
- return {
51
- init: [{ status: "disconnected" }],
52
- update(msg, model) {
53
- switch (msg.type) {
54
- // -----------------------------------------------------------------
55
- // start
56
- // -----------------------------------------------------------------
57
- case "start": {
58
- if (model.status !== "disconnected") return [model];
59
- return [
60
- { status: "connecting", attempt: 1 },
61
- { type: "create-event-source", url, attempt: 1 }
62
- ];
63
- }
64
- // -----------------------------------------------------------------
65
- // event-source-opened
66
- // -----------------------------------------------------------------
67
- case "event-source-opened": {
68
- if (model.status !== "connecting") return [model];
69
- return [
70
- { status: "connected" },
71
- { type: "add-channel-and-establish" }
72
- ];
73
- }
74
- // -----------------------------------------------------------------
75
- // event-source-error
76
- // -----------------------------------------------------------------
77
- case "event-source-error": {
78
- const reason = {
79
- type: "error",
80
- error: new Error("EventSource connection error")
81
- };
82
- if (model.status === "connecting") {
83
- return tryReconnect(model.attempt, reason, {
84
- type: "close-event-source"
85
- });
86
- }
87
- if (model.status === "connected") {
88
- return tryReconnect(
89
- 0,
90
- reason,
91
- { type: "remove-channel" },
92
- { type: "close-event-source" },
93
- { type: "abort-pending-posts" }
94
- );
95
- }
96
- return [model];
97
- }
98
- // -----------------------------------------------------------------
99
- // reconnect-timer-fired
100
- // -----------------------------------------------------------------
101
- case "reconnect-timer-fired": {
102
- if (model.status !== "reconnecting") return [model];
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
- // src/client-transport.ts
137
- var DEFAULT_FRAGMENT_THRESHOLD = 6e4;
138
- var DEFAULT_POST_RETRY = {
139
- maxAttempts: 3,
140
- baseDelay: 1e3,
141
- maxDelay: 1e4
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
- #peerId;
145
- #options;
146
- // Observable program handle — created in onStart(), drives all state
147
- #handle;
148
- // Executor-local I/O state — not in the program model
149
- #eventSource;
150
- #serverChannel;
151
- #reconnectTimer;
152
- // Fragmentation
153
- #fragmentThreshold;
154
- // Inbound reassembly for fragmented SSE messages from server
155
- #reassembler;
156
- // POST retry
157
- #currentRetryAbortController;
158
- constructor(options) {
159
- super({ transportType: "sse-client" });
160
- this.#options = options;
161
- this.#fragmentThreshold = options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
162
- this.#reassembler = new TextReassembler({
163
- timeoutMs: 1e4
164
- });
165
- const program = createSseClientProgram({
166
- url: "__deferred__",
167
- reconnect: options.reconnect
168
- });
169
- this.#handle = createObservableProgram(program, (effect, dispatch) => {
170
- this.#executeEffect(effect, dispatch);
171
- });
172
- this.#setupLifecycleEvents();
173
- }
174
- // ==========================================================================
175
- // Lifecycle event forwarding
176
- // ==========================================================================
177
- /**
178
- * Subscribe to the observable handle's transitions and forward them to
179
- * the lifecycle callbacks. `wasConnectedBefore` is observer-local state,
180
- * not in the program model.
181
- */
182
- #setupLifecycleEvents() {
183
- let wasConnectedBefore = false;
184
- this.#handle.subscribeToTransitions((transition) => {
185
- this.#options.lifecycle?.onStateChange?.(transition);
186
- const { from, to } = transition;
187
- if (to.status === "disconnected" && to.reason) {
188
- this.#options.lifecycle?.onDisconnect?.(to.reason);
189
- }
190
- if (to.status === "reconnecting") {
191
- this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs);
192
- }
193
- if (wasConnectedBefore && (from.status === "reconnecting" || from.status === "connecting") && to.status === "connected") {
194
- this.#options.lifecycle?.onReconnected?.();
195
- }
196
- if (to.status === "connected") {
197
- wasConnectedBefore = true;
198
- }
199
- if (to.status === "disconnected" && to.reason?.type === "intentional") {
200
- wasConnectedBefore = false;
201
- }
202
- });
203
- }
204
- // ==========================================================================
205
- // Effect executor — interprets data effects as I/O
206
- // ==========================================================================
207
- #executeEffect(effect, dispatch) {
208
- switch (effect.type) {
209
- case "create-event-source": {
210
- this.#doCreateEventSource(dispatch);
211
- break;
212
- }
213
- case "close-event-source": {
214
- if (this.#eventSource) {
215
- this.#eventSource.onopen = null;
216
- this.#eventSource.onmessage = null;
217
- this.#eventSource.onerror = null;
218
- this.#eventSource.close();
219
- this.#eventSource = void 0;
220
- }
221
- break;
222
- }
223
- case "add-channel-and-establish": {
224
- if (this.#serverChannel) {
225
- this.removeChannel(this.#serverChannel.channelId);
226
- this.#serverChannel = void 0;
227
- }
228
- this.#serverChannel = this.addChannel();
229
- this.establishChannel(this.#serverChannel.channelId);
230
- break;
231
- }
232
- case "remove-channel": {
233
- if (this.#serverChannel) {
234
- this.removeChannel(this.#serverChannel.channelId);
235
- this.#serverChannel = void 0;
236
- }
237
- break;
238
- }
239
- case "start-reconnect-timer": {
240
- this.#reconnectTimer = setTimeout(() => {
241
- this.#reconnectTimer = void 0;
242
- dispatch({ type: "reconnect-timer-fired" });
243
- }, effect.delayMs);
244
- break;
245
- }
246
- case "cancel-reconnect-timer": {
247
- if (this.#reconnectTimer !== void 0) {
248
- clearTimeout(this.#reconnectTimer);
249
- this.#reconnectTimer = void 0;
250
- }
251
- break;
252
- }
253
- case "abort-pending-posts": {
254
- if (this.#currentRetryAbortController) {
255
- this.#currentRetryAbortController.abort();
256
- this.#currentRetryAbortController = void 0;
257
- }
258
- break;
259
- }
260
- }
261
- }
262
- /**
263
- * Create an EventSource and wire up event handlers.
264
- * The URL is resolved here (may be a function of peerId).
265
- */
266
- #doCreateEventSource(dispatch) {
267
- if (!this.#peerId) {
268
- throw new Error("Cannot connect: peerId not set");
269
- }
270
- const url = typeof this.#options.eventSourceUrl === "function" ? this.#options.eventSourceUrl(this.#peerId) : this.#options.eventSourceUrl;
271
- try {
272
- this.#eventSource = new EventSource(url);
273
- this.#eventSource.onopen = () => {
274
- dispatch({ type: "event-source-opened" });
275
- };
276
- this.#eventSource.onmessage = (event) => {
277
- this.#handleMessage(event);
278
- };
279
- this.#eventSource.onerror = () => {
280
- dispatch({ type: "event-source-error" });
281
- };
282
- } catch (_error) {
283
- dispatch({ type: "event-source-error" });
284
- }
285
- }
286
- // ==========================================================================
287
- // State observation — delegated to the observable handle
288
- // ==========================================================================
289
- /**
290
- * Get the current connection state.
291
- */
292
- getState() {
293
- return this.#handle.getState();
294
- }
295
- /**
296
- * Subscribe to state transitions.
297
- */
298
- subscribeToTransitions(listener) {
299
- return this.#handle.subscribeToTransitions(listener);
300
- }
301
- /**
302
- * Wait for a specific state.
303
- */
304
- waitForState(predicate, options) {
305
- return this.#handle.waitForState(predicate, options);
306
- }
307
- /**
308
- * Wait for a specific status.
309
- */
310
- waitForStatus(status, options) {
311
- return this.#handle.waitForStatus(status, options);
312
- }
313
- /**
314
- * Whether the client is connected and ready to send/receive.
315
- */
316
- get isConnected() {
317
- return this.#handle.getState().status === "connected";
318
- }
319
- // ==========================================================================
320
- // Transport abstract method implementations
321
- // ==========================================================================
322
- generate() {
323
- return {
324
- transportType: this.transportType,
325
- send: (msg) => {
326
- if (!this.#peerId) {
327
- return;
328
- }
329
- if (!this.#eventSource || this.#eventSource.readyState === 2) {
330
- return;
331
- }
332
- const resolvedPostUrl = typeof this.#options.postUrl === "function" ? this.#options.postUrl(this.#peerId) : this.#options.postUrl;
333
- const textFrame = encodeTextComplete(textCodec, msg);
334
- if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
335
- const payload = JSON.stringify(textCodec.encode(msg));
336
- const fragments = fragmentTextPayload(
337
- payload,
338
- this.#fragmentThreshold
339
- );
340
- for (const fragment of fragments) {
341
- void this.#sendTextWithRetry(resolvedPostUrl, fragment);
342
- }
343
- } else {
344
- void this.#sendTextWithRetry(resolvedPostUrl, textFrame);
345
- }
346
- },
347
- stop: () => {
348
- }
349
- };
350
- }
351
- async onStart() {
352
- if (!this.identity) {
353
- throw new Error(
354
- "Adapter not properly initialized \u2014 identity not available"
355
- );
356
- }
357
- this.#peerId = this.identity.peerId;
358
- this.#handle.dispatch({ type: "start" });
359
- }
360
- async onStop() {
361
- this.#reassembler.dispose();
362
- this.#handle.dispatch({ type: "stop" });
363
- }
364
- // ==========================================================================
365
- // Inbound message handling
366
- // ==========================================================================
367
- /**
368
- * Handle incoming SSE message.
369
- *
370
- * Each SSE `data:` event contains a text wire frame string.
371
- * Feed it through the TextReassembler to handle both complete
372
- * and fragmented frames.
373
- */
374
- #handleMessage(event) {
375
- if (!this.#serverChannel) {
376
- return;
377
- }
378
- const data = event.data;
379
- if (typeof data !== "string") {
380
- return;
381
- }
382
- const result = this.#reassembler.receive(data);
383
- if (result.status === "complete") {
384
- try {
385
- const parsed = JSON.parse(result.frame.content.payload);
386
- const messages = textCodec.decode(parsed);
387
- for (const msg of messages) {
388
- this.#serverChannel.onReceive(msg);
389
- }
390
- } catch (error) {
391
- console.error("Failed to decode SSE message:", error);
392
- }
393
- } else if (result.status === "error") {
394
- console.error("SSE message reassembly error:", result.error);
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
- return () => new SseClientTransport(options);
414
+ return () => new SseClientTransport(options);
489
415
  }
490
- export {
491
- DEFAULT_FRAGMENT_THRESHOLD,
492
- SseClientTransport,
493
- createSseClient,
494
- createSseClientProgram
495
- };
416
+ //#endregion
417
+ export { DEFAULT_FRAGMENT_THRESHOLD, SseClientTransport, createSseClient, createSseClientProgram };
418
+
496
419
  //# sourceMappingURL=client.js.map