@kyneta/sse-transport 1.8.0 → 2.0.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 CHANGED
@@ -185,18 +185,18 @@ Asymmetric wire encoding — text frames downstream (SSE), binary CBOR upstream
185
185
  | Direction | Transport | Wire format |
186
186
  |-----------|-----------|-------------|
187
187
  | Client → Server | HTTP POST (`application/octet-stream`) | Binary CBOR frame |
188
- | Server → Client | SSE `data:` event | Text frame (`["1c", <payload>]`) |
188
+ | Server → Client | SSE `data:` event | Text frame (`["2c", seq, <payload>]`) |
189
189
 
190
190
  ### Text Frames
191
191
 
192
- Every message is wrapped in a text frame — a JSON array with a 2-character prefix:
192
+ Every message is wrapped in a text frame — a JSON array with a 2-character prefix followed by the per-direction `seq`:
193
193
 
194
194
  ```/dev/null/text-frame-example.txt#L1-5
195
- Complete frame: ["0c", {"type":"present","docs":[{"docId":"doc-1"}]}]
196
- Fragment frame: ["0f", "a1b2c3d4", 0, 3, 1500, "{\"type\":\"offer\"..."]
195
+ Complete frame: ["2c", 42, {"type":"present","docs":[{"docId":"doc-1"}]}]
196
+ Fragment frame: ["2f", 42, 0, 3, 1500, "{\"type\":\"offer\"..."]
197
197
  ```
198
198
 
199
- The `"0c"` prefix means "version 0, complete, no hash". Fragments use `"0f"` and carry `frameId`, `index`, `total`, `totalSize`, and a JSON substring chunk.
199
+ The `"2c"` prefix means "version 2, complete, no hash". `seq` is the per-direction message id (it groups a message's fragments). Fragments use `"2f"` and carry `seq`, `index`, `total`, `totalSize`, and a JSON substring chunk.
200
200
 
201
201
  ### Why Text Instead of Binary?
202
202
 
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { n as SseClientLifecycleEvents, r as SseClientState, t as DisconnectReason } from "./types-Bg1SdZ2w.js";
2
2
  import { Program, StateTransition, StateTransition as StateTransition$1, TransitionListener, TransitionListener as TransitionListener$1 } from "@kyneta/machine";
3
- import { GeneratedChannel, PeerId, ReconnectOptions, Transport, TransportFactory } from "@kyneta/transport";
3
+ import { GeneratedChannel, PeerId, ReconnectOptions, Transport } from "@kyneta/transport";
4
4
 
5
5
  //#region src/client-transport.d.ts
6
6
  /**
@@ -107,7 +107,7 @@ declare class SseClientTransport extends Transport<void> {
107
107
  * })
108
108
  * ```
109
109
  */
110
- declare function createSseClient(options: SseClientOptions): TransportFactory;
110
+ declare function createSseClient(options: SseClientOptions): SseClientTransport;
111
111
  //#endregion
112
112
  //#region src/client-program.d.ts
113
113
  type SseClientMsg = {
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"client.d.ts","names":[],"sources":["../src/client-transport.ts","../src/client-program.ts"],"mappings":";;;;;AAwEA;;;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;EAgNT;;;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;;;;;;;;AAwL4D;;;;AC/mB9E;;;;;iBD+mBgB,eAAA,CAAgB,OAAA,EAAS,gBAAA,GAAmB,kBAAkB;;;KC/mBlE,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;ADuGF;;;;;;;AAAA,iBC7FgB,sBAAA,CACd,OAAA,EAAS,uBAAA,GACR,OAAA,CAAQ,YAAA,EAAc,cAAA,EAAgB,eAAA"}
package/dist/client.js CHANGED
@@ -154,7 +154,8 @@ var SseClientTransport = class extends Transport {
154
154
  opts: {
155
155
  threshold: options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD,
156
156
  reassemblyTimeoutMs: 1e4,
157
- onError: (e, dir) => console.warn(`[SseClientTransport] wire error (${dir}):`, e)
157
+ onError: (e, dir) => console.warn(`[SseClientTransport] wire error (${dir}):`, e),
158
+ onFrame: (ev) => this.frameObserver?.(ev)
158
159
  }
159
160
  });
