@kyneta/sse-transport 1.6.0 → 1.7.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.
@@ -1,5 +1,4 @@
1
- import { Transport, randomPeerId } from "@kyneta/transport";
2
- import { TEXT_WIRE_VERSION, TextReassembler, applyInboundAliasing, applyOutboundAliasing, complete, createFrameIdCounter, decodeTextWires, emptyAliasState, encodeTextFrame, encodeTextWireMessage, fragmentTextPayload } from "@kyneta/wire";
1
+ import { Pipeline, Transport, randomPeerId } from "@kyneta/transport";
3
2
  //#region src/connection.ts
4
3
  /**
5
4
  * Default fragment threshold in characters for outbound SSE messages.
@@ -13,10 +12,9 @@ const DEFAULT_FRAGMENT_THRESHOLD = 6e4;
13
12
  * resolution for one connected client. Created by
14
13
  * `SseServerTransport.registerConnection()`.
15
14
  *
16
- * The connection uses the alias-aware text pipelinethe same
17
- * `applyOutboundAliasing` / `applyInboundAliasing` transformer that
18
- * every other transport uses. This means SSE now participates in
19
- * docId/schemaHash aliasing just like binary transports.
15
+ * Uses Pipeline<"text", "binary">asymmetric encoding:
16
+ * - Send (text): ChannelMsg → text frame → SSE data event
17
+ * - Receive (binary): POST body (Uint8Array) ChannelMsg
20
18
  */
