@kyneta/sse-transport 1.1.0 → 1.2.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,7 +1,8 @@
1
1
  import "./chunk-7D4SUZUM.js";
2
2
 
3
3
  // src/client-transport.ts
4
- import { Transport } from "@kyneta/exchange";
4
+ import { createObservableProgram } from "@kyneta/machine";
5
+ import { Transport } from "@kyneta/transport";
5
6
  import {
6
7
  encodeTextComplete,
7
8
  fragmentTextPayload,
@@ -9,37 +10,131 @@ import {
9
10
  textCodec
10
11
  } from "@kyneta/wire";
11
12
 
12
- // src/client-state-machine.ts
13
- import { ClientStateMachine } from "@kyneta/exchange";
14
- var SSE_VALID_TRANSITIONS = {
15
- disconnected: ["connecting"],
16
- connecting: ["connected", "disconnected", "reconnecting"],
17
- connected: ["disconnected", "reconnecting"],
18
- reconnecting: ["connecting", "disconnected"]
19
- };
20
- var SseClientStateMachine = class extends ClientStateMachine {
21
- constructor() {
22
- super({
23
- initialState: { status: "disconnected" },
24
- validTransitions: SSE_VALID_TRANSITIONS
25
- });
26
- }
27
- /**
28
- * Check if the client is connected (EventSource open, channel established).
29
- */
30
- isConnected() {
31
- return this.getStatus() === "connected";
13
+ // src/client-program.ts
14
+ import { computeBackoffDelay, DEFAULT_RECONNECT } from "@kyneta/transport";
15
+ 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
+ ];
32
49
  }
33
- };
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
+ };
134
+ }
34
135
 
35
136
  // src/client-transport.ts
36
137
  var DEFAULT_FRAGMENT_THRESHOLD = 6e4;
37
- var DEFAULT_RECONNECT = {
38
- enabled: true,
39
- maxAttempts: 10,
40
- baseDelay: 1e3,
41
- maxDelay: 3e4
42
- };
43
138
  var DEFAULT_POST_RETRY = {
44
139
  maxAttempts: 3,
45
140
  baseDelay: 1e3,
@@ -47,14 +142,13 @@ var DEFAULT_POST_RETRY = {
47
142
  };
48
143
  var SseClientTransport = class extends Transport {
49
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
50
149
  #eventSource;
51
150
  #serverChannel;
52
151
  #reconnectTimer;
53
- #options;
54
- #shouldReconnect = true;
55
- #wasConnectedBefore = false;
56
- // State machine
57
- #stateMachine = new SseClientStateMachine();
58
152
  // Fragmentation
59
153
  #fragmentThreshold;
60
154
  // Inbound reassembly for fragmented SSE messages from server
@@ -68,13 +162,26 @@ var SseClientTransport = class extends Transport {
68
162
  this.#reassembler = new TextReassembler({
69
163
  timeoutMs: 1e4
70
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
+ });
71
172
  this.#setupLifecycleEvents();
72
173
  }
73
174
  // ==========================================================================
74
175
  // Lifecycle event forwarding
75
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
+ */
76
182
  #setupLifecycleEvents() {
77
- this.#stateMachine.subscribeToTransitions((transition) => {
183
+ let wasConnectedBefore = false;
184
+ this.#handle.subscribeToTransitions((transition) => {
78
185
  this.#options.lifecycle?.onStateChange?.(transition);
79
186
  const { from, to } = transition;
80
187
  if (to.status === "disconnected" && to.reason) {
@@ -83,47 +190,134 @@ var SseClientTransport = class extends Transport {
83
190
  if (to.status === "reconnecting") {
84
191
  this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs);
85
192
  }
86
- if (this.#wasConnectedBefore && (from.status === "reconnecting" || from.status === "connecting") && to.status === "connected") {
193
+ if (wasConnectedBefore && (from.status === "reconnecting" || from.status === "connecting") && to.status === "connected") {
87
194
  this.#options.lifecycle?.onReconnected?.();
88
195
  }
196
+ if (to.status === "connected") {
197
+ wasConnectedBefore = true;
198
+ }
199
+ if (to.status === "disconnected" && to.reason?.type === "intentional") {
200
+ wasConnectedBefore = false;
201
+ }
89
202
  });
90
203
  }
91
204
  // ==========================================================================
92
- // State observation API
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
93
288
  // ==========================================================================
94
289
  /**
95
- * Get the current state of the connection.
290
+ * Get the current connection state.
96
291
  */
