@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
|
@@ -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 };
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
})
|