@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Duane Johnson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,334 @@
1
+ # @kyneta/sse-network-adapter
2
+
3
+ SSE (Server-Sent Events) adapter for `@kyneta/exchange` — client, server, and Express integration. Provides real-time sync using SSE for server→client messages and HTTP POST for client→server messages, both encoded with the `@kyneta/wire` text protocol (JSON codec + text framing + text fragmentation).
4
+
5
+ ## Subpath Exports
6
+
7
+ | Export | Entry point | Environment |
8
+ |--------|-------------|-------------|
9
+ | `@kyneta/sse-network-adapter/client` | `./dist/client.js` | Browser, Bun, Node.js |
10
+ | `@kyneta/sse-network-adapter/server` | `./dist/server.js` | Bun, Node.js |
11
+ | `@kyneta/sse-network-adapter/express` | `./dist/express.js` | Node.js (Express) |
12
+
13
+ ## Server Setup
14
+
15
+ ### Express (recommended)
16
+
17
+ Use `createSseExpressRouter` for zero-boilerplate integration with Express:
18
+
19
+ ```/dev/null/express-server.ts#L1-20
20
+ import { Exchange } from "@kyneta/exchange"
21
+ import { SseServerAdapter } from "@kyneta/sse-network-adapter/server"
22
+ import { createSseExpressRouter } from "@kyneta/sse-network-adapter/express"
23
+ import express from "express"
24
+
25
+ const app = express()
26
+
27
+ const serverAdapter = new SseServerAdapter()
28
+
29
+ const exchange = new Exchange({
30
+ identity: { peerId: "server", name: "server", type: "service" },
31
+ adapters: [() => serverAdapter],
32
+ })
33
+
34
+ app.use("/sse", createSseExpressRouter(serverAdapter, {
35
+ syncPath: "/sync",
36
+ eventsPath: "/events",
37
+ heartbeatInterval: 30000,
38
+ }))
39
+
40
+ app.listen(3000)
41
+ ```
42
+
43
+ ### Hono
44
+
45
+ For Hono or other frameworks, use `parseTextPostBody` and `SseServerAdapter.registerConnection` directly:
46
+
47
+ ```/dev/null/hono-server.ts#L1-45
48
+ import { SseServerAdapter } from "@kyneta/sse-network-adapter/server"
49
+ import { parseTextPostBody } from "@kyneta/sse-network-adapter/express"
50
+ import { Hono } from "hono"
51
+ import { streamSSE } from "hono/streaming"
52
+
53
+ const sseAdapter = new SseServerAdapter()
54
+
55
+ const app = new Hono()
56
+
57
+ app.get("/sse/events", async (c) => {
58
+ const peerId = c.req.query("peerId")
59
+ if (!peerId) return c.json({ error: "peerId required" }, 400)
60
+
61
+ return streamSSE(c, async (stream) => {
62
+ const connection = sseAdapter.registerConnection(peerId)
63
+
64
+ // sendFn receives pre-encoded text frame strings
65
+ connection.setSendFunction((textFrame) => {
66
+ stream.writeSSE({ data: textFrame })
67
+ })
68
+
69
+ stream.onAbort(() => {
70
+ sseAdapter.unregisterConnection(peerId)
71
+ })
72
+
73
+ await new Promise(() => {}) // keep alive
74
+ })
75
+ })
76
+
77
+ app.post("/sse/sync", async (c) => {
78
+ const peerId = c.req.header("x-peer-id")
79
+ if (!peerId) return c.json({ error: "x-peer-id required" }, 400)
80
+
81
+ const connection = sseAdapter.getConnection(peerId)
82
+ if (!connection) return c.json({ error: "Not connected" }, 404)
83
+
84
+ const body = await c.req.text()
85
+ const result = parseTextPostBody(connection.reassembler, body)
86
+
87
+ if (result.type === "messages") {
88
+ for (const msg of result.messages) {
89
+ connection.receive(msg)
90
+ }
91
+ }
92
+
93
+ return c.json(result.response.body, result.response.status)
94
+ })
95
+ ```
96
+
97
+ ## Client Setup
98
+
99
+ ### Browser
100
+
101
+ Use `createSseClient` for browser-to-server connections:
102
+
103
+ ```/dev/null/browser-client.ts#L1-13
104
+ import { Exchange } from "@kyneta/exchange"
105
+ import { createSseClient } from "@kyneta/sse-network-adapter/client"
106
+
107
+ const exchange = new Exchange({
108
+ identity: { peerId: "browser-client", name: "Alice", type: "user" },
109
+ adapters: [createSseClient({
110
+ postUrl: "/sse/sync",
111
+ eventSourceUrl: (peerId) => `/sse/events?peerId=${peerId}`,
112
+ reconnect: { enabled: true },
113
+ })],
114
+ })
115
+ ```
116
+
117
+ ## Connection Lifecycle
118
+
119
+ The client adapter manages connection state through a validated state machine. Unlike the WebSocket adapter, SSE has no separate "ready" signal — the connection is usable as soon as `EventSource.onopen` fires.
120
+
121
+ ```/dev/null/state-machine.txt#L1-7
122
+ disconnected → connecting → connected
123
+ ↓ ↓
124
+ reconnecting ← ─ ─┘
125
+
126
+ connecting (retry)
127
+
128
+ disconnected (max retries)
129
+ ```
130
+
131
+ | State | Description |
132
+ |-------|-------------|
133
+ | `disconnected` | No active connection. Optional `reason` field describes why. |
134
+ | `connecting` | EventSource being created. Tracks `attempt` number. |
135
+ | `connected` | EventSource open — protocol messages can flow. |
136
+ | `reconnecting` | Connection lost, scheduling next attempt. Tracks `attempt` and `nextAttemptMs`. |
137
+
138
+ ### Connection Handshake
139
+
140
+ 1. Client creates `EventSource`, transitions to `connecting`
141
+ 2. `EventSource.onopen` fires, transitions to `connected`
142
+ 3. Client creates its channel, calls `establishChannel()`
143
+ 4. Synchronizer exchanges `establish-request` / `establish-response`
144
+
145
+ ### EventSource Reconnection
146
+
147
+ On `EventSource.onerror`, the adapter **closes the EventSource immediately** and takes over reconnection via the state machine's backoff logic. This prevents the browser's built-in `EventSource` reconnection from running, giving full control over backoff timing and attempt counting.
148
+
149
+ ### Observing State
150
+
151
+ ```/dev/null/observe-state.ts#L1-18
152
+ import { createSseClient } from "@kyneta/sse-network-adapter/client"
153
+
154
+ const adapter = createSseClient({
155
+ postUrl: "/sse/sync",
156
+ eventSourceUrl: (peerId) => `/sse/events?peerId=${peerId}`,
157
+ lifecycle: {
158
+ onStateChange: ({ from, to }) => console.log(`${from.status} → ${to.status}`),
159
+ onDisconnect: (reason) => console.log("disconnected:", reason.type),
160
+ onReconnecting: (attempt, nextMs) => console.log(`retry #${attempt} in ${nextMs}ms`),
161
+ onReconnected: () => console.log("reconnected"),
162
+ },
163
+ })
164
+
165
+ // Or subscribe to transitions programmatically
166
+ const unsub = adapter.subscribeToTransitions(({ from, to }) => {
167
+ console.log(`${from.status} → ${to.status}`)
168
+ })
169
+
170
+ await adapter.waitForStatus("connected", { timeoutMs: 5000 })
171
+ ```
172
+
173
+ ## Wire Format
174
+
175
+ Both directions use the `@kyneta/wire` text pipeline — symmetric encoding with asymmetric transport:
176
+
177
+ | Direction | Transport | Wire format |
178
+ |-----------|-----------|-------------|
179
+ | Client → Server | HTTP POST (`text/plain`) | Text frame (`["0c", <payload>]`) |
180
+ | Server → Client | SSE `data:` event | Text frame (`["0c", <payload>]`) |
181
+
182
+ ### Text Frames
183
+
184
+ Every message is wrapped in a text frame — a JSON array with a 2-character prefix:
185
+
186
+ ```/dev/null/text-frame-example.txt#L1-5
187
+ Complete frame: ["0c", {"type":"discover","docIds":["doc-1"]}]
188
+ Fragment frame: ["0f", "a1b2c3d4", 0, 3, 1500, "{\"type\":\"offer\"..."]
189
+ ```
190
+
191
+ The `"0c"` prefix means "version 0, complete, no hash". Fragments use `"0f"` and carry `frameId`, `index`, `total`, `totalSize`, and a JSON substring chunk.
192
+
193
+ ### Why Text Instead of Binary?
194
+
195
+ The old `@loro-extended/adapter-sse` used an asymmetric format: binary CBOR for POST, ad-hoc JSON for SSE. The new adapter uses uniform text encoding because:
196
+
197
+ - Single code path for encode/decode on both client and server
198
+ - Human-readable POST bodies and SSE events for debugging
199
+ - No need for `express.raw()` with `application/octet-stream`
200
+ - Text fragmentation works in both directions
201
+
202
+ The ~33% bandwidth overhead of base64 for binary payloads (vs. native CBOR byte strings) is acceptable for SSE's use case (chat, presence, signaling). For bandwidth-sensitive workloads, use the WebSocket adapter.
203
+
204
+ ## Configuration
205
+
206
+ ### Client Options
207
+
208
+ | Option | Default | Description |
209
+ |--------|---------|-------------|
210
+ | `postUrl` | — | POST URL. String or `(peerId) => string` function. |
211
+ | `eventSourceUrl` | — | SSE URL. String or `(peerId) => string` function. |
212
+ | `reconnect.enabled` | `true` | Enable automatic reconnection. |
213
+ | `reconnect.maxAttempts` | `10` | Maximum reconnection attempts. |
214
+ | `reconnect.baseDelay` | `1000` | Base delay in ms for exponential backoff. |
215
+ | `reconnect.maxDelay` | `30000` | Maximum delay cap in ms. |
216
+ | `postRetry.maxAttempts` | `3` | Maximum POST retry attempts. |
217
+ | `postRetry.baseDelay` | `1000` | Base delay in ms for POST retry backoff. |
218
+ | `postRetry.maxDelay` | `10000` | Maximum POST retry delay in ms. |
219
+ | `fragmentThreshold` | `60000` | Character threshold for text fragmentation. |
220
+
221
+ ### Server Options
222
+
223
+ | Option | Default | Description |
224
+ |--------|---------|-------------|
225
+ | `fragmentThreshold` | `60000` | Character threshold for text fragmentation. |
226
+
227
+ ### Express Router Options
228
+
229
+ | Option | Default | Description |
230
+ |--------|---------|-------------|
231
+ | `syncPath` | `"/sync"` | Path for POST endpoint. |
232
+ | `eventsPath` | `"/events"` | Path for SSE endpoint. |
233
+ | `heartbeatInterval` | `30000` | Heartbeat interval in ms. |
234
+ | `getPeerIdFromSyncRequest` | reads `x-peer-id` header | Custom peerId extraction for POST. |
235
+ | `getPeerIdFromEventsRequest` | reads `peerId` query param | Custom peerId extraction for SSE. |
236
+
237
+ ### Heartbeat
238
+
239
+ The Express router sends SSE comment heartbeats (`: heartbeat\n\n`) at the configured interval. SSE comments are silently ignored by `EventSource` clients. This keeps connections alive through proxies and load balancers that terminate idle connections.
240
+
241
+ ## Custom Framework Integration
242
+
243
+ The `parseTextPostBody` function provides a framework-agnostic handler for POST requests:
244
+
245
+ ```/dev/null/custom-framework.ts#L1-13
246
+ import { parseTextPostBody } from "@kyneta/sse-network-adapter/express"
247
+
248
+ // In your framework's request handler
249
+ const result = parseTextPostBody(connection.reassembler, bodyAsString)
250
+
251
+ if (result.type === "messages") {
252
+ for (const msg of result.messages) {
253
+ connection.receive(msg)
254
+ }
255
+ }
256
+
257
+ // Send response
258
+ response.status(result.response.status).json(result.response.body)
259
+ ```
260
+
261
+ ### Response Types
262
+
263
+ | Result Type | HTTP Status | Meaning |
264
+ |-------------|-------------|---------|
265
+ | `messages` | 200 | Message(s) decoded successfully |
266
+ | `pending` | 202 | Fragment received, waiting for more |
267
+ | `error` | 400 | Decode or reassembly error |
268
+
269
+ ### The `sendFn` Pattern
270
+
271
+ `SseConnection.send()` handles encoding and fragmentation internally. The injected `sendFn` receives pre-encoded text frame strings — the framework integration just wraps them in transport syntax:
272
+
273
+ ```/dev/null/sendfn-pattern.ts#L1-8
274
+ // Express
275
+ connection.setSendFunction((textFrame) => {
276
+ res.write(`data: ${textFrame}\n\n`)
277
+ })
278
+
279
+ // Hono
280
+ connection.setSendFunction((textFrame) => {
281
+ stream.writeSSE({ data: textFrame })
282
+ })
283
+ ```
284
+
285
+ ## Architecture
286
+
287
+ ```/dev/null/architecture.txt#L1-17
288
+ ┌──────────────────────────────────────────────────────────┐
289
+ │ Client │
290
+ │ ┌──────────────────┐ ┌───────────────────┐ │
291
+ │ │ SseClientAdapter │ │ EventSource │ │
292
+ │ │ (text POST) │───────▶│ (text receive) │ │
293
+ │ └──────────────────┘ └───────────────────┘ │
294
+ └──────────────────────────────────────────────────────────┘
295
+ │ ▲
296
+ │ HTTP POST │ SSE
297
+ │ (text wire frame) │ (text wire frame)
298
+ ▼ │
299
+ ┌──────────────────────────────────────────────────────────┐
300
+ │ Server │
301
+ │ ┌──────────────────┐ ┌───────────────────┐ │
302
+ │ │ Express Router │ │ SSE Writer │ │
303
+ │ │ (parseTextPost) │───────▶│ (sendFn) │ │
304
+ │ └──────────────────┘ └───────────────────┘ │
305
+ │ │ ▲ │
306
+ │ ▼ │ │
307
+ │ ┌───────────────────────────────────────────────────┐ │
308
+ │ │ SseServerAdapter │ │
309
+ │ │ ┌────────────────────────────────────────────┐ │ │
310
+ │ │ │ SseConnection (per peer) │ │ │
311
+ │ │ │ - TextReassembler (handles fragmented POST)│ │ │
312
+ │ │ │ - textCodec encoding (handles outbound SSE)│ │ │
313
+ │ │ │ - Channel reference │ │ │
314
+ │ │ └────────────────────────────────────────────┘ │ │
315
+ │ └───────────────────────────────────────────────────┘ │
316
+ └──────────────────────────────────────────────────────────┘
317
+ ```
318
+
319
+ ## Peer Dependencies
320
+
321
+ ```/dev/null/package.json#L1-4
322
+ {
323
+ "peerDependencies": {
324
+ "@kyneta/exchange": ">=0.0.1",
325
+ "@kyneta/wire": ">=0.0.1"
326
+ }
327
+ }
328
+ ```
329
+
330
+ Express is an optional peer dependency — only needed if using `@kyneta/sse-network-adapter/express`.
331
+
332
+ ## License
333
+
334
+ MIT
@@ -0,0 +1,38 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,255 @@
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/exchange";
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-TR4Y3HFB.js.map