@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 +21 -0
- package/README.md +334 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/chunk-TR4Y3HFB.js +255 -0
- package/dist/chunk-TR4Y3HFB.js.map +1 -0
- package/dist/client.d.ts +144 -0
- package/dist/client.js +460 -0
- package/dist/client.js.map +1 -0
- package/dist/express.d.ts +135 -0
- package/dist/express.js +23021 -0
- package/dist/express.js.map +1 -0
- package/dist/server-transport-BrMRLsmp.d.ts +180 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -0
- package/dist/types-BTgljZGe.d.ts +83 -0
- package/package.json +60 -0
- package/src/__tests__/client-state-machine.test.ts +201 -0
- package/src/__tests__/connection.test.ts +184 -0
- package/src/__tests__/sse-handler.test.ts +145 -0
- package/src/client-state-machine.ts +69 -0
- package/src/client-transport.ts +722 -0
- package/src/client.ts +30 -0
- package/src/connection.ts +181 -0
- package/src/express-router.ts +231 -0
- package/src/express.ts +29 -0
- package/src/server-transport.ts +229 -0
- package/src/server.ts +33 -0
- package/src/sse-handler.ts +116 -0
- package/src/types.ts +108 -0
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
|