160
161
  const program = createSseClientProgram({
@@ -412,7 +413,7 @@ var SseClientTransport = class extends Transport {
412
413
  * ```
413
414
  */
414
415
  function createSseClient(options) {
415
- return () => new SseClientTransport(options);
416
+ return new SseClientTransport(options);
416
417
  }
417
418
  //#endregion
418
419
  export { SseClientTransport, createSseClient, createSseClientProgram };
@@ -1 +1 @@
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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"}
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} 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 // Lazy: pipeline built in the constructor (pre-`_initialize`).\n onFrame: ev => this.frameObserver?.(ev),\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): SseClientTransport {\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-PIK5bPSw.js";
1
+ import { a as SseConnectionConfig, i as SseConnection, o as SsePostResponse, s as SsePostResult, t as SseServerTransport } from "./server-transport-BrVOGl9F.js";
2
2
  import { PeerId } from "@kyneta/transport";
3
3
  import { Request, Router } from "express";
4
4
 
package/dist/express.js CHANGED
@@ -1,4 +1,4 @@
1
- import { r as SseConnection, t as SseServerTransport } from "./server-transport-CwEVpOpu.js";
1
+ import { r as SseConnection, t as SseServerTransport } from "./server-transport-CXfoHfvJ.js";
2
2
  import { createRequire } from "node:module";
3
3
  //#region \0rolldown/runtime.js
4
4
  var __create = Object.create;
@@ -1,4 +1,4 @@
1
- import { Channel, ChannelMsg, GeneratedChannel, PeerId, Transport } from "@kyneta/transport";
1
+ import { Channel, ChannelMsg, FrameTrace, GeneratedChannel, PeerId, Transport } from "@kyneta/transport";
2
2
 
3
3
  //#region src/connection.d.ts
4
4
  /**
@@ -48,6 +48,8 @@ interface SseConnectionConfig {
48
48
  * Default: 60000 (60K chars)
49
49
  */
50
50
  fragmentThreshold?: number;
51
+ /** Optional DevTools per-frame trace hook, threaded into the Pipeline. */
52
+ onFrame?: (ev: FrameTrace) => void;
51
53
  }
52
54
  /**
53
55
  * Represents a single SSE connection to a peer (server-side).
@@ -224,4 +226,4 @@ declare class SseServerTransport extends Transport<PeerId> {
224
226
  }
225
227
  //#endregion
226
228
  export { SseConnectionConfig as a, SseConnection as i, SseServerTransportOptions as n, SsePostResponse as o, DEFAULT_FRAGMENT_THRESHOLD as r, SsePostResult as s, SseServerTransport as t };
227
- //# sourceMappingURL=server-transport-PIK5bPSw.d.ts.map
229
+ //# sourceMappingURL=server-transport-BrVOGl9F.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-transport-BrVOGl9F.d.ts","names":[],"sources":["../src/connection.ts","../src/server-transport.ts"],"mappings":";;;;AA4BA;;UAAiB,eAAA;EACf,MAAA;EACA,IAAA;IAAQ,EAAA;EAAA;IAAe,OAAA;EAAA;IAAoB,KAAA;EAAA;AAAA;;;;;;;;;KAWjC,aAAA;EACN,IAAA;EAAkB,QAAA,EAAU,UAAA;EAAc,QAAA,EAAU,eAAA;AAAA;EACpD,IAAA;EAAiB,QAAA,EAAU,eAAA;AAAA;EAC3B,IAAA;EAAe,QAAA,EAAU,eAAA;AAAA;;AAAe;AAM9C;;cAAa,0BAAA;;AAA0B;AAKvC;UAAiB,mBAAA;;;;;;EAMf,iBAAA;EAEyB;EAAzB,OAAA,IAAW,EAAA,EAAI,UAAU;AAAA;;;;;;;;;;;;cAcd,aAAA;EAAA;WACF,MAAA,EAAQ,MAAA;EAAA,SACR,SAAA;cASG,MAAA,EAAQ,MAAA,EAAQ,SAAA,UAAmB,MAAA,GAAS,mBAAA;EAT/C;;;;;EAqCT,WAAA,CAAY,OAAA,EAAS,OAAA;EA5B0B;;;;;;;;;;EA8C/C,eAAA,CAAgB,MAAA,GAAS,SAAA;EAoBpB;;;EAbL,oBAAA,CAAqB,OAAA;EAsCN;;;;;;;;AA+DR;EAxFP,IAAA,CAAK,GAAA,EAAK,UAAA;;;AC1HZ;;;;AAOmB;AA4BnB;;;;;;EDgHE,cAAA,CAAe,IAAA,EAAM,UAAA,CAAW,WAAA,IAAe,aAAA;ECnF9B;;;;;;ED8HjB,OAAA,CAAQ,GAAA,EAAK,UAAA;EC9BQ;;;ED0CrB,UAAA,CAAA;ECvK+C;;;;ED+K/C,OAAA,CAAA;AAAA;;;AAtNF;;;AAAA,UCIiB,yBAAA;EDHf;;;;;;ECUA,iBAAiB;AAAA;;;;;;;;;;;;;;;;;;;;;ADK2B;cCuBjC,kBAAA,SAA2B,SAAA,CAAU,MAAA;EAAA;cAIpC,OAAA,GAAU,yBAAA;EAAA,UAUZ,QAAA,CAAS,MAAA,EAAQ,MAAA,GAAS,gBAAA;EAe9B,OAAA,CAAA,GAAW,OAAA;EAIX,MAAA,CAAA,GAAU,OAAA;ED7CkB;;;;;;;;AAQT;AAc3B;;;;;;;;;;;;;;;;;EC6DE,kBAAA,CAAmB,MAAA,GAAS,MAAA,GAAS,aAAA;;;;;;;;;;EAmCrC,oBAAA,CAAqB,MAAA,EAAQ,MAAA;EDvCJ;;;ECmDzB,aAAA,CAAc,MAAA,EAAQ,MAAA,GAAS,aAAA;ED/B/B;;;ECsCA,iBAAA,CAAA,GAAqB,aAAA;EDbA;;;ECoBrB,WAAA,CAAY,MAAA,EAAQ,MAAA;EDuBpB;;;EAAA,IChBI,eAAA,CAAA;AAAA"}
@@ -32,7 +32,8 @@ var SseConnection = class {
32
32
  opts: {
33
33
  threshold: config?.fragmentThreshold ?? 6e4,
34
34
  reassemblyTimeoutMs: 1e4,
35
- onError: (e, dir) => console.warn(`[SseConnection] wire error (${dir}) for peer ${peerId}:`, e)
35
+ onError: (e, dir) => console.warn(`[SseConnection] wire error (${dir}) for peer ${peerId}:`, e),
36
+ onFrame: config?.onFrame
36
37
  }
37
38
  });
38
39
  }
@@ -232,7 +233,10 @@ var SseServerTransport = class extends Transport {
232
233
  this.unregisterConnection(resolvedPeerId);
233
234
  }
234
235
  const channel = this.addChannel(resolvedPeerId);
235
- const connection = new SseConnection(resolvedPeerId, channel.channelId, { fragmentThreshold: this.#fragmentThreshold });
236
+ const connection = new SseConnection(resolvedPeerId, channel.channelId, {
237
+ fragmentThreshold: this.#fragmentThreshold,
238
+ onFrame: (ev) => this.frameObserver?.(ev)
239
+ });
236
240
  connection._setChannel(channel);
237
241
  this.#connections.set(resolvedPeerId, connection);
238
242
  return connection;
@@ -282,4 +286,4 @@ var SseServerTransport = class extends Transport {
282
286
  //#endregion
283
287
  export { DEFAULT_FRAGMENT_THRESHOLD as n, SseConnection as r, SseServerTransport as t };
284
288
 
285
- //# sourceMappingURL=server-transport-CwEVpOpu.js.map
289
+ //# sourceMappingURL=server-transport-CXfoHfvJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server-transport-CXfoHfvJ.js","names":["#pipeline","#channel","#sendFn","#onDisconnect","#fragmentThreshold","#connections"],"sources":["../src/connection.ts","../src/server-transport.ts"],"sourcesContent":["// connection — SseConnection for server-side peer connections.\n//\n// Wraps a Pipeline<\"text\", \"binary\"> to provide send/receive\n// for ChannelMsg over a single SSE connection.\n//\n// Used by SseServerTransport to manage individual client connections.\n// The client adapter handles its own encoding/decoding inline since it\n// manages a single EventSource with reconnection logic.\n//\n// The sendFn receives pre-encoded text frame strings. Framework\n// integrations just wrap them in SSE syntax:\n// Express: res.write(`data: ${textFrame}\\n\\n`)\n// Hono: stream.writeSSE({ data: textFrame })\n//\n// Asymmetric encoding:\n// send direction: text (SSE data events → EventSource)\n// receive direction: binary (POST body → Uint8Array)\n\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/transport\"\nimport { type FrameTrace, Pipeline } from \"@kyneta/transport\"\n\n// ---------------------------------------------------------------------------\n// Result types\n// ---------------------------------------------------------------------------\n\n/**\n * Response to send back to the client after processing a POST.\n */\nexport interface SsePostResponse {\n status: 200 | 202 | 400\n body: { ok: true } | { pending: true } | { error: string }\n}\n\n/**\n * Result of parsing a POST body.\n *\n * Discriminated union describing what happened:\n * - \"messages\": Complete message(s) decoded, ready to deliver\n * - \"pending\": Fragment received, waiting for more\n * - \"error\": Decode/reassembly error\n */\nexport type SsePostResult =\n | { type: \"messages\"; messages: ChannelMsg[]; response: SsePostResponse }\n | { type: \"pending\"; response: SsePostResponse }\n | { type: \"error\"; response: SsePostResponse }\n\n/**\n * Default fragment threshold in characters for outbound SSE messages.\n * 60K chars provides a safety margin below typical infrastructure limits.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 60_000\n\n/**\n * Configuration for creating an SseConnection.\n */\nexport interface SseConnectionConfig {\n /**\n * Fragment threshold in characters. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation.\n * Default: 60000 (60K chars)\n */\n fragmentThreshold?: number\n /** Optional DevTools per-frame trace hook, threaded into the Pipeline. */\n onFrame?: (ev: FrameTrace) => void\n}\n\n/**\n * Represents a single SSE connection to a peer (server-side).\n *\n * Manages encoding, framing, fragmentation, reassembly, and alias\n * resolution for one connected client. Created by\n * `SseServerTransport.registerConnection()`.\n *\n * Uses Pipeline<\"text\", \"binary\"> — asymmetric encoding:\n * - Send (text): ChannelMsg → text frame → SSE data event\n * - Receive (binary): POST body (Uint8Array) → ChannelMsg\n */\nexport class SseConnection {\n readonly peerId: PeerId\n readonly channelId: number\n\n #channel: Channel | null = null\n #sendFn: ((textFrame: string) => void) | null = null\n #onDisconnect: (() => void) | null = null\n\n // Asymmetric wire pipeline: send text, receive binary\n #pipeline: Pipeline<\"text\", \"binary\">\n\n constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig) {\n this.peerId = peerId\n this.channelId = channelId\n this.#pipeline = new Pipeline({\n send: \"text\",\n receive: \"binary\",\n opts: {\n threshold: config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD,\n reassemblyTimeoutMs: 10_000,\n onError: (e, dir) =>\n console.warn(\n `[SseConnection] wire error (${dir}) for peer ${peerId}:`,\n e,\n ),\n onFrame: config?.onFrame,\n },\n })\n }\n\n // ==========================================================================\n // INTERNAL API — for adapter use\n // ==========================================================================\n\n /**\n * Set the channel reference.\n * Called by the adapter when the channel is created.\n * @internal\n */\n _setChannel(channel: Channel): void {\n this.#channel = channel\n }\n\n // ==========================================================================\n // PUBLIC API\n // ==========================================================================\n\n /**\n * Set the function to call when sending messages to this peer.\n *\n * The function receives a fully encoded text frame string.\n * The framework integration just wraps it in SSE syntax:\n * - Express: `res.write(\\`data: \\${textFrame}\\\\n\\\\n\\`)`\n * - Hono: `stream.writeSSE({ data: textFrame })`\n *\n * @param sendFn Function that writes a text frame string to the SSE stream\n */\n setSendFunction(sendFn: (textFrame: string) => void): void {\n this.#sendFn = sendFn\n }\n\n /**\n * Set the function to call when this connection is disconnected.\n */\n setDisconnectHandler(handler: () => void): void {\n this.#onDisconnect = handler\n }\n\n /**\n * Send a ChannelMsg to the peer through the SSE stream.\n *\n * Runs the Pipeline<\"text\"> send path:\n * ChannelMsg → Pipeline.send() → text frame(s) → sendFn()\n *\n * Fragmentation is transparent to callers — the pipeline splits\n * large frames into multiple sendFn calls automatically.\n */\n send(msg: ChannelMsg): void {\n if (!this.#sendFn) {\n throw new Error(\n `Cannot send message: send function not set for peer ${this.peerId}`,\n )\n }\n\n for (const r of this.#pipeline.send(msg)) {\n if (r.ok) this.#sendFn(r.value)\n }\n }\n\n /**\n * Handle an inbound POST body through the Pipeline<\"binary\"> receive path.\n *\n * Pipeline: Uint8Array → Pipeline.receive() → ChannelMsg[]\n *\n * Messages that fail alias resolution or wire-message validation are\n * surfaced as `type: \"error\"` results — the connection continues\n * processing remaining messages. This matches the error-dropping\n * behavior of every other transport.\n *\n * @param body - Binary POST body (Uint8Array)\n * @returns Discriminated result: `\"messages\"`, `\"pending\"`, or `\"error\"`\n */\n handlePostBody(body: Uint8Array<ArrayBuffer>): SsePostResult {\n try {\n const messages: ChannelMsg[] = []\n let hadError = false\n for (const r of this.#pipeline.receive(body)) {\n if (r.ok) messages.push(r.value)\n else hadError = true\n }\n\n if (hadError && messages.length === 0) {\n return {\n type: \"error\",\n response: { status: 400, body: { error: \"decode_failed\" } },\n }\n }\n\n if (messages.length === 0) {\n return {\n type: \"pending\",\n response: { status: 202, body: { pending: true } },\n }\n }\n\n return {\n type: \"messages\",\n messages,\n response: { status: 200, body: { ok: true } },\n }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : \"decode_failed\"\n return {\n type: \"error\",\n response: { status: 400, body: { error: errorMessage } },\n }\n }\n }\n\n /**\n * Receive a message from the peer and route it to the channel.\n *\n * Called by the framework integration after processing a POST body\n * through `handlePostBody`.\n */\n receive(msg: ChannelMsg): void {\n if (!this.#channel) {\n throw new Error(\n `Cannot receive message: channel not set for peer ${this.peerId}`,\n )\n }\n this.#channel.onReceive(msg)\n }\n\n /**\n * Disconnect this connection.\n */\n disconnect(): void {\n this.#onDisconnect?.()\n }\n\n /**\n * Dispose of resources held by this connection.\n * Must be called when the connection is closed to prevent timer leaks.\n */\n dispose(): void {\n this.#pipeline.dispose()\n }\n}\n","// server-adapter — SSE server adapter for @kyneta/exchange.\n//\n// Manages SSE connections from clients, encoding/decoding via the\n// kyneta text wire format. Framework-agnostic — works with any HTTP\n// framework through the SseConnection's setSendFunction() callback.\n//\n// Usage with Express:\n// import { SseServerTransport } from \"@kyneta/sse-network-adapter/server\"\n// import { createSseExpressRouter } from \"@kyneta/sse-network-adapter/express\"\n//\n// const serverAdapter = new SseServerTransport()\n// app.use(\"/sse\", createSseExpressRouter(serverAdapter))\n//\n// Usage with Hono:\n// import { SseServerTransport } from \"@kyneta/sse-network-adapter/server\"\n// import { SseConnection } from \"@kyneta/sse-network-adapter/express\"\n//\n// const serverAdapter = new SseServerTransport()\n// // Wire up GET /events and POST /sync manually using\n// // serverAdapter.registerConnection() and connection.handlePostBody()\n\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/transport\"\nimport { randomPeerId, Transport } from \"@kyneta/transport\"\nimport { DEFAULT_FRAGMENT_THRESHOLD, SseConnection } from \"./connection.js\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for the SSE server adapter.\n */\nexport interface SseServerTransportOptions {\n /**\n * Fragment threshold in characters. Messages larger than this are fragmented\n * into multiple SSE events.\n * Set to 0 to disable fragmentation.\n * Default: 60000 (60K chars)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// SseServerTransport\n// ---------------------------------------------------------------------------\n\n/**\n * SSE server network adapter.\n *\n * Framework-agnostic — works with any HTTP framework through the\n * `SseConnection.setSendFunction()` callback. Use `registerConnection()`\n * to integrate with your framework's SSE endpoint handler.\n *\n * Each client connection is tracked as an `SseConnection` keyed by peer ID.\n * The adapter creates a channel per connection and routes outbound messages\n * through the connection's send method (which encodes to text wire format\n * and calls the injected sendFn).\n *\n * The connection handshake:\n * 1. Client opens EventSource (GET /events)\n * 2. Server calls `registerConnection(peerId)` → creates channel\n * 3. Client's EventSource.onopen fires → client sends establish (POST)\n * 4. Server receives establish → Synchronizer upgrades channel and sends present (SSE)\n *\n * The server does NOT call `establishChannel()` — it waits for the client's\n * establish message, which arrives via POST after the EventSource is open.\n */\nexport class SseServerTransport extends Transport<PeerId> {\n #connections = new Map<PeerId, SseConnection>()\n readonly #fragmentThreshold: number\n\n constructor(options?: SseServerTransportOptions) {\n super({ transportType: \"sse-server\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(peerId: PeerId): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.send(msg)\n }\n },\n stop: () => {\n this.unregisterConnection(peerId)\n },\n }\n }\n\n async onStart(): Promise<void> {\n // Server adapter starts passively — connections arrive via registerConnection()\n }\n\n async onStop(): Promise<void> {\n // Disconnect all active connections\n for (const connection of this.#connections.values()) {\n connection.disconnect()\n }\n this.#connections.clear()\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Register a new peer connection.\n *\n * Call this from your framework's SSE endpoint handler when a client\n * connects via EventSource. Returns an `SseConnection` that you wire\n * up with `setSendFunction()` and `setDisconnectHandler()`.\n *\n * @param peerId The unique identifier for the peer (from query param or header)\n * @returns An SseConnection object for managing the connection\n *\n * @example Express\n * ```typescript\n * const connection = serverAdapter.registerConnection(peerId)\n * connection.setSendFunction((textFrame) => {\n * res.write(`data: ${textFrame}\\n\\n`)\n * })\n * ```\n *\n * @example Hono\n * ```typescript\n * const connection = serverAdapter.registerConnection(peerId)\n * connection.setSendFunction((textFrame) => {\n * stream.writeSSE({ data: textFrame })\n * })\n * ```\n */\n registerConnection(peerId?: PeerId): SseConnection {\n const resolvedPeerId = peerId ?? (`sse-${randomPeerId()}` as PeerId)\n\n // Check for existing connection and clean it up\n const existingConnection = this.#connections.get(resolvedPeerId)\n if (existingConnection) {\n existingConnection.dispose()\n this.unregisterConnection(resolvedPeerId)\n }\n\n // Create channel for this peer\n const channel = this.addChannel(resolvedPeerId)\n\n // Create connection object with fragmentation config\n const connection = new SseConnection(resolvedPeerId, channel.channelId, {\n fragmentThreshold: this.#fragmentThreshold,\n onFrame: ev => this.frameObserver?.(ev),\n })\n connection._setChannel(channel)\n\n // Store connection\n this.#connections.set(resolvedPeerId, connection)\n\n return connection\n }\n\n /**\n * Unregister a peer connection.\n *\n * Removes the channel, disposes the connection's pipeline,\n * and cleans up tracking state. Called automatically when the\n * client disconnects (via req.on(\"close\")) or manually.\n *\n * @param peerId The unique identifier for the peer\n */\n unregisterConnection(peerId: PeerId): void {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.dispose()\n this.removeChannel(connection.channelId)\n this.#connections.delete(peerId)\n }\n }\n\n /**\n * Get an active connection by peer ID.\n */\n getConnection(peerId: PeerId): SseConnection | undefined {\n return this.#connections.get(peerId)\n }\n\n /**\n * Get all active connections.\n */\n getAllConnections(): SseConnection[] {\n return Array.from(this.#connections.values())\n }\n\n /**\n * Check if a peer is connected.\n */\n isConnected(peerId: PeerId): boolean {\n return this.#connections.has(peerId)\n }\n\n /**\n * Get the number of connected peers.\n */\n get connectionCount(): number {\n return this.#connections.size\n }\n}\n"],"mappings":"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"}
package/dist/server.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  import { a as SseConnectionResult, i as SseConnectionHandle, t as DisconnectReason } from "./types-Bg1SdZ2w.js";
2
- import { a as SseConnectionConfig, i as SseConnection, n as SseServerTransportOptions, r as DEFAULT_FRAGMENT_THRESHOLD, t as SseServerTransport } from "./server-transport-PIK5bPSw.js";
2
+ import { a as SseConnectionConfig, i as SseConnection, n as SseServerTransportOptions, r as DEFAULT_FRAGMENT_THRESHOLD, t as SseServerTransport } from "./server-transport-BrVOGl9F.js";
3
3
  export { DEFAULT_FRAGMENT_THRESHOLD, type DisconnectReason, SseConnection, type SseConnectionConfig, type SseConnectionHandle, type SseConnectionResult, SseServerTransport, type SseServerTransportOptions };
package/dist/server.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as DEFAULT_FRAGMENT_THRESHOLD, r as SseConnection, t as SseServerTransport } from "./server-transport-CwEVpOpu.js";
1
+ import { n as DEFAULT_FRAGMENT_THRESHOLD, r as SseConnection, t as SseServerTransport } from "./server-transport-CXfoHfvJ.js";
2
2
  export { DEFAULT_FRAGMENT_THRESHOLD, SseConnection, SseServerTransport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyneta/sse-transport",
3
- "version": "1.8.0",
3
+ "version": "2.0.0",
4
4
  "description": "SSE (Server-Sent Events) network adapter for @kyneta/exchange — client, server, and Express integration",
5
5
  "author": "Duane Johnson",
6
6
  "license": "MIT",
@@ -32,8 +32,8 @@
32
32
  }
