@kyneta/sse-transport 1.4.0 → 1.5.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.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +25 -15
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts +4 -68
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +5 -72
- package/dist/express.js.map +1 -1
- package/dist/{server-transport-Dq0kBllJ.js → server-transport-C-yuOKEa.js} +75 -23
- package/dist/server-transport-C-yuOKEa.js.map +1 -0
- package/dist/{server-transport-C0bHmtVV.d.ts → server-transport-DXK7KMx4.d.ts} +61 -11
- package/dist/server-transport-DXK7KMx4.d.ts.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +11 -10
- package/src/__tests__/client-transport.test.ts +234 -0
- package/src/__tests__/connection.test.ts +170 -30
- package/src/client-transport.ts +58 -26
- package/src/connection.ts +119 -19
- package/src/express-router.ts +6 -6
- package/src/express.ts +4 -3
- package/src/server-transport.ts +4 -19
- package/dist/server-transport-C0bHmtVV.d.ts.map +0 -1
- package/dist/server-transport-Dq0kBllJ.js.map +0 -1
- package/src/__tests__/sse-handler.test.ts +0 -149
- package/src/sse-handler.ts +0 -116
package/dist/client.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ type SseClientStateTransition = StateTransition$1<SseClientState>;
|
|
|
46
46
|
* - **EventSource** (GET, long-lived) for server→client messages
|
|
47
47
|
* - **fetch POST** for client→server messages
|
|
48
48
|
*
|
|
49
|
-
* Both directions use the
|
|
49
|
+
* Both directions use the alias-aware text pipeline.
|
|
50
50
|
*
|
|
51
51
|
* Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —
|
|
52
52
|
* a pure Mealy machine whose transitions are deterministically testable.
|
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":";;;;;AAmFA;;;;;AAAA,cAAa,0BAAA;;;;UAKI,gBAAA;EA0BH;EAxBZ,OAAA,aAAoB,MAAA,EAAQ,MAAA;EAwBQ;EArBpC,cAAA,aAA2B,MAAA,EAAQ,MAAA;EAHP;EAM5B,SAAA;IACE,OAAA;IACA,WAAA;IACA,SAAA;IACA,QAAA;EAAA;EAFA;EAMF,SAAA;IACE,WAAA;IACA,SAAA;IACA,QAAA;EAAA;EAAA;EAIF,iBAAA;EAGA;EAAA,SAAA,GAAY,wBAAA;AAAA;;AAmBd;;KAAY,wBAAA,GAA2B,iBAAA,CAAgB,cAAA;;;AAiCvD;;;;;;;;;;;;;;;;;;;;;;;;;cAAa,kBAAA,SAA2B,SAAA;EAAA;cAwB1B,OAAA,EAAS,gBAAA;EAyMU;;;EAR/B,QAAA,CAAA,GAAY,cAAA;EAiBE;;;EAVd,sBAAA,CACE,QAAA,EAAU,oBAAA,CAAmB,cAAA;EAW5B;;;EAHH,YAAA,CACE,SAAA,GAAY,KAAA,EAAO,cAAA,cACnB,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,cAAA;EAST;;;EAFF,aAAA,CACE,MAAA,EAAQ,cAAA,YACR,OAAA;IAAY,SAAA;EAAA,IACX,OAAA,CAAQ,cAAA;EA4EL;;;EAAA,IArEF,WAAA,CAAA;EAAA,UAQM,QAAA,CAAA,GAAY,gBAAA;EA6DhB,OAAA,CAAA,GAAW,OAAA;EAUX,MAAA,CAAA,GAAU,OAAA;AAAA;;;;;;;;;;;AC/dlB;;;;;;iBDupBgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAmB,gBAAA;;;KCvpBhD,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,OAAA,CAAQ,gBAAA;EDqER;ECnEZ,QAAA;AAAA;ADsFF;;;;;AAiCA;;AAjCA,iBC5EgB,sBAAA,CACd,OAAA,EAAS,uBAAA,GACR,OAAA,CAAQ,YAAA,EAAc,cAAA,EAAgB,eAAA"}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createObservableProgram } from "@kyneta/machine";
|
|
2
2
|
import { DEFAULT_RECONNECT, Transport, computeBackoffDelay } from "@kyneta/transport";
|
|
3
|
-
import { TextReassembler,
|
|
3
|
+
import { TEXT_WIRE_VERSION, TextReassembler, applyInboundAliasing, applyOutboundAliasing, complete, createFrameIdCounter, decodeTextWires, emptyAliasState, encodeTextFrame, encodeTextWireMessage, fragmentTextPayload } from "@kyneta/wire";
|
|
4
4
|
//#region src/client-program.ts
|
|
5
5
|
/**
|
|
6
6
|
* Create the SSE client connection lifecycle program — a pure Mealy machine.
|
|
@@ -121,7 +121,7 @@ const DEFAULT_POST_RETRY = {
|
|
|
121
121
|
* - **EventSource** (GET, long-lived) for server→client messages
|
|
122
122
|
* - **fetch POST** for client→server messages
|
|
123
123
|
*
|
|
124
|
-
* Both directions use the
|
|
124
|
+
* Both directions use the alias-aware text pipeline.
|
|
125
125
|
*
|
|
126
126
|
* Internally, the connection lifecycle is a `Program<Msg, Model, Fx>` —
|
|
127
127
|
* a pure Mealy machine whose transitions are deterministically testable.
|
|
@@ -149,6 +149,7 @@ var SseClientTransport = class extends Transport {
|
|
|
149
149
|
#serverChannel;
|
|
150
150
|
#reconnectTimer;
|
|
151
151
|
#fragmentThreshold;
|
|
152
|
+
#aliasState = emptyAliasState();
|
|
152
153
|
#reassembler;
|
|
153
154
|
#currentRetryAbortController;
|
|
154
155
|
constructor(options) {
|
|
@@ -201,6 +202,7 @@ var SseClientTransport = class extends Transport {
|
|
|
201
202
|
this.removeChannel(this.#serverChannel.channelId);
|
|
202
203
|
this.#serverChannel = void 0;
|
|
203
204
|
}
|
|
205
|
+
this.#reassembler.reset();
|
|
204
206
|
this.#serverChannel = this.addChannel();
|
|
205
207
|
this.establishChannel(this.#serverChannel.channelId);
|
|
206
208
|
break;
|
|
@@ -283,15 +285,20 @@ var SseClientTransport = class extends Transport {
|
|
|
283
285
|
return this.#handle.getState().status === "connected";
|
|
284
286
|
}
|
|
285
287
|
generate() {
|
|
288
|
+
const nextFrameId = createFrameIdCounter();
|
|
289
|
+
this.#aliasState = emptyAliasState();
|
|
286
290
|
return {
|
|
287
291
|
transportType: this.transportType,
|
|
288
292
|
send: (msg) => {
|
|
289
293
|
if (!this.#peerId) return;
|
|
290
294
|
if (!this.#eventSource || this.#eventSource.readyState === 2) return;
|
|
291
295
|
const resolvedPostUrl = typeof this.#options.postUrl === "function" ? this.#options.postUrl(this.#peerId) : this.#options.postUrl;
|
|
292
|
-
const
|
|
296
|
+
const { state: nextState, wire } = applyOutboundAliasing(this.#aliasState, msg);
|
|
297
|
+
this.#aliasState = nextState;
|
|
298
|
+
const payload = JSON.stringify(encodeTextWireMessage(wire));
|
|
299
|
+
const textFrame = encodeTextFrame(complete(TEXT_WIRE_VERSION, payload));
|
|
293
300
|
if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
|
|
294
|
-
const fragments = fragmentTextPayload(
|
|
301
|
+
const fragments = fragmentTextPayload(payload, this.#fragmentThreshold, nextFrameId());
|
|
295
302
|
for (const fragment of fragments) this.#sendTextWithRetry(resolvedPostUrl, fragment);
|
|
296
303
|
} else this.#sendTextWithRetry(resolvedPostUrl, textFrame);
|
|
297
304
|
},
|
|
@@ -311,22 +318,24 @@ var SseClientTransport = class extends Transport {
|
|
|
311
318
|
* Handle incoming SSE message.
|
|
312
319
|
*
|
|
313
320
|
* Each SSE `data:` event contains a text wire frame string.
|
|
314
|
-
* Feed it through the TextReassembler
|
|
315
|
-
* and
|
|
321
|
+
* Feed it through the TextReassembler, then through the alias-aware
|
|
322
|
+
* inbound pipeline to resolve aliases and deliver ChannelMsgs.
|
|
316
323
|
*/
|
|
317
324
|
#handleMessage(event) {
|
|
318
325
|
if (!this.#serverChannel) return;
|
|
319
326
|
const data = event.data;
|
|
320
327
|
if (typeof data !== "string") return;
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
+
const wires = decodeTextWires(this.#reassembler, data);
|
|
329
|
+
if (!wires) return;
|
|
330
|
+
for (const wire of wires) {
|
|
331
|
+
const result = applyInboundAliasing(this.#aliasState, wire);
|
|
332
|
+
this.#aliasState = result.state;
|
|
333
|
+
if (result.error || !result.msg) {
|
|
334
|
+
console.warn("[sse-client] alias resolution failed:", result.error);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
this.#serverChannel?.onReceive(result.msg);
|
|
328
338
|
}
|
|
329
|
-
else if (result.status === "error") console.error("SSE message reassembly error:", result.error);
|
|
330
339
|
}
|
|
331
340
|
/**
|
|
332
341
|
* Send a text frame via POST with retry logic.
|
|
@@ -365,7 +374,8 @@ var SseClientTransport = class extends Transport {
|
|
|
365
374
|
}
|
|
366
375
|
if (attempt >= maxAttempts) {
|
|
367
376
|
this.#currentRetryAbortController = void 0;
|
|
368
|
-
|
|
377
|
+
console.error(`[SseClientTransport] Failed to send message after ${attempt} attempts:`, error.message);
|
|
378
|
+
return;
|
|
369
379
|
}
|
|
370
380
|
const delay = Math.min(baseDelay * 2 ** (attempt - 1) + Math.random() * 100, maxDelay);
|
|
371
381
|
await new Promise((resolve, reject) => {
|
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","#sendTextWithRetry"],"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 text wire format (textCodec + text framing).\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 encodeTextComplete,\n fragmentTextPayload,\n TextReassembler,\n textCodec,\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 text wire format (`textCodec` + text framing).\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 // 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 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 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 // Encode to text wire format\n const textFrame = encodeTextComplete(textCodec, msg)\n\n // Fragment large payloads\n if (\n this.#fragmentThreshold > 0 &&\n textFrame.length > this.#fragmentThreshold\n ) {\n const payload = JSON.stringify(textCodec.encode(msg))\n const fragments = fragmentTextPayload(\n payload,\n this.#fragmentThreshold,\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 to handle both complete\n * and fragmented frames.\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 // Feed through reassembler (handles both complete and fragment frames)\n const result = this.#reassembler.receive(data)\n\n if (result.status === \"complete\") {\n try {\n // Two-step decode: Frame<string> → JSON.parse → textCodec.decode\n const parsed = JSON.parse(result.frame.content.payload)\n const messages = textCodec.decode(parsed)\n for (const msg of messages) {\n this.#serverChannel.onReceive(msg)\n }\n } catch (error) {\n console.error(\"Failed to decode SSE message:\", error)\n }\n } else if (result.status === \"error\") {\n console.error(\"SSE message reassembly error:\", result.error)\n }\n // \"pending\" status means we're waiting for more fragments — nothing to do\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, throw the last error\n if (attempt >= maxAttempts) {\n this.#currentRetryAbortController = undefined\n throw error\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":["#fragmentThreshold","#reassembler","#options","#handle","#executeEffect","#setupLifecycleEvents","#doCreateEventSource","#eventSource","#serverChannel","#reconnectTimer","#currentRetryAbortController","#peerId","#handleMessage","#aliasState","#sendTextWithRetry"],"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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"}
|
package/dist/express.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { t as SseServerTransport } from "./server-transport-
|
|
2
|
-
import {
|
|
3
|
-
import { TextReassembler } from "@kyneta/wire";
|
|
1
|
+
import { a as SseConnectionConfig, i as SseConnection, o as SsePostResponse, s as SsePostResult, t as SseServerTransport } from "./server-transport-DXK7KMx4.js";
|
|
2
|
+
import { PeerId } from "@kyneta/transport";
|
|
4
3
|
import { Request, Router } from "express";
|
|
5
4
|
|
|
6
5
|
//#region src/express-router.d.ts
|
|
@@ -43,7 +42,7 @@ interface SseExpressRouterOptions {
|
|
|
43
42
|
* ## Wire Format
|
|
44
43
|
*
|
|
45
44
|
* The POST endpoint accepts text/plain bodies containing text wire frames
|
|
46
|
-
* (JSON arrays with "
|
|
45
|
+
* (JSON arrays with "1c"/"1f" prefix). The SSE endpoint sends text wire
|
|
47
46
|
* frames as `data:` events. Both directions use the same encoding.
|
|
48
47
|
*
|
|
49
48
|
* @param adapter The SseServerTransport instance
|
|
@@ -71,68 +70,5 @@ interface SseExpressRouterOptions {
|
|
|
71
70
|
*/
|
|
72
71
|
declare function createSseExpressRouter(adapter: SseServerTransport, options?: SseExpressRouterOptions): Router;
|
|
73
72
|
//#endregion
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Response to send back to the client after processing a POST.
|
|
77
|
-
*/
|
|
78
|
-
interface SsePostResponse {
|
|
79
|
-
status: 200 | 202 | 400;
|
|
80
|
-
body: {
|
|
81
|
-
ok: true;
|
|
82
|
-
} | {
|
|
83
|
-
pending: true;
|
|
84
|
-
} | {
|
|
85
|
-
error: string;
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Result of parsing a text POST body.
|
|
90
|
-
*
|
|
91
|
-
* Discriminated union describing what happened:
|
|
92
|
-
* - "messages": Complete message(s) decoded, ready to deliver
|
|
93
|
-
* - "pending": Fragment received, waiting for more
|
|
94
|
-
* - "error": Decode/reassembly error
|
|
95
|
-
*/
|
|
96
|
-
type SsePostResult = {
|
|
97
|
-
type: "messages";
|
|
98
|
-
messages: ChannelMsg[];
|
|
99
|
-
response: SsePostResponse;
|
|
100
|
-
} | {
|
|
101
|
-
type: "pending";
|
|
102
|
-
response: SsePostResponse;
|
|
103
|
-
} | {
|
|
104
|
-
type: "error";
|
|
105
|
-
response: SsePostResponse;
|
|
106
|
-
};
|
|
107
|
-
/**
|
|
108
|
-
* Parse a text POST body through the reassembler.
|
|
109
|
-
*
|
|
110
|
-
* This is the functional core of POST handling. It:
|
|
111
|
-
* 1. Passes the body through the reassembler (handles fragmentation)
|
|
112
|
-
* 2. If complete, decodes the text frame payload to ChannelMsg(s)
|
|
113
|
-
* 3. Returns a result describing what happened
|
|
114
|
-
*
|
|
115
|
-
* The caller (framework adapter) executes side effects based on the result.
|
|
116
|
-
*
|
|
117
|
-
* @param reassembler - The connection's text fragment reassembler
|
|
118
|
-
* @param body - Text wire frame string (JSON array with "0c"/"0f" prefix)
|
|
119
|
-
* @returns Result describing what to do
|
|
120
|
-
*
|
|
121
|
-
* @example
|
|
122
|
-
* ```typescript
|
|
123
|
-
* // In Express router (imperative shell)
|
|
124
|
-
* const result = parseTextPostBody(connection.reassembler, req.body)
|
|
125
|
-
*
|
|
126
|
-
* if (result.type === "messages") {
|
|
127
|
-
* for (const msg of result.messages) {
|
|
128
|
-
* connection.receive(msg)
|
|
129
|
-
* }
|
|
130
|
-
* }
|
|
131
|
-
*
|
|
132
|
-
* res.status(result.response.status).json(result.response.body)
|
|
133
|
-
* ```
|
|
134
|
-
*/
|
|
135
|
-
declare function parseTextPostBody(reassembler: TextReassembler, body: string): SsePostResult;
|
|
136
|
-
//#endregion
|
|
137
|
-
export { type SseExpressRouterOptions, type SsePostResponse, type SsePostResult, SseServerTransport, createSseExpressRouter, parseTextPostBody };
|
|
73
|
+
export { SseConnection, type SseConnectionConfig, type SseExpressRouterOptions, type SsePostResponse, type SsePostResult, SseServerTransport, createSseExpressRouter };
|
|
138
74
|
//# sourceMappingURL=express.d.ts.map
|
package/dist/express.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"express.d.ts","names":[],"sources":["../src/express-router.ts"
|
|
1
|
+
{"version":3,"file":"express.d.ts","names":[],"sources":["../src/express-router.ts"],"mappings":";;;;;UAqBiB,uBAAA;;AAAjB;;;EAKE,QAAA;EAkB6C;;;;EAZ7C,UAAA;EANA;;;;EAYA,iBAAA;EAM4B;;;;EAA5B,wBAAA,IAA4B,GAAA,EAAK,OAAA,KAAY,MAAA;EAME;;;AA6CjD;EA7CE,0BAAA,IAA8B,GAAA,EAAK,OAAA,KAAY,MAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6CjC,sBAAA,CACd,OAAA,EAAS,kBAAA,EACT,OAAA,GAAS,uBAAA,GACR,MAAA"}
|
package/dist/express.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { t as SseServerTransport } from "./server-transport-
|
|
1
|
+
import { r as SseConnection, t as SseServerTransport } from "./server-transport-C-yuOKEa.js";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
import { textCodec } from "@kyneta/wire";
|
|
4
3
|
//#region \0rolldown/runtime.js
|
|
5
4
|
var __create = Object.create;
|
|
6
5
|
var __defProp = Object.defineProperty;
|
|
@@ -29561,77 +29560,11 @@ var require_express$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
|
29561
29560
|
});
|
|
29562
29561
|
}));
|
|
29563
29562
|
//#endregion
|
|
29564
|
-
//#region src/
|
|
29563
|
+
//#region src/express-router.ts
|
|
29565
29564
|
var import_express = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
29566
29565
|
module.exports = require_express$1();
|
|
29567
29566
|
})))(), 1);
|
|
29568
29567
|
/**
|
|
29569
|
-
* Parse a text POST body through the reassembler.
|
|
29570
|
-
*
|
|
29571
|
-
* This is the functional core of POST handling. It:
|
|
29572
|
-
* 1. Passes the body through the reassembler (handles fragmentation)
|
|
29573
|
-
* 2. If complete, decodes the text frame payload to ChannelMsg(s)
|
|
29574
|
-
* 3. Returns a result describing what happened
|
|
29575
|
-
*
|
|
29576
|
-
* The caller (framework adapter) executes side effects based on the result.
|
|
29577
|
-
*
|
|
29578
|
-
* @param reassembler - The connection's text fragment reassembler
|
|
29579
|
-
* @param body - Text wire frame string (JSON array with "0c"/"0f" prefix)
|
|
29580
|
-
* @returns Result describing what to do
|
|
29581
|
-
*
|
|
29582
|
-
* @example
|
|
29583
|
-
* ```typescript
|
|
29584
|
-
* // In Express router (imperative shell)
|
|
29585
|
-
* const result = parseTextPostBody(connection.reassembler, req.body)
|
|
29586
|
-
*
|
|
29587
|
-
* if (result.type === "messages") {
|
|
29588
|
-
* for (const msg of result.messages) {
|
|
29589
|
-
* connection.receive(msg)
|
|
29590
|
-
* }
|
|
29591
|
-
* }
|
|
29592
|
-
*
|
|
29593
|
-
* res.status(result.response.status).json(result.response.body)
|
|
29594
|
-
* ```
|
|
29595
|
-
*/
|
|
29596
|
-
function parseTextPostBody(reassembler, body) {
|
|
29597
|
-
const result = reassembler.receive(body);
|
|
29598
|
-
if (result.status === "complete") try {
|
|
29599
|
-
const parsed = JSON.parse(result.frame.content.payload);
|
|
29600
|
-
return {
|
|
29601
|
-
type: "messages",
|
|
29602
|
-
messages: textCodec.decode(parsed),
|
|
29603
|
-
response: {
|
|
29604
|
-
status: 200,
|
|
29605
|
-
body: { ok: true }
|
|
29606
|
-
}
|
|
29607
|
-
};
|
|
29608
|
-
} catch (err) {
|
|
29609
|
-
return {
|
|
29610
|
-
type: "error",
|
|
29611
|
-
response: {
|
|
29612
|
-
status: 400,
|
|
29613
|
-
body: { error: err instanceof Error ? err.message : "decode_failed" }
|
|
29614
|
-
}
|
|
29615
|
-
};
|
|
29616
|
-
}
|
|
29617
|
-
else if (result.status === "pending") return {
|
|
29618
|
-
type: "pending",
|
|
29619
|
-
response: {
|
|
29620
|
-
status: 202,
|
|
29621
|
-
body: { pending: true }
|
|
29622
|
-
}
|
|
29623
|
-
};
|
|
29624
|
-
else return {
|
|
29625
|
-
type: "error",
|
|
29626
|
-
response: {
|
|
29627
|
-
status: 400,
|
|
29628
|
-
body: { error: result.error.type }
|
|
29629
|
-
}
|
|
29630
|
-
};
|
|
29631
|
-
}
|
|
29632
|
-
//#endregion
|
|
29633
|
-
//#region src/express-router.ts
|
|
29634
|
-
/**
|
|
29635
29568
|
* Create an Express router for SSE server adapter.
|
|
29636
29569
|
*
|
|
29637
29570
|
* This factory function creates Express routes that integrate with the
|
|
@@ -29643,7 +29576,7 @@ function parseTextPostBody(reassembler, body) {
|
|
|
29643
29576
|
* ## Wire Format
|
|
29644
29577
|
*
|
|
29645
29578
|
* The POST endpoint accepts text/plain bodies containing text wire frames
|
|
29646
|
-
* (JSON arrays with "
|
|
29579
|
+
* (JSON arrays with "1c"/"1f" prefix). The SSE endpoint sends text wire
|
|
29647
29580
|
* frames as `data:` events. Both directions use the same encoding.
|
|
29648
29581
|
*
|
|
29649
29582
|
* @param adapter The SseServerTransport instance
|
|
@@ -29691,7 +29624,7 @@ function createSseExpressRouter(adapter, options = {}) {
|
|
|
29691
29624
|
res.status(400).json({ error: "Expected text body" });
|
|
29692
29625
|
return;
|
|
29693
29626
|
}
|
|
29694
|
-
const result =
|
|
29627
|
+
const result = connection.handlePostBody(req.body);
|
|
29695
29628
|
if (result.type === "messages") for (const msg of result.messages) connection.receive(msg);
|
|
29696
29629
|
res.status(result.response.status).json(result.response.body);
|
|
29697
29630
|
});
|
|
@@ -29743,6 +29676,6 @@ function createSseExpressRouter(adapter, options = {}) {
|
|
|
29743
29676
|
return router;
|
|
29744
29677
|
}
|
|
29745
29678
|
//#endregion
|
|
29746
|
-
export { SseServerTransport, createSseExpressRouter
|
|
29679
|
+
export { SseConnection, SseServerTransport, createSseExpressRouter };
|
|
29747
29680
|
|
|
29748
29681
|
//# sourceMappingURL=express.js.map
|