@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/src/client.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// client — barrel export for @kyneta/sse-network-adapter/client.
|
|
2
|
+
//
|
|
3
|
+
// This is the client-side entry point. It exports everything needed
|
|
4
|
+
// to create an SSE client adapter for browser connections.
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Client adapter + factory function
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createSseClient,
|
|
12
|
+
DEFAULT_FRAGMENT_THRESHOLD,
|
|
13
|
+
type DisconnectReason,
|
|
14
|
+
type SseClientLifecycleEvents,
|
|
15
|
+
type SseClientOptions,
|
|
16
|
+
type SseClientState,
|
|
17
|
+
SseClientTransport,
|
|
18
|
+
} from "./client-transport.js"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// State machine
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export { SseClientStateMachine } from "./client-state-machine.js"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Shared types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export type { StateTransition, TransitionListener } from "./types.js"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// connection — SseConnection for server-side peer connections.
|
|
2
|
+
//
|
|
3
|
+
// Wraps a TextReassembler + textCodec to provide send/receive for
|
|
4
|
+
// ChannelMsg over a single SSE connection.
|
|
5
|
+
//
|
|
6
|
+
// Used by SseServerTransport to manage individual client connections.
|
|
7
|
+
// The client adapter handles its own encoding/decoding inline since it
|
|
8
|
+
// manages a single EventSource with reconnection logic.
|
|
9
|
+
//
|
|
10
|
+
// The sendFn receives pre-encoded text frame strings. Framework
|
|
11
|
+
// integrations just wrap them in SSE syntax:
|
|
12
|
+
// Express: res.write(`data: ${textFrame}\n\n`)
|
|
13
|
+
// Hono: stream.writeSSE({ data: textFrame })
|
|
14
|
+
|
|
15
|
+
import type { Channel, ChannelMsg, PeerId } from "@kyneta/exchange"
|
|
16
|
+
import {
|
|
17
|
+
encodeTextComplete,
|
|
18
|
+
fragmentTextPayload,
|
|
19
|
+
TextReassembler,
|
|
20
|
+
textCodec,
|
|
21
|
+
} from "@kyneta/wire"
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default fragment threshold in characters for outbound SSE messages.
|
|
25
|
+
* 60K chars provides a safety margin below typical infrastructure limits.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_FRAGMENT_THRESHOLD = 60_000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configuration for creating an SseConnection.
|
|
31
|
+
*/
|
|
32
|
+
export interface SseConnectionConfig {
|
|
33
|
+
/**
|
|
34
|
+
* Fragment threshold in characters. Messages larger than this are fragmented.
|
|
35
|
+
* Set to 0 to disable fragmentation.
|
|
36
|
+
* Default: 60000 (60K chars)
|
|
37
|
+
*/
|
|
38
|
+
fragmentThreshold?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Represents a single SSE connection to a peer (server-side).
|
|
43
|
+
*
|
|
44
|
+
* Manages encoding, framing, fragmentation, and reassembly for one
|
|
45
|
+
* connected client. Created by `SseServerTransport.registerConnection()`.
|
|
46
|
+
*
|
|
47
|
+
* The connection uses the text codec for transport — this is the natural
|
|
48
|
+
* choice for SSE's text-only protocol.
|
|
49
|
+
*/
|
|
50
|
+
export class SseConnection {
|
|
51
|
+
readonly peerId: PeerId
|
|
52
|
+
readonly channelId: number
|
|
53
|
+
|
|
54
|
+
#channel: Channel | null = null
|
|
55
|
+
#sendFn: ((textFrame: string) => void) | null = null
|
|
56
|
+
#onDisconnect: (() => void) | null = null
|
|
57
|
+
|
|
58
|
+
// Fragmentation support
|
|
59
|
+
readonly #fragmentThreshold: number
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Text reassembler for handling fragmented POST bodies.
|
|
63
|
+
* Each connection has its own reassembler to track in-flight fragment batches.
|
|
64
|
+
*/
|
|
65
|
+
readonly reassembler: TextReassembler
|
|
66
|
+
|
|
67
|
+
constructor(peerId: PeerId, channelId: number, config?: SseConnectionConfig) {
|
|
68
|
+
this.peerId = peerId
|
|
69
|
+
this.channelId = channelId
|
|
70
|
+
this.#fragmentThreshold =
|
|
71
|
+
config?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
|
|
72
|
+
this.reassembler = new TextReassembler({
|
|
73
|
+
timeoutMs: 10_000,
|
|
74
|
+
onTimeout: (frameId: string) => {
|
|
75
|
+
console.warn(
|
|
76
|
+
`[SseConnection] Fragment batch timed out for peer ${peerId}: ${frameId}`,
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ==========================================================================
|
|
83
|
+
// INTERNAL API — for adapter use
|
|
84
|
+
// ==========================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Set the channel reference.
|
|
88
|
+
* Called by the adapter when the channel is created.
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
_setChannel(channel: Channel): void {
|
|
92
|
+
this.#channel = channel
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ==========================================================================
|
|
96
|
+
// PUBLIC API
|
|
97
|
+
// ==========================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Set the function to call when sending messages to this peer.
|
|
101
|
+
*
|
|
102
|
+
* The function receives a fully encoded text frame string.
|
|
103
|
+
* The framework integration just wraps it in SSE syntax:
|
|
104
|
+
* - Express: `res.write(\`data: \${textFrame}\\n\\n\`)`
|
|
105
|
+
* - Hono: `stream.writeSSE({ data: textFrame })`
|
|
106
|
+
*
|
|
107
|
+
* @param sendFn Function that writes a text frame string to the SSE stream
|
|
108
|
+
*/
|
|
109
|
+
setSendFunction(sendFn: (textFrame: string) => void): void {
|
|
110
|
+
this.#sendFn = sendFn
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Set the function to call when this connection is disconnected.
|
|
115
|
+
*/
|
|
116
|
+
setDisconnectHandler(handler: () => void): void {
|
|
117
|
+
this.#onDisconnect = handler
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Send a ChannelMsg to the peer through the SSE stream.
|
|
122
|
+
*
|
|
123
|
+
* Encodes via textCodec → text frame → fragment if needed → sendFn().
|
|
124
|
+
* Encoding and fragmentation are the connection's concern — the
|
|
125
|
+
* framework integration only needs to write strings.
|
|
126
|
+
*/
|
|
127
|
+
send(msg: ChannelMsg): void {
|
|
128
|
+
if (!this.#sendFn) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Cannot send message: send function not set for peer ${this.peerId}`,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Encode to text wire format
|
|
135
|
+
const textFrame = encodeTextComplete(textCodec, msg)
|
|
136
|
+
|
|
137
|
+
// Fragment large payloads
|
|
138
|
+
if (
|
|
139
|
+
this.#fragmentThreshold > 0 &&
|
|
140
|
+
textFrame.length > this.#fragmentThreshold
|
|
141
|
+
) {
|
|
142
|
+
const payload = JSON.stringify(textCodec.encode(msg))
|
|
143
|
+
const fragments = fragmentTextPayload(payload, this.#fragmentThreshold)
|
|
144
|
+
for (const fragment of fragments) {
|
|
145
|
+
this.#sendFn(fragment)
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
this.#sendFn(textFrame)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Receive a message from the peer and route it to the channel.
|
|
154
|
+
*
|
|
155
|
+
* Called by the framework integration after parsing a POST body
|
|
156
|
+
* through `parseTextPostBody`.
|
|
157
|
+
*/
|
|
158
|
+
receive(msg: ChannelMsg): void {
|
|
159
|
+
if (!this.#channel) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Cannot receive message: channel not set for peer ${this.peerId}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
this.#channel.onReceive(msg)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Disconnect this connection.
|
|
169
|
+
*/
|
|
170
|
+
disconnect(): void {
|
|
171
|
+
this.#onDisconnect?.()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Dispose of resources held by this connection.
|
|
176
|
+
* Must be called when the connection is closed to prevent timer leaks.
|
|
177
|
+
*/
|
|
178
|
+
dispose(): void {
|
|
179
|
+
this.reassembler.dispose()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// express-router — Express integration for @kyneta/sse-network-adapter.
|
|
2
|
+
//
|
|
3
|
+
// Creates Express routes that integrate with SseServerTransport:
|
|
4
|
+
// - GET endpoint for clients to establish SSE connections
|
|
5
|
+
// - POST endpoint for clients to send text wire frame messages
|
|
6
|
+
//
|
|
7
|
+
// The POST endpoint accepts text/plain bodies containing text wire frames.
|
|
8
|
+
// The GET endpoint sends text wire frames as SSE data events.
|
|
9
|
+
//
|
|
10
|
+
// Design: Imperative Shell — delegates parsing to parseTextPostBody()
|
|
11
|
+
// (functional core) and message delivery to SseConnection.
|
|
12
|
+
|
|
13
|
+
import type { PeerId } from "@kyneta/exchange"
|
|
14
|
+
import type { Request, Response, Router } from "express"
|
|
15
|
+
import express from "express"
|
|
16
|
+
import type { SseServerTransport } from "./server-transport.js"
|
|
17
|
+
import { parseTextPostBody } from "./sse-handler.js"
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Options
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface SseExpressRouterOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Path for the sync endpoint where clients POST messages.
|
|
26
|
+
* @default "/sync"
|
|
27
|
+
*/
|
|
28
|
+
syncPath?: string
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Path for the events endpoint where clients connect via SSE.
|
|
32
|
+
* @default "/events"
|
|
33
|
+
*/
|
|
34
|
+
eventsPath?: string
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Interval in milliseconds for sending heartbeat comments to keep connections alive.
|
|
38
|
+
* @default 30000 (30 seconds)
|
|
39
|
+
*/
|
|
40
|
+
heartbeatInterval?: number
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Custom function to extract peerId from the sync request.
|
|
44
|
+
* By default, reads from the "x-peer-id" header.
|
|
45
|
+
*/
|
|
46
|
+
getPeerIdFromSyncRequest?: (req: Request) => PeerId | undefined
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Custom function to extract peerId from the events request.
|
|
50
|
+
* By default, reads from the "peerId" query parameter.
|
|
51
|
+
*/
|
|
52
|
+
getPeerIdFromEventsRequest?: (req: Request) => PeerId | undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// createSseExpressRouter
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Create an Express router for SSE server adapter.
|
|
61
|
+
*
|
|
62
|
+
* This factory function creates Express routes that integrate with the
|
|
63
|
+
* SseServerTransport. It handles:
|
|
64
|
+
* - POST endpoint for clients to send text wire frame messages to the server
|
|
65
|
+
* - GET endpoint for clients to establish SSE connections
|
|
66
|
+
* - Heartbeat mechanism to detect stale connections
|
|
67
|
+
*
|
|
68
|
+
* ## Wire Format
|
|
69
|
+
*
|
|
70
|
+
* The POST endpoint accepts text/plain bodies containing text wire frames
|
|
71
|
+
* (JSON arrays with "0c"/"0f" prefix). The SSE endpoint sends text wire
|
|
72
|
+
* frames as `data:` events. Both directions use the same encoding.
|
|
73
|
+
*
|
|
74
|
+
* @param adapter The SseServerTransport instance
|
|
75
|
+
* @param options Configuration options for the router
|
|
76
|
+
* @returns An Express Router ready to be mounted
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* import { SseServerTransport } from "@kyneta/sse-network-adapter/server"
|
|
81
|
+
* import { createSseExpressRouter } from "@kyneta/sse-network-adapter/express"
|
|
82
|
+
* import { Exchange } from "@kyneta/exchange"
|
|
83
|
+
*
|
|
84
|
+
* const serverAdapter = new SseServerTransport()
|
|
85
|
+
* const exchange = new Exchange({
|
|
86
|
+
* identity: { peerId: "server", name: "server", type: "service" },
|
|
87
|
+
* transports: [() => serverAdapter],
|
|
88
|
+
* })
|
|
89
|
+
*
|
|
90
|
+
* app.use("/sse", createSseExpressRouter(serverAdapter, {
|
|
91
|
+
* syncPath: "/sync",
|
|
92
|
+
* eventsPath: "/events",
|
|
93
|
+
* heartbeatInterval: 30000,
|
|
94
|
+
* }))
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function createSseExpressRouter(
|
|
98
|
+
adapter: SseServerTransport,
|
|
99
|
+
options: SseExpressRouterOptions = {},
|
|
100
|
+
): Router {
|
|
101
|
+
const {
|
|
102
|
+
syncPath = "/sync",
|
|
103
|
+
eventsPath = "/events",
|
|
104
|
+
heartbeatInterval = 30000,
|
|
105
|
+
getPeerIdFromSyncRequest = req => req.headers["x-peer-id"] as PeerId,
|
|
106
|
+
getPeerIdFromEventsRequest = req => req.query.peerId as PeerId,
|
|
107
|
+
} = options
|
|
108
|
+
|
|
109
|
+
const router = express.Router()
|
|
110
|
+
const heartbeats = new Map<PeerId, NodeJS.Timeout>()
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// POST /sync — clients send text wire frame messages TO the server
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
router.post(
|
|
117
|
+
syncPath,
|
|
118
|
+
express.text({ type: "text/plain", limit: "1mb" }),
|
|
119
|
+
(req: Request, res: Response) => {
|
|
120
|
+
// Extract peerId from request
|
|
121
|
+
const peerId = getPeerIdFromSyncRequest(req)
|
|
122
|
+
|
|
123
|
+
if (!peerId) {
|
|
124
|
+
res.status(400).json({ error: "Missing peer ID" })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Get connection for this peer
|
|
129
|
+
const connection = adapter.getConnection(peerId)
|
|
130
|
+
|
|
131
|
+
if (!connection) {
|
|
132
|
+
res.status(404).json({ error: "Peer not connected" })
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Ensure we have text data
|
|
137
|
+
if (typeof req.body !== "string") {
|
|
138
|
+
res.status(400).json({ error: "Expected text body" })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Functional core: parse body through reassembler
|
|
143
|
+
const result = parseTextPostBody(connection.reassembler, req.body)
|
|
144
|
+
|
|
145
|
+
// Imperative shell: execute side effects based on result
|
|
146
|
+
if (result.type === "messages") {
|
|
147
|
+
for (const msg of result.messages) {
|
|
148
|
+
connection.receive(msg)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// "pending" type means fragment received, waiting for more — no action needed
|
|
152
|
+
// "error" type is logged implicitly by the response status
|
|
153
|
+
|
|
154
|
+
res.status(result.response.status).json(result.response.body)
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// GET /events — clients connect and listen for events FROM the server
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
router.get(eventsPath, (req: Request, res: Response) => {
|
|
163
|
+
const peerId = getPeerIdFromEventsRequest(req)
|
|
164
|
+
if (!peerId) {
|
|
165
|
+
res.status(400).end("peerId is required")
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Set headers for SSE
|
|
170
|
+
res.writeHead(200, {
|
|
171
|
+
"Content-Type": "text/event-stream",
|
|
172
|
+
"Cache-Control": "no-cache",
|
|
173
|
+
Connection: "keep-alive",
|
|
174
|
+
})
|
|
175
|
+
res.flushHeaders()
|
|
176
|
+
// Send initial comment to ensure headers are flushed and connection is established
|
|
177
|
+
res.write(": ok\n\n")
|
|
178
|
+
|
|
179
|
+
// Register connection with adapter
|
|
180
|
+
const connection = adapter.registerConnection(peerId)
|
|
181
|
+
|
|
182
|
+
// Set up send function to write pre-encoded text frames to SSE stream.
|
|
183
|
+
// The connection's send() method handles encoding and fragmentation —
|
|
184
|
+
// the sendFn just wraps the text frame in SSE data syntax.
|
|
185
|
+
connection.setSendFunction((textFrame: string) => {
|
|
186
|
+
res.write(`data: ${textFrame}\n\n`)
|
|
187
|
+
// Flush the response buffer to ensure immediate delivery
|
|
188
|
+
// Note: 'flush' is added by compression middleware or some environments
|
|
189
|
+
if (typeof (res as any).flush === "function") {
|
|
190
|
+
;(res as any).flush()
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Set up disconnect handler
|
|
195
|
+
connection.setDisconnectHandler(() => {
|
|
196
|
+
const hb = heartbeats.get(peerId)
|
|
197
|
+
if (hb) {
|
|
198
|
+
clearInterval(hb)
|
|
199
|
+
heartbeats.delete(peerId)
|
|
200
|
+
}
|
|
201
|
+
res.end()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Setup heartbeat to detect stale connections
|
|
205
|
+
const hb = setInterval(() => {
|
|
206
|
+
try {
|
|
207
|
+
// Send a heartbeat comment (SSE comments are ignored by EventSource clients)
|
|
208
|
+
res.write(": heartbeat\n\n")
|
|
209
|
+
} catch (_err) {
|
|
210
|
+
// If we can't write to the response, the connection is dead
|
|
211
|
+
adapter.unregisterConnection(peerId)
|
|
212
|
+
clearInterval(hb)
|
|
213
|
+
heartbeats.delete(peerId)
|
|
214
|
+
}
|
|
215
|
+
}, heartbeatInterval)
|
|
216
|
+
|
|
217
|
+
heartbeats.set(peerId, hb)
|
|
218
|
+
|
|
219
|
+
// Handle client disconnect
|
|
220
|
+
req.on("close", () => {
|
|
221
|
+
adapter.unregisterConnection(peerId)
|
|
222
|
+
const existingHb = heartbeats.get(peerId)
|
|
223
|
+
if (existingHb) {
|
|
224
|
+
clearInterval(existingHb)
|
|
225
|
+
heartbeats.delete(peerId)
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return router
|
|
231
|
+
}
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// express — barrel export for @kyneta/sse-network-adapter/express.
|
|
2
|
+
//
|
|
3
|
+
// This is the Express integration entry point. It exports everything
|
|
4
|
+
// needed to integrate SseServerTransport with Express.
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Express router factory
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createSseExpressRouter,
|
|
12
|
+
type SseExpressRouterOptions,
|
|
13
|
+
} from "./express-router.js"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Server adapter (re-exported for convenience)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export { SseServerTransport } from "./server-transport.js"
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Handler (for custom framework integration)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
parseTextPostBody,
|
|
27
|
+
type SsePostResponse,
|
|
28
|
+
type SsePostResult,
|
|
29
|
+
} from "./sse-handler.js"
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// server-adapter — SSE server adapter for @kyneta/exchange.
|
|
2
|
+
//
|
|
3
|
+
// Manages SSE connections from clients, encoding/decoding via the
|
|
4
|
+
// kyneta text wire format. Framework-agnostic — works with any HTTP
|
|
5
|
+
// framework through the SseConnection's setSendFunction() callback.
|
|
6
|
+
//
|
|
7
|
+
// Usage with Express:
|
|
8
|
+
// import { SseServerTransport } from "@kyneta/sse-network-adapter/server"
|
|
9
|
+
// import { createSseExpressRouter } from "@kyneta/sse-network-adapter/express"
|
|
10
|
+
//
|
|
11
|
+
// const serverAdapter = new SseServerTransport()
|
|
12
|
+
// app.use("/sse", createSseExpressRouter(serverAdapter))
|
|
13
|
+
//
|
|
14
|
+
// Usage with Hono:
|
|
15
|
+
// import { SseServerTransport } from "@kyneta/sse-network-adapter/server"
|
|
16
|
+
// import { parseTextPostBody } from "@kyneta/sse-network-adapter/express"
|
|
17
|
+
//
|
|
18
|
+
// const serverAdapter = new SseServerTransport()
|
|
19
|
+
// // Wire up GET /events and POST /sync manually using
|
|
20
|
+
// // serverAdapter.registerConnection() and parseTextPostBody()
|
|
21
|
+
|
|
22
|
+
import type { ChannelMsg, GeneratedChannel, PeerId } from "@kyneta/exchange"
|
|
23
|
+
import { Transport } from "@kyneta/exchange"
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_FRAGMENT_THRESHOLD,
|
|
26
|
+
SseConnection,
|
|
27
|
+
type SseConnectionConfig,
|
|
28
|
+
} from "./connection.js"
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Options
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Options for the SSE server adapter.
|
|
36
|
+
*/
|
|
37
|
+
export interface SseServerTransportOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Fragment threshold in characters. Messages larger than this are fragmented
|
|
40
|
+
* into multiple SSE events.
|
|
41
|
+
* Set to 0 to disable fragmentation.
|
|
42
|
+
* Default: 60000 (60K chars)
|
|
43
|
+
*/
|
|
44
|
+
fragmentThreshold?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Peer ID generation
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate a random peer ID for connections that don't provide one.
|
|
53
|
+
*/
|
|
54
|
+
function generatePeerId(): PeerId {
|
|
55
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
56
|
+
let result = "sse-"
|
|
57
|
+
for (let i = 0; i < 12; i++) {
|
|
58
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
|
59
|
+
}
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// SseServerTransport
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* SSE server network adapter.
|
|
69
|
+
*
|
|
70
|
+
* Framework-agnostic — works with any HTTP framework through the
|
|
71
|
+
* `SseConnection.setSendFunction()` callback. Use `registerConnection()`
|
|
72
|
+
* to integrate with your framework's SSE endpoint handler.
|
|
73
|
+
*
|
|
74
|
+
* Each client connection is tracked as an `SseConnection` keyed by peer ID.
|
|
75
|
+
* The adapter creates a channel per connection and routes outbound messages
|
|
76
|
+
* through the connection's send method (which encodes to text wire format
|
|
77
|
+
* and calls the injected sendFn).
|
|
78
|
+
*
|
|
79
|
+
* The connection handshake:
|
|
80
|
+
* 1. Client opens EventSource (GET /events)
|
|
81
|
+
* 2. Server calls `registerConnection(peerId)` → creates channel
|
|
82
|
+
* 3. Client's EventSource.onopen fires → client sends establish-request (POST)
|
|
83
|
+
* 4. Server receives establish-request → Synchronizer responds with establish-response (SSE)
|
|
84
|
+
*
|
|
85
|
+
* The server does NOT call `establishChannel()` — it waits for the client's
|
|
86
|
+
* establish-request, which arrives via POST after the EventSource is open.
|
|
87
|
+
*/
|
|
88
|
+
export class SseServerTransport extends Transport<PeerId> {
|
|
89
|
+
#connections = new Map<PeerId, SseConnection>()
|
|
90
|
+
readonly #fragmentThreshold: number
|
|
91
|
+
|
|
92
|
+
constructor(options?: SseServerTransportOptions) {
|
|
93
|
+
super({ transportType: "sse-server" })
|
|
94
|
+
this.#fragmentThreshold =
|
|
95
|
+
options?.fragmentThreshold ?? DEFAULT_FRAGMENT_THRESHOLD
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ==========================================================================
|
|
99
|
+
// Adapter abstract method implementations
|
|
100
|
+
// ==========================================================================
|
|
101
|
+
|
|
102
|
+
protected generate(peerId: PeerId): GeneratedChannel {
|
|
103
|
+
return {
|
|
104
|
+
transportType: this.transportType,
|
|
105
|
+
send: (msg: ChannelMsg) => {
|
|
106
|
+
const connection = this.#connections.get(peerId)
|
|
107
|
+
if (connection) {
|
|
108
|
+
connection.send(msg)
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
stop: () => {
|
|
112
|
+
this.unregisterConnection(peerId)
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async onStart(): Promise<void> {
|
|
118
|
+
// Server adapter starts passively — connections arrive via registerConnection()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async onStop(): Promise<void> {
|
|
122
|
+
// Disconnect all active connections
|
|
123
|
+
for (const connection of this.#connections.values()) {
|
|
124
|
+
connection.disconnect()
|
|
125
|
+
}
|
|
126
|
+
this.#connections.clear()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ==========================================================================
|
|
130
|
+
// Connection management
|
|
131
|
+
// ==========================================================================
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Register a new peer connection.
|
|
135
|
+
*
|
|
136
|
+
* Call this from your framework's SSE endpoint handler when a client
|
|
137
|
+
* connects via EventSource. Returns an `SseConnection` that you wire
|
|
138
|
+
* up with `setSendFunction()` and `setDisconnectHandler()`.
|
|
139
|
+
*
|
|
140
|
+
* @param peerId The unique identifier for the peer (from query param or header)
|
|
141
|
+
* @returns An SseConnection object for managing the connection
|
|
142
|
+
*
|
|
143
|
+
* @example Express
|
|
144
|
+
* ```typescript
|
|
145
|
+
* const connection = serverAdapter.registerConnection(peerId)
|
|
146
|
+
* connection.setSendFunction((textFrame) => {
|
|
147
|
+
* res.write(`data: ${textFrame}\n\n`)
|
|
148
|
+
* })
|
|
149
|
+
* ```
|
|
150
|
+
*
|
|
151
|
+
* @example Hono
|
|
152
|
+
* ```typescript
|
|
153
|
+
* const connection = serverAdapter.registerConnection(peerId)
|
|
154
|
+
* connection.setSendFunction((textFrame) => {
|
|
155
|
+
* stream.writeSSE({ data: textFrame })
|
|
156
|
+
* })
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
registerConnection(peerId?: PeerId): SseConnection {
|
|
160
|
+
const resolvedPeerId = peerId ?? generatePeerId()
|
|
161
|
+
|
|
162
|
+
// Check for existing connection and clean it up
|
|
163
|
+
const existingConnection = this.#connections.get(resolvedPeerId)
|
|
164
|
+
if (existingConnection) {
|
|
165
|
+
existingConnection.dispose()
|
|
166
|
+
this.unregisterConnection(resolvedPeerId)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Create channel for this peer
|
|
170
|
+
const channel = this.addChannel(resolvedPeerId)
|
|
171
|
+
|
|
172
|
+
// Create connection object with fragmentation config
|
|
173
|
+
const connection = new SseConnection(resolvedPeerId, channel.channelId, {
|
|
174
|
+
fragmentThreshold: this.#fragmentThreshold,
|
|
175
|
+
})
|
|
176
|
+
connection._setChannel(channel)
|
|
177
|
+
|
|
178
|
+
// Store connection
|
|
179
|
+
this.#connections.set(resolvedPeerId, connection)
|
|
180
|
+
|
|
181
|
+
return connection
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Unregister a peer connection.
|
|
186
|
+
*
|
|
187
|
+
* Removes the channel, disposes the connection's reassembler,
|
|
188
|
+
* and cleans up tracking state. Called automatically when the
|
|
189
|
+
* client disconnects (via req.on("close")) or manually.
|
|
190
|
+
*
|
|
191
|
+
* @param peerId The unique identifier for the peer
|
|
192
|
+
*/
|
|
193
|
+
unregisterConnection(peerId: PeerId): void {
|
|
194
|
+
const connection = this.#connections.get(peerId)
|
|
195
|
+
if (connection) {
|
|
196
|
+
connection.dispose()
|
|
197
|
+
this.removeChannel(connection.channelId)
|
|
198
|
+
this.#connections.delete(peerId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get an active connection by peer ID.
|
|
204
|
+
*/
|
|
205
|
+
getConnection(peerId: PeerId): SseConnection | undefined {
|
|
206
|
+
return this.#connections.get(peerId)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get all active connections.
|
|
211
|
+
*/
|
|
212
|
+
getAllConnections(): SseConnection[] {
|
|
213
|
+
return Array.from(this.#connections.values())
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Check if a peer is connected.
|
|
218
|
+
*/
|
|
219
|
+
isConnected(peerId: PeerId): boolean {
|
|
220
|
+
return this.#connections.has(peerId)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get the number of connected peers.
|
|
225
|
+
*/
|
|
226
|
+
get connectionCount(): number {
|
|
227
|
+
return this.#connections.size
|
|
228
|
+
}
|
|
229
|
+
}
|