33
33
  },
34
34
  "peerDependencies": {
35
- "@kyneta/machine": "^1.8.0",
36
- "@kyneta/transport": "^1.8.0"
35
+ "@kyneta/machine": "^2.0.0",
36
+ "@kyneta/transport": "^2.0.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
39
39
  "express": {
@@ -47,11 +47,11 @@
47
47
  "tsdown": "^0.22.0",
48
48
  "typescript": "^5.9.2",
49
49
  "vitest": "^4.0.17",
50
- "@kyneta/exchange": "^1.8.0",
51
- "@kyneta/schema": "^1.8.0",
52
- "@kyneta/machine": "^1.8.0",
53
- "@kyneta/transport": "^1.8.0",
54
- "@kyneta/wire": "^1.8.0"
50
+ "@kyneta/exchange": "^2.0.0",
51
+ "@kyneta/schema": "^2.0.0",
52
+ "@kyneta/machine": "^2.0.0",
53
+ "@kyneta/transport": "^2.0.0",
54
+ "@kyneta/wire": "^2.0.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsdown",
@@ -191,7 +191,7 @@ describe("SseClientTransport — unhandled rejections", () => {
191
191
  {
192
192
  docId: "doc-abc",
193
193
  replicaType: ["plain", 1, 0],
194
- syncProtocol: SYNC_AUTHORITATIVE,
194
+ syncMode: SYNC_AUTHORITATIVE,
195
195
  schemaHash: "sha-001",
196
196
  },
197
197
  ],
@@ -45,13 +45,13 @@ const presentMsg: ChannelMsg = {
45
45
  {
46
46
  docId: "doc-1",
47
47
  replicaType: ["plain", 1, 0] as const,
48
- syncProtocol: SYNC_AUTHORITATIVE,
48
+ syncMode: SYNC_AUTHORITATIVE,
49
49
  schemaHash: "test-hash",
50
50
  },
51
51
  {
52
52
  docId: "doc-2",
53
53
  replicaType: ["plain", 1, 0] as const,
54
- syncProtocol: SYNC_AUTHORITATIVE,
54
+ syncMode: SYNC_AUTHORITATIVE,
55
55
  schemaHash: "test-hash",
56
56
  },
57
57
  ],
@@ -231,7 +231,7 @@ describe("SseConnection — handlePostBody", () => {
231
231
  // Construct a structurally valid binary frame wrapping garbage CBOR.
232
232
  // The frame parser accepts it (valid header), but CBOR decode fails.
233
233
  const garbageCbor = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc])
234
- const frame = encodeBinaryFrame(complete(WIRE_VERSION, garbageCbor))
234
+ const frame = encodeBinaryFrame(complete(WIRE_VERSION, 1, garbageCbor))
235
235
 
236
236
  const result = conn.handlePostBody(frame)
237
237
  expect(result.type).toBe("error")
@@ -11,7 +11,7 @@ import type {
11
11
  InterestMsg,
12
12
  PresentMsg,
13
13
  } from "@kyneta/transport"
14
- import { Pipeline } from "@kyneta/transport"
14
+ import { Pipeline, PROTOCOL_VERSION } from "@kyneta/transport"
15
15
  import type { Result, WireError } from "@kyneta/wire"
16
16
  import { describe, expect, it } from "vitest"
17
17
 
@@ -50,12 +50,14 @@ const serverEstablish: EstablishMsg = {
50
50
  type: "establish",
51
51
  identity: { peerId: "server", name: "Server", type: "service" },
52
52
  features: { alias: true },
53
+ protocolVersion: PROTOCOL_VERSION,
53
54
  }
54
55
 
55
56
  const clientEstablish: EstablishMsg = {
56
57
  type: "establish",
57
58
  identity: { peerId: "client", name: "Client", type: "user" },
58
59
  features: { alias: true },
60
+ protocolVersion: PROTOCOL_VERSION,
59
61
  }
60
62
 
61
63
  // ---------------------------------------------------------------------------
@@ -142,7 +144,7 @@ describe("SSE asymmetric encoding", () => {
142
144
  docId: "doc-1",
143
145
  schemaHash: "h-1",
144
146
  replicaType: ["plain", 1, 0],
145
- syncProtocol: SYNC_AUTHORITATIVE,
147
+ syncMode: SYNC_AUTHORITATIVE,
146
148
  },
147
149
  ],
148
150
  }
@@ -166,7 +168,7 @@ describe("SSE asymmetric encoding", () => {
166
168
  docId: "doc-1",
167
169
  schemaHash: "h-1",
168
170
  replicaType: ["plain", 1, 0],
169
- syncProtocol: SYNC_AUTHORITATIVE,
171
+ syncMode: SYNC_AUTHORITATIVE,
170
172
  },
171
173
  ],
172
174
  }
@@ -40,7 +40,6 @@ import type {
40
40
  ChannelMsg,
41
41
  GeneratedChannel,
42
42
  PeerId,
43
- TransportFactory,
44
43
  } from "@kyneta/transport"
45
44
  import { Pipeline, Transport } from "@kyneta/transport"
46
45
  import {
@@ -178,6 +177,8 @@ export class SseClientTransport extends Transport<void> {
178
177
  reassemblyTimeoutMs: 10_000,
179
178
  onError: (e, dir) =>
180
179
  console.warn(`[SseClientTransport] wire error (${dir}):`, e),
180
+ // Lazy: pipeline built in the constructor (pre-`_initialize`).
181
+ onFrame: ev => this.frameObserver?.(ev),
181
182
  },
182
183
  })
