@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.
@@ -0,0 +1,180 @@
1
+ import { PeerId, Channel, ChannelMsg, Transport, GeneratedChannel } from '@kyneta/exchange';
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 };
@@ -0,0 +1,4 @@
1
+ export { D as DEFAULT_FRAGMENT_THRESHOLD, S as SseConnection, a as SseConnectionConfig, b as SseServerTransport, c as SseServerTransportOptions } from './server-transport-BrMRLsmp.js';
2
+ export { D as DisconnectReason, S as SseConnectionHandle, a as SseConnectionResult } from './types-BTgljZGe.js';
3
+ import '@kyneta/exchange';
4
+ import '@kyneta/wire';
package/dist/server.js ADDED
@@ -0,0 +1,12 @@
1
+ import {
2
+ DEFAULT_FRAGMENT_THRESHOLD,
3
+ SseConnection,
4
+ SseServerTransport
5
+ } from "./chunk-TR4Y3HFB.js";
6
+ import "./chunk-7D4SUZUM.js";
7
+ export {
8
+ DEFAULT_FRAGMENT_THRESHOLD,
9
+ SseConnection,
10
+ SseServerTransport
11
+ };
12
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,83 @@
1
+ import { PeerId } from '@kyneta/exchange';
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
+ /**
48
+ * Handle for an active SSE connection (server-side).
49
+ */
50
+ interface SseConnectionHandle {
51
+ /** The peer ID for this connection. */
52
+ readonly peerId: PeerId;
53
+ /** The channel ID for this connection. */
54
+ readonly channelId: number;
55
+ /** Disconnect this connection. */
56
+ disconnect(): void;
57
+ }
58
+ /**
59
+ * Result of registering an SSE connection on the server.
60
+ */
61
+ interface SseConnectionResult {
62
+ /** The connection handle for managing this peer. */
63
+ connection: SseConnectionHandle;
64
+ }
65
+ /**
66
+ * Lifecycle event callbacks for the SSE client.
67
+ */
68
+ interface SseClientLifecycleEvents {
69
+ /** Called on every state transition (delivered async via microtask). */
70
+ onStateChange?: (transition: {
71
+ from: SseClientState;
72
+ to: SseClientState;
73
+ timestamp: number;
74
+ }) => void;
75
+ /** Called when the connection is lost. */
76
+ onDisconnect?: (reason: DisconnectReason) => void;
77
+ /** Called when a reconnection attempt is scheduled. */
78
+ onReconnecting?: (attempt: number, nextAttemptMs: number) => void;
79
+ /** Called when reconnection succeeds after a previous connection. */
80
+ onReconnected?: () => void;
81
+ }
82
+
83
+ export type { DisconnectReason as D, SseConnectionHandle as S, SseConnectionResult as a, SseClientLifecycleEvents as b, SseClientState as c };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@kyneta/sse-transport",
3
+ "version": "1.1.0",
4
+ "description": "SSE (Server-Sent Events) network adapter for @kyneta/exchange — client, server, and Express integration",
5
+ "author": "Duane Johnson",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/halecraft/kyneta",
10
+ "directory": "packages/exchange/network-adapters/sse"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "files": [
17
+ "dist",
18
+ "src"
19
+ ],
20
+ "exports": {
21
+ "./client": {
22
+ "types": "./dist/client.d.ts",
23
+ "import": "./dist/client.js"
24
+ },
25
+ "./server": {
26
+ "types": "./dist/server.d.ts",
27
+ "import": "./dist/server.js"
28
+ },
29
+ "./express": {
30
+ "types": "./dist/express.d.ts",
31
+ "import": "./dist/express.js"
32
+ },
33
+ "./src/*": "./src/*"
34
+ },
35
+ "peerDependencies": {
36
+ "@kyneta/exchange": "^1.1.0",
37
+ "@kyneta/wire": "^1.1.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "express": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@types/express": "^4.17.23",
46
+ "@types/node": "^22",
47
+ "express": "^4.21.0",
48
+ "tsup": "^8.5.0",
49
+ "typescript": "^5.9.2",
50
+ "vitest": "^4.0.17",
51
+ "@kyneta/exchange": "^1.1.0",
52
+ "@kyneta/schema": "^1.1.0",
53
+ "@kyneta/wire": "^1.1.0"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "test": "verify logic",
58
+ "verify": "verify"
59
+ }
60
+ }
@@ -0,0 +1,201 @@
1
+ // SseClientStateMachine tests.
2
+ //
3
+ // Tests only SSE-specific concerns: the 4-state transition map, the
4
+ // isConnected() helper, and a full lifecycle. Generic state machine
5
+ // mechanics (async delivery, waitForState, batching, etc.) are tested
6
+ // in packages/exchange/src/__tests__/client-state-machine.test.ts.
7
+
8
+ import { describe, expect, it } from "vitest"
9
+ import { SseClientStateMachine } from "../client-state-machine.js"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Initial state
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe("SseClientStateMachine — initial state", () => {
16
+ it("starts in disconnected state", () => {
17
+ const sm = new SseClientStateMachine()
18
+ expect(sm.getState()).toEqual({ status: "disconnected" })
19
+ expect(sm.getStatus()).toBe("disconnected")
20
+ })
21
+
22
+ it("isConnected() returns false initially", () => {
23
+ const sm = new SseClientStateMachine()
24
+ expect(sm.isConnected()).toBe(false)
25
+ })
26
+ })
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Valid transitions
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe("SseClientStateMachine — valid transitions", () => {
33
+ it("disconnected → connecting", () => {
34
+ const sm = new SseClientStateMachine()
35
+ sm.transition({ status: "connecting", attempt: 1 })
36
+ expect(sm.getStatus()).toBe("connecting")
37
+ })
38
+
39
+ it("connecting → connected", () => {
40
+ const sm = new SseClientStateMachine()
41
+ sm.transition({ status: "connecting", attempt: 1 })
42
+ sm.transition({ status: "connected" })
43
+ expect(sm.getStatus()).toBe("connected")
44
+ expect(sm.isConnected()).toBe(true)
45
+ })
46
+
47
+ it("connecting → disconnected", () => {
48
+ const sm = new SseClientStateMachine()
49
+ sm.transition({ status: "connecting", attempt: 1 })
50
+ sm.transition({
51
+ status: "disconnected",
52
+ reason: { type: "error", error: new Error("fail") },
53
+ })
54
+ expect(sm.getStatus()).toBe("disconnected")
55
+ })
56
+
57
+ it("connecting → reconnecting", () => {
58
+ const sm = new SseClientStateMachine()
59
+ sm.transition({ status: "connecting", attempt: 1 })
60
+ sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
61
+ expect(sm.getStatus()).toBe("reconnecting")
62
+ })
63
+
64
+ it("connected → disconnected", () => {
65
+ const sm = new SseClientStateMachine()
66
+ sm.transition({ status: "connecting", attempt: 1 })
67
+ sm.transition({ status: "connected" })
68
+ sm.transition({ status: "disconnected", reason: { type: "intentional" } })
69
+ expect(sm.getStatus()).toBe("disconnected")
70
+ })
71
+
72
+ it("connected → reconnecting", () => {
73
+ const sm = new SseClientStateMachine()
74
+ sm.transition({ status: "connecting", attempt: 1 })
75
+ sm.transition({ status: "connected" })
76
+ sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 2000 })
77
+ expect(sm.getStatus()).toBe("reconnecting")
78
+ })
79
+
80
+ it("reconnecting → connecting", () => {
81
+ const sm = new SseClientStateMachine()
82
+ sm.transition({ status: "connecting", attempt: 1 })
83
+ sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
84
+ sm.transition({ status: "connecting", attempt: 2 })
85
+ expect(sm.getStatus()).toBe("connecting")
86
+ expect((sm.getState() as { attempt: number }).attempt).toBe(2)
87
+ })
88
+
89
+ it("reconnecting → disconnected", () => {
90
+ const sm = new SseClientStateMachine()
91
+ sm.transition({ status: "connecting", attempt: 1 })
92
+ sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
93
+ sm.transition({
94
+ status: "disconnected",
95
+ reason: { type: "max-retries-exceeded", attempts: 10 },
96
+ })
97
+ expect(sm.getStatus()).toBe("disconnected")
98
+ })
99
+ })
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Invalid transitions
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("SseClientStateMachine — invalid transitions", () => {
106
+ it("rejects disconnected → connected (must go through connecting)", () => {
107
+ const sm = new SseClientStateMachine()
108
+ expect(() => sm.transition({ status: "connected" })).toThrow(
109
+ "Invalid state transition: disconnected -> connected",
110
+ )
111
+ })
112
+
113
+ it("rejects disconnected → reconnecting", () => {
114
+ const sm = new SseClientStateMachine()
115
+ expect(() =>
116
+ sm.transition({
117
+ status: "reconnecting",
118
+ attempt: 1,
119
+ nextAttemptMs: 1000,
120
+ }),
121
+ ).toThrow("Invalid state transition: disconnected -> reconnecting")
122
+ })
123
+
124
+ it("rejects connected → connecting (must go through reconnecting)", () => {
125
+ const sm = new SseClientStateMachine()
126
+ sm.transition({ status: "connecting", attempt: 1 })
127
+ sm.transition({ status: "connected" })
128
+ expect(() => sm.transition({ status: "connecting", attempt: 2 })).toThrow(
129
+ "Invalid state transition: connected -> connecting",
130
+ )
131
+ })
132
+
133
+ // SSE has no "ready" state — verify it's not in the transition map
134
+ it("rejects transition to ready (SSE has no ready state)", () => {
135
+ const sm = new SseClientStateMachine()
136
+ sm.transition({ status: "connecting", attempt: 1 })
137
+ sm.transition({ status: "connected" })
138
+ expect(() => sm.transition({ status: "ready" } as any)).toThrow(
139
+ "Invalid state transition: connected -> ready",
140
+ )
141
+ })
142
+ })
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // isConnected
146
+ // ---------------------------------------------------------------------------
147
+
148
+ describe("SseClientStateMachine — isConnected", () => {
149
+ it("returns false for disconnected", () => {
150
+ const sm = new SseClientStateMachine()
151
+ expect(sm.isConnected()).toBe(false)
152
+ })
153
+
154
+ it("returns false for connecting", () => {
155
+ const sm = new SseClientStateMachine()
156
+ sm.transition({ status: "connecting", attempt: 1 })
157
+ expect(sm.isConnected()).toBe(false)
158
+ })
159
+
160
+ it("returns true for connected", () => {
161
+ const sm = new SseClientStateMachine()
162
+ sm.transition({ status: "connecting", attempt: 1 })
163
+ sm.transition({ status: "connected" })
164
+ expect(sm.isConnected()).toBe(true)
165
+ })
166
+
167
+ it("returns false for reconnecting", () => {
168
+ const sm = new SseClientStateMachine()
169
+ sm.transition({ status: "connecting", attempt: 1 })
170
+ sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
171
+ expect(sm.isConnected()).toBe(false)
172
+ })
173
+ })
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Full lifecycle
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe("SseClientStateMachine — full lifecycle", () => {
180
+ it("disconnected → connecting → connected → reconnecting → connecting → connected → disconnected", () => {
181
+ const sm = new SseClientStateMachine()
182
+
183
+ sm.transition({ status: "connecting", attempt: 1 })
184
+ sm.transition({ status: "connected" })
185
+ expect(sm.isConnected()).toBe(true)
186
+
187
+ // Connection lost
188
+ sm.transition({ status: "reconnecting", attempt: 1, nextAttemptMs: 1000 })
189
+ expect(sm.isConnected()).toBe(false)
190
+
191
+ // Retry
192
+ sm.transition({ status: "connecting", attempt: 2 })
193
+ sm.transition({ status: "connected" })
194
+ expect(sm.isConnected()).toBe(true)
195
+
196
+ // Intentional disconnect
197
+ sm.transition({ status: "disconnected", reason: { type: "intentional" } })
198
+ expect(sm.getStatus()).toBe("disconnected")
199
+ expect(sm.isConnected()).toBe(false)
200
+ })
201
+ })