21
19
  var SseConnection = class {
22
20
  peerId;
@@ -24,22 +22,17 @@ var SseConnection = class {
24
22
  #channel = null;
25
23
  #sendFn = null;
26
24
  #onDisconnect = null;
27
- #fragmentThreshold;
28
- #nextFrameId = createFrameIdCounter();
29
- #aliasState = emptyAliasState();
30
- /**
31
- * Text reassembler for handling fragmented POST bodies.
32
- * Each connection has its own reassembler to track in-flight fragment batches.
33
- */
34
- reassembler;
25
+ #pipeline;
35
26
  constructor(peerId, channelId, config) {
36
27
  this.peerId = peerId;
37
28
  this.channelId = channelId;
38
- this.#fragmentThreshold = config?.fragmentThreshold ?? 6e4;
39
- this.reassembler = new TextReassembler({
40
- timeoutMs: 1e4,
41
- onTimeout: (frameId) => {
42
- console.warn(`[SseConnection] Fragment batch timed out for peer ${peerId}: ${frameId}`);
29
+ this.#pipeline = new Pipeline({
30
+ send: "text",
31
+ receive: "binary",
32
+ opts: {
33
+ threshold: config?.fragmentThreshold ?? 6e4,
34
+ reassemblyTimeoutMs: 1e4,
35
+ onError: (e, dir) => console.warn(`[SseConnection] wire error (${dir}) for peer ${peerId}:`, e)
43
36
  }
44
37
  });
45
38
  }
@@ -73,55 +66,49 @@ var SseConnection = class {
73
66
  /**
74
67
  * Send a ChannelMsg to the peer through the SSE stream.
75
68
  *
76
- * Runs the alias-aware outbound pipeline:
77
- * ChannelMsg → applyOutboundAliasingWireMessage → encodeTextWireMessage → text frame → sendFn()
69
+ * Runs the Pipeline<"text"> send path:
70
+ * ChannelMsg → Pipeline.send() → text frame(s) → sendFn()
78
71
  *
79
- * Fragmentation is transparent to callers — the connection splits
72
+ * Fragmentation is transparent to callers — the pipeline splits
80
73
  * large frames into multiple sendFn calls automatically.
81
74
  */
82
75
  send(msg) {
83
76
  if (!this.#sendFn) throw new Error(`Cannot send message: send function not set for peer ${this.peerId}`);
84
- const { state, wire } = applyOutboundAliasing(this.#aliasState, msg);
85
- this.#aliasState = state;
86
- const payload = JSON.stringify(encodeTextWireMessage(wire));
87
- const textFrame = encodeTextFrame(complete(TEXT_WIRE_VERSION, payload));
88
- if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
89
- const fragments = fragmentTextPayload(payload, this.#fragmentThreshold, this.#nextFrameId());
90
- for (const fragment of fragments) this.#sendFn(fragment);
91
- } else this.#sendFn(textFrame);
77
+ for (const r of this.#pipeline.send(msg)) if (r.ok) this.#sendFn(r.value);
92
78
  }
93
79
  /**
94
- * Handle an inbound POST body through the full alias-aware inbound pipeline.
80
+ * Handle an inbound POST body through the Pipeline<"binary"> receive path.
95
81
  *
96
- * Pipeline: text frame TextReassemblerdecodeTextWireMessage → applyInboundAliasing → ChannelMsg
82
+ * Pipeline: Uint8ArrayPipeline.receive() → ChannelMsg[]
97
83
  *
98
- * Messages that fail alias resolution are silently skipped (logged and
99
- * dropped) — the connection continues processing remaining messages.
100
- * This matches the error-dropping behavior of every other transport.
84
+ * Messages that fail alias resolution or wire-message validation are
85
+ * surfaced as `type: "error"` results — the connection continues
86
+ * processing remaining messages. This matches the error-dropping
87
+ * behavior of every other transport.
101
88
  *
102
- * @param body - Text wire frame string (JSON array with "1c"/"1f" prefix)
103
- * @returns Result describing what happened
89
+ * @param body - Binary POST body (Uint8Array)
90
+ * @returns Discriminated result: `"messages"`, `"pending"`, or `"error"`
104
91
  */
105
92
  handlePostBody(body) {
106
93
  try {
107
- const wires = decodeTextWires(this.reassembler, body);
108
- if (wires === null) return {
94
+ const messages = [];
95
+ let hadError = false;
96
+ for (const r of this.#pipeline.receive(body)) if (r.ok) messages.push(r.value);
97
+ else hadError = true;
98
+ if (hadError && messages.length === 0) return {
99
+ type: "error",
100
+ response: {
101
+ status: 400,
102
+ body: { error: "decode_failed" }
103
+ }
104
+ };
105
+ if (messages.length === 0) return {
109
106
  type: "pending",
110
107
  response: {
111
108
  status: 202,
112
109
  body: { pending: true }
113
110
  }
114
111
  };
115
- const messages = [];
116
- for (const wire of wires) {
117
- const result = applyInboundAliasing(this.#aliasState, wire);
118
- this.#aliasState = result.state;
119
- if (result.error) {
120
- console.warn(`[SseConnection] alias resolution failed for peer ${this.peerId}:`, result.error);
121
- continue;
122
- }
123
- if (result.msg) messages.push(result.msg);
124
- }
125
112
  return {
126
113
  type: "messages",
127
114
  messages,
@@ -161,7 +148,7 @@ var SseConnection = class {
161
148
  * Must be called when the connection is closed to prevent timer leaks.
162
149
  */
163
150
  dispose() {
164
- this.reassembler.dispose();
151
+ this.#pipeline.dispose();
165
152
  }
166
153
  };
167
154
  //#endregion
@@ -253,7 +240,7 @@ var SseServerTransport = class extends Transport {
253
240
  /**
254
241
  * Unregister a peer connection.
255
242
  *
256
- * Removes the channel, disposes the connection's reassembler,
243
+ * Removes the channel, disposes the connection's pipeline,
257
244
  * and cleans up tracking state. Called automatically when the
258
245
  * client disconnects (via req.on("close")) or manually.
259
246
  *
@@ -295,4 +282,4 @@ var SseServerTransport = class extends Transport {
295
282
  //#endregion
296
283
  export { DEFAULT_FRAGMENT_THRESHOLD as n, SseConnection as r, SseServerTransport as t };
297
284
 
298
- //# sourceMappingURL=server-transport-DX3-TbCZ.js.map
285
+ //# sourceMappingURL=server-transport-CwEVpOpu.js.map
@@ -0,0 +1 @@
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,5 +1,4 @@
1
1
  import { Channel, ChannelMsg, GeneratedChannel, PeerId, Transport } from "@kyneta/transport";
2
- import { TextReassembler } from "@kyneta/wire";
3
2
 
4
3
  //#region src/connection.d.ts
5
4
  /**
@@ -16,7 +15,7 @@ interface SsePostResponse {
16
15
  };
17
16
  }
18
17
  /**
19
- * Result of parsing a text POST body.
18
+ * Result of parsing a POST body.
20
19
  *
21
20
  * Discriminated union describing what happened:
22
21
  * - "messages": Complete message(s) decoded, ready to deliver
@@ -57,20 +56,14 @@ interface SseConnectionConfig {
57
56
  * resolution for one connected client. Created by
58
57
  * `SseServerTransport.registerConnection()`.
59
58
  *
60
- * The connection uses the alias-aware text pipelinethe same
61
- * `applyOutboundAliasing` / `applyInboundAliasing` transformer that
62
- * every other transport uses. This means SSE now participates in
63
- * docId/schemaHash aliasing just like binary transports.
59
+ * Uses Pipeline<"text", "binary">asymmetric encoding:
60
+ * - Send (text): ChannelMsg → text frame → SSE data event
61
+ * - Receive (binary): POST body (Uint8Array) ChannelMsg
64
62
  */
65
63
  declare class SseConnection {
66
64
  #private;
67
65
  readonly peerId: PeerId;
68
66
  readonly channelId: number;
69
- /**
70
- * Text reassembler for handling fragmented POST bodies.
71
- * Each connection has its own reassembler to track in-flight fragment batches.
72
- */
73
- readonly reassembler: TextReassembler;
74
67
  constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig);
75
68
  /**
76
69
  * Set the channel reference.
@@ -96,26 +89,27 @@ declare class SseConnection {
96
89
  /**
97
90
  * Send a ChannelMsg to the peer through the SSE stream.
98
91
  *
99
- * Runs the alias-aware outbound pipeline:
100
- * ChannelMsg → applyOutboundAliasingWireMessage → encodeTextWireMessage → text frame → sendFn()
92
+ * Runs the Pipeline<"text"> send path:
93
+ * ChannelMsg → Pipeline.send() → text frame(s) → sendFn()
101
94
  *
102
- * Fragmentation is transparent to callers — the connection splits
95
+ * Fragmentation is transparent to callers — the pipeline splits
103
96
  * large frames into multiple sendFn calls automatically.
104
97
  */
105
98
  send(msg: ChannelMsg): void;
106
99
  /**
107
- * Handle an inbound POST body through the full alias-aware inbound pipeline.
100
+ * Handle an inbound POST body through the Pipeline<"binary"> receive path.
108
101
  *
109
- * Pipeline: text frame TextReassemblerdecodeTextWireMessage → applyInboundAliasing → ChannelMsg
102
+ * Pipeline: Uint8ArrayPipeline.receive() → ChannelMsg[]
110
103
  *
111
- * Messages that fail alias resolution are silently skipped (logged and
112
- * dropped) — the connection continues processing remaining messages.
113
- * This matches the error-dropping behavior of every other transport.
104
+ * Messages that fail alias resolution or wire-message validation are
105
+ * surfaced as `type: "error"` results — the connection continues
106
+ * processing remaining messages. This matches the error-dropping
107
+ * behavior of every other transport.
114
108
  *
115
- * @param body - Text wire frame string (JSON array with "1c"/"1f" prefix)
116
- * @returns Result describing what happened
109
+ * @param body - Binary POST body (Uint8Array)
110
+ * @returns Discriminated result: `"messages"`, `"pending"`, or `"error"`
117
111
  */
118
- handlePostBody(body: string): SsePostResult;
112
+ handlePostBody(body: Uint8Array<ArrayBuffer>): SsePostResult;
119
113
  /**
120
114
  * Receive a message from the peer and route it to the channel.
121
115
  *
@@ -204,7 +198,7 @@ declare class SseServerTransport extends Transport<PeerId> {
204
198
  /**
205
199
  * Unregister a peer connection.
206
200
  *
207
- * Removes the channel, disposes the connection's reassembler,
201
+ * Removes the channel, disposes the connection's pipeline,
208
202
  * and cleans up tracking state. Called automatically when the
209
203
  * client disconnects (via req.on("close")) or manually.
210
204
  *
@@ -230,4 +224,4 @@ declare class SseServerTransport extends Transport<PeerId> {
230
224
  }
231
225
  //#endregion
232
226
  export { SseConnectionConfig as a, SseConnection as i, SseServerTransportOptions as n, SsePostResponse as o, DEFAULT_FRAGMENT_THRESHOLD as r, SsePostResult as s, SseServerTransport as t };
233
- //# sourceMappingURL=server-transport-DXK7KMx4.d.ts.map
227
+ //# sourceMappingURL=server-transport-PIK5bPSw.d.ts.map
@@ -0,0 +1 @@
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"}
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-DXK7KMx4.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";
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-DX3-TbCZ.js";
1
+ import { n as DEFAULT_FRAGMENT_THRESHOLD, r as SseConnection, t as SseServerTransport } from "./server-transport-CwEVpOpu.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.6.0",
3
+ "version": "1.7.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",
@@ -29,13 +29,11 @@
29
29
  "./express": {
30
30
  "types": "./dist/express.d.ts",
31
31
  "import": "./dist/express.js"
32
- },
33
- "./src/*": "./src/*"
32
+ }
34
33
  },
35
34
  "peerDependencies": {
36
- "@kyneta/machine": "^1.6.0",
37
- "@kyneta/transport": "^1.6.0",
38
- "@kyneta/wire": "^1.6.0"
35
+ "@kyneta/machine": "^1.7.0",
36
+ "@kyneta/transport": "^1.7.0"
39
37
  },
40
38
  "peerDependenciesMeta": {
41
39
  "express": {
@@ -49,11 +47,11 @@
49
47
  "tsdown": "^0.22.0",
50
48
  "typescript": "^5.9.2",
51
49
  "vitest": "^4.0.17",
52
- "@kyneta/exchange": "^1.6.0",
53
- "@kyneta/schema": "^1.6.0",
54
- "@kyneta/transport": "^1.6.0",
55
- "@kyneta/wire": "^1.6.0",
56
- "@kyneta/machine": "^1.6.0"
50
+ "@kyneta/exchange": "^1.7.0",
51
+ "@kyneta/schema": "^1.7.0",
52
+ "@kyneta/machine": "^1.7.0",
53
+ "@kyneta/wire": "^1.7.0",
54
+ "@kyneta/transport": "^1.7.0"
57
55
  },
58
56
  "scripts": {
59
57
  "build": "tsdown",
@@ -17,7 +17,7 @@ const URL = "http://localhost:3000/events"
17
17
  function setup(opts: { maxAttempts?: number; enabled?: boolean } = {}) {
18
18
  const program = createSseClientProgram({
19
19
  url: URL,
20
- jitterFn: () => 0,
20
+ randomFn: () => 0,
21
21
  reconnect: {
22
22
  enabled: opts.enabled ?? true,
23
23
  maxAttempts: opts.maxAttempts ?? 10,
@@ -14,12 +14,9 @@
14
14
  // POSTs, so any thrown error escapes the promise chain.
15
15
 
16
16
  import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
17
- import type {
18
- ChannelMsg,
19
- PeerIdentityDetails,
20
- TransportContext,
21
- } from "@kyneta/transport"
22
- import * as wireModule from "@kyneta/wire"
17
+ import type { ChannelMsg, PeerIdentityDetails } from "@kyneta/transport"
18
+ import { Pipeline } from "@kyneta/transport"
19
+ import { createTestTransportContext } from "@kyneta/transport/testing"
23
20
  import { describe, expect, it, vi } from "vitest"
24
21
  import type { SseClientOptions } from "../client-transport.js"
25
22
  import { SseClientTransport } from "../client-transport.js"
@@ -34,18 +31,8 @@ const testIdentity: PeerIdentityDetails = {
34
31
  type: "user",
35
32
  }
36
33
 
37
- function createContext(
38
- overrides?: Partial<TransportContext>,
39
- ): TransportContext {
40
- return {
41
- identity: testIdentity,
42
- onChannelReceive: vi.fn(),
43
- onChannelAdded: vi.fn(),
44
- onChannelRemoved: vi.fn(),
45
- onChannelEstablish: vi.fn(),
46
- ...overrides,
47
- }
48
- }
34
+ const createContext = (overrides = {}) =>
35
+ createTestTransportContext({ identity: testIdentity, ...overrides })
49
36
 
50
37
  /**
51
38
  * A minimal EventSource mock that stays in CONNECTING state and never fires
@@ -79,15 +66,13 @@ class MockEventSource {
79
66
  // Alias state reset on reconnect
80
67
  // ---------------------------------------------------------------------------
81
68
 
82
- describe("SseClientTransport — alias state on reconnect", () => {
83
- it("resets alias state when generating a new channel", async () => {
84
- // Each call to generate() must produce a send function with a fresh
85
- // alias table. We verify this by spying on `emptyAliasState`: after
86
- // two addChannel() calls (simulating initial connect + reconnect),
87
- // the spy should count 3 invocations — 1 from the constructor field
88
- // initializer, plus 1 per generate().
69
+ describe("SseClientTransport — pipeline reset on reconnect", () => {
70
+ it("resets pipeline state when generating a new channel", async () => {
71
+ // Each call to generate() must call pipeline.reset() to ensure
72
+ // a fresh alias table and reassembler state for the new connection.
73
+ // We verify this by spying on Pipeline.prototype.reset.
89
74
 
90
- const spy = vi.spyOn(wireModule, "emptyAliasState")
75
+ const spy = vi.spyOn(Pipeline.prototype, "reset")
91
76
 
92
77
  const oldEventSource = (globalThis as Record<string, unknown>).EventSource
93
78
  ;(globalThis as Record<string, unknown>).EventSource = MockEventSource
@@ -106,30 +91,27 @@ describe("SseClientTransport — alias state on reconnect", () => {
106
91
  }
107
92
 
108
93
  const transport = new SseClientTransport(options)
109
- // Constructor field init: #aliasState = emptyAliasState()
110
94
  const afterConstructor = spy.mock.calls.length
111
95
 
112
96
  const ctx = createContext()
113
- transport._initialize(ctx)
97
+ await transport._initialize(ctx)
114
98
  await transport._start()
115
99
 
116
- // Connection 1: addChannel calls generate()
117
- // addChannel and generate() are protected; use any-cast in tests.
100
+ // Connection 1: addChannel calls generate() which calls pipeline.reset()
118
101
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
102
  const channel1 = (transport as any).addChannel()
120
103
  const afterFirstAddChannel = spy.mock.calls.length
121
104
 
122
105
  // Simulate reconnect: remove old channel, add new one
123
- // removeChannel is also protected.
124
106
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
125
107
  ;(transport as any).removeChannel(channel1.channelId)
126
108
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
109
  ;(transport as any).addChannel()
128
110
  const afterSecondAddChannel = spy.mock.calls.length
129
111
 
130
- expect(afterConstructor).toBe(1) // field init only
131
- expect(afterFirstAddChannel).toBe(2) // + 1 from first generate()
132
- expect(afterSecondAddChannel).toBe(3) // + 1 from second generate()
112
+ expect(afterConstructor).toBe(0) // no reset in constructor
113
+ expect(afterFirstAddChannel).toBe(1) // +1 from first generate()
114
+ expect(afterSecondAddChannel).toBe(2) // +1 from second generate()
133
115
  } finally {
134
116
  spy.mockRestore()
135
117
  globalThis.fetch = oldFetch
@@ -185,7 +167,7 @@ describe("SseClientTransport — unhandled rejections", () => {
185
167
  const transport = new SseClientTransport(options)
186
168
  const ctx = createContext()
187
169
 
188
- transport._initialize(ctx)
170
+ await transport._initialize(ctx)
189
171
  await transport._start()
190
172
 
191
173
  // addChannel is protected; use any-cast.