@kyneta/sse-transport 1.3.1 → 1.4.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.
@@ -5,6 +5,7 @@
5
5
  // 2. Fragment text frame body → returns pending, then complete on final fragment
6
6
  // 3. Malformed body → returns error
7
7
 
8
+ import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
8
9
  import type { ChannelMsg } from "@kyneta/transport"
9
10
  import {
10
11
  encodeTextComplete,
@@ -25,13 +26,13 @@ const presentMsg: ChannelMsg = {
25
26
  {
26
27
  docId: "doc-1",
27
28
  replicaType: ["plain", 1, 0] as const,
28
- mergeStrategy: "authoritative" as const,
29
+ syncProtocol: SYNC_AUTHORITATIVE,
29
30
  schemaHash: "test-hash",
30
31
  },
31
32
  {
32
33
  docId: "doc-2",
33
34
  replicaType: ["plain", 1, 0] as const,
34
- mergeStrategy: "authoritative" as const,
35
+ syncProtocol: SYNC_AUTHORITATIVE,
35
36
  schemaHash: "test-hash",
36
37
  },
37
38
  ],
@@ -24,7 +24,7 @@
24
24
  // The connection handshake:
25
25
  // 1. Client creates EventSource, waits for open
26
26
  // 2. EventSource.onopen fires → client creates channel + calls establishChannel()
27
- // 3. Synchronizer exchanges establish-request / establish-response via POST + SSE
27
+ // 3. Synchronizer exchanges establish messages via POST + SSE
28
28
  //
29
29
  // On EventSource.onerror, the adapter closes the EventSource immediately and
30
30
  // takes over reconnection via the program's backoff logic, rather than
@@ -147,7 +147,7 @@ export type SseClientStateTransition = StateTransition<SseClientState>
147
147
  * import { createSseClient } from "@kyneta/sse-transport/client"
148
148
  *
149
149
  * const exchange = new Exchange({
150
- * identity: { peerId: "browser-client" },
150
+ * id: "browser-client",
151
151
  * transports: [createSseClient({
152
152
  * postUrl: "/sync",
153
153
  * eventSourceUrl: (peerId) => `/events?peerId=${peerId}`,
@@ -83,7 +83,7 @@ export interface SseExpressRouterOptions {
83
83
  *
84
84
  * const serverAdapter = new SseServerTransport()
85
85
  * const exchange = new Exchange({
86
- * identity: { peerId: "server", name: "server", type: "service" },
86
+ * id: { peerId: "server", name: "server", type: "service" },
87
87
  * transports: [() => serverAdapter],
88
88
  * })
89
89
  *
@@ -75,11 +75,11 @@ function generatePeerId(): PeerId {
75
75
  * The connection handshake:
76
76
  * 1. Client opens EventSource (GET /events)
77
77
  * 2. Server calls `registerConnection(peerId)` → creates channel
78
- * 3. Client's EventSource.onopen fires → client sends establish-request (POST)
79
- * 4. Server receives establish-request → Synchronizer responds with establish-response (SSE)
78
+ * 3. Client's EventSource.onopen fires → client sends establish (POST)
79
+ * 4. Server receives establish → Synchronizer upgrades channel and sends present (SSE)
80
80
  *
81
81
  * The server does NOT call `establishChannel()` — it waits for the client's
82
- * establish-request, which arrives via POST after the EventSource is open.
82
+ * establish message, which arrives via POST after the EventSource is open.
83
83
  */
84
84
  export class SseServerTransport extends Transport<PeerId> {
85
85
  #connections = new Map<PeerId, SseConnection>()
@@ -1,38 +0,0 @@
1
- var __create = Object.create;
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __getProtoOf = Object.getPrototypeOf;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
8
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
9
- }) : x)(function(x) {
10
- if (typeof require !== "undefined") return require.apply(this, arguments);
11
- throw Error('Dynamic require of "' + x + '" is not supported');
12
- });
13
- var __commonJS = (cb, mod) => function __require2() {
14
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
15
- };
16
- var __copyProps = (to, from, except, desc) => {
17
- if (from && typeof from === "object" || typeof from === "function") {
18
- for (let key of __getOwnPropNames(from))
19
- if (!__hasOwnProp.call(to, key) && key !== except)
20
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
21
- }
22
- return to;
23
- };
24
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
25
- // If the importer is in node compatibility mode or this is not an ESM
26
- // file that has been converted to a CommonJS file using a Babel-
27
- // compatible transform (i.e. "__esModule" has not been set), then set
28
- // "default" to the CommonJS "module.exports" for node compatibility.
29
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
30
- mod
31
- ));
32
-
33
- export {
34
- __require,
35
- __commonJS,
36
- __toESM
37
- };
38
- //# sourceMappingURL=chunk-7D4SUZUM.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -1,255 +0,0 @@
1
- // src/connection.ts
2
- import {
3
- encodeTextComplete,
4
- fragmentTextPayload,
5
- TextReassembler,
6
- textCodec
7
- } from "@kyneta/wire";
8
- var DEFAULT_FRAGMENT_THRESHOLD = 6e4;
9
- var SseConnection = class {
10
- peerId;
11
- channelId;
12
- #channel = null;
13
- #sendFn = null;
14
- #onDisconnect = null;
15
- // Fragmentation support
16
- #fragmentThreshold;
17
- /**
18
- * Text reassembler for handling fragmented POST bodies.
19
- * Each connection has its own reassembler to track in-flight fragment batches.
20
- */
21
- reassembler;
22
- constructor(peerId, channelId, config) {
23
- this.peerId = peerId;
24
- this.channelId = channelId;
25
- this.#fragmentThreshold = config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
26
- this.reassembler = new TextReassembler({
27
- timeoutMs: 1e4,
28
- onTimeout: (frameId) => {
29
- console.warn(
30
- `[SseConnection] Fragment batch timed out for peer ${peerId}: ${frameId}`
31
- );
32
- }
33
- });
34
- }
35
- // ==========================================================================
36
- // INTERNAL API — for adapter use
37
- // ==========================================================================
38
- /**
39
- * Set the channel reference.
40
- * Called by the adapter when the channel is created.
41
- * @internal
42
- */
43
- _setChannel(channel) {
44
- this.#channel = channel;
45
- }
46
- // ==========================================================================
47
- // PUBLIC API
48
- // ==========================================================================
49
- /**
50
- * Set the function to call when sending messages to this peer.
51
- *
52
- * The function receives a fully encoded text frame string.
53
- * The framework integration just wraps it in SSE syntax:
54
- * - Express: `res.write(\`data: \${textFrame}\\n\\n\`)`
55
- * - Hono: `stream.writeSSE({ data: textFrame })`
56
- *
57
- * @param sendFn Function that writes a text frame string to the SSE stream
58
- */
59
- setSendFunction(sendFn) {
60
- this.#sendFn = sendFn;
61
- }
62
- /**
63
- * Set the function to call when this connection is disconnected.
64
- */
65
- setDisconnectHandler(handler) {
66
- this.#onDisconnect = handler;
67
- }
68
- /**
69
- * Send a ChannelMsg to the peer through the SSE stream.
70
- *
71
- * Encodes via textCodec → text frame → fragment if needed → sendFn().
72
- * Encoding and fragmentation are the connection's concern — the
73
- * framework integration only needs to write strings.
74
- */
75
- send(msg) {
76
- if (!this.#sendFn) {
77
- throw new Error(
78
- `Cannot send message: send function not set for peer ${this.peerId}`
79
- );
80
- }
81
- const textFrame = encodeTextComplete(textCodec, msg);
82
- if (this.#fragmentThreshold > 0 && textFrame.length > this.#fragmentThreshold) {
83
- const payload = JSON.stringify(textCodec.encode(msg));
84
- const fragments = fragmentTextPayload(payload, this.#fragmentThreshold);
85
- for (const fragment of fragments) {
86
- this.#sendFn(fragment);
87
- }
88
- } else {
89
- this.#sendFn(textFrame);
90
- }
91
- }
92
- /**
93
- * Receive a message from the peer and route it to the channel.
94
- *
95
- * Called by the framework integration after parsing a POST body
96
- * through `parseTextPostBody`.
97
- */
98
- receive(msg) {
99
- if (!this.#channel) {
100
- throw new Error(
101
- `Cannot receive message: channel not set for peer ${this.peerId}`
102
- );
103
- }
104
- this.#channel.onReceive(msg);
105
- }
106
- /**
107
- * Disconnect this connection.
108
- */
109
- disconnect() {
110
- this.#onDisconnect?.();
111
- }
112
- /**
113
- * Dispose of resources held by this connection.
114
- * Must be called when the connection is closed to prevent timer leaks.
115
- */
116
- dispose() {
117
- this.reassembler.dispose();
118
- }
119
- };
120
-
121
- // src/server-transport.ts
122
- import { Transport } from "@kyneta/transport";
123
- function generatePeerId() {
124
- const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
125
- let result = "sse-";
126
- for (let i = 0; i < 12; i++) {
127
- result += chars.charAt(Math.floor(Math.random() * chars.length));
128
- }
129
- return result;
130
- }
131
- var SseServerTransport = class extends Transport {
132
- #connections = /* @__PURE__ */ new Map();
133
- #fragmentThreshold;
134
- constructor(options) {
135
- super({ transportType: "sse-server" });
136
- this.#fragmentThreshold = options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD;
137
- }
138
- // ==========================================================================
139
- // Adapter abstract method implementations
140
- // ==========================================================================
141
- generate(peerId) {
142
- return {
143
- transportType: this.transportType,
144
- send: (msg) => {
145
- const connection = this.#connections.get(peerId);
146
- if (connection) {
147
- connection.send(msg);
148
- }
149
- },
150
- stop: () => {
151
- this.unregisterConnection(peerId);
152
- }
153
- };
154
- }
155
- async onStart() {
156
- }
157
- async onStop() {
158
- for (const connection of this.#connections.values()) {
159
- connection.disconnect();
160
- }
161
- this.#connections.clear();
162
- }
163
- // ==========================================================================
164
- // Connection management
165
- // ==========================================================================
166
- /**
167
- * Register a new peer connection.
168
- *
169
- * Call this from your framework's SSE endpoint handler when a client
170
- * connects via EventSource. Returns an `SseConnection` that you wire
171
- * up with `setSendFunction()` and `setDisconnectHandler()`.
172
- *
173
- * @param peerId The unique identifier for the peer (from query param or header)
174
- * @returns An SseConnection object for managing the connection
175
- *
176
- * @example Express
177
- * ```typescript
178
- * const connection = serverAdapter.registerConnection(peerId)
179
- * connection.setSendFunction((textFrame) => {
180
- * res.write(`data: ${textFrame}\n\n`)
181
- * })
182
- * ```
183
- *
184
- * @example Hono
185
- * ```typescript
186
- * const connection = serverAdapter.registerConnection(peerId)
187
- * connection.setSendFunction((textFrame) => {
188
- * stream.writeSSE({ data: textFrame })
189
- * })
190
- * ```
191
- */
192
- registerConnection(peerId) {
193
- const resolvedPeerId = peerId ?? generatePeerId();
194
- const existingConnection = this.#connections.get(resolvedPeerId);
195
- if (existingConnection) {
196
- existingConnection.dispose();
197
- this.unregisterConnection(resolvedPeerId);
198
- }
199
- const channel = this.addChannel(resolvedPeerId);
200
- const connection = new SseConnection(resolvedPeerId, channel.channelId, {
201
- fragmentThreshold: this.#fragmentThreshold
202
- });
203
- connection._setChannel(channel);
204
- this.#connections.set(resolvedPeerId, connection);
205
- return connection;
206
- }
207
- /**
208
- * Unregister a peer connection.
209
- *
210
- * Removes the channel, disposes the connection's reassembler,
211
- * and cleans up tracking state. Called automatically when the
212
- * client disconnects (via req.on("close")) or manually.
213
- *
214
- * @param peerId The unique identifier for the peer
215
- */
216
- unregisterConnection(peerId) {
217
- const connection = this.#connections.get(peerId);
218
- if (connection) {
219
- connection.dispose();
220
- this.removeChannel(connection.channelId);
221
- this.#connections.delete(peerId);
222
- }
223
- }
224
- /**
225
- * Get an active connection by peer ID.
226
- */
227
- getConnection(peerId) {
228
- return this.#connections.get(peerId);
229
- }
230
- /**
231
- * Get all active connections.
232
- */
233
- getAllConnections() {
234
- return Array.from(this.#connections.values());
235
- }
236
- /**
237
- * Check if a peer is connected.
238
- */
239
- isConnected(peerId) {
240
- return this.#connections.has(peerId);
241
- }
242
- /**
243
- * Get the number of connected peers.
244
- */
245
- get connectionCount() {
246
- return this.#connections.size;
247
- }
248
- };
249
-
250
- export {
251
- DEFAULT_FRAGMENT_THRESHOLD,
252
- SseConnection,
253
- SseServerTransport
254
- };
255
- //# sourceMappingURL=chunk-ZBE5AMNA.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/connection.ts","../src/server-transport.ts"],"sourcesContent":["// connection — SseConnection for server-side peer connections.\n//\n// Wraps a TextReassembler + textCodec to provide send/receive for\n// 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\nimport type { Channel, ChannelMsg, PeerId } from \"@kyneta/transport\"\nimport {\n encodeTextComplete,\n fragmentTextPayload,\n TextReassembler,\n textCodec,\n} from \"@kyneta/wire\"\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, and reassembly for one\n * connected client. Created by `SseServerTransport.registerConnection()`.\n *\n * The connection uses the text codec for transport — this is the natural\n * choice for SSE's text-only protocol.\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 // Fragmentation support\n readonly #fragmentThreshold: number\n\n /**\n * Text reassembler for handling fragmented POST bodies.\n * Each connection has its own reassembler to track in-flight fragment batches.\n */\n readonly reassembler: TextReassembler\n\n constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig) {\n this.peerId = peerId\n this.channelId = channelId\n this.#fragmentThreshold =\n config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD\n this.reassembler = new TextReassembler({\n timeoutMs: 10_000,\n onTimeout: (frameId: string) => {\n console.warn(\n `[SseConnection] Fragment batch timed out for peer ${peerId}: ${frameId}`,\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 * Encodes via textCodec → text frame → fragment if needed → sendFn().\n * Encoding and fragmentation are the connection's concern — the\n * framework integration only needs to write strings.\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 // 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(payload, this.#fragmentThreshold)\n for (const fragment of fragments) {\n this.#sendFn(fragment)\n }\n } else {\n this.#sendFn(textFrame)\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 parsing a POST body\n * through `parseTextPostBody`.\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.reassembler.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 { parseTextPostBody } 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 parseTextPostBody()\n\nimport type { ChannelMsg, GeneratedChannel, PeerId } from \"@kyneta/transport\"\nimport { 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// Peer ID generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a random peer ID for connections that don't provide one.\n */\nfunction generatePeerId(): PeerId {\n const chars = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\"\n let result = \"sse-\"\n for (let i = 0; i < 12; i++) {\n result += chars.charAt(Math.floor(Math.random() * chars.length))\n }\n return result\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-request (POST)\n * 4. Server receives establish-request → Synchronizer responds with establish-response (SSE)\n *\n * The server does NOT call `establishChannel()` — it waits for the client's\n * establish-request, 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 ?? generatePeerId()\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 reassembler,\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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","names":[]}
@@ -1,180 +0,0 @@
1
- import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel } from '@kyneta/transport';
2
- import { TextReassembler } from '@kyneta/wire';
3
-
4
- /**
5
- * Default fragment threshold in characters for outbound SSE messages.
6
- * 60K chars provides a safety margin below typical infrastructure limits.
7
- */
8
- declare const DEFAULT_FRAGMENT_THRESHOLD = 60000;
9
- /**
10
- * Configuration for creating an SseConnection.
11
- */
12
- interface SseConnectionConfig {
13
- /**
14
- * Fragment threshold in characters. Messages larger than this are fragmented.
15
- * Set to 0 to disable fragmentation.
16
- * Default: 60000 (60K chars)
17
- */
18
- fragmentThreshold?: number;
19
- }
20
- /**
21
- * Represents a single SSE connection to a peer (server-side).
22
- *
23
- * Manages encoding, framing, fragmentation, and reassembly for one
24
- * connected client. Created by `SseServerTransport.registerConnection()`.
25
- *
26
- * The connection uses the text codec for transport — this is the natural
27
- * choice for SSE's text-only protocol.
28
- */
29
- declare class SseConnection {
30
- #private;
31
- readonly peerId: PeerId;
32
- readonly channelId: number;
33
- /**
34
- * Text reassembler for handling fragmented POST bodies.
35
- * Each connection has its own reassembler to track in-flight fragment batches.
36
- */
37
- readonly reassembler: TextReassembler;
38
- constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig);
39
- /**
40
- * Set the channel reference.
41
- * Called by the adapter when the channel is created.
42
- * @internal
43
- */
44
- _setChannel(channel: Channel): void;
45
- /**
46
- * Set the function to call when sending messages to this peer.
47
- *
48
- * The function receives a fully encoded text frame string.
49
- * The framework integration just wraps it in SSE syntax:
50
- * - Express: `res.write(\`data: \${textFrame}\\n\\n\`)`
51
- * - Hono: `stream.writeSSE({ data: textFrame })`
52
- *
53
- * @param sendFn Function that writes a text frame string to the SSE stream
54
- */
55
- setSendFunction(sendFn: (textFrame: string) => void): void;
56
- /**
57
- * Set the function to call when this connection is disconnected.
58
- */
59
- setDisconnectHandler(handler: () => void): void;
60
- /**
61
- * Send a ChannelMsg to the peer through the SSE stream.
62
- *
63
- * Encodes via textCodec → text frame → fragment if needed → sendFn().
64
- * Encoding and fragmentation are the connection's concern — the
65
- * framework integration only needs to write strings.
66
- */
67
- send(msg: ChannelMsg): void;
68
- /**
69
- * Receive a message from the peer and route it to the channel.
70
- *
71
- * Called by the framework integration after parsing a POST body
72
- * through `parseTextPostBody`.
73
- */
74
- receive(msg: ChannelMsg): void;
75
- /**
76
- * Disconnect this connection.
77
- */
78
- disconnect(): void;
79
- /**
80
- * Dispose of resources held by this connection.
81
- * Must be called when the connection is closed to prevent timer leaks.
82
- */
83
- dispose(): void;
84
- }
85
-
86
- /**
87
- * Options for the SSE server adapter.
88
- */
89
- interface SseServerTransportOptions {
90
- /**
91
- * Fragment threshold in characters. Messages larger than this are fragmented
92
- * into multiple SSE events.
93
- * Set to 0 to disable fragmentation.
94
- * Default: 60000 (60K chars)
95
- */
96
- fragmentThreshold?: number;
97
- }
98
- /**
99
- * SSE server network adapter.
100
- *
101
- * Framework-agnostic — works with any HTTP framework through the
102
- * `SseConnection.setSendFunction()` callback. Use `registerConnection()`
103
- * to integrate with your framework's SSE endpoint handler.
104
- *
105
- * Each client connection is tracked as an `SseConnection` keyed by peer ID.
106
- * The adapter creates a channel per connection and routes outbound messages
107
- * through the connection's send method (which encodes to text wire format
108
- * and calls the injected sendFn).
109
- *
110
- * The connection handshake:
111
- * 1. Client opens EventSource (GET /events)
112
- * 2. Server calls `registerConnection(peerId)` → creates channel
113
- * 3. Client's EventSource.onopen fires → client sends establish-request (POST)
114
- * 4. Server receives establish-request → Synchronizer responds with establish-response (SSE)
115
- *
116
- * The server does NOT call `establishChannel()` — it waits for the client's
117
- * establish-request, which arrives via POST after the EventSource is open.
118
- */
119
- declare class SseServerTransport extends Transport<PeerId> {
120
- #private;
121
- constructor(options?: SseServerTransportOptions);
122
- protected generate(peerId: PeerId): GeneratedChannel;
123
- onStart(): Promise<void>;
124
- onStop(): Promise<void>;
125
- /**
126
- * Register a new peer connection.
127
- *
128
- * Call this from your framework's SSE endpoint handler when a client
129
- * connects via EventSource. Returns an `SseConnection` that you wire
130
- * up with `setSendFunction()` and `setDisconnectHandler()`.
131
- *
132
- * @param peerId The unique identifier for the peer (from query param or header)
133
- * @returns An SseConnection object for managing the connection
134
- *
135
- * @example Express
136
- * ```typescript
137
- * const connection = serverAdapter.registerConnection(peerId)
138
- * connection.setSendFunction((textFrame) => {
139
- * res.write(`data: ${textFrame}\n\n`)
140
- * })
141
- * ```
142
- *
143
- * @example Hono
144
- * ```typescript
145
- * const connection = serverAdapter.registerConnection(peerId)
146
- * connection.setSendFunction((textFrame) => {
147
- * stream.writeSSE({ data: textFrame })
148
- * })
149
- * ```
150
- */
151
- registerConnection(peerId?: PeerId): SseConnection;
152
- /**
153
- * Unregister a peer connection.
154
- *
155
- * Removes the channel, disposes the connection's reassembler,
156
- * and cleans up tracking state. Called automatically when the
157
- * client disconnects (via req.on("close")) or manually.
158
- *
159
- * @param peerId The unique identifier for the peer
160
- */
161
- unregisterConnection(peerId: PeerId): void;
162
- /**
163
- * Get an active connection by peer ID.
164
- */
165
- getConnection(peerId: PeerId): SseConnection | undefined;
166
- /**
167
- * Get all active connections.
168
- */
169
- getAllConnections(): SseConnection[];
170
- /**
171
- * Check if a peer is connected.
172
- */
173
- isConnected(peerId: PeerId): boolean;
174
- /**
175
- * Get the number of connected peers.
176
- */
177
- get connectionCount(): number;
178
- }
179
-
180
- export { DEFAULT_FRAGMENT_THRESHOLD as D, SseConnection as S, type SseConnectionConfig as a, SseServerTransport as b, type SseServerTransportOptions as c };
@@ -1 +0,0 @@
1
- {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -1,82 +0,0 @@
1
- import { PeerId } from '@kyneta/transport';
2
-
3
- /**
4
- * Discriminated union describing why an SSE connection was lost.
5
- *
6
- * Unlike WebSocket's DisconnectReason, SSE does not have:
7
- * - `{ type: "closed"; code; reason }` — SSE has no close codes
8
- * - `{ type: "not-started" }` — SSE has no "ready" gate
9
- */
10
- type DisconnectReason = {
11
- type: "intentional";
12
- } | {
13
- type: "error";
14
- error: Error;
15
- } | {
16
- type: "max-retries-exceeded";
17
- attempts: number;
18
- };
19
- /**
20
- * All possible states of the SSE client.
21
- *
22
- * State machine transitions (4 states, no "ready"):
23
- * ```
24
- * disconnected → connecting → connected
25
- * ↓ ↓
26
- * reconnecting ← ─ ─┘
27
- * ↓
28
- * connecting (retry)
29
- * ↓
30
- * disconnected (max retries)
31
- * ```
32
- */
33
- type SseClientState = {
34
- status: "disconnected";
35
- reason?: DisconnectReason;
36
- } | {
37
- status: "connecting";
38
- attempt: number;
39
- } | {
40
- status: "connected";
41
- } | {
42
- status: "reconnecting";
43
- attempt: number;
44
- nextAttemptMs: number;
45
- };
46
- /**
47
- * Handle for an active SSE connection (server-side).
48
- */
49
- interface SseConnectionHandle {
50
- /** The peer ID for this connection. */
51
- readonly peerId: PeerId;
52
- /** The channel ID for this connection. */
53
- readonly channelId: number;
54
- /** Disconnect this connection. */
55
- disconnect(): void;
56
- }
57
- /**
58
- * Result of registering an SSE connection on the server.
59
- */
60
- interface SseConnectionResult {
61
- /** The connection handle for managing this peer. */
62
- connection: SseConnectionHandle;
63
- }
64
- /**
65
- * Lifecycle event callbacks for the SSE client.
66
- */
67
- interface SseClientLifecycleEvents {
68
- /** Called on every state transition (delivered async via microtask). */
69
- onStateChange?: (transition: {
70
- from: SseClientState;
71
- to: SseClientState;
72
- timestamp: number;
73
- }) => void;
74
- /** Called when the connection is lost. */
75
- onDisconnect?: (reason: DisconnectReason) => void;
76
- /** Called when a reconnection attempt is scheduled. */
77
- onReconnecting?: (attempt: number, nextAttemptMs: number) => void;
78
- /** Called when reconnection succeeds after a previous connection. */
79
- onReconnected?: () => void;
80
- }
81
-
82
- export type { DisconnectReason as D, SseConnectionHandle as S, SseConnectionResult as a, SseClientLifecycleEvents as b, SseClientState as c };