@kyneta/sse-transport 1.6.1 → 1.8.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 +5 -5
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +46 -55
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts +4 -4
- package/dist/express.js +10 -9
- package/dist/express.js.map +1 -1
- package/dist/{server-transport-DX3-TbCZ.js → server-transport-CwEVpOpu.js} +39 -52
- package/dist/server-transport-CwEVpOpu.js.map +1 -0
- package/dist/{server-transport-DXK7KMx4.d.ts → server-transport-PIK5bPSw.d.ts} +18 -24
- package/dist/server-transport-PIK5bPSw.d.ts.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +9 -11
- package/src/__tests__/client-program.test.ts +1 -1
- package/src/__tests__/client-transport.test.ts +17 -35
- package/src/__tests__/connection.test.ts +64 -143
- package/src/__tests__/sse-asymmetric.test.ts +205 -0
- package/src/client-program.ts +17 -30
- package/src/client-transport.ts +46 -87
- package/src/client.ts +0 -1
- package/src/connection.ts +52 -90
- package/src/express-router.ts +12 -11
- package/src/server-transport.ts +1 -1
- package/dist/server-transport-DX3-TbCZ.js.map +0 -1
- package/dist/server-transport-DXK7KMx4.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -180,12 +180,12 @@ await adapter.waitForStatus("connected", { timeoutMs: 5000 })
|
|
|
180
180
|
|
|
181
181
|
## Wire Format
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
Asymmetric wire encoding — text frames downstream (SSE), binary CBOR upstream (POST):
|
|
184
184
|
|
|
185
185
|
| Direction | Transport | Wire format |
|
|
186
186
|
|-----------|-----------|-------------|
|
|
187
|
-
| Client → Server | HTTP POST (`
|
|
188
|
-
| Server → Client | SSE `data:` event | Text frame (`["
|
|
187
|
+
| Client → Server | HTTP POST (`application/octet-stream`) | Binary CBOR frame |
|
|
188
|
+
| Server → Client | SSE `data:` event | Text frame (`["1c", <payload>]`) |
|
|
189
189
|
|
|
190
190
|
### Text Frames
|
|
191
191
|
|
|
@@ -316,8 +316,8 @@ connection.setSendFunction((textFrame) => {
|
|
|
316
316
|
│ │ SseServerAdapter │ │
|
|
317
317
|
│ │ ┌────────────────────────────────────────────┐ │ │
|
|
318
318
|
│ │ │ SseConnection (per peer) │ │ │
|
|
319
|
-
│ │ │ -
|
|
320
|
-
│ │ │ -
|
|
319
|
+
│ │ │ - Pipeline (asymmetric encode/decode) │ │ │
|
|
320
|
+
│ │ │ - Pipeline handles send/receive framing │ │ │
|
|
321
321
|
│ │ │ - Channel reference │ │ │
|
|
322
322
|
│ │ └────────────────────────────────────────────┘ │ │
|
|
323
323
|
│ └───────────────────────────────────────────────────┘ │
|
package/dist/client.d.ts
CHANGED
|
@@ -3,12 +3,6 @@ import { Program, StateTransition, StateTransition as StateTransition$1, Transit
|
|
|
3
3
|
import { GeneratedChannel, PeerId, ReconnectOptions, Transport, TransportFactory } from "@kyneta/transport";
|
|
4
4
|
|
|
5
5
|
//#region src/client-transport.d.ts
|
|
6
|
-
/**
|
|
7
|
-
* Default fragment threshold in characters.
|
|
8
|
-
* 60K chars provides a safety margin below typical 100KB body-parser limits,
|
|
9
|
-
* accounting for JSON overhead and potential base64 expansion.
|
|
10
|
-
*/
|
|
11
|
-
declare const DEFAULT_FRAGMENT_THRESHOLD = 60000;
|
|
12
6
|
/**
|
|
13
7
|
* Options for the SSE client adapter.
|
|
14
8
|
*/
|
|
@@ -148,8 +142,8 @@ type SseClientEffect = {
|
|
|
148
142
|
interface SseClientProgramOptions {
|
|
149
143
|
url: string;
|
|
150
144
|
reconnect?: Partial<ReconnectOptions>;
|
|
151
|
-
/**
|
|
152
|
-
|
|
145
|
+
/** Source of `[0, 1)` random values for jitter. Default: `Math.random` */
|
|
146
|
+
randomFn?: () => number;
|
|
153
147
|
}
|
|
154
148
|
/**
|
|
155
149
|
* Create the SSE client connection lifecycle program — a pure Mealy machine.
|
|
@@ -160,5 +154,5 @@ interface SseClientProgramOptions {
|
|
|
160
154
|
*/
|
|
161
155
|
declare function createSseClientProgram(options: SseClientProgramOptions): Program<SseClientMsg, SseClientState, SseClientEffect>;
|
|
162
156
|
//#endregion
|
|
163
|
-
export {
|
|
157
|
+
export { type DisconnectReason, type SseClientEffect, type SseClientLifecycleEvents, type SseClientMsg, type SseClientOptions, type SseClientProgramOptions, type SseClientState, type SseClientStateTransition, SseClientTransport, type StateTransition, type TransitionListener, createSseClient, createSseClientProgram };
|
|
164
158
|
//# sourceMappingURL=client.d.ts.map
|
package/dist/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","names":[],"sources":["../src/client-transport.ts","../src/client-program.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"client.d.ts","names":[],"sources":["../src/client-transport.ts","../src/client-program.ts"],"mappings":";;;;;AAyEA;;;AAAA,UAAiB,gBAAA;EAKoB;EAHnC,OAAA,aAAoB,MAAA,EAAQ,MAAA;EAwBQ;EArBpC,cAAA,aAA2B,MAAA,EAAQ,MAAA;EAHnC;EAMA,SAAA;IACE,OAAA;IACA,WAAA;IACA,SAAA;IACA,QAAA;EAAA;EAHA;EAOF,SAAA;IACE,WAAA;IACA,SAAA;IACA,QAAA;EAAA;EADA;EAKF,iBAAA;EAAA;EAGA,SAAA,GAAY,wBAAA;AAAA;;AAAwB;AAmBtC;KAAY,wBAAA,GAA2B,iBAAe,CAAC,cAAA;;;AAAc;AAiCrE;;;;;;;;;;;;;;;;;;;;;;;;cAAa,kBAAA,SAA2B,SAAA;EAAA;cAkB1B,OAAA,EAAS,gBAAA;EA8MT;;;EARZ,QAAA,CAAA,GAAY,cAAA;EAiBS;;;EAVrB,sBAAA,CACE,QAAA,EAAU,oBAAA,CAAmB,cAAA;EAU7B;;;EAFF,YAAA,CACE,SAAA,GAAY,KAAA,EAAO,cAAA,cACnB,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,cAAA;EASG;;;EAFd,aAAA,CACE,MAAA,EAAQ,cAAA,YACR,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,cAAA;EAeW;;;EAAA,IARlB,WAAA,CAAA;EAAA,UAQM,QAAA,CAAA,GAAY,gBAAA;EAoChB,OAAA,CAAA,GAAW,OAAA;EAUX,MAAA,CAAA,GAAU,OAAA;AAAA;;;;;;;;AAwL0D;;;;AC9mB5E;;;;;iBD8mBgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAmB,gBAAgB;;;KC9mBhE,YAAA;EACN,IAAA;AAAA;EACA,IAAA;AAAA;EACA,IAAA;AAAA;EACA,IAAA;AAAA;EACA,IAAA;AAAA;AAAA,KAMM,eAAA;EACN,IAAA;EAA6B,GAAA;EAAa,OAAA;AAAA;EAC1C,IAAA;AAAA;EACA,IAAA;AAAA;EACA,IAAA;AAAA;EACA,IAAA;EAA+B,OAAA;AAAA;EAC/B,IAAA;AAAA;EACA,IAAA;AAAA;AAAA,UAMW,uBAAA;EACf,GAAA;EACA,SAAA,GAAY,OAAO,CAAC,gBAAA;;EAEpB,QAAA;AAAA;ADwGF;;;;;;;AAAA,iBC9FgB,sBAAA,CACd,OAAA,EAAS,uBAAA,GACR,OAAA,CAAQ,YAAA,EAAc,cAAA,EAAgB,eAAA"}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createObservableProgram } from "@kyneta/machine";
|
|
2
|
-
import { DEFAULT_RECONNECT, Transport,
|
|
3
|
-
import { TEXT_WIRE_VERSION, TextReassembler, applyInboundAliasing, applyOutboundAliasing, complete, createFrameIdCounter, decodeTextWires, emptyAliasState, encodeTextFrame, encodeTextWireMessage, fragmentTextPayload } from "@kyneta/wire";
|
|
2
|
+
import { DEFAULT_RECONNECT, Pipeline, Transport, shouldReconnect } from "@kyneta/transport";
|
|
4
3
|
//#region src/client-program.ts
|
|
5
4
|
/**
|
|
6
5
|
* Create the SSE client connection lifecycle program — a pure Mealy machine.
|
|
@@ -10,7 +9,7 @@ import { TEXT_WIRE_VERSION, TextReassembler, applyInboundAliasing, applyOutbound
|
|
|
10
9
|
* shell interprets `SseClientEffect` as actual I/O.
|
|
11
10
|
*/
|
|
12
11
|
function createSseClientProgram(options) {
|
|
13
|
-
const { url,
|
|
12
|
+
const { url, randomFn = Math.random } = options;
|
|
14
13
|
const reconnect = {
|
|
15
14
|
...DEFAULT_RECONNECT,
|
|
16
15
|
...options.reconnect
|
|
@@ -18,33 +17,29 @@ function createSseClientProgram(options) {
|
|
|
18
17
|
/**
|
|
19
18
|
* Attempt to transition into reconnecting, or give up and disconnect.
|
|
20
19
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Wraps the pure `shouldReconnect` decision and builds the SSE-specific
|
|
21
|
+
* state/effect tuple. Returns a tuple suitable for spreading into an
|
|
22
|
+
* `update` return.
|
|
24
23
|
*/
|
|
25
24
|
function tryReconnect(currentAttempt, reason, ...extraEffects) {
|
|
26
|
-
|
|
25
|
+
const d = shouldReconnect(reconnect, currentAttempt, randomFn);
|
|
26
|
+
if (!d.reconnect) return [{
|
|
27
27
|
status: "disconnected",
|
|
28
|
-
reason
|
|
29
|
-
}, ...extraEffects];
|
|
30
|
-
if (currentAttempt >= reconnect.maxAttempts) return [{
|
|
31
|
-
status: "disconnected",
|
|
32
|
-
reason: {
|
|
28
|
+
reason: d.cause === "max-attempts-exceeded" ? {
|
|
33
29
|
type: "max-retries-exceeded",
|
|
34
|
-
attempts:
|
|
35
|
-
}
|
|
30
|
+
attempts: d.attempts
|
|
31
|
+
} : reason
|
|
36
32
|
}, ...extraEffects];
|
|
37
|
-
const delay = computeBackoffDelay(currentAttempt + 1, reconnect.baseDelay, reconnect.maxDelay, jitterFn());
|
|
38
33
|
return [
|
|
39
34
|
{
|
|
40
35
|
status: "reconnecting",
|
|
41
|
-
attempt:
|
|
42
|
-
nextAttemptMs:
|
|
36
|
+
attempt: d.attempt,
|
|
37
|
+
nextAttemptMs: d.delayMs
|
|
43
38
|
},
|
|
44
39
|
...extraEffects,
|
|
45
40
|
{
|
|
46
41
|
type: "start-reconnect-timer",
|
|
47
|
-
delayMs:
|
|
42
|
+
delayMs: d.delayMs
|
|
48
43
|
}
|
|
49
44
|
];
|
|
50
45
|
}
|
|
@@ -148,15 +143,20 @@ var SseClientTransport = class extends Transport {
|
|
|
148
143
|
#eventSource;
|
|
149
144
|
#serverChannel;
|
|
150
145
|
#reconnectTimer;
|
|
151
|
-
#
|
|
152
|
-
#aliasState = emptyAliasState();
|
|
153
|
-
#reassembler;
|
|
146
|
+
#pipeline;
|
|
154
147
|
#currentRetryAbortController;
|
|
155
148
|
constructor(options) {
|
|
156
149
|
super({ transportType: "sse-client" });
|
|
157
150
|
this.#options = options;
|
|
158
|
-
this.#
|
|
159
|
-
|
|
151
|
+
this.#pipeline = new Pipeline({
|
|
152
|
+
send: "binary",
|
|
153
|
+
receive: "text",
|
|
154
|
+
opts: {
|
|
155
|
+
threshold: options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD,
|
|
156
|
+
reassemblyTimeoutMs: 1e4,
|
|
157
|
+
onError: (e, dir) => console.warn(`[SseClientTransport] wire error (${dir}):`, e)
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
160
|
const program = createSseClientProgram({
|
|
161
161
|
url: "__deferred__",
|
|
162
162
|
reconnect: options.reconnect
|
|
@@ -202,7 +202,7 @@ var SseClientTransport = class extends Transport {
|
|
|
202
202
|
this.removeChannel(this.#serverChannel.channelId);
|
|
203
203
|
this.#serverChannel = void 0;
|
|
204
204
|
}
|
|
205
|
-
this.#
|
|
205
|
+
this.#pipeline.reset();
|
|
206
206
|
this.#serverChannel = this.addChannel();
|
|
207
207
|
this.establishChannel(this.#serverChannel.channelId);
|
|
208
208
|
break;
|
|
@@ -285,22 +285,17 @@ var SseClientTransport = class extends Transport {
|
|
|
285
285
|
return this.#handle.getState().status === "connected";
|
|
286
286
|
}
|
|
287
287
|
generate() {
|
|
288
|
-
|
|
289
|
-
this.#aliasState = emptyAliasState();
|
|
288
|
+
this.#pipeline.reset();
|
|
290
289
|
return {
|
|
291
290
|
transportType: this.transportType,
|
|
292
291
|
send: (msg) => {
|
|
293
292
|
if (!this.#peerId) return;
|
|
294
293
|
if (!this.#eventSource || this.#eventSource.readyState === 2) return;
|
|
295
294
|
const resolvedPostUrl = typeof this.#options.postUrl === "function" ? this.#options.postUrl(this.#peerId) : this.#options.postUrl;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
|
|
301
|
-
const fragments = fragmentTextPayload(payload, this.#fragmentThreshold, nextFrameId());
|
|
302
|
-
for (const fragment of fragments) this.#sendTextWithRetry(resolvedPostUrl, fragment);
|
|
303
|
-
} else this.#sendTextWithRetry(resolvedPostUrl, textFrame);
|
|
295
|
+
for (const r of this.#pipeline.send(msg)) {
|
|
296
|
+
if (!r.ok) continue;
|
|
297
|
+
this.#sendBinaryWithRetry(resolvedPostUrl, r.value);
|
|
298
|
+
}
|
|
304
299
|
},
|
|
305
300
|
stop: () => {}
|
|
306
301
|
};
|
|
@@ -311,36 +306,32 @@ var SseClientTransport = class extends Transport {
|
|
|
311
306
|
this.#handle.dispatch({ type: "start" });
|
|
312
307
|
}
|
|
313
308
|
async onStop() {
|
|
314
|
-
this.#
|
|
309
|
+
this.#pipeline.dispose();
|
|
315
310
|
this.#handle.dispatch({ type: "stop" });
|
|
316
311
|
}
|
|
317
312
|
/**
|
|
318
313
|
* Handle incoming SSE message.
|
|
319
314
|
*
|
|
320
315
|
* Each SSE `data:` event contains a text wire frame string.
|
|
321
|
-
* Feed it through
|
|
322
|
-
*
|
|
316
|
+
* Feed it through Pipeline<"text">.receive() to decode and
|
|
317
|
+
* resolve aliases, then deliver ChannelMsgs.
|
|
323
318
|
*/
|
|
324
319
|
#handleMessage(event) {
|
|
325
320
|
if (!this.#serverChannel) return;
|
|
326
|
-
const data = event.data;
|
|
327
|
-
if (
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
this.#serverChannel?.onReceive(result.msg);
|
|
338
|
-
}
|
|
321
|
+
const data = typeof event.data === "string" ? event.data : String(event.data);
|
|
322
|
+
if (data === "ready") return;
|
|
323
|
+
for (const r of this.#pipeline.receive(data)) if (r.ok) this.#handleChannelMessage(r.value);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Deliver a decoded ChannelMsg to the server channel.
|
|
327
|
+
*/
|
|
328
|
+
#handleChannelMessage(msg) {
|
|
329
|
+
this.#serverChannel?.onReceive(msg);
|
|
339
330
|
}
|
|
340
331
|
/**
|
|
341
|
-
* Send a
|
|
332
|
+
* Send a binary frame via POST with retry logic.
|
|
342
333
|
*/
|
|
343
|
-
async #
|
|
334
|
+
async #sendBinaryWithRetry(url, body) {
|
|
344
335
|
let attempt = 0;
|
|
345
336
|
const { maxAttempts, baseDelay, maxDelay } = {
|
|
346
337
|
...DEFAULT_POST_RETRY,
|
|
@@ -352,10 +343,10 @@ var SseClientTransport = class extends Transport {
|
|
|
352
343
|
const response = await fetch(url, {
|
|
353
344
|
method: "POST",
|
|
354
345
|
headers: {
|
|
355
|
-
"Content-Type": "
|
|
346
|
+
"Content-Type": "application/octet-stream",
|
|
356
347
|
"X-Peer-Id": this.#peerId
|
|
357
348
|
},
|
|
358
|
-
body
|
|
349
|
+
body,
|
|
359
350
|
signal: this.#currentRetryAbortController.signal
|
|
360
351
|
});
|
|
361
352
|
if (!response.ok) {
|
|
@@ -424,6 +415,6 @@ function createSseClient(options) {
|
|
|
424
415
|
return () => new SseClientTransport(options);
|
|
425
416
|
}
|
|
426
417
|
//#endregion
|
|
427
|
-
export {
|
|
418
|
+
export { SseClientTransport, createSseClient, createSseClientProgram };
|
|
428
419
|
|
|
429
420
|
//# sourceMappingURL=client.js.map
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":["#fragmentThreshold","#reassembler","#options","#handle","#executeEffect","#setupLifecycleEvents","#doCreateEventSource","#eventSource","#serverChannel","#reconnectTimer","#currentRetryAbortController","#peerId","#handleMessage","#aliasState","#sendTextWithRetry","err"],"sources":["../src/client-program.ts","../src/client-transport.ts"],"sourcesContent":["// client-program — pure Mealy machine for SSE client connection lifecycle.\n//\n// The client program encodes every state transition and effect as data.\n// The imperative shell (client-transport.ts) interprets effects as I/O.\n// Tests assert on data — no EventSource, no timing, never flaky.\n//\n// Algebra: Program<SseClientMsg, SseClientState, SseClientEffect>\n// Interpreter: client-transport.ts executeClientEffect()\n\nimport type { Program } from \"@kyneta/machine\"\nimport type { ReconnectOptions } from \"@kyneta/transport\"\nimport { computeBackoffDelay, DEFAULT_RECONNECT } from \"@kyneta/transport\"\n\nimport type { DisconnectReason, SseClientState } from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Messages\n// ---------------------------------------------------------------------------\n\nexport type SseClientMsg =\n | { type: \"start\" }\n | { type: \"event-source-opened\" }\n | { type: \"event-source-error\" }\n | { type: \"stop\" }\n | { type: \"reconnect-timer-fired\" }\n\n// ---------------------------------------------------------------------------\n// Effects (data — interpreted by the imperative shell)\n// ---------------------------------------------------------------------------\n\nexport type SseClientEffect =\n | { type: \"create-event-source\"; url: string; attempt: number }\n | { type: \"close-event-source\" }\n | { type: \"add-channel-and-establish\" }\n | { type: \"remove-channel\" }\n | { type: \"start-reconnect-timer\"; delayMs: number }\n | { type: \"cancel-reconnect-timer\" }\n | { type: \"abort-pending-posts\" }\n\n// ---------------------------------------------------------------------------\n// Program factory\n// ---------------------------------------------------------------------------\n\nexport interface SseClientProgramOptions {\n url: string\n reconnect?: Partial<ReconnectOptions>\n /** Inject jitter source for deterministic testing. Default: () => Math.random() * 1000 */\n jitterFn?: () => number\n}\n\n/**\n * Create the SSE client connection lifecycle program — a pure Mealy machine.\n *\n * The returned `Program<SseClientMsg, SseClientState, SseClientEffect>`\n * encodes every state transition and effect as inspectable data. The imperative\n * shell interprets `SseClientEffect` as actual I/O.\n */\nexport function createSseClientProgram(\n options: SseClientProgramOptions,\n): Program<SseClientMsg, SseClientState, SseClientEffect> {\n const { url, jitterFn = () => Math.random() * 1000 } = options\n const reconnect: ReconnectOptions = {\n ...DEFAULT_RECONNECT,\n ...options.reconnect,\n }\n\n /**\n * Attempt to transition into reconnecting, or give up and disconnect.\n *\n * Pure — computes the next state and effects from the current attempt\n * count and reconnect configuration. Returns a tuple suitable for\n * spreading into an `update` return.\n */\n function tryReconnect(\n currentAttempt: number,\n reason: DisconnectReason,\n ...extraEffects: SseClientEffect[]\n ): [SseClientState, ...SseClientEffect[]] {\n if (!reconnect.enabled) {\n return [{ status: \"disconnected\", reason }, ...extraEffects]\n }\n\n if (currentAttempt >= reconnect.maxAttempts) {\n return [\n {\n status: \"disconnected\",\n reason: { type: \"max-retries-exceeded\", attempts: currentAttempt },\n },\n ...extraEffects,\n ]\n }\n\n const delay = computeBackoffDelay(\n currentAttempt + 1,\n reconnect.baseDelay,\n reconnect.maxDelay,\n jitterFn(),\n )\n\n return [\n {\n status: \"reconnecting\",\n attempt: currentAttempt + 1,\n nextAttemptMs: delay,\n },\n ...extraEffects,\n { type: \"start-reconnect-timer\", delayMs: delay },\n ]\n }\n\n return {\n init: [{ status: \"disconnected\" }],\n\n update(msg, model): [SseClientState, ...SseClientEffect[]] {\n switch (msg.type) {\n // -----------------------------------------------------------------\n // start\n // -----------------------------------------------------------------\n case \"start\": {\n if (model.status !== \"disconnected\") return [model]\n return [\n { status: \"connecting\", attempt: 1 },\n { type: \"create-event-source\", url, attempt: 1 },\n ]\n }\n\n // -----------------------------------------------------------------\n // event-source-opened\n // -----------------------------------------------------------------\n case \"event-source-opened\": {\n if (model.status !== \"connecting\") return [model]\n return [\n { status: \"connected\" },\n { type: \"add-channel-and-establish\" },\n ]\n }\n\n // -----------------------------------------------------------------\n // event-source-error\n // -----------------------------------------------------------------\n case \"event-source-error\": {\n const reason: DisconnectReason = {\n type: \"error\",\n error: new Error(\"EventSource connection error\"),\n }\n\n if (model.status === \"connecting\") {\n return tryReconnect(model.attempt, reason, {\n type: \"close-event-source\",\n })\n }\n\n if (model.status === \"connected\") {\n return tryReconnect(\n 0,\n reason,\n { type: \"remove-channel\" },\n { type: \"close-event-source\" },\n { type: \"abort-pending-posts\" },\n )\n }\n\n return [model]\n }\n\n // -----------------------------------------------------------------\n // reconnect-timer-fired\n // -----------------------------------------------------------------\n case \"reconnect-timer-fired\": {\n if (model.status !== \"reconnecting\") return [model]\n return [\n { status: \"connecting\", attempt: model.attempt },\n { type: \"create-event-source\", url, attempt: model.attempt },\n ]\n }\n\n // -----------------------------------------------------------------\n // stop\n // -----------------------------------------------------------------\n case \"stop\": {\n if (model.status === \"disconnected\") return [model]\n\n const effects: SseClientEffect[] = [\n { type: \"cancel-reconnect-timer\" },\n ]\n\n if (model.status === \"connecting\") {\n effects.push({ type: \"close-event-source\" })\n }\n\n if (model.status === \"connected\") {\n effects.push(\n { type: \"close-event-source\" },\n { type: \"remove-channel\" },\n { type: \"abort-pending-posts\" },\n )\n }\n\n return [\n { status: \"disconnected\", reason: { type: \"intentional\" } },\n ...effects,\n ]\n }\n }\n },\n }\n}\n","// client-transport — SSE client transport for @kyneta/exchange.\n//\n// Thin imperative shell around the pure client program (client-program.ts).\n// The program produces data effects; this module interprets them as I/O.\n//\n// FC/IS design:\n// - client-program.ts: pure Mealy machine (functional core)\n// - client-transport.ts: effect executor (imperative shell)\n//\n// Uses two HTTP channels:\n// - EventSource (GET) for server→client messages\n// - fetch POST for client→server messages\n//\n// Both directions use the alias-aware text pipeline.\n//\n// Features:\n// - Pure Mealy machine for connection lifecycle (client-program.ts)\n// - Exponential backoff reconnection with jitter\n// - POST retry with exponential backoff\n// - Text-level fragmentation for large payloads\n// - Inbound TextReassembler for fragmented SSE messages\n// - Observable connection state via subscribeToTransitions()\n//\n// The connection handshake:\n// 1. Client creates EventSource, waits for open\n// 2. EventSource.onopen fires → client creates channel + calls establishChannel()\n// 3. Synchronizer exchanges establish messages via POST + SSE\n//\n// On EventSource.onerror, the adapter closes the EventSource immediately and\n// takes over reconnection via the program's backoff logic, rather than\n// letting the browser's built-in EventSource reconnection run.\n\nimport type {\n ObservableHandle,\n StateTransition,\n TransitionListener,\n} from \"@kyneta/machine\"\nimport { createObservableProgram } from \"@kyneta/machine\"\nimport type {\n Channel,\n ChannelMsg,\n GeneratedChannel,\n PeerId,\n TransportFactory,\n} from \"@kyneta/transport\"\nimport { Transport } from \"@kyneta/transport\"\nimport {\n type AliasState,\n applyInboundAliasing,\n applyOutboundAliasing,\n complete,\n createFrameIdCounter,\n decodeTextWires,\n emptyAliasState,\n encodeTextFrame,\n encodeTextWireMessage,\n fragmentTextPayload,\n TEXT_WIRE_VERSION,\n TextReassembler,\n} from \"@kyneta/wire\"\nimport {\n createSseClientProgram,\n type SseClientEffect,\n type SseClientMsg,\n} from \"./client-program.js\"\nimport type {\n DisconnectReason,\n SseClientLifecycleEvents,\n SseClientState,\n} from \"./types.js\"\n\n// Re-export state types for convenience\nexport type { DisconnectReason, SseClientLifecycleEvents, SseClientState }\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in characters.\n * 60K chars provides a safety margin below typical 100KB body-parser limits,\n * accounting for JSON overhead and potential base64 expansion.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 60_000\n\n/**\n * Options for the SSE client adapter.\n */\nexport interface SseClientOptions {\n /** URL for POST requests (client→server). String or function of peerId. */\n postUrl: string | ((peerId: PeerId) => string)\n\n /** URL for SSE EventSource (server→client). String or function of peerId. */\n eventSourceUrl: string | ((peerId: PeerId) => string)\n\n /** Reconnection options for EventSource. */\n reconnect?: {\n enabled?: boolean\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** POST retry options. */\n postRetry?: {\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** Fragment threshold in characters. Default: 60000 (60K chars). */\n fragmentThreshold?: number\n\n /** Lifecycle event callbacks. */\n lifecycle?: SseClientLifecycleEvents\n}\n\n/**\n * Default POST retry options.\n */\nconst DEFAULT_POST_RETRY = {\n maxAttempts: 3,\n baseDelay: 1000,\n maxDelay: 10000,\n}\n\n// ---------------------------------------------------------------------------\n// State transition type alias\n// ---------------------------------------------------------------------------\n\n/**\n * State transition event for SSE client states.\n */\nexport type SseClientStateTransition = StateTransition<SseClientState>\n\n// ---------------------------------------------------------------------------\n// SseClientTransport\n// ---------------------------------------------------------------------------\n\n/**\n * SSE client network adapter for @kyneta/exchange.\n *\n * Uses two HTTP channels:\n * - **EventSource** (GET, long-lived) for server→client messages\n * - **fetch POST** for client→server messages\n *\n * Both directions use the alias-aware text pipeline.\n *\n * Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —\n * a pure Mealy machine whose transitions are deterministically testable.\n * This class is the imperative shell that interprets data effects as I/O.\n *\n * @example\n * ```typescript\n * import { createSseClient } from \"@kyneta/sse-transport/client\"\n *\n * const exchange = new Exchange({\n * id: \"browser-client\",\n * transports: [createSseClient({\n * postUrl: \"/sync\",\n * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport class SseClientTransport extends Transport<void> {\n #peerId?: PeerId\n #options: SseClientOptions\n\n // Observable program handle — created in onStart(), drives all state\n #handle: ObservableHandle<SseClientMsg, SseClientState>\n\n // Executor-local I/O state — not in the program model\n #eventSource?: EventSource\n #serverChannel?: Channel\n #reconnectTimer?: ReturnType<typeof setTimeout>\n\n // Fragmentation\n readonly #fragmentThreshold: number\n\n // Alias-aware pipeline state — tracks alias bindings per connection\n #aliasState: AliasState = emptyAliasState()\n\n // Inbound reassembly for fragmented SSE messages from server\n readonly #reassembler: TextReassembler\n\n // POST retry\n #currentRetryAbortController?: AbortController\n\n constructor(options: SseClientOptions) {\n super({ transportType: \"sse-client\" })\n this.#options = options\n this.#fragmentThreshold =\n options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new TextReassembler({\n timeoutMs: 10_000,\n })\n\n // Create the program with a placeholder URL — the executor resolves the\n // real eventSourceUrl (which may be a function of peerId) at effect time.\n // The URL in the program is used only as a marker; the executor overrides it.\n const program = createSseClientProgram({\n url: \"__deferred__\",\n reconnect: options.reconnect,\n })\n\n this.#handle = createObservableProgram(program, (effect, dispatch) => {\n this.#executeEffect(effect, dispatch)\n })\n\n // Set up lifecycle event forwarding\n this.#setupLifecycleEvents()\n }\n\n // ==========================================================================\n // Lifecycle event forwarding\n // ==========================================================================\n\n /**\n * Subscribe to the observable handle's transitions and forward them to\n * the lifecycle callbacks. `wasConnectedBefore` is observer-local state,\n * not in the program model.\n */\n #setupLifecycleEvents(): void {\n let wasConnectedBefore = false\n\n this.#handle.subscribeToTransitions(transition => {\n // Forward to onStateChange callback\n this.#options.lifecycle?.onStateChange?.(transition)\n\n const { from, to } = transition\n\n // onDisconnect: transitioning TO disconnected\n if (to.status === \"disconnected\" && to.reason) {\n this.#options.lifecycle?.onDisconnect?.(to.reason)\n }\n\n // onReconnecting: transitioning TO reconnecting\n if (to.status === \"reconnecting\") {\n this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)\n }\n\n // onReconnected: from reconnecting/connecting TO connected (after prior connection)\n if (\n wasConnectedBefore &&\n (from.status === \"reconnecting\" || from.status === \"connecting\") &&\n to.status === \"connected\"\n ) {\n this.#options.lifecycle?.onReconnected?.()\n }\n\n // Track whether we've ever been connected\n if (to.status === \"connected\") {\n wasConnectedBefore = true\n }\n\n // Reset on intentional disconnect (stop)\n if (to.status === \"disconnected\" && to.reason?.type === \"intentional\") {\n wasConnectedBefore = false\n }\n })\n }\n\n // ==========================================================================\n // Effect executor — interprets data effects as I/O\n // ==========================================================================\n\n #executeEffect(\n effect: SseClientEffect,\n dispatch: (msg: SseClientMsg) => void,\n ): void {\n switch (effect.type) {\n case \"create-event-source\": {\n this.#doCreateEventSource(dispatch)\n break\n }\n\n case \"close-event-source\": {\n if (this.#eventSource) {\n this.#eventSource.onopen = null\n this.#eventSource.onmessage = null\n this.#eventSource.onerror = null\n this.#eventSource.close()\n this.#eventSource = undefined\n }\n break\n }\n\n case \"add-channel-and-establish\": {\n // Remove any stale channel from a previous connection\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n // Fresh reassembler for the new connection — stale fragments from\n // the old connection must not collide with new fragments.\n this.#reassembler.reset()\n\n this.#serverChannel = this.addChannel()\n\n // No \"ready\" handshake — establish immediately\n this.establishChannel(this.#serverChannel.channelId)\n break\n }\n\n case \"remove-channel\": {\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n break\n }\n\n case \"start-reconnect-timer\": {\n this.#reconnectTimer = setTimeout(() => {\n this.#reconnectTimer = undefined\n dispatch({ type: \"reconnect-timer-fired\" })\n }, effect.delayMs)\n break\n }\n\n case \"cancel-reconnect-timer\": {\n if (this.#reconnectTimer !== undefined) {\n clearTimeout(this.#reconnectTimer)\n this.#reconnectTimer = undefined\n }\n break\n }\n\n case \"abort-pending-posts\": {\n if (this.#currentRetryAbortController) {\n this.#currentRetryAbortController.abort()\n this.#currentRetryAbortController = undefined\n }\n break\n }\n }\n }\n\n /**\n * Create an EventSource and wire up event handlers.\n * The URL is resolved here (may be a function of peerId).\n */\n #doCreateEventSource(dispatch: (msg: SseClientMsg) => void): void {\n if (!this.#peerId) {\n throw new Error(\"Cannot connect: peerId not set\")\n }\n\n // Resolve URL — may be a string or function of peerId\n const url =\n typeof this.#options.eventSourceUrl === \"function\"\n ? this.#options.eventSourceUrl(this.#peerId)\n : this.#options.eventSourceUrl\n\n try {\n this.#eventSource = new EventSource(url)\n\n this.#eventSource.onopen = () => {\n dispatch({ type: \"event-source-opened\" })\n }\n\n this.#eventSource.onmessage = (event: MessageEvent) => {\n this.#handleMessage(event)\n }\n\n this.#eventSource.onerror = () => {\n dispatch({ type: \"event-source-error\" })\n }\n } catch (_error) {\n // EventSource constructor threw (e.g. invalid URL) — treat as error\n dispatch({ type: \"event-source-error\" })\n }\n }\n\n // ==========================================================================\n // State observation — delegated to the observable handle\n // ==========================================================================\n\n /**\n * Get the current connection state.\n */\n getState(): SseClientState {\n return this.#handle.getState()\n }\n\n /**\n * Subscribe to state transitions.\n */\n subscribeToTransitions(\n listener: TransitionListener<SseClientState>,\n ): () => void {\n return this.#handle.subscribeToTransitions(listener)\n }\n\n /**\n * Wait for a specific state.\n */\n waitForState(\n predicate: (state: SseClientState) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<SseClientState> {\n return this.#handle.waitForState(predicate, options)\n }\n\n /**\n * Wait for a specific status.\n */\n waitForStatus(\n status: SseClientState[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<SseClientState> {\n return this.#handle.waitForStatus(status, options)\n }\n\n /**\n * Whether the client is connected and ready to send/receive.\n */\n get isConnected(): boolean {\n return this.#handle.getState().status === \"connected\"\n }\n\n // ==========================================================================\n // Transport abstract method implementations\n // ==========================================================================\n\n protected generate(): GeneratedChannel {\n const nextFrameId = createFrameIdCounter()\n // New channel = fresh alias state; reset on (re)connect.\n this.#aliasState = emptyAliasState()\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n if (!this.#peerId) {\n return\n }\n\n // Check if EventSource is closed before sending\n // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED\n if (!this.#eventSource || this.#eventSource.readyState === 2) {\n return\n }\n\n // Resolve the postUrl with the peerId\n const resolvedPostUrl =\n typeof this.#options.postUrl === \"function\"\n ? this.#options.postUrl(this.#peerId)\n : this.#options.postUrl\n\n // Alias-aware outbound: ChannelMsg → AliasWireMessage\n const { state: nextState, wire } = applyOutboundAliasing(\n this.#aliasState,\n msg,\n )\n this.#aliasState = nextState\n\n // AliasWireMessage → JSON-safe object → JSON string\n const payload = JSON.stringify(encodeTextWireMessage(wire))\n\n // JSON string → text frame\n const textFrame = encodeTextFrame(complete(TEXT_WIRE_VERSION, payload))\n\n // Fragment large payloads\n if (\n this.#fragmentThreshold > 0 &&\n textFrame.length > this.#fragmentThreshold\n ) {\n const fragments = fragmentTextPayload(\n payload,\n this.#fragmentThreshold,\n nextFrameId(),\n )\n for (const fragment of fragments) {\n void this.#sendTextWithRetry(resolvedPostUrl, fragment)\n }\n } else {\n void this.#sendTextWithRetry(resolvedPostUrl, textFrame)\n }\n },\n stop: () => {\n // Don't call disconnect here — channel.stop() is called when\n // the channel is removed, which can happen during effect execution.\n // The actual disconnect is handled by onStop() or the program.\n },\n }\n }\n\n async onStart(): Promise<void> {\n if (!this.identity) {\n throw new Error(\n \"Adapter not properly initialized — identity not available\",\n )\n }\n this.#peerId = this.identity.peerId\n this.#handle.dispatch({ type: \"start\" })\n }\n\n async onStop(): Promise<void> {\n this.#reassembler.dispose()\n this.#handle.dispatch({ type: \"stop\" })\n }\n\n // ==========================================================================\n // Inbound message handling\n // ==========================================================================\n\n /**\n * Handle incoming SSE message.\n *\n * Each SSE `data:` event contains a text wire frame string.\n * Feed it through the TextReassembler, then through the alias-aware\n * inbound pipeline to resolve aliases and deliver ChannelMsgs.\n */\n #handleMessage(event: MessageEvent): void {\n if (!this.#serverChannel) {\n return\n }\n\n const data = event.data\n if (typeof data !== \"string\") {\n return\n }\n\n // Text wire → AliasWireMessage[] (complete batches only)\n const wires = decodeTextWires(this.#reassembler, data)\n if (!wires) {\n // Still assembling fragments, or reassembly error already logged\n return\n }\n\n for (const wire of wires) {\n const result = applyInboundAliasing(this.#aliasState, wire)\n this.#aliasState = result.state\n if (result.error || !result.msg) {\n console.warn(\"[sse-client] alias resolution failed:\", result.error)\n continue\n }\n this.#serverChannel?.onReceive(result.msg)\n }\n }\n\n // ==========================================================================\n // POST sending with retry\n // ==========================================================================\n\n /**\n * Send a text frame via POST with retry logic.\n */\n async #sendTextWithRetry(url: string, textFrame: string): Promise<void> {\n let attempt = 0\n const postRetryOpts = {\n ...DEFAULT_POST_RETRY,\n ...this.#options.postRetry,\n }\n const { maxAttempts, baseDelay, maxDelay } = postRetryOpts\n\n while (attempt < maxAttempts) {\n try {\n if (!this.#currentRetryAbortController) {\n this.#currentRetryAbortController = new AbortController()\n }\n\n if (!this.#peerId) {\n throw new Error(\"PeerId not available for retry\")\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"text/plain\",\n \"X-Peer-Id\": this.#peerId,\n },\n body: textFrame,\n signal: this.#currentRetryAbortController.signal,\n })\n\n if (!response.ok) {\n // Don't retry on client errors (4xx)\n if (response.status >= 400 && response.status < 500) {\n throw new Error(`Failed to send message: ${response.statusText}`)\n }\n throw new Error(`Server error: ${response.statusText}`)\n }\n\n // Success\n this.#currentRetryAbortController = undefined\n return\n } catch (error: unknown) {\n attempt++\n\n const err = error as Error\n\n // If aborted, stop retrying\n if (err.name === \"AbortError\") {\n throw error\n }\n\n // If controller was cleared (e.g. by abort-pending-posts effect), stop retrying\n if (!this.#currentRetryAbortController) {\n const abortError = new Error(\"Retry aborted by connection reset\")\n abortError.name = \"AbortError\"\n throw abortError\n }\n\n // If max attempts reached, log and stop — don't throw.\n // The send callback is synchronous and uses `void`, so\n // a throw here would be an unhandled promise rejection.\n if (attempt >= maxAttempts) {\n this.#currentRetryAbortController = undefined\n console.error(\n `[SseClientTransport] Failed to send message after ${attempt} attempts:`,\n (error as Error).message,\n )\n return\n }\n\n // Calculate delay with exponential backoff and jitter\n const delay = Math.min(\n baseDelay * 2 ** (attempt - 1) + Math.random() * 100,\n maxDelay,\n )\n\n // Wait for delay or abort signal\n await new Promise<void>((resolve, reject) => {\n if (this.#currentRetryAbortController?.signal.aborted) {\n const error = new Error(\"Retry aborted\")\n error.name = \"AbortError\"\n reject(error)\n return\n }\n\n const timer = setTimeout(() => {\n cleanup()\n resolve()\n }, delay)\n\n const onAbort = () => {\n clearTimeout(timer)\n cleanup()\n const error = new Error(\"Retry aborted\")\n error.name = \"AbortError\"\n reject(error)\n }\n\n const cleanup = () => {\n this.#currentRetryAbortController?.signal.removeEventListener(\n \"abort\",\n onAbort,\n )\n }\n\n this.#currentRetryAbortController?.signal.addEventListener(\n \"abort\",\n onAbort,\n )\n })\n }\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create an SSE client adapter for browser-to-server connections.\n *\n * @example\n * ```typescript\n * import { createSseClient } from \"@kyneta/sse-transport/client\"\n *\n * const exchange = new Exchange({\n * transports: [createSseClient({\n * postUrl: \"/sync\",\n * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createSseClient(options: SseClientOptions): TransportFactory {\n return () => new SseClientTransport(options)\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":["#options","#pipeline","#handle","#executeEffect","#setupLifecycleEvents","#doCreateEventSource","#eventSource","#serverChannel","#reconnectTimer","#currentRetryAbortController","#peerId","#handleMessage","#sendBinaryWithRetry","#handleChannelMessage","err"],"sources":["../src/client-program.ts","../src/client-transport.ts"],"sourcesContent":["// client-program — pure Mealy machine for SSE client connection lifecycle.\n//\n// The client program encodes every state transition and effect as data.\n// The imperative shell (client-transport.ts) interprets effects as I/O.\n// Tests assert on data — no EventSource, no timing, never flaky.\n//\n// Algebra: Program<SseClientMsg, SseClientState, SseClientEffect>\n// Interpreter: client-transport.ts executeClientEffect()\n\nimport type { Program } from \"@kyneta/machine\"\nimport type { ReconnectOptions } from \"@kyneta/transport\"\nimport { DEFAULT_RECONNECT, shouldReconnect } from \"@kyneta/transport\"\n\nimport type { DisconnectReason, SseClientState } from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// Messages\n// ---------------------------------------------------------------------------\n\nexport type SseClientMsg =\n | { type: \"start\" }\n | { type: \"event-source-opened\" }\n | { type: \"event-source-error\" }\n | { type: \"stop\" }\n | { type: \"reconnect-timer-fired\" }\n\n// ---------------------------------------------------------------------------\n// Effects (data — interpreted by the imperative shell)\n// ---------------------------------------------------------------------------\n\nexport type SseClientEffect =\n | { type: \"create-event-source\"; url: string; attempt: number }\n | { type: \"close-event-source\" }\n | { type: \"add-channel-and-establish\" }\n | { type: \"remove-channel\" }\n | { type: \"start-reconnect-timer\"; delayMs: number }\n | { type: \"cancel-reconnect-timer\" }\n | { type: \"abort-pending-posts\" }\n\n// ---------------------------------------------------------------------------\n// Program factory\n// ---------------------------------------------------------------------------\n\nexport interface SseClientProgramOptions {\n url: string\n reconnect?: Partial<ReconnectOptions>\n /** Source of `[0, 1)` random values for jitter. Default: `Math.random` */\n randomFn?: () => number\n}\n\n/**\n * Create the SSE client connection lifecycle program — a pure Mealy machine.\n *\n * The returned `Program<SseClientMsg, SseClientState, SseClientEffect>`\n * encodes every state transition and effect as inspectable data. The imperative\n * shell interprets `SseClientEffect` as actual I/O.\n */\nexport function createSseClientProgram(\n options: SseClientProgramOptions,\n): Program<SseClientMsg, SseClientState, SseClientEffect> {\n const { url, randomFn = Math.random } = options\n const reconnect: ReconnectOptions = {\n ...DEFAULT_RECONNECT,\n ...options.reconnect,\n }\n\n /**\n * Attempt to transition into reconnecting, or give up and disconnect.\n *\n * Wraps the pure `shouldReconnect` decision and builds the SSE-specific\n * state/effect tuple. Returns a tuple suitable for spreading into an\n * `update` return.\n */\n function tryReconnect(\n currentAttempt: number,\n reason: DisconnectReason,\n ...extraEffects: SseClientEffect[]\n ): [SseClientState, ...SseClientEffect[]] {\n const d = shouldReconnect(reconnect, currentAttempt, randomFn)\n if (!d.reconnect) {\n const finalReason: DisconnectReason =\n d.cause === \"max-attempts-exceeded\"\n ? { type: \"max-retries-exceeded\", attempts: d.attempts }\n : reason\n return [{ status: \"disconnected\", reason: finalReason }, ...extraEffects]\n }\n return [\n {\n status: \"reconnecting\",\n attempt: d.attempt,\n nextAttemptMs: d.delayMs,\n },\n ...extraEffects,\n { type: \"start-reconnect-timer\", delayMs: d.delayMs },\n ]\n }\n\n return {\n init: [{ status: \"disconnected\" }],\n\n update(msg, model): [SseClientState, ...SseClientEffect[]] {\n switch (msg.type) {\n // -----------------------------------------------------------------\n // start\n // -----------------------------------------------------------------\n case \"start\": {\n if (model.status !== \"disconnected\") return [model]\n return [\n { status: \"connecting\", attempt: 1 },\n { type: \"create-event-source\", url, attempt: 1 },\n ]\n }\n\n // -----------------------------------------------------------------\n // event-source-opened\n // -----------------------------------------------------------------\n case \"event-source-opened\": {\n if (model.status !== \"connecting\") return [model]\n return [\n { status: \"connected\" },\n { type: \"add-channel-and-establish\" },\n ]\n }\n\n // -----------------------------------------------------------------\n // event-source-error\n // -----------------------------------------------------------------\n case \"event-source-error\": {\n const reason: DisconnectReason = {\n type: \"error\",\n error: new Error(\"EventSource connection error\"),\n }\n\n if (model.status === \"connecting\") {\n return tryReconnect(model.attempt, reason, {\n type: \"close-event-source\",\n })\n }\n\n if (model.status === \"connected\") {\n return tryReconnect(\n 0,\n reason,\n { type: \"remove-channel\" },\n { type: \"close-event-source\" },\n { type: \"abort-pending-posts\" },\n )\n }\n\n return [model]\n }\n\n // -----------------------------------------------------------------\n // reconnect-timer-fired\n // -----------------------------------------------------------------\n case \"reconnect-timer-fired\": {\n if (model.status !== \"reconnecting\") return [model]\n return [\n { status: \"connecting\", attempt: model.attempt },\n { type: \"create-event-source\", url, attempt: model.attempt },\n ]\n }\n\n // -----------------------------------------------------------------\n // stop\n // -----------------------------------------------------------------\n case \"stop\": {\n if (model.status === \"disconnected\") return [model]\n\n const effects: SseClientEffect[] = [\n { type: \"cancel-reconnect-timer\" },\n ]\n\n if (model.status === \"connecting\") {\n effects.push({ type: \"close-event-source\" })\n }\n\n if (model.status === \"connected\") {\n effects.push(\n { type: \"close-event-source\" },\n { type: \"remove-channel\" },\n { type: \"abort-pending-posts\" },\n )\n }\n\n return [\n { status: \"disconnected\", reason: { type: \"intentional\" } },\n ...effects,\n ]\n }\n }\n },\n }\n}\n","// client-transport — SSE client transport for @kyneta/exchange.\n//\n// Thin imperative shell around the pure client program (client-program.ts).\n// The program produces data effects; this module interprets them as I/O.\n//\n// FC/IS design:\n// - client-program.ts: pure Mealy machine (functional core)\n// - client-transport.ts: effect executor (imperative shell)\n//\n// Uses two HTTP channels:\n// - EventSource (GET) for server→client messages\n// - fetch POST for client→server messages\n//\n// Both directions use the alias-aware text pipeline.\n//\n// Features:\n// - Pure Mealy machine for connection lifecycle (client-program.ts)\n// - Exponential backoff reconnection with jitter\n// - POST retry with exponential backoff\n// - Pipeline-managed fragmentation and reassembly\n// - Observable connection state via subscribeToTransitions()\n//\n// The connection handshake:\n// 1. Client creates EventSource, waits for open\n// 2. EventSource.onopen fires → client creates channel + calls establishChannel()\n// 3. Synchronizer exchanges establish messages via POST + SSE\n//\n// On EventSource.onerror, the adapter closes the EventSource immediately and\n// takes over reconnection via the program's backoff logic, rather than\n// letting the browser's built-in EventSource reconnection run.\n\nimport type {\n ObservableHandle,\n StateTransition,\n TransitionListener,\n} from \"@kyneta/machine\"\nimport { createObservableProgram } from \"@kyneta/machine\"\nimport type {\n Channel,\n ChannelMsg,\n GeneratedChannel,\n PeerId,\n TransportFactory,\n} from \"@kyneta/transport\"\nimport { Pipeline, Transport } from \"@kyneta/transport\"\nimport {\n createSseClientProgram,\n type SseClientEffect,\n type SseClientMsg,\n} from \"./client-program.js\"\nimport type {\n DisconnectReason,\n SseClientLifecycleEvents,\n SseClientState,\n} from \"./types.js\"\n\n// Re-export state types for convenience\nexport type { DisconnectReason, SseClientLifecycleEvents, SseClientState }\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in characters.\n * 60K chars provides a safety margin below typical 100KB body-parser limits,\n * accounting for JSON overhead and potential base64 expansion.\n */\nconst DEFAULT_FRAGMENT_THRESHOLD = 60_000\n\n/**\n * Options for the SSE client adapter.\n */\nexport interface SseClientOptions {\n /** URL for POST requests (client→server). String or function of peerId. */\n postUrl: string | ((peerId: PeerId) => string)\n\n /** URL for SSE EventSource (server→client). String or function of peerId. */\n eventSourceUrl: string | ((peerId: PeerId) => string)\n\n /** Reconnection options for EventSource. */\n reconnect?: {\n enabled?: boolean\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** POST retry options. */\n postRetry?: {\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** Fragment threshold in characters. Default: 60000 (60K chars). */\n fragmentThreshold?: number\n\n /** Lifecycle event callbacks. */\n lifecycle?: SseClientLifecycleEvents\n}\n\n/**\n * Default POST retry options.\n */\nconst DEFAULT_POST_RETRY = {\n maxAttempts: 3,\n baseDelay: 1000,\n maxDelay: 10000,\n}\n\n// ---------------------------------------------------------------------------\n// State transition type alias\n// ---------------------------------------------------------------------------\n\n/**\n * State transition event for SSE client states.\n */\nexport type SseClientStateTransition = StateTransition<SseClientState>\n\n// ---------------------------------------------------------------------------\n// SseClientTransport\n// ---------------------------------------------------------------------------\n\n/**\n * SSE client network adapter for @kyneta/exchange.\n *\n * Uses two HTTP channels:\n * - **EventSource** (GET, long-lived) for server→client messages\n * - **fetch POST** for client→server messages\n *\n * Both directions use the alias-aware text pipeline.\n *\n * Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —\n * a pure Mealy machine whose transitions are deterministically testable.\n * This class is the imperative shell that interprets data effects as I/O.\n *\n * @example\n * ```typescript\n * import { createSseClient } from \"@kyneta/sse-transport/client\"\n *\n * const exchange = new Exchange({\n * id: \"browser-client\",\n * transports: [createSseClient({\n * postUrl: \"/sync\",\n * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport class SseClientTransport extends Transport<void> {\n #peerId?: PeerId\n #options: SseClientOptions\n\n // Observable program handle — created in onStart(), drives all state\n #handle: ObservableHandle<SseClientMsg, SseClientState>\n\n // Executor-local I/O state — not in the program model\n #eventSource?: EventSource\n #serverChannel?: Channel\n #reconnectTimer?: ReturnType<typeof setTimeout>\n\n // Asymmetric wire pipeline: send binary (POST), receive text (SSE)\n #pipeline: Pipeline<\"binary\", \"text\">\n\n // POST retry\n #currentRetryAbortController?: AbortController\n\n constructor(options: SseClientOptions) {\n super({ transportType: \"sse-client\" })\n this.#options = options\n this.#pipeline = new Pipeline({\n send: \"binary\",\n receive: \"text\",\n opts: {\n threshold: options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD,\n reassemblyTimeoutMs: 10_000,\n onError: (e, dir) =>\n console.warn(`[SseClientTransport] wire error (${dir}):`, e),\n },\n })\n\n // Create the program with a placeholder URL — the executor resolves the\n // real eventSourceUrl (which may be a function of peerId) at effect time.\n // The URL in the program is used only as a marker; the executor overrides it.\n const program = createSseClientProgram({\n url: \"__deferred__\",\n reconnect: options.reconnect,\n })\n\n this.#handle = createObservableProgram(program, (effect, dispatch) => {\n this.#executeEffect(effect, dispatch)\n })\n\n // Set up lifecycle event forwarding\n this.#setupLifecycleEvents()\n }\n\n // ==========================================================================\n // Lifecycle event forwarding\n // ==========================================================================\n\n /**\n * Subscribe to the observable handle's transitions and forward them to\n * the lifecycle callbacks. `wasConnectedBefore` is observer-local state,\n * not in the program model.\n */\n #setupLifecycleEvents(): void {\n let wasConnectedBefore = false\n\n this.#handle.subscribeToTransitions(transition => {\n // Forward to onStateChange callback\n this.#options.lifecycle?.onStateChange?.(transition)\n\n const { from, to } = transition\n\n // onDisconnect: transitioning TO disconnected\n if (to.status === \"disconnected\" && to.reason) {\n this.#options.lifecycle?.onDisconnect?.(to.reason)\n }\n\n // onReconnecting: transitioning TO reconnecting\n if (to.status === \"reconnecting\") {\n this.#options.lifecycle?.onReconnecting?.(to.attempt, to.nextAttemptMs)\n }\n\n // onReconnected: from reconnecting/connecting TO connected (after prior connection)\n if (\n wasConnectedBefore &&\n (from.status === \"reconnecting\" || from.status === \"connecting\") &&\n to.status === \"connected\"\n ) {\n this.#options.lifecycle?.onReconnected?.()\n }\n\n // Track whether we've ever been connected\n if (to.status === \"connected\") {\n wasConnectedBefore = true\n }\n\n // Reset on intentional disconnect (stop)\n if (to.status === \"disconnected\" && to.reason?.type === \"intentional\") {\n wasConnectedBefore = false\n }\n })\n }\n\n // ==========================================================================\n // Effect executor — interprets data effects as I/O\n // ==========================================================================\n\n #executeEffect(\n effect: SseClientEffect,\n dispatch: (msg: SseClientMsg) => void,\n ): void {\n switch (effect.type) {\n case \"create-event-source\": {\n this.#doCreateEventSource(dispatch)\n break\n }\n\n case \"close-event-source\": {\n if (this.#eventSource) {\n this.#eventSource.onopen = null\n this.#eventSource.onmessage = null\n this.#eventSource.onerror = null\n this.#eventSource.close()\n this.#eventSource = undefined\n }\n break\n }\n\n case \"add-channel-and-establish\": {\n // Remove any stale channel from a previous connection\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n // Fresh pipeline for the new connection — stale fragments and alias\n // state from the old connection must not collide with the new one.\n this.#pipeline.reset()\n\n this.#serverChannel = this.addChannel()\n\n // No \"ready\" handshake — establish immediately\n this.establishChannel(this.#serverChannel.channelId)\n break\n }\n\n case \"remove-channel\": {\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n break\n }\n\n case \"start-reconnect-timer\": {\n this.#reconnectTimer = setTimeout(() => {\n this.#reconnectTimer = undefined\n dispatch({ type: \"reconnect-timer-fired\" })\n }, effect.delayMs)\n break\n }\n\n case \"cancel-reconnect-timer\": {\n if (this.#reconnectTimer !== undefined) {\n clearTimeout(this.#reconnectTimer)\n this.#reconnectTimer = undefined\n }\n break\n }\n\n case \"abort-pending-posts\": {\n if (this.#currentRetryAbortController) {\n this.#currentRetryAbortController.abort()\n this.#currentRetryAbortController = undefined\n }\n break\n }\n }\n }\n\n /**\n * Create an EventSource and wire up event handlers.\n * The URL is resolved here (may be a function of peerId).\n */\n #doCreateEventSource(dispatch: (msg: SseClientMsg) => void): void {\n if (!this.#peerId) {\n throw new Error(\"Cannot connect: peerId not set\")\n }\n\n // Resolve URL — may be a string or function of peerId\n const url =\n typeof this.#options.eventSourceUrl === \"function\"\n ? this.#options.eventSourceUrl(this.#peerId)\n : this.#options.eventSourceUrl\n\n try {\n this.#eventSource = new EventSource(url)\n\n this.#eventSource.onopen = () => {\n dispatch({ type: \"event-source-opened\" })\n }\n\n this.#eventSource.onmessage = (event: MessageEvent) => {\n this.#handleMessage(event)\n }\n\n this.#eventSource.onerror = () => {\n dispatch({ type: \"event-source-error\" })\n }\n } catch (_error) {\n // EventSource constructor threw (e.g. invalid URL) — treat as error\n dispatch({ type: \"event-source-error\" })\n }\n }\n\n // ==========================================================================\n // State observation — delegated to the observable handle\n // ==========================================================================\n\n /**\n * Get the current connection state.\n */\n getState(): SseClientState {\n return this.#handle.getState()\n }\n\n /**\n * Subscribe to state transitions.\n */\n subscribeToTransitions(\n listener: TransitionListener<SseClientState>,\n ): () => void {\n return this.#handle.subscribeToTransitions(listener)\n }\n\n /**\n * Wait for a specific state.\n */\n waitForState(\n predicate: (state: SseClientState) => boolean,\n options?: { timeoutMs?: number },\n ): Promise<SseClientState> {\n return this.#handle.waitForState(predicate, options)\n }\n\n /**\n * Wait for a specific status.\n */\n waitForStatus(\n status: SseClientState[\"status\"],\n options?: { timeoutMs?: number },\n ): Promise<SseClientState> {\n return this.#handle.waitForStatus(status, options)\n }\n\n /**\n * Whether the client is connected and ready to send/receive.\n */\n get isConnected(): boolean {\n return this.#handle.getState().status === \"connected\"\n }\n\n // ==========================================================================\n // Transport abstract method implementations\n // ==========================================================================\n\n protected generate(): GeneratedChannel {\n // Fresh pipeline state for the new channel/connection.\n this.#pipeline.reset()\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n if (!this.#peerId) {\n return\n }\n\n // Check if EventSource is closed before sending\n // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED\n if (!this.#eventSource || this.#eventSource.readyState === 2) {\n return\n }\n\n // Resolve the postUrl with the peerId\n const resolvedPostUrl =\n typeof this.#options.postUrl === \"function\"\n ? this.#options.postUrl(this.#peerId)\n : this.#options.postUrl\n\n // Pipeline<\"binary\"> send: ChannelMsg → Uint8Array frame(s)\n for (const r of this.#pipeline.send(msg)) {\n if (!r.ok) continue\n void this.#sendBinaryWithRetry(resolvedPostUrl, r.value)\n }\n },\n stop: () => {\n // Don't call disconnect here — channel.stop() is called when\n // the channel is removed, which can happen during effect execution.\n // The actual disconnect is handled by onStop() or the program.\n },\n }\n }\n\n async onStart(): Promise<void> {\n if (!this.identity) {\n throw new Error(\n \"Adapter not properly initialized — identity not available\",\n )\n }\n this.#peerId = this.identity.peerId\n this.#handle.dispatch({ type: \"start\" })\n }\n\n async onStop(): Promise<void> {\n this.#pipeline.dispose()\n this.#handle.dispatch({ type: \"stop\" })\n }\n\n // ==========================================================================\n // Inbound message handling\n // ==========================================================================\n\n /**\n * Handle incoming SSE message.\n *\n * Each SSE `data:` event contains a text wire frame string.\n * Feed it through Pipeline<\"text\">.receive() to decode and\n * resolve aliases, then deliver ChannelMsgs.\n */\n #handleMessage(event: MessageEvent): void {\n if (!this.#serverChannel) {\n return\n }\n\n const data =\n typeof event.data === \"string\" ? event.data : String(event.data)\n\n if (data === \"ready\") {\n // \"ready\" is a handshake signal, not a wire message\n return\n }\n\n for (const r of this.#pipeline.receive(data)) {\n if (r.ok) this.#handleChannelMessage(r.value)\n }\n }\n\n /**\n * Deliver a decoded ChannelMsg to the server channel.\n */\n #handleChannelMessage(msg: ChannelMsg): void {\n this.#serverChannel?.onReceive(msg)\n }\n\n // ==========================================================================\n // POST sending with retry\n // ==========================================================================\n\n /**\n * Send a binary frame via POST with retry logic.\n */\n async #sendBinaryWithRetry(\n url: string,\n body: Uint8Array<ArrayBuffer>,\n ): Promise<void> {\n let attempt = 0\n const postRetryOpts = {\n ...DEFAULT_POST_RETRY,\n ...this.#options.postRetry,\n }\n const { maxAttempts, baseDelay, maxDelay } = postRetryOpts\n\n while (attempt < maxAttempts) {\n try {\n if (!this.#currentRetryAbortController) {\n this.#currentRetryAbortController = new AbortController()\n }\n\n if (!this.#peerId) {\n throw new Error(\"PeerId not available for retry\")\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/octet-stream\",\n \"X-Peer-Id\": this.#peerId,\n },\n body,\n signal: this.#currentRetryAbortController.signal,\n })\n\n if (!response.ok) {\n // Don't retry on client errors (4xx)\n if (response.status >= 400 && response.status < 500) {\n throw new Error(`Failed to send message: ${response.statusText}`)\n }\n throw new Error(`Server error: ${response.statusText}`)\n }\n\n // Success\n this.#currentRetryAbortController = undefined\n return\n } catch (error: unknown) {\n attempt++\n\n const err = error as Error\n\n // If aborted, stop retrying\n if (err.name === \"AbortError\") {\n throw error\n }\n\n // If controller was cleared (e.g. by abort-pending-posts effect), stop retrying\n if (!this.#currentRetryAbortController) {\n const abortError = new Error(\"Retry aborted by connection reset\")\n abortError.name = \"AbortError\"\n throw abortError\n }\n\n // If max attempts reached, log and stop — don't throw.\n // The send callback is synchronous and uses `void`, so\n // a throw here would be an unhandled promise rejection.\n if (attempt >= maxAttempts) {\n this.#currentRetryAbortController = undefined\n console.error(\n `[SseClientTransport] Failed to send message after ${attempt} attempts:`,\n (error as Error).message,\n )\n return\n }\n\n // Calculate delay with exponential backoff and jitter\n const delay = Math.min(\n baseDelay * 2 ** (attempt - 1) + Math.random() * 100,\n maxDelay,\n )\n\n // Wait for delay or abort signal\n await new Promise<void>((resolve, reject) => {\n if (this.#currentRetryAbortController?.signal.aborted) {\n const error = new Error(\"Retry aborted\")\n error.name = \"AbortError\"\n reject(error)\n return\n }\n\n const timer = setTimeout(() => {\n cleanup()\n resolve()\n }, delay)\n\n const onAbort = () => {\n clearTimeout(timer)\n cleanup()\n const error = new Error(\"Retry aborted\")\n error.name = \"AbortError\"\n reject(error)\n }\n\n const cleanup = () => {\n this.#currentRetryAbortController?.signal.removeEventListener(\n \"abort\",\n onAbort,\n )\n }\n\n this.#currentRetryAbortController?.signal.addEventListener(\n \"abort\",\n onAbort,\n )\n })\n }\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory function\n// ---------------------------------------------------------------------------\n\n/**\n * Create an SSE client adapter for browser-to-server connections.\n *\n * @example\n * ```typescript\n * import { createSseClient } from \"@kyneta/sse-transport/client\"\n *\n * const exchange = new Exchange({\n * transports: [createSseClient({\n * postUrl: \"/sync\",\n * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,\n * reconnect: { enabled: true },\n * })],\n * })\n * ```\n */\nexport function createSseClient(options: SseClientOptions): TransportFactory {\n return () => new SseClientTransport(options)\n}\n"],"mappings":"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"}
|
package/dist/express.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as SseConnectionConfig, i as SseConnection, o as SsePostResponse, s as SsePostResult, t as SseServerTransport } from "./server-transport-
|
|
1
|
+
import { a as SseConnectionConfig, i as SseConnection, o as SsePostResponse, s as SsePostResult, t as SseServerTransport } from "./server-transport-PIK5bPSw.js";
|
|
2
2
|
import { PeerId } from "@kyneta/transport";
|
|
3
3
|
import { Request, Router } from "express";
|
|
4
4
|
|
|
@@ -41,9 +41,9 @@ interface SseExpressRouterOptions {
|
|
|
41
41
|
*
|
|
42
42
|
* ## Wire Format
|
|
43
43
|
*
|
|
44
|
-
* The POST endpoint accepts
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* The POST endpoint accepts application/octet-stream bodies containing binary
|
|
45
|
+
* wire frames. The SSE endpoint sends text wire frames as `data:` events.
|
|
46
|
+
* The two directions use different encodings (binary POST, text SSE).
|
|
47
47
|
*
|
|
48
48
|
* @param adapter The SseServerTransport instance
|
|
49
49
|
* @param options Configuration options for the router
|
package/dist/express.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as SseConnection, t as SseServerTransport } from "./server-transport-
|
|
1
|
+
import { r as SseConnection, t as SseServerTransport } from "./server-transport-CwEVpOpu.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
//#region \0rolldown/runtime.js
|
|
4
4
|
var __create = Object.create;
|
|
@@ -29582,9 +29582,9 @@ var import_express = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((ex
|
|
|
29582
29582
|
*
|
|
29583
29583
|
* ## Wire Format
|
|
29584
29584
|
*
|
|
29585
|
-
* The POST endpoint accepts
|
|
29586
|
-
*
|
|
29587
|
-
*
|
|
29585
|
+
* The POST endpoint accepts application/octet-stream bodies containing binary
|
|
29586
|
+
* wire frames. The SSE endpoint sends text wire frames as `data:` events.
|
|
29587
|
+
* The two directions use different encodings (binary POST, text SSE).
|
|
29588
29588
|
*
|
|
29589
29589
|
* @param adapter The SseServerTransport instance
|
|
29590
29590
|
* @param options Configuration options for the router
|
|
@@ -29613,8 +29613,8 @@ function createSseExpressRouter(adapter, options = {}) {
|
|
|
29613
29613
|
const { syncPath = "/sync", eventsPath = "/events", heartbeatInterval = 3e4, getPeerIdFromSyncRequest = (req) => req.headers["x-peer-id"], getPeerIdFromEventsRequest = (req) => req.query.peerId } = options;
|
|
29614
29614
|
const router = import_express.Router();
|
|
29615
29615
|
const heartbeats = /* @__PURE__ */ new Map();
|
|
29616
|
-
router.post(syncPath, import_express.
|
|
29617
|
-
type: "
|
|
29616
|
+
router.post(syncPath, import_express.raw({
|
|
29617
|
+
type: "application/octet-stream",
|
|
29618
29618
|
limit: "1mb"
|
|
29619
29619
|
}), (req, res) => {
|
|
29620
29620
|
const peerId = getPeerIdFromSyncRequest(req);
|
|
@@ -29627,11 +29627,12 @@ function createSseExpressRouter(adapter, options = {}) {
|
|
|
29627
29627
|
res.status(404).json({ error: "Peer not connected" });
|
|
29628
29628
|
return;
|
|
29629
29629
|
}
|
|
29630
|
-
if (
|
|
29631
|
-
res.status(400).json({ error: "Expected
|
|
29630
|
+
if (!(req.body instanceof Uint8Array)) {
|
|
29631
|
+
res.status(400).json({ error: "Expected binary body" });
|
|
29632
29632
|
return;
|
|
29633
29633
|
}
|
|
29634
|
-
const
|
|
29634
|
+
const body = req.body;
|
|
29635
|
+
const result = connection.handlePostBody(body);
|
|
29635
29636
|
if (result.type === "messages") for (const msg of result.messages) connection.receive(msg);
|
|
29636
29637
|
res.status(result.response.status).json(result.response.body);
|
|
29637
29638
|
});
|