@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,184 @@
1
+ // SseConnection tests.
2
+ //
3
+ // Tests the core behavioral contracts:
4
+ // 1. send() encodes a ChannelMsg to a decodable text frame and calls sendFn
5
+ // 2. send() fragments large messages into multiple sendFn calls
6
+ // 3. receive() routes messages to the channel's onReceive
7
+ // 4. Guard: send() throws if sendFn not set
8
+ // 5. Guard: receive() throws if channel not set
9
+
10
+ import type { ChannelMsg } from "@kyneta/exchange"
11
+ import { decodeTextFrame, textCodec } from "@kyneta/wire"
12
+ import { describe, expect, it, vi } from "vitest"
13
+ import { SseConnection } from "../connection.js"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function createConnection(config?: { fragmentThreshold?: number }) {
20
+ const conn = new SseConnection("test-peer", 1, config)
21
+ return conn
22
+ }
23
+
24
+ function createMockChannel() {
25
+ return {
26
+ channelId: 1,
27
+ onReceive: vi.fn(),
28
+ send: vi.fn(),
29
+ type: "connected" as const,
30
+ transportType: "sse-server",
31
+ }
32
+ }
33
+
34
+ const presentMsg: ChannelMsg = {
35
+ type: "present",
36
+ docs: [
37
+ {
38
+ docId: "doc-1",
39
+ replicaType: ["plain", 1, 0] as const,
40
+ mergeStrategy: "sequential" as const,
41
+ },
42
+ {
43
+ docId: "doc-2",
44
+ replicaType: ["plain", 1, 0] as const,
45
+ mergeStrategy: "sequential" as const,
46
+ },
47
+ ],
48
+ }
49
+
50
+ const establishMsg: ChannelMsg = {
51
+ type: "establish-request",
52
+ identity: { peerId: "peer-1", name: "Peer 1", type: "user" },
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // send() — encoding round-trip
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe("SseConnection — send", () => {
60
+ it("encodes a ChannelMsg to a valid text frame and calls sendFn", () => {
61
+ const conn = createConnection()
62
+ const sent: string[] = []
63
+ conn.setSendFunction(textFrame => sent.push(textFrame))
64
+ conn._setChannel(createMockChannel() as any)
65
+
66
+ conn.send(presentMsg)
67
+
68
+ expect(sent).toHaveLength(1)
69
+
70
+ // The sent string should be a valid text frame that round-trips
71
+ const frame = decodeTextFrame(sent[0]!)
72
+ expect(frame.content.kind).toBe("complete")
73
+ const parsed = JSON.parse(frame.content.payload)
74
+ const decoded = textCodec.decode(parsed)
75
+ expect(decoded).toHaveLength(1)
76
+ expect(decoded[0]).toEqual(presentMsg)
77
+ })
78
+
79
+ it("round-trips establish-request with identity", () => {
80
+ const conn = createConnection()
81
+ const sent: string[] = []
82
+ conn.setSendFunction(textFrame => sent.push(textFrame))
83
+ conn._setChannel(createMockChannel() as any)
84
+
85
+ conn.send(establishMsg)
86
+
87
+ const frame = decodeTextFrame(sent[0]!)
88
+ const decoded = textCodec.decode(JSON.parse(frame.content.payload))
89
+ expect(decoded[0]).toEqual(establishMsg)
90
+ })
91
+
92
+ it("throws if sendFn not set", () => {
93
+ const conn = createConnection()
94
+ conn._setChannel(createMockChannel() as any)
95
+
96
+ expect(() => conn.send(presentMsg)).toThrow(
97
+ "Cannot send message: send function not set",
98
+ )
99
+ })
100
+ })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // send() — fragmentation
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("SseConnection — fragmentation", () => {
107
+ it("sends a single text frame when below threshold", () => {
108
+ const conn = createConnection({ fragmentThreshold: 100_000 })
109
+ const sent: string[] = []
110
+ conn.setSendFunction(textFrame => sent.push(textFrame))
111
+ conn._setChannel(createMockChannel() as any)
112
+
113
+ conn.send(presentMsg)
114
+
115
+ // Small message → single call
116
+ expect(sent).toHaveLength(1)
117
+ // Should be a complete frame (starts with ["0c",...])
118
+ expect(sent[0]).toContain('"0c"')
119
+ })
120
+
121
+ it("fragments into multiple sendFn calls when above threshold", () => {
122
+ // Use a very low threshold to force fragmentation on a normal message
123
+ const conn = createConnection({ fragmentThreshold: 20 })
124
+ const sent: string[] = []
125
+ conn.setSendFunction(textFrame => sent.push(textFrame))
126
+ conn._setChannel(createMockChannel() as any)
127
+
128
+ conn.send(presentMsg)
129
+
130
+ // Should produce multiple fragments
131
+ expect(sent.length).toBeGreaterThan(1)
132
+ // Each fragment should be a fragment frame (contains "0f" prefix)
133
+ for (const frame of sent) {
134
+ expect(frame).toContain('"0f"')
135
+ }
136
+ })
137
+
138
+ it("does not fragment when threshold is 0 (disabled)", () => {
139
+ const conn = createConnection({ fragmentThreshold: 0 })
140
+ const sent: string[] = []
141
+ conn.setSendFunction(textFrame => sent.push(textFrame))
142
+ conn._setChannel(createMockChannel() as any)
143
+
144
+ conn.send(presentMsg)
145
+
146
+ expect(sent).toHaveLength(1)
147
+ })
148
+ })
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // receive() — delivery
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe("SseConnection — receive", () => {
155
+ it("delivers a ChannelMsg to the channel onReceive", () => {
156
+ const conn = createConnection()
157
+ const channel = createMockChannel()
158
+ conn._setChannel(channel as any)
159
+
160
+ conn.receive(presentMsg)
161
+
162
+ expect(channel.onReceive).toHaveBeenCalledTimes(1)
163
+ expect(channel.onReceive).toHaveBeenCalledWith(presentMsg)
164
+ })
165
+
166
+ it("throws if channel not set", () => {
167
+ const conn = createConnection()
168
+
169
+ expect(() => conn.receive(presentMsg)).toThrow(
170
+ "Cannot receive message: channel not set",
171
+ )
172
+ })
173
+ })
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // dispose
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe("SseConnection — dispose", () => {
180
+ it("can be called without error", () => {
181
+ const conn = createConnection()
182
+ expect(() => conn.dispose()).not.toThrow()
183
+ })
184
+ })
@@ -0,0 +1,145 @@
1
+ // parseTextPostBody tests.
2
+ //
3
+ // Tests the framework-agnostic POST handler (functional core):
4
+ // 1. Complete text frame body → returns decoded ChannelMsg[]
5
+ // 2. Fragment text frame body → returns pending, then complete on final fragment
6
+ // 3. Malformed body → returns error
7
+
8
+ import type { ChannelMsg } from "@kyneta/exchange"
9
+ import {
10
+ encodeTextComplete,
11
+ fragmentTextPayload,
12
+ TextReassembler,
13
+ textCodec,
14
+ } from "@kyneta/wire"
15
+ import { describe, expect, it } from "vitest"
16
+ import { parseTextPostBody } from "../sse-handler.js"
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const presentMsg: ChannelMsg = {
23
+ type: "present",
24
+ docs: [
25
+ {
26
+ docId: "doc-1",
27
+ replicaType: ["plain", 1, 0] as const,
28
+ mergeStrategy: "sequential" as const,
29
+ },
30
+ {
31
+ docId: "doc-2",
32
+ replicaType: ["plain", 1, 0] as const,
33
+ mergeStrategy: "sequential" as const,
34
+ },
35
+ ],
36
+ }
37
+
38
+ const interestMsg: ChannelMsg = {
39
+ type: "interest",
40
+ docId: "doc-1",
41
+ version: "v1",
42
+ reciprocate: true,
43
+ }
44
+
45
+ function encodeAsTextFrame(msg: ChannelMsg): string {
46
+ return encodeTextComplete(textCodec, msg)
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Complete frame
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe("parseTextPostBody — complete frame", () => {
54
+ it("decodes a present message from a complete text frame", () => {
55
+ const reassembler = new TextReassembler()
56
+ const body = encodeAsTextFrame(presentMsg)
57
+
58
+ const result = parseTextPostBody(reassembler, body)
59
+
60
+ expect(result.type).toBe("messages")
61
+ if (result.type !== "messages") throw new Error("unreachable")
62
+ expect(result.messages).toHaveLength(1)
63
+ expect(result.messages[0]).toEqual(presentMsg)
64
+ expect(result.response).toEqual({ status: 200, body: { ok: true } })
65
+
66
+ reassembler.dispose()
67
+ })
68
+
69
+ it("decodes an interest message with all fields", () => {
70
+ const reassembler = new TextReassembler()
71
+ const body = encodeAsTextFrame(interestMsg)
72
+
73
+ const result = parseTextPostBody(reassembler, body)
74
+
75
+ expect(result.type).toBe("messages")
76
+ if (result.type !== "messages") throw new Error("unreachable")
77
+ expect(result.messages[0]).toEqual(interestMsg)
78
+
79
+ reassembler.dispose()
80
+ })
81
+ })
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Fragment frames
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe("parseTextPostBody — fragments", () => {
88
+ it("returns pending for intermediate fragments, then complete on the last", () => {
89
+ const reassembler = new TextReassembler()
90
+
91
+ // Encode the message payload and fragment it into small chunks
92
+ const payload = JSON.stringify(textCodec.encode(presentMsg))
93
+ const fragments = fragmentTextPayload(payload, 20) // very small chunks
94
+
95
+ expect(fragments.length).toBeGreaterThan(1)
96
+
97
+ // All but the last fragment should return pending
98
+ for (let i = 0; i < fragments.length - 1; i++) {
99
+ const result = parseTextPostBody(reassembler, fragments[i]!)
100
+ expect(result.type).toBe("pending")
101
+ expect(result.response).toEqual({ status: 202, body: { pending: true } })
102
+ }
103
+
104
+ // The last fragment should complete and return messages
105
+ const finalResult = parseTextPostBody(
106
+ reassembler,
107
+ fragments[fragments.length - 1]!,
108
+ )
109
+ expect(finalResult.type).toBe("messages")
110
+ if (finalResult.type !== "messages") throw new Error("unreachable")
111
+ expect(finalResult.messages).toHaveLength(1)
112
+ expect(finalResult.messages[0]).toEqual(presentMsg)
113
+ expect(finalResult.response).toEqual({ status: 200, body: { ok: true } })
114
+
115
+ reassembler.dispose()
116
+ })
117
+ })
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Malformed body
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe("parseTextPostBody — errors", () => {
124
+ it("returns error for invalid JSON", () => {
125
+ const reassembler = new TextReassembler()
126
+
127
+ const result = parseTextPostBody(reassembler, "not valid json at all")
128
+
129
+ expect(result.type).toBe("error")
130
+ expect(result.response.status).toBe(400)
131
+
132
+ reassembler.dispose()
133
+ })
134
+
135
+ it("returns error for valid JSON that is not a text frame", () => {
136
+ const reassembler = new TextReassembler()
137
+
138
+ const result = parseTextPostBody(reassembler, '{"type":"present"}')
139
+
140
+ expect(result.type).toBe("error")
141
+ expect(result.response.status).toBe(400)
142
+
143
+ reassembler.dispose()
144
+ })
145
+ })
@@ -0,0 +1,69 @@
1
+ // client-state-machine — SSE client state machine.
2
+ //
3
+ // Thin wrapper around the generic ClientStateMachine<S> from @kyneta/exchange,
4
+ // providing the SSE-specific 4-state transition map and an isConnected() helper.
5
+ //
6
+ // States: disconnected → connecting → connected
7
+ // ↓ ↓
8
+ // reconnecting ← ─ ─┘
9
+ // ↓
10
+ // connecting (retry)
11
+ // ↓
12
+ // disconnected (max retries)
13
+
14
+ import { ClientStateMachine } from "@kyneta/exchange"
15
+ import type { SseClientState } from "./types.js"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // SSE transition map
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const SSE_VALID_TRANSITIONS: Record<string, string[]> = {
22
+ disconnected: ["connecting"],
23
+ connecting: ["connected", "disconnected", "reconnecting"],
24
+ connected: ["disconnected", "reconnecting"],
25
+ reconnecting: ["connecting", "disconnected"],
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // SseClientStateMachine
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Observable state machine for SSE client connection lifecycle.
34
+ *
35
+ * Extends the generic `ClientStateMachine<SseClientState>` with
36
+ * an SSE-specific convenience helper. Unlike the WebSocket state machine,
37
+ * SSE has no `"ready"` state — the connection is usable as soon as
38
+ * `EventSource.onopen` fires.
39
+ *
40
+ * Usage:
41
+ * ```typescript
42
+ * const sm = new SseClientStateMachine()
43
+ *
44
+ * sm.subscribeToTransitions(({ from, to }) => {
45
+ * console.log(`${from.status} → ${to.status}`)
46
+ * })
47
+ *
48
+ * sm.transition({ status: "connecting", attempt: 1 })
49
+ * sm.transition({ status: "connected" })
50
+ *
51
+ * // Transitions are delivered asynchronously via microtask
52
+ * // Listener will see: disconnected → connecting, connecting → connected
53
+ * ```
54
+ */
55
+ export class SseClientStateMachine extends ClientStateMachine<SseClientState> {
56
+ constructor() {
57
+ super({
58
+ initialState: { status: "disconnected" },
59
+ validTransitions: SSE_VALID_TRANSITIONS,
60
+ })
61
+ }
62
+
63
+ /**
64
+ * Check if the client is connected (EventSource open, channel established).
65
+ */
66
+ isConnected(): boolean {
67
+ return this.getStatus() === "connected"
68
+ }
69
+ }