@kyneta/sse-transport 1.1.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/client-transport.ts","../src/client-state-machine.ts"],"sourcesContent":["// client-adapter — SSE client adapter for @kyneta/exchange.\n//\n// Connects to an SSE server using two HTTP channels:\n// - EventSource (GET) for server→client messages\n// - fetch POST for client→server messages\n//\n// Both directions use the text wire format (textCodec + text framing).\n//\n// Features:\n// - State machine with validated transitions (disconnected → connecting → connected)\n// - Exponential backoff reconnection with jitter\n// - POST retry with exponential backoff\n// - Text-level fragmentation for large payloads\n// - Inbound TextReassembler for fragmented SSE messages\n// - Observable connection state via subscribeToTransitions()\n//\n// The connection handshake:\n// 1. Client creates EventSource, waits for open\n// 2. EventSource.onopen fires → client creates channel + calls establishChannel()\n// 3. Synchronizer exchanges establish-request / establish-response via POST + SSE\n//\n// On EventSource.onerror, the adapter closes the EventSource immediately and\n// takes over reconnection via the state machine's backoff logic, rather than\n// letting the browser's built-in EventSource reconnection run.\n\nimport type {\n Channel,\n ChannelMsg,\n GeneratedChannel,\n PeerId,\n StateTransition,\n TransitionListener,\n TransportFactory,\n} from \"@kyneta/exchange\"\nimport { Transport } from \"@kyneta/exchange\"\nimport {\n encodeTextComplete,\n fragmentTextPayload,\n TextReassembler,\n textCodec,\n} from \"@kyneta/wire\"\nimport { SseClientStateMachine } from \"./client-state-machine.js\"\nimport type {\n DisconnectReason,\n SseClientLifecycleEvents,\n SseClientState,\n} from \"./types.js\"\n\n// Re-export state types for convenience\nexport type { DisconnectReason, SseClientLifecycleEvents, SseClientState }\n\n// ---------------------------------------------------------------------------\n// Options\n// ---------------------------------------------------------------------------\n\n/**\n * Default fragment threshold in characters.\n * 60K chars provides a safety margin below typical 100KB body-parser limits,\n * accounting for JSON overhead and potential base64 expansion.\n */\nexport const DEFAULT_FRAGMENT_THRESHOLD = 60_000\n\n/**\n * Options for the SSE client adapter.\n */\nexport interface SseClientOptions {\n /** URL for POST requests (client→server). String or function of peerId. */\n postUrl: string | ((peerId: PeerId) => string)\n\n /** URL for SSE EventSource (server→client). String or function of peerId. */\n eventSourceUrl: string | ((peerId: PeerId) => string)\n\n /** Reconnection options for EventSource. */\n reconnect?: {\n enabled?: boolean\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** POST retry options. */\n postRetry?: {\n maxAttempts?: number\n baseDelay?: number\n maxDelay?: number\n }\n\n /** Fragment threshold in characters. Default: 60000 (60K chars). */\n fragmentThreshold?: number\n\n /** Lifecycle event callbacks. */\n lifecycle?: SseClientLifecycleEvents\n}\n\n/**\n * Default reconnection options.\n */\nconst DEFAULT_RECONNECT = {\n enabled: true,\n maxAttempts: 10,\n baseDelay: 1000,\n maxDelay: 30000,\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// SseClientTransport\n// ---------------------------------------------------------------------------\n\n/**\n * SSE client network adapter for @kyneta/exchange.\n *\n * Uses two HTTP channels:\n * - **EventSource** (GET, long-lived) for server→client messages\n * - **fetch POST** for client→server messages\n *\n * Both directions use the text wire format (`textCodec` + text framing).\n *\n * @example\n * ```typescript\n * import { createSseClient } from \"@kyneta/sse-network-adapter/client\"\n *\n * const adapter = createSseClient({\n * postUrl: \"/sync\",\n * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,\n * reconnect: { enabled: true },\n * })\n *\n * const exchange = new Exchange({\n * identity: { peerId: \"browser-client\" },\n * transports: [adapter],\n * })\n * ```\n */\nexport class SseClientTransport extends Transport<void> {\n #peerId?: PeerId\n #eventSource?: EventSource\n #serverChannel?: Channel\n #reconnectTimer?: ReturnType<typeof setTimeout>\n #options: SseClientOptions\n #shouldReconnect = true\n #wasConnectedBefore = false\n\n // State machine\n readonly #stateMachine = new SseClientStateMachine()\n\n // Fragmentation\n readonly #fragmentThreshold: number\n\n // Inbound reassembly for fragmented SSE messages from server\n readonly #reassembler: TextReassembler\n\n // POST retry\n #currentRetryAbortController?: AbortController\n\n constructor(options: SseClientOptions) {\n super({ transportType: \"sse-client\" })\n this.#options = options\n this.#fragmentThreshold =\n options.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.#reassembler = new TextReassembler({\n timeoutMs: 10_000,\n })\n\n // Set up lifecycle event forwarding\n this.#setupLifecycleEvents()\n }\n\n // ==========================================================================\n // Lifecycle event forwarding\n // ==========================================================================\n\n #setupLifecycleEvents(): void {\n this.#stateMachine.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 this.#wasConnectedBefore &&\n (from.status === \"reconnecting\" || from.status === \"connecting\") &&\n to.status === \"connected\"\n ) {\n this.#options.lifecycle?.onReconnected?.()\n }\n })\n }\n\n // ==========================================================================\n // State observation API\n // ==========================================================================\n\n /**\n * Get the current state of the connection.\n */\n getState(): SseClientState {\n return this.#stateMachine.getState()\n }\n\n /**\n * Subscribe to state transitions.\n * @returns Unsubscribe function\n */\n subscribeToTransitions(\n listener: TransitionListener<SseClientState>,\n ): () => void {\n return this.#stateMachine.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.#stateMachine.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.#stateMachine.waitForStatus(status, options)\n }\n\n /**\n * Check if the client is connected (EventSource open, channel established).\n */\n get isConnected(): boolean {\n return this.#stateMachine.isConnected()\n }\n\n // ==========================================================================\n // Adapter abstract method implementations\n // ==========================================================================\n\n protected generate(): GeneratedChannel {\n return {\n transportType: this.transportType,\n send: (msg: ChannelMsg) => {\n if (!this.#peerId) {\n return\n }\n\n // Check if EventSource is closed before sending\n // readyState: 0=CONNECTING, 1=OPEN, 2=CLOSED\n if (!this.#eventSource || this.#eventSource.readyState === 2) {\n return\n }\n\n // Resolve the postUrl with the peerId\n const resolvedPostUrl =\n typeof this.#options.postUrl === \"function\"\n ? this.#options.postUrl(this.#peerId)\n : this.#options.postUrl\n\n // Encode to text wire format\n const textFrame = encodeTextComplete(textCodec, msg)\n\n // Fragment large payloads\n if (\n this.#fragmentThreshold > 0 &&\n textFrame.length > this.#fragmentThreshold\n ) {\n const payload = JSON.stringify(textCodec.encode(msg))\n const fragments = fragmentTextPayload(\n payload,\n this.#fragmentThreshold,\n )\n for (const fragment of fragments) {\n void this.#sendTextWithRetry(resolvedPostUrl, fragment)\n }\n } else {\n void this.#sendTextWithRetry(resolvedPostUrl, textFrame)\n }\n },\n stop: () => {\n // Don't call disconnect() here — channel.stop() is called when\n // the channel is removed, which can happen during handleClose().\n // The actual disconnect is handled by onStop() or handleClose().\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.#shouldReconnect = true\n this.#wasConnectedBefore = false\n this.#connect()\n }\n\n async onStop(): Promise<void> {\n this.#shouldReconnect = false\n this.#reassembler.dispose()\n this.#currentRetryAbortController?.abort()\n this.#currentRetryAbortController = undefined\n this.#disconnect({ type: \"intentional\" })\n }\n\n // ==========================================================================\n // Connection management\n // ==========================================================================\n\n /**\n * Connect to the SSE server by creating an EventSource.\n */\n #connect(): void {\n const currentState = this.#stateMachine.getState()\n if (currentState.status === \"connecting\") {\n return\n }\n\n if (!this.#peerId) {\n throw new Error(\"Cannot connect: peerId not set\")\n }\n\n // Determine attempt number\n const attempt =\n currentState.status === \"reconnecting\" ? currentState.attempt : 1\n\n this.#stateMachine.transition({ status: \"connecting\", attempt })\n\n // Resolve URL\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 this.#handleOpen()\n }\n\n this.#eventSource.onmessage = (event: MessageEvent) => {\n this.#handleMessage(event)\n }\n\n this.#eventSource.onerror = () => {\n this.#handleError()\n }\n } catch (error) {\n // EventSource constructor threw (e.g. invalid URL)\n this.#scheduleReconnect({\n type: \"error\",\n error: error instanceof Error ? error : new Error(String(error)),\n })\n }\n }\n\n /**\n * Disconnect from the SSE server.\n */\n #disconnect(reason: DisconnectReason): void {\n this.#clearReconnectTimer()\n\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\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n // Only transition if not already disconnected\n const currentState = this.#stateMachine.getState()\n if (currentState.status !== \"disconnected\") {\n this.#stateMachine.transition({ status: \"disconnected\", reason })\n }\n }\n\n // ==========================================================================\n // Event handlers\n // ==========================================================================\n\n /**\n * Handle EventSource open event.\n *\n * The SSE connection is usable immediately — no \"ready\" signal needed.\n * Create the channel and initiate establishment.\n */\n #handleOpen(): void {\n const currentState = this.#stateMachine.getState()\n\n // Handle potential race: onopen before state machine caught up\n if (\n currentState.status !== \"connecting\" &&\n currentState.status !== \"connected\"\n ) {\n // Might be in reconnecting → connecting path; just ignore\n return\n }\n\n if (currentState.status === \"connecting\") {\n this.#stateMachine.transition({ status: \"connected\" })\n }\n\n this.#wasConnectedBefore = true\n\n // Cancel any pending POST retries from previous connection\n if (this.#currentRetryAbortController) {\n this.#currentRetryAbortController.abort()\n this.#currentRetryAbortController = undefined\n }\n\n // Create channel if not exists\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n this.#serverChannel = this.addChannel()\n\n // Initiate establishment handshake\n this.establishChannel(this.#serverChannel.channelId)\n }\n\n /**\n * Handle incoming SSE message.\n *\n * Each SSE `data:` event contains a text wire frame string.\n * Feed it through the TextReassembler to handle both complete\n * and fragmented frames.\n */\n #handleMessage(event: MessageEvent): void {\n if (!this.#serverChannel) {\n return\n }\n\n const data = event.data\n if (typeof data !== \"string\") {\n return\n }\n\n // Feed through reassembler (handles both complete and fragment frames)\n const result = this.#reassembler.receive(data)\n\n if (result.status === \"complete\") {\n try {\n // Two-step decode: Frame<string> → JSON.parse → textCodec.decode\n const parsed = JSON.parse(result.frame.content.payload)\n const messages = textCodec.decode(parsed)\n for (const msg of messages) {\n this.#serverChannel.onReceive(msg)\n }\n } catch (error) {\n console.error(\"Failed to decode SSE message:\", error)\n }\n } else if (result.status === \"error\") {\n console.error(\"SSE message reassembly error:\", result.error)\n }\n // \"pending\" status means we're waiting for more fragments — nothing to do\n }\n\n /**\n * Handle EventSource error.\n *\n * Closes the EventSource immediately and takes over reconnection\n * via the state machine's backoff logic. This prevents the browser's\n * built-in EventSource reconnection from running.\n */\n #handleError(): void {\n // Close immediately to prevent browser auto-reconnect\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\n if (this.#serverChannel) {\n this.removeChannel(this.#serverChannel.channelId)\n this.#serverChannel = undefined\n }\n\n // Schedule reconnect or transition to disconnected\n this.#scheduleReconnect({\n type: \"error\",\n error: new Error(\"EventSource connection error\"),\n })\n }\n\n // ==========================================================================\n // POST sending with retry\n // ==========================================================================\n\n /**\n * Send a text frame via POST with retry logic.\n */\n async #sendTextWithRetry(url: string, textFrame: string): Promise<void> {\n let attempt = 0\n const postRetryOpts = {\n ...DEFAULT_POST_RETRY,\n ...this.#options.postRetry,\n }\n const { maxAttempts, baseDelay, maxDelay } = postRetryOpts\n\n while (attempt < maxAttempts) {\n try {\n if (!this.#currentRetryAbortController) {\n this.#currentRetryAbortController = new AbortController()\n }\n\n if (!this.#peerId) {\n throw new Error(\"PeerId not available for retry\")\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"text/plain\",\n \"X-Peer-Id\": this.#peerId,\n },\n body: textFrame,\n signal: this.#currentRetryAbortController.signal,\n })\n\n if (!response.ok) {\n // Don't retry on client errors (4xx)\n if (response.status >= 400 && response.status < 500) {\n throw new Error(`Failed to send message: ${response.statusText}`)\n }\n throw new Error(`Server error: ${response.statusText}`)\n }\n\n // Success\n this.#currentRetryAbortController = undefined\n return\n } catch (error: unknown) {\n attempt++\n\n const err = error as Error\n\n // If aborted, stop retrying\n if (err.name === \"AbortError\") {\n throw error\n }\n\n // If controller was cleared (e.g. by onopen), stop retrying\n if (!this.#currentRetryAbortController) {\n const abortError = new Error(\"Retry aborted by connection reset\")\n abortError.name = \"AbortError\"\n throw abortError\n }\n\n // If max attempts reached, throw the last error\n if (attempt >= maxAttempts) {\n this.#currentRetryAbortController = undefined\n throw error\n }\n\n // Calculate delay with exponential backoff and jitter\n const delay = Math.min(\n baseDelay * 2 ** (attempt - 1) + Math.random() * 100,\n maxDelay,\n )\n\n // Wait for delay or abort signal\n await new Promise<void>((resolve, reject) => {\n if (this.#currentRetryAbortController?.signal.aborted) {\n const error = new Error(\"Retry aborted\")\n error.name = \"AbortError\"\n reject(error)\n return\n }\n\n const timer = setTimeout(() => {\n cleanup()\n resolve()\n }, delay)\n\n const onAbort = () => {\n clearTimeout(timer)\n cleanup()\n const error = new Error(\"Retry aborted\")\n error.name = \"AbortError\"\n reject(error)\n }\n\n const cleanup = () => {\n this.#currentRetryAbortController?.signal.removeEventListener(\n \"abort\",\n onAbort,\n )\n }\n\n this.#currentRetryAbortController?.signal.addEventListener(\n \"abort\",\n onAbort,\n )\n })\n }\n }\n }\n\n // ==========================================================================\n // Reconnection\n // ==========================================================================\n\n /**\n * Schedule a reconnection attempt or transition to disconnected.\n */\n #scheduleReconnect(reason: DisconnectReason): void {\n const currentState = this.#stateMachine.getState()\n\n // If already disconnected, don't transition again\n if (currentState.status === \"disconnected\") {\n return\n }\n\n const reconnectOpts = {\n ...DEFAULT_RECONNECT,\n ...this.#options.reconnect,\n }\n\n if (!this.#shouldReconnect || !reconnectOpts.enabled) {\n this.#stateMachine.transition({ status: \"disconnected\", reason })\n return\n }\n\n // Get current attempt count from state\n const currentAttempt =\n currentState.status === \"reconnecting\"\n ? currentState.attempt\n : currentState.status === \"connecting\"\n ? (currentState as { attempt: number }).attempt\n : 0\n\n if (currentAttempt >= reconnectOpts.maxAttempts) {\n this.#stateMachine.transition({\n status: \"disconnected\",\n reason: { type: \"max-retries-exceeded\", attempts: currentAttempt },\n })\n return\n }\n\n const nextAttempt = currentAttempt + 1\n\n // Exponential backoff with jitter\n const delay = Math.min(\n reconnectOpts.baseDelay * 2 ** (nextAttempt - 1) + Math.random() * 1000,\n reconnectOpts.maxDelay,\n )\n\n this.#stateMachine.transition({\n status: \"reconnecting\",\n attempt: nextAttempt,\n nextAttemptMs: delay,\n })\n\n this.#reconnectTimer = setTimeout(() => {\n this.#connect()\n }, delay)\n }\n\n #clearReconnectTimer(): void {\n if (this.#reconnectTimer) {\n clearTimeout(this.#reconnectTimer)\n this.#reconnectTimer = undefined\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-network-adapter/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","// client-state-machine — SSE client state machine.\n//\n// Thin wrapper around the generic ClientStateMachine<S> from @kyneta/exchange,\n// providing the SSE-specific 4-state transition map and an isConnected() helper.\n//\n// States: disconnected → connecting → connected\n// ↓ ↓\n// reconnecting ← ─ ─┘\n// ↓\n// connecting (retry)\n// ↓\n// disconnected (max retries)\n\nimport { ClientStateMachine } from \"@kyneta/exchange\"\nimport type { SseClientState } from \"./types.js\"\n\n// ---------------------------------------------------------------------------\n// SSE transition map\n// ---------------------------------------------------------------------------\n\nconst SSE_VALID_TRANSITIONS: Record<string, string[]> = {\n disconnected: [\"connecting\"],\n connecting: [\"connected\", \"disconnected\", \"reconnecting\"],\n connected: [\"disconnected\", \"reconnecting\"],\n reconnecting: [\"connecting\", \"disconnected\"],\n}\n\n// ---------------------------------------------------------------------------\n// SseClientStateMachine\n// ---------------------------------------------------------------------------\n\n/**\n * Observable state machine for SSE client connection lifecycle.\n *\n * Extends the generic `ClientStateMachine<SseClientState>` with\n * an SSE-specific convenience helper. Unlike the WebSocket state machine,\n * SSE has no `\"ready\"` state — the connection is usable as soon as\n * `EventSource.onopen` fires.\n *\n * Usage:\n * ```typescript\n * const sm = new SseClientStateMachine()\n *\n * sm.subscribeToTransitions(({ from, to }) => {\n * console.log(`${from.status} → ${to.status}`)\n * })\n *\n * sm.transition({ status: \"connecting\", attempt: 1 })\n * sm.transition({ status: \"connected\" })\n *\n * // Transitions are delivered asynchronously via microtask\n * // Listener will see: disconnected → connecting, connecting → connected\n * ```\n */\nexport class SseClientStateMachine extends ClientStateMachine<SseClientState> {\n constructor() {\n super({\n initialState: { status: \"disconnected\" },\n validTransitions: SSE_VALID_TRANSITIONS,\n })\n }\n\n /**\n * Check if the client is connected (EventSource open, channel established).\n */\n isConnected(): boolean {\n return this.getStatus() === \"connected\"\n }\n}\n"],"mappings":"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","names":["error"]}
@@ -0,0 +1,135 @@
1
+ import { PeerId, ChannelMsg } from '@kyneta/exchange';
2
+ import { Request, Router } from 'express';
3
+ import { b as SseServerTransport } from './server-transport-BrMRLsmp.js';
4
+ import { TextReassembler } from '@kyneta/wire';
5
+
6
+ interface SseExpressRouterOptions {
7
+ /**
8
+ * Path for the sync endpoint where clients POST messages.
9
+ * @default "/sync"
10
+ */
11
+ syncPath?: string;
12
+ /**
13
+ * Path for the events endpoint where clients connect via SSE.
14
+ * @default "/events"
15
+ */
16
+ eventsPath?: string;
17
+ /**
18
+ * Interval in milliseconds for sending heartbeat comments to keep connections alive.
19
+ * @default 30000 (30 seconds)
20
+ */
21
+ heartbeatInterval?: number;
22
+ /**
23
+ * Custom function to extract peerId from the sync request.
24
+ * By default, reads from the "x-peer-id" header.
25
+ */
26
+ getPeerIdFromSyncRequest?: (req: Request) => PeerId | undefined;
27
+ /**
28
+ * Custom function to extract peerId from the events request.
29
+ * By default, reads from the "peerId" query parameter.
30
+ */
31
+ getPeerIdFromEventsRequest?: (req: Request) => PeerId | undefined;
32
+ }
33
+ /**
34
+ * Create an Express router for SSE server adapter.
35
+ *
36
+ * This factory function creates Express routes that integrate with the
37
+ * SseServerTransport. It handles:
38
+ * - POST endpoint for clients to send text wire frame messages to the server
39
+ * - GET endpoint for clients to establish SSE connections
40
+ * - Heartbeat mechanism to detect stale connections
41
+ *
42
+ * ## Wire Format
43
+ *
44
+ * The POST endpoint accepts text/plain bodies containing text wire frames
45
+ * (JSON arrays with "0c"/"0f" prefix). The SSE endpoint sends text wire
46
+ * frames as `data:` events. Both directions use the same encoding.
47
+ *
48
+ * @param adapter The SseServerTransport instance
49
+ * @param options Configuration options for the router
50
+ * @returns An Express Router ready to be mounted
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * import { SseServerTransport } from "@kyneta/sse-network-adapter/server"
55
+ * import { createSseExpressRouter } from "@kyneta/sse-network-adapter/express"
56
+ * import { Exchange } from "@kyneta/exchange"
57
+ *
58
+ * const serverAdapter = new SseServerTransport()
59
+ * const exchange = new Exchange({
60
+ * identity: { peerId: "server", name: "server", type: "service" },
61
+ * transports: [() => serverAdapter],
62
+ * })
63
+ *
64
+ * app.use("/sse", createSseExpressRouter(serverAdapter, {
65
+ * syncPath: "/sync",
66
+ * eventsPath: "/events",
67
+ * heartbeatInterval: 30000,
68
+ * }))
69
+ * ```
70
+ */
71
+ declare function createSseExpressRouter(adapter: SseServerTransport, options?: SseExpressRouterOptions): Router;
72
+
73
+ /**
74
+ * Response to send back to the client after processing a POST.
75
+ */
76
+ interface SsePostResponse {
77
+ status: 200 | 202 | 400;
78
+ body: {
79
+ ok: true;
80
+ } | {
81
+ pending: true;
82
+ } | {
83
+ error: string;
84
+ };
85
+ }
86
+ /**
87
+ * Result of parsing a text POST body.
88
+ *
89
+ * Discriminated union describing what happened:
90
+ * - "messages": Complete message(s) decoded, ready to deliver
91
+ * - "pending": Fragment received, waiting for more
92
+ * - "error": Decode/reassembly error
93
+ */
94
+ type SsePostResult = {
95
+ type: "messages";
96
+ messages: ChannelMsg[];
97
+ response: SsePostResponse;
98
+ } | {
99
+ type: "pending";
100
+ response: SsePostResponse;
101
+ } | {
102
+ type: "error";
103
+ response: SsePostResponse;
104
+ };
105
+ /**
106
+ * Parse a text POST body through the reassembler.
107
+ *
108
+ * This is the functional core of POST handling. It:
109
+ * 1. Passes the body through the reassembler (handles fragmentation)
110
+ * 2. If complete, decodes the text frame payload to ChannelMsg(s)
111
+ * 3. Returns a result describing what happened
112
+ *
113
+ * The caller (framework adapter) executes side effects based on the result.
114
+ *
115
+ * @param reassembler - The connection's text fragment reassembler
116
+ * @param body - Text wire frame string (JSON array with "0c"/"0f" prefix)
117
+ * @returns Result describing what to do
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * // In Express router (imperative shell)
122
+ * const result = parseTextPostBody(connection.reassembler, req.body)
123
+ *
124
+ * if (result.type === "messages") {
125
+ * for (const msg of result.messages) {
126
+ * connection.receive(msg)
127
+ * }
128
+ * }
129
+ *
130
+ * res.status(result.response.status).json(result.response.body)
131
+ * ```
132
+ */
133
+ declare function parseTextPostBody(reassembler: TextReassembler, body: string): SsePostResult;
134
+
135
+ export { type SseExpressRouterOptions, type SsePostResponse, type SsePostResult, SseServerTransport, createSseExpressRouter, parseTextPostBody };