183
184
 
@@ -639,6 +640,6 @@ export class SseClientTransport extends Transport<void> {
639
640
  * })
640
641
  * ```
641
642
  */
642
- export function createSseClient(options: SseClientOptions): TransportFactory {
643
- return () => new SseClientTransport(options)
643
+ export function createSseClient(options: SseClientOptions): SseClientTransport {
644
+ return new SseClientTransport(options)
644
645
  }
package/src/connection.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  // receive direction: binary (POST body → Uint8Array)
18
18
 
19
19
  import type { Channel, ChannelMsg, PeerId } from "@kyneta/transport"
20
- import { Pipeline } from "@kyneta/transport"
20
+ import { type FrameTrace, Pipeline } from "@kyneta/transport"
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
23
  // Result types
@@ -60,6 +60,8 @@ export interface SseConnectionConfig {
60
60
  * Default: 60000 (60K chars)
61
61
  */
62
62
  fragmentThreshold?: number
63
+ /** Optional DevTools per-frame trace hook, threaded into the Pipeline. */
64
+ onFrame?: (ev: FrameTrace) => void
63
65
  }
64
66
 
65
67
  /**
@@ -98,6 +100,7 @@ export class SseConnection {
98
100
  `[SseConnection] wire error (${dir}) for peer ${peerId}:`,
99
101
  e,
100
102
  ),
103
+ onFrame: config?.onFrame,
101
104
  },
102
105
  })
103
106
  }
@@ -152,6 +152,7 @@ export class SseServerTransport extends Transport<PeerId> {
152
152
  // Create connection object with fragmentation config
153
153
  const connection = new SseConnection(resolvedPeerId, channel.channelId, {
154
154
  fragmentThreshold: this.#fragmentThreshold,
155
+ onFrame: ev => this.frameObserver?.(ev),
155
156
  })
156
157
  connection._setChannel(channel)
157
158
 
@@ -1 +0,0 @@
1
- {"version":3,"file":"server-transport-CwEVpOpu.js","names":["#pipeline","#channel","#sendFn","#onDisconnect","#fragmentThreshold","#connections"],"sources":["../src/connection.ts","../src/server-transport.ts"],"sourcesContent":["// connection — SseConnection for server-side peer connections.\n//\n// Wraps a Pipeline<\"text\", \"binary\"> to provide send/receive\n// for ChannelMsg over a single SSE connection.\n//\n// Used by SseServerTransport to manage individual client connections.\n// The client adapter handles its own encoding/decoding inline since it\n// manages a single EventSource with reconnection logic.\n//\n// The sendFn receives pre-encoded text frame strings. Framework\n// integrations just wrap them in SSE syntax:\n// Express: res.write(`data: ${textFrame}\\n\\n`)\n// Hono: stream.writeSSE({ data: textFrame })\n//\n// Asymmetric encoding:\n// send direction: text (SSE data events → EventSource)\n// receive direction: binary (POST body → Uint8Array)\n\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/transport\"\nimport { Pipeline } from \"@kyneta/transport\"\n\n// ---------------------------------------------------------------------------\n// Result types\n// ---------------------------------------------------------------------------\n\n/**\n * Response to send back to the client after processing a POST.\n */\nexport interface SsePostResponse {\n status: 200 | 202 | 400\n body: { ok: true } | { pending: true } | { error: string }\n}\n\n/**\n * Result of parsing a POST body.\n *\n * Discriminated union describing what happened:\n * - \"messages\": Complete message(s) decoded, ready to deliver\n * - \"pending\": Fragment received, waiting for more\n * - \"error\": Decode/reassembly error\n */\nexport type SsePostResult =\n | { type: \"messages\"; messages: ChannelMsg[]; response: SsePostResponse }\n | { type: \"pending\"; response: SsePostResponse }\n | { type: \"error\"; response: SsePostResponse }\n\n/**\n * Default fragment threshold in characters for outbound SSE messages.\n * 60K chars provides a safety margin below typical infrastructure limits.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 60_000\n\n/**\n * Configuration for creating an SseConnection.\n */\nexport interface SseConnectionConfig {\n /**\n * Fragment threshold in characters. Messages larger than this are fragmented.\n * Set to 0 to disable fragmentation.\n * Default: 60000 (60K chars)\n */\n fragmentThreshold?: number\n}\n\n/**\n * Represents a single SSE connection to a peer (server-side).\n *\n * Manages encoding, framing, fragmentation, reassembly, and alias\n * resolution for one connected client. Created by\n * `SseServerTransport.registerConnection()`.\n *\n * Uses Pipeline<\"text\", \"binary\"> — asymmetric encoding:\n * - Send (text): ChannelMsg → text frame → SSE data event\n * - Receive (binary): POST body (Uint8Array) → ChannelMsg\n */\nexport class SseConnection {\n readonly peerId: PeerId\n readonly channelId: number\n\n #channel: Channel | null = null\n #sendFn: ((textFrame: string) => void) | null = null\n #onDisconnect: (() => void) | null = null\n\n // Asymmetric wire pipeline: send text, receive binary\n #pipeline: Pipeline<\"text\", \"binary\">\n\n constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig) {\n this.peerId = peerId\n this.channelId = channelId\n this.#pipeline = new Pipeline({\n send: \"text\",\n receive: \"binary\",\n opts: {\n threshold: config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD,\n reassemblyTimeoutMs: 10_000,\n onError: (e, dir) =>\n console.warn(\n `[SseConnection] wire error (${dir}) for peer ${peerId}:`,\n e,\n ),\n },\n })\n }\n\n // ==========================================================================\n // INTERNAL API — for adapter use\n // ==========================================================================\n\n /**\n * Set the channel reference.\n * Called by the adapter when the channel is created.\n * @internal\n */\n _setChannel(channel: Channel): void {\n this.#channel = channel\n }\n\n // ==========================================================================\n // PUBLIC API\n // ==========================================================================\n\n /**\n * Set the function to call when sending messages to this peer.\n *\n * The function receives a fully encoded text frame string.\n * The framework integration just wraps it in SSE syntax:\n * - Express: `res.write(\\`data: \\${textFrame}\\\\n\\\\n\\`)`\n * - Hono: `stream.writeSSE({ data: textFrame })`\n *\n * @param sendFn Function that writes a text frame string to the SSE stream\n */\n setSendFunction(sendFn: (textFrame: string) => void): void {\n this.#sendFn = sendFn\n }\n\n /**\n * Set the function to call when this connection is disconnected.\n */\n setDisconnectHandler(handler: () => void): void {\n this.#onDisconnect = handler\n }\n\n /**\n * Send a ChannelMsg to the peer through the SSE stream.\n *\n * Runs the Pipeline<\"text\"> send path:\n * ChannelMsg → Pipeline.send() → text frame(s) → sendFn()\n *\n * Fragmentation is transparent to callers — the pipeline splits\n * large frames into multiple sendFn calls automatically.\n */\n send(msg: ChannelMsg): void {\n if (!this.#sendFn) {\n throw new Error(\n `Cannot send message: send function not set for peer ${this.peerId}`,\n )\n }\n\n for (const r of this.#pipeline.send(msg)) {\n if (r.ok) this.#sendFn(r.value)\n }\n }\n\n /**\n * Handle an inbound POST body through the Pipeline<\"binary\"> receive path.\n *\n * Pipeline: Uint8Array → Pipeline.receive() → ChannelMsg[]\n *\n * Messages that fail alias resolution or wire-message validation are\n * surfaced as `type: \"error\"` results — the connection continues\n * processing remaining messages. This matches the error-dropping\n * behavior of every other transport.\n *\n * @param body - Binary POST body (Uint8Array)\n * @returns Discriminated result: `\"messages\"`, `\"pending\"`, or `\"error\"`\n */\n handlePostBody(body: Uint8Array<ArrayBuffer>): SsePostResult {\n try {\n const messages: ChannelMsg[] = []\n let hadError = false\n for (const r of this.#pipeline.receive(body)) {\n if (r.ok) messages.push(r.value)\n else hadError = true\n }\n\n if (hadError && messages.length === 0) {\n return {\n type: \"error\",\n response: { status: 400, body: { error: \"decode_failed\" } },\n }\n }\n\n if (messages.length === 0) {\n return {\n type: \"pending\",\n response: { status: 202, body: { pending: true } },\n }\n }\n\n return {\n type: \"messages\",\n messages,\n response: { status: 200, body: { ok: true } },\n }\n } catch (err) {\n const errorMessage = err instanceof Error ? err.message : \"decode_failed\"\n return {\n type: \"error\",\n response: { status: 400, body: { error: errorMessage } },\n }\n }\n }\n\n /**\n * Receive a message from the peer and route it to the channel.\n *\n * Called by the framework integration after processing a POST body\n * through `handlePostBody`.\n */\n receive(msg: ChannelMsg): void {\n if (!this.#channel) {\n throw new Error(\n `Cannot receive message: channel not set for peer ${this.peerId}`,\n )\n }\n this.#channel.onReceive(msg)\n }\n\n /**\n * Disconnect this connection.\n */\n disconnect(): void {\n this.#onDisconnect?.()\n }\n\n /**\n * Dispose of resources held by this connection.\n * Must be called when the connection is closed to prevent timer leaks.\n */\n dispose(): void {\n this.#pipeline.dispose()\n }\n}\n","// server-adapter — SSE server adapter for @kyneta/exchange.\n//\n// Manages SSE connections from clients, encoding/decoding via the\n// kyneta text wire format. Framework-agnostic — works with any HTTP\n// framework through the SseConnection's setSendFunction() callback.\n//\n// Usage with Express:\n// import { SseServerTransport } from \"@kyneta/sse-network-adapter/server\"\n// import { createSseExpressRouter } from \"@kyneta/sse-network-adapter/express\"\n//\n// const serverAdapter = new SseServerTransport()\n// app.use(\"/sse\", createSseExpressRouter(serverAdapter))\n//\n// Usage with Hono:\n// import { SseServerTransport } from \"@kyneta/sse-network-adapter/server\"\n// import { SseConnection } from \"@kyneta/sse-network-adapter/express\"\n//\n// const serverAdapter = new SseServerTransport()\n// // Wire up GET /events and POST /sync manually using\n// // serverAdapter.registerConnection() and connection.handlePostBody()\n\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/transport\"\nimport { randomPeerId, Transport } from \"@kyneta/transport\"\nimport { DEFAULT_FRAGMENT_THRESHOLD, SseConnection } from \"./connection.js\"\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Options for the SSE server adapter.\n */\nexport interface SseServerTransportOptions {\n /**\n * Fragment threshold in characters. Messages larger than this are fragmented\n * into multiple SSE events.\n * Set to 0 to disable fragmentation.\n * Default: 60000 (60K chars)\n */\n fragmentThreshold?: number\n}\n\n// ---------------------------------------------------------------------------\n// SseServerTransport\n// ---------------------------------------------------------------------------\n\n/**\n * SSE server network adapter.\n *\n * Framework-agnostic — works with any HTTP framework through the\n * `SseConnection.setSendFunction()` callback. Use `registerConnection()`\n * to integrate with your framework's SSE endpoint handler.\n *\n * Each client connection is tracked as an `SseConnection` keyed by peer ID.\n * The adapter creates a channel per connection and routes outbound messages\n * through the connection's send method (which encodes to text wire format\n * and calls the injected sendFn).\n *\n * The connection handshake:\n * 1. Client opens EventSource (GET /events)\n * 2. Server calls `registerConnection(peerId)` → creates channel\n * 3. Client's EventSource.onopen fires → client sends establish (POST)\n * 4. Server receives establish → Synchronizer upgrades channel and sends present (SSE)\n *\n * The server does NOT call `establishChannel()` — it waits for the client's\n * establish message, which arrives via POST after the EventSource is open.\n */\nexport class SseServerTransport extends Transport<PeerId> {\n #connections = new Map<PeerId, SseConnection>()\n readonly #fragmentThreshold: number\n\n constructor(options?: SseServerTransportOptions) {\n super({ transportType: \"sse-server\" })\n this.#fragmentThreshold =\n options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(peerId: PeerId): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.send(msg)\n }\n },\n stop: () => {\n this.unregisterConnection(peerId)\n },\n }\n }\n\n async onStart(): Promise<void> {\n // Server adapter starts passively — connections arrive via registerConnection()\n }\n\n async onStop(): Promise<void> {\n // Disconnect all active connections\n for (const connection of this.#connections.values()) {\n connection.disconnect()\n }\n this.#connections.clear()\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Register a new peer connection.\n *\n * Call this from your framework's SSE endpoint handler when a client\n * connects via EventSource. Returns an `SseConnection` that you wire\n * up with `setSendFunction()` and `setDisconnectHandler()`.\n *\n * @param peerId The unique identifier for the peer (from query param or header)\n * @returns An SseConnection object for managing the connection\n *\n * @example Express\n * ```typescript\n * const connection = serverAdapter.registerConnection(peerId)\n * connection.setSendFunction((textFrame) => {\n * res.write(`data: ${textFrame}\\n\\n`)\n * })\n * ```\n *\n * @example Hono\n * ```typescript\n * const connection = serverAdapter.registerConnection(peerId)\n * connection.setSendFunction((textFrame) => {\n * stream.writeSSE({ data: textFrame })\n * })\n * ```\n */\n registerConnection(peerId?: PeerId): SseConnection {\n const resolvedPeerId = peerId ?? (`sse-${randomPeerId()}` as PeerId)\n\n // Check for existing connection and clean it up\n const existingConnection = this.#connections.get(resolvedPeerId)\n if (existingConnection) {\n existingConnection.dispose()\n this.unregisterConnection(resolvedPeerId)\n }\n\n // Create channel for this peer\n const channel = this.addChannel(resolvedPeerId)\n\n // Create connection object with fragmentation config\n const connection = new SseConnection(resolvedPeerId, channel.channelId, {\n fragmentThreshold: this.#fragmentThreshold,\n })\n connection._setChannel(channel)\n\n // Store connection\n this.#connections.set(resolvedPeerId, connection)\n\n return connection\n }\n\n /**\n * Unregister a peer connection.\n *\n * Removes the channel, disposes the connection's pipeline,\n * and cleans up tracking state. Called automatically when the\n * client disconnects (via req.on(\"close\")) or manually.\n *\n * @param peerId The unique identifier for the peer\n */\n unregisterConnection(peerId: PeerId): void {\n const connection = this.#connections.get(peerId)\n if (connection) {\n connection.dispose()\n this.removeChannel(connection.channelId)\n this.#connections.delete(peerId)\n }\n }\n\n /**\n * Get an active connection by peer ID.\n */\n getConnection(peerId: PeerId): SseConnection | undefined {\n return this.#connections.get(peerId)\n }\n\n /**\n * Get all active connections.\n */\n getAllConnections(): SseConnection[] {\n return Array.from(this.#connections.values())\n }\n\n /**\n * Check if a peer is connected.\n */\n isConnected(peerId: PeerId): boolean {\n return this.#connections.has(peerId)\n }\n\n /**\n * Get the number of connected peers.\n */\n get connectionCount(): number {\n return this.#connections.size\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"server-transport-PIK5bPSw.d.ts","names":[],"sources":["../src/connection.ts","../src/server-transport.ts"],"mappings":";;;;;AA4BA;UAAiB,eAAA;EACf,MAAA;EACA,IAAA;IAAQ,EAAA;EAAA;IAAe,OAAA;EAAA;IAAoB,KAAA;EAAA;AAAA;AAW7C;;;;;;;;AAAA,KAAY,aAAA;EACN,IAAA;EAAkB,QAAA,EAAU,UAAA;EAAc,QAAA,EAAU,eAAA;AAAA;EACpD,IAAA;EAAiB,QAAA,EAAU,eAAA;AAAA;EAC3B,IAAA;EAAe,QAAA,EAAU,eAAA;AAAA;;;AAAe;AAM9C;cAAa,0BAAA;;;AAA0B;UAKtB,mBAAA;EAAmB;;;AAMjB;AAcnB;EAdE,iBAAiB;AAAA;;;;;;;;;;;;cAcN,aAAA;EAAA;WACF,MAAA,EAAQ,MAAA;EAAA,SACR,SAAA;cASG,MAAA,EAAQ,MAAA,EAAQ,SAAA,UAAmB,MAAA,GAAS,mBAAA;EAApC;;;;;EA2BpB,WAAA,CAAY,OAAA,EAAS,OAAA;EAAA;;;;;;;;;;EAkBrB,eAAA,CAAgB,MAAA,GAAS,SAAA;EA6CJ;;;EAtCrB,oBAAA,CAAqB,OAAA;EAiFrB;;;;;;AAoBO;;;EAxFP,IAAA,CAAK,GAAA,EAAK,UAAA;ECvHK;;;;AAOE;AA4BnB;;;;;;;;ED6GE,cAAA,CAAe,IAAA,EAAM,UAAA,CAAW,WAAA,IAAe,aAAA;ECtCnB;;;;;;EDiF5B,OAAA,CAAQ,GAAA,EAAK,UAAA;ECxJyB;;;EDoKtC,UAAA,CAAA;ECpKgD;;;;ED4KhD,OAAA,CAAA;AAAA;;;AAnNF;;;AAAA,UCIiB,yBAAA;EDHf;;;;;;ECUA,iBAAiB;AAAA;;;;;;;;;;;;;;;;;;;;;ADK2B;cCuBjC,kBAAA,SAA2B,SAAA,CAAU,MAAA;EAAA;cAIpC,OAAA,GAAU,yBAAA;EAAA,UAUZ,QAAA,CAAS,MAAA,EAAQ,MAAA,GAAS,gBAAA;EAe9B,OAAA,CAAA,GAAW,OAAA;EAIX,MAAA,CAAA,GAAU,OAAA;ED7CkB;;;AAMjB;AAcnB;;;;;;;;;;;;;;;;;;;;;;EC+DE,kBAAA,CAAmB,MAAA,GAAS,MAAA,GAAS,aAAA;EDpDU;;;;;;;;;ECsF/C,oBAAA,CAAqB,MAAA,EAAQ,MAAA;EDrBnB;;;ECiCV,aAAA,CAAc,MAAA,EAAQ,MAAA,GAAS,aAAA;EDRC;;;ECehC,iBAAA,CAAA,GAAqB,aAAA;ED4BR;;;ECrBb,WAAA,CAAY,MAAA,EAAQ,MAAA;EDyCb;AAAA;;EAAA,IClCH,eAAA,CAAA;AAAA"}