97
292
  getState() {
98
- return this.#stateMachine.getState();
293
+ return this.#handle.getState();
99
294
  }
100
295
  /**
101
296
  * Subscribe to state transitions.
102
- * @returns Unsubscribe function
103
297
  */
104
298
  subscribeToTransitions(listener) {
105
- return this.#stateMachine.subscribeToTransitions(listener);
299
+ return this.#handle.subscribeToTransitions(listener);
106
300
  }
107
301
  /**
108
302
  * Wait for a specific state.
109
303
  */
110
304
  waitForState(predicate, options) {
111
- return this.#stateMachine.waitForState(predicate, options);
305
+ return this.#handle.waitForState(predicate, options);
112
306
  }
113
307
  /**
114
308
  * Wait for a specific status.
115
309
  */
116
310
  waitForStatus(status, options) {
117
- return this.#stateMachine.waitForStatus(status, options);
311
+ return this.#handle.waitForStatus(status, options);
118
312
  }
119
313
  /**
120
- * Check if the client is connected (EventSource open, channel established).
314
+ * Whether the client is connected and ready to send/receive.
121
315
  */
122
316
  get isConnected() {
123
- return this.#stateMachine.isConnected();
317
+ return this.#handle.getState().status === "connected";
124
318
  }
125
319
  // ==========================================================================
126
- // Adapter abstract method implementations
320
+ // Transport abstract method implementations
127
321
  // ==========================================================================
128
322
  generate() {
129
323
  return {
@@ -161,102 +355,15 @@ var SseClientTransport = class extends Transport {
161
355
  );
162
356
  }
163
357
  this.#peerId = this.identity.peerId;
164
- this.#shouldReconnect = true;
165
- this.#wasConnectedBefore = false;
166
- this.#connect();
358
+ this.#handle.dispatch({ type: "start" });
167
359
  }
168
360
  async onStop() {
169
- this.#shouldReconnect = false;
170
361
  this.#reassembler.dispose();
171
- this.#currentRetryAbortController?.abort();
172
- this.#currentRetryAbortController = void 0;
173
- this.#disconnect({ type: "intentional" });
174
- }
175
- // ==========================================================================
176
- // Connection management
177
- // ==========================================================================
178
- /**
179
- * Connect to the SSE server by creating an EventSource.
180
- */
181
- #connect() {
182
- const currentState = this.#stateMachine.getState();
183
- if (currentState.status === "connecting") {
184
- return;
185
- }
186
- if (!this.#peerId) {
187
- throw new Error("Cannot connect: peerId not set");
188
- }
189
- const attempt = currentState.status === "reconnecting" ? currentState.attempt : 1;
190
- this.#stateMachine.transition({ status: "connecting", attempt });
191
- const url = typeof this.#options.eventSourceUrl === "function" ? this.#options.eventSourceUrl(this.#peerId) : this.#options.eventSourceUrl;
192
- try {
193
- this.#eventSource = new EventSource(url);
194
- this.#eventSource.onopen = () => {
195
- this.#handleOpen();
196
- };
197
- this.#eventSource.onmessage = (event) => {
198
- this.#handleMessage(event);
199
- };
200
- this.#eventSource.onerror = () => {
201
- this.#handleError();
202
- };
203
- } catch (error) {
204
- this.#scheduleReconnect({
205
- type: "error",
206
- error: error instanceof Error ? error : new Error(String(error))
207
- });
208
- }
209
- }
210
- /**
211
- * Disconnect from the SSE server.
212
- */
213
- #disconnect(reason) {
214
- this.#clearReconnectTimer();
215
- if (this.#eventSource) {
216
- this.#eventSource.onopen = null;
217
- this.#eventSource.onmessage = null;
218
- this.#eventSource.onerror = null;
219
- this.#eventSource.close();
220
- this.#eventSource = void 0;
221
- }
222
- if (this.#serverChannel) {
223
- this.removeChannel(this.#serverChannel.channelId);
224
- this.#serverChannel = void 0;
225
- }
226
- const currentState = this.#stateMachine.getState();
227
- if (currentState.status !== "disconnected") {
228
- this.#stateMachine.transition({ status: "disconnected", reason });
229
- }
362
+ this.#handle.dispatch({ type: "stop" });
230
363
  }
231
364
  // ==========================================================================
232
- // Event handlers
365
+ // Inbound message handling
233
366
  // ==========================================================================
