@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,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
|
+
}
|