234
- /**
235
- * Handle EventSource open event.
236
- *
237
- * The SSE connection is usable immediately — no "ready" signal needed.
238
- * Create the channel and initiate establishment.
239
- */
240
- #handleOpen() {
241
- const currentState = this.#stateMachine.getState();
242
- if (currentState.status !== "connecting" && currentState.status !== "connected") {
243
- return;
244
- }
245
- if (currentState.status === "connecting") {
246
- this.#stateMachine.transition({ status: "connected" });
247
- }
248
- this.#wasConnectedBefore = true;
249
- if (this.#currentRetryAbortController) {
250
- this.#currentRetryAbortController.abort();
251
- this.#currentRetryAbortController = void 0;
252
- }
253
- if (this.#serverChannel) {
254
- this.removeChannel(this.#serverChannel.channelId);
255
- this.#serverChannel = void 0;
256
- }
257
- this.#serverChannel = this.addChannel();
258
- this.establishChannel(this.#serverChannel.channelId);
259
- }
260
367
  /**
261
368
  * Handle incoming SSE message.
262
369
  *
@@ -287,30 +394,6 @@ var SseClientTransport = class extends Transport {
287
394
  console.error("SSE message reassembly error:", result.error);
288
395
  }
289
396
  }
290
- /**
291
- * Handle EventSource error.
292
- *
293
- * Closes the EventSource immediately and takes over reconnection
294
- * via the state machine's backoff logic. This prevents the browser's
295
- * built-in EventSource reconnection from running.
296
- */
297
- #handleError() {
298
- if (this.#eventSource) {
299
- this.#eventSource.onopen = null;
300
- this.#eventSource.onmessage = null;
301
- this.#eventSource.onerror = null;
302
- this.#eventSource.close();
303
- this.#eventSource = void 0;
304
- }
305
- if (this.#serverChannel) {
306
- this.removeChannel(this.#serverChannel.channelId);
307
- this.#serverChannel = void 0;
308
- }
309
- this.#scheduleReconnect({
310
- type: "error",
311
- error: new Error("EventSource connection error")
312
- });
313
- }
314
397
  // ==========================================================================
315
398
  // POST sending with retry
316
399
  // ==========================================================================
@@ -400,61 +483,14 @@ var SseClientTransport = class extends Transport {
400
483
  }
401
484
  }
402
485
  }
403
- // ==========================================================================
404
- // Reconnection
405
- // ==========================================================================
406
- /**
407
- * Schedule a reconnection attempt or transition to disconnected.
408
- */
409
- #scheduleReconnect(reason) {
410
- const currentState = this.#stateMachine.getState();
411
- if (currentState.status === "disconnected") {
412
- return;
413
- }
414
- const reconnectOpts = {
415
- ...DEFAULT_RECONNECT,
416
- ...this.#options.reconnect
417
- };
418
- if (!this.#shouldReconnect || !reconnectOpts.enabled) {
419
- this.#stateMachine.transition({ status: "disconnected", reason });
420
- return;
421
- }
422
- const currentAttempt = currentState.status === "reconnecting" ? currentState.attempt : currentState.status === "connecting" ? currentState.attempt : 0;
423
- if (currentAttempt >= reconnectOpts.maxAttempts) {
424
- this.#stateMachine.transition({
425
- status: "disconnected",
426
- reason: { type: "max-retries-exceeded", attempts: currentAttempt }
427
- });
428
- return;
429
- }
430
- const nextAttempt = currentAttempt + 1;
431
- const delay = Math.min(
432
- reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1e3,
433
- reconnectOpts.maxDelay
434
- );
435
- this.#stateMachine.transition({
436
- status: "reconnecting",
437
- attempt: nextAttempt,
438
- nextAttemptMs: delay
439
- });
440
- this.#reconnectTimer = setTimeout(() => {
441
- this.#connect();
442
- }, delay);
443
- }
444
- #clearReconnectTimer() {
445
- if (this.#reconnectTimer) {
446
- clearTimeout(this.#reconnectTimer);
447
- this.#reconnectTimer = void 0;
448
- }
449
- }
450
486
  };
451
487
  function createSseClient(options) {
452
488
  return () => new SseClientTransport(options);
453
489
  }
454
490
  export {
455
491
  DEFAULT_FRAGMENT_THRESHOLD,
456
- SseClientStateMachine,
457
492
  SseClientTransport,
458
- createSseClient
493
+ createSseClient,
494
+ createSseClientProgram
459
495
  };
460
496
  //# sourceMappingURL=client.js.map