@kyneta/sse-transport 1.6.0 → 1.7.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/README.md +5 -5
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +46 -55
- package/dist/client.js.map +1 -1
- package/dist/express.d.ts +4 -4
- package/dist/express.js +10 -9
- package/dist/express.js.map +1 -1
- package/dist/{server-transport-DX3-TbCZ.js → server-transport-CwEVpOpu.js} +39 -52
- package/dist/server-transport-CwEVpOpu.js.map +1 -0
- package/dist/{server-transport-DXK7KMx4.d.ts → server-transport-PIK5bPSw.d.ts} +18 -24
- package/dist/server-transport-PIK5bPSw.d.ts.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +9 -11
- package/src/__tests__/client-program.test.ts +1 -1
- package/src/__tests__/client-transport.test.ts +17 -35
- package/src/__tests__/connection.test.ts +64 -143
- package/src/__tests__/sse-asymmetric.test.ts +205 -0
- package/src/client-program.ts +17 -30
- package/src/client-transport.ts +46 -87
- package/src/client.ts +0 -1
- package/src/connection.ts +52 -90
- package/src/express-router.ts +12 -11
- package/src/server-transport.ts +1 -1
- package/dist/server-transport-DX3-TbCZ.js.map +0 -1
- package/dist/server-transport-DXK7KMx4.d.ts.map +0 -1
|
@@ -2,20 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
|
|
4
4
|
import type { ChannelMsg } from "@kyneta/transport"
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
applyInboundAliasing,
|
|
8
|
-
applyOutboundAliasing,
|
|
9
|
-
complete,
|
|
10
|
-
decodeTextFrame,
|
|
11
|
-
decodeTextWireMessage,
|
|
12
|
-
emptyAliasState,
|
|
13
|
-
encodeTextFrame,
|
|
14
|
-
encodeTextWireMessage,
|
|
15
|
-
fragmentTextPayload,
|
|
16
|
-
MessageType,
|
|
17
|
-
TEXT_WIRE_VERSION,
|
|
18
|
-
} from "@kyneta/wire"
|
|
5
|
+
import { Pipeline } from "@kyneta/transport"
|
|
6
|
+
import { complete, encodeBinaryFrame, WIRE_VERSION } from "@kyneta/wire"
|
|
19
7
|
import { describe, expect, it, vi } from "vitest"
|
|
20
8
|
import { SseConnection } from "../connection.js"
|
|
21
9
|
|
|
@@ -39,12 +27,16 @@ function createMockChannel() {
|
|
|
39
27
|
}
|
|
40
28
|
|
|
41
29
|
/**
|
|
42
|
-
* Encode a ChannelMsg into
|
|
30
|
+
* Encode a ChannelMsg into binary frame bytes via Pipeline<"binary">.
|
|
31
|
+
* This simulates what the client sends over POST.
|
|
43
32
|
*/
|
|
44
|
-
function
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
33
|
+
function encodeToBinaryFrame(msg: ChannelMsg): Uint8Array<ArrayBuffer> {
|
|
34
|
+
const pipeline = new Pipeline({ send: "binary" })
|
|
35
|
+
const results = pipeline.send(msg)
|
|
36
|
+
pipeline.dispose()
|
|
37
|
+
const r = results[0]
|
|
38
|
+
if (!r || !r.ok) throw new Error("Failed to encode message via pipeline")
|
|
39
|
+
return r.value
|
|
48
40
|
}
|
|
49
41
|
|
|
50
42
|
const presentMsg: ChannelMsg = {
|
|
@@ -80,41 +72,16 @@ describe("SseConnection — send", () => {
|
|
|
80
72
|
|
|
81
73
|
expect(sent).toHaveLength(1)
|
|
82
74
|
|
|
75
|
+
// Verify the sent text frame can be decoded back via Pipeline<"text">
|
|
76
|
+
const recvPipeline = new Pipeline({ send: "text" })
|
|
83
77
|
const sentFrame = sent.at(0)
|
|
84
78
|
if (!sentFrame) throw new Error("expected sent frame")
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
expect(
|
|
91
|
-
expect(decoded.msg).toEqual(presentMsg)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it("sends messages with short field names (alias-form wire format)", () => {
|
|
95
|
-
const conn = createConnection()
|
|
96
|
-
const sent: string[] = []
|
|
97
|
-
conn.setSendFunction(textFrame => sent.push(textFrame))
|
|
98
|
-
conn._setChannel(createMockChannel() as any)
|
|
99
|
-
|
|
100
|
-
conn.send(presentMsg)
|
|
101
|
-
|
|
102
|
-
const sentFrame = sent.at(0)
|
|
103
|
-
if (!sentFrame) throw new Error("expected sent frame")
|
|
104
|
-
const frame = decodeTextFrame(sentFrame)
|
|
105
|
-
const payload = JSON.parse(frame.content.payload) as Record<string, unknown>
|
|
106
|
-
|
|
107
|
-
// The alias-aware pipeline uses compact integer discriminators and
|
|
108
|
-
// short field names, not the long-name human-readable format.
|
|
109
|
-
expect(payload.t).toBe(MessageType.Present)
|
|
110
|
-
expect(payload.docs).toBeInstanceOf(Array)
|
|
111
|
-
const doc = (payload.docs as Array<Record<string, unknown>>)[0]
|
|
112
|
-
if (!doc) throw new Error("expected doc entry")
|
|
113
|
-
expect(doc.d).toBe("doc-1")
|
|
114
|
-
expect(doc.sh).toBe("test-hash")
|
|
115
|
-
// No long-name fields from the old textCodec format
|
|
116
|
-
expect(doc.docId).toBeUndefined()
|
|
117
|
-
expect(doc.schemaHash).toBeUndefined()
|
|
79
|
+
const results = recvPipeline.receive(sentFrame)
|
|
80
|
+
recvPipeline.dispose()
|
|
81
|
+
expect(results).toHaveLength(1)
|
|
82
|
+
const r = results[0]
|
|
83
|
+
if (!r || !r.ok) throw new Error("expected successful decode")
|
|
84
|
+
expect(r.value).toEqual(presentMsg)
|
|
118
85
|
})
|
|
119
86
|
|
|
120
87
|
it("throws if sendFn not set", () => {
|
|
@@ -141,7 +108,6 @@ describe("SseConnection — fragmentation", () => {
|
|
|
141
108
|
conn.send(presentMsg)
|
|
142
109
|
|
|
143
110
|
expect(sent).toHaveLength(1)
|
|
144
|
-
expect(sent[0]).toContain('"1c"')
|
|
145
111
|
})
|
|
146
112
|
|
|
147
113
|
it("fragments into multiple sendFn calls when above threshold", () => {
|
|
@@ -153,9 +119,6 @@ describe("SseConnection — fragmentation", () => {
|
|
|
153
119
|
conn.send(presentMsg)
|
|
154
120
|
|
|
155
121
|
expect(sent.length).toBeGreaterThan(1)
|
|
156
|
-
for (const frame of sent) {
|
|
157
|
-
expect(frame).toContain('"1f"')
|
|
158
|
-
}
|
|
159
122
|
})
|
|
160
123
|
|
|
161
124
|
it("does not fragment when threshold is 0 (disabled)", () => {
|
|
@@ -200,11 +163,11 @@ describe("SseConnection — receive", () => {
|
|
|
200
163
|
// ---------------------------------------------------------------------------
|
|
201
164
|
|
|
202
165
|
describe("SseConnection — handlePostBody", () => {
|
|
203
|
-
it("decodes a complete frame to messages", () => {
|
|
166
|
+
it("decodes a complete binary frame to messages", () => {
|
|
204
167
|
const conn = createConnection()
|
|
205
168
|
|
|
206
|
-
const
|
|
207
|
-
const result = conn.handlePostBody(
|
|
169
|
+
const frameBytes = encodeToBinaryFrame(presentMsg)
|
|
170
|
+
const result = conn.handlePostBody(frameBytes)
|
|
208
171
|
|
|
209
172
|
expect(result.type).toBe("messages")
|
|
210
173
|
if (result.type !== "messages") throw new Error("expected messages")
|
|
@@ -213,109 +176,67 @@ describe("SseConnection — handlePostBody", () => {
|
|
|
213
176
|
expect(result.response).toEqual({ status: 200, body: { ok: true } })
|
|
214
177
|
})
|
|
215
178
|
|
|
216
|
-
it("skips messages with alias resolution errors, continues processing", () => {
|
|
217
|
-
const conn = createConnection()
|
|
218
|
-
|
|
219
|
-
// An interest with an unresolved dx alias will fail alias resolution.
|
|
220
|
-
// The connection should skip it (not throw, not break) and return
|
|
221
|
-
// an empty messages array with a 200 (no fatal error).
|
|
222
|
-
const unresolvedInterest: WireInterestMsg = {
|
|
223
|
-
t: MessageType.Interest,
|
|
224
|
-
dx: 999,
|
|
225
|
-
}
|
|
226
|
-
const payload = JSON.stringify(encodeTextWireMessage(unresolvedInterest))
|
|
227
|
-
const textFrame = encodeTextFrame(complete(TEXT_WIRE_VERSION, payload))
|
|
228
|
-
|
|
229
|
-
const result = conn.handlePostBody(textFrame)
|
|
230
|
-
|
|
231
|
-
expect(result.type).toBe("messages")
|
|
232
|
-
if (result.type !== "messages") throw new Error("expected messages")
|
|
233
|
-
expect(result.messages).toHaveLength(0)
|
|
234
|
-
expect(result.response).toEqual({ status: 200, body: { ok: true } })
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
it("reassembles fragmented payloads", () => {
|
|
238
|
-
const conn = createConnection()
|
|
239
|
-
|
|
240
|
-
const { wire } = applyOutboundAliasing(emptyAliasState(), presentMsg)
|
|
241
|
-
const payload = JSON.stringify(encodeTextWireMessage(wire))
|
|
242
|
-
const fragments = fragmentTextPayload(payload, 50, 42)
|
|
243
|
-
|
|
244
|
-
expect(fragments.length).toBeGreaterThan(1)
|
|
245
|
-
|
|
246
|
-
for (let i = 0; i < fragments.length - 1; i++) {
|
|
247
|
-
const fragment = fragments.at(i)
|
|
248
|
-
if (fragment === undefined) throw new Error(`missing fragment ${i}`)
|
|
249
|
-
const result = conn.handlePostBody(fragment)
|
|
250
|
-
expect(result.type).toBe("pending")
|
|
251
|
-
if (result.type === "pending") {
|
|
252
|
-
expect(result.response).toEqual({
|
|
253
|
-
status: 202,
|
|
254
|
-
body: { pending: true },
|
|
255
|
-
})
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const lastFragment = fragments.at(fragments.length - 1)
|
|
260
|
-
if (lastFragment === undefined) throw new Error("missing last fragment")
|
|
261
|
-
const finalResult = conn.handlePostBody(lastFragment)
|
|
262
|
-
|
|
263
|
-
expect(finalResult.type).toBe("messages")
|
|
264
|
-
if (finalResult.type !== "messages") throw new Error("expected messages")
|
|
265
|
-
expect(finalResult.messages).toHaveLength(1)
|
|
266
|
-
expect(finalResult.messages[0]).toEqual(presentMsg)
|
|
267
|
-
})
|
|
268
|
-
|
|
269
179
|
it("resolves docId aliases across messages", () => {
|
|
270
180
|
const conn = createConnection()
|
|
271
181
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
)
|
|
276
|
-
const presentPayload = JSON.stringify(encodeTextWireMessage(presentWire))
|
|
277
|
-
const presentFrame = encodeTextFrame(
|
|
278
|
-
complete(TEXT_WIRE_VERSION, presentPayload),
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
const presentResult = conn.handlePostBody(presentFrame)
|
|
182
|
+
// First message: present (establishes alias bindings)
|
|
183
|
+
const presentBytes = encodeToBinaryFrame(presentMsg)
|
|
184
|
+
const presentResult = conn.handlePostBody(presentBytes)
|
|
282
185
|
expect(presentResult.type).toBe("messages")
|
|
283
186
|
if (presentResult.type !== "messages") throw new Error("expected messages")
|
|
284
187
|
expect(presentResult.messages[0]).toEqual(presentMsg)
|
|
285
188
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
189
|
+
// Second message: interest using the alias established by present.
|
|
190
|
+
// To build this, we use the same pipeline so its alias table is in sync.
|
|
191
|
+
const sendPipeline = new Pipeline({ send: "binary" })
|
|
192
|
+
// Send the present first to establish alias bindings in the send pipeline
|
|
193
|
+
sendPipeline.send(presentMsg)
|
|
194
|
+
// Now send an interest for the first doc
|
|
195
|
+
const interestMsg: ChannelMsg = {
|
|
196
|
+
type: "interest",
|
|
197
|
+
docId: "doc-1",
|
|
292
198
|
}
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
)
|
|
199
|
+
const interestResults = sendPipeline.send(interestMsg)
|
|
200
|
+
sendPipeline.dispose()
|
|
201
|
+
const interestFrame = interestResults[0]
|
|
202
|
+
if (!interestFrame || !interestFrame.ok)
|
|
203
|
+
throw new Error("expected interest frame")
|
|
297
204
|
|
|
298
|
-
const interestResult = conn.handlePostBody(interestFrame)
|
|
205
|
+
const interestResult = conn.handlePostBody(interestFrame.value)
|
|
299
206
|
expect(interestResult.type).toBe("messages")
|
|
300
207
|
if (interestResult.type !== "messages") throw new Error("expected messages")
|
|
301
208
|
expect(interestResult.messages).toHaveLength(1)
|
|
302
209
|
|
|
303
|
-
const
|
|
304
|
-
if (!
|
|
305
|
-
expect(
|
|
306
|
-
if (
|
|
307
|
-
expect(
|
|
210
|
+
const decoded = interestResult.messages[0]
|
|
211
|
+
if (!decoded) throw new Error("expected interest message")
|
|
212
|
+
expect(decoded.type).toBe("interest")
|
|
213
|
+
if (decoded.type !== "interest") throw new Error("expected interest")
|
|
214
|
+
expect(decoded.docId).toBe("doc-1")
|
|
308
215
|
})
|
|
309
216
|
|
|
310
|
-
it("returns error on malformed
|
|
217
|
+
it("returns error on malformed binary input", () => {
|
|
311
218
|
const conn = createConnection()
|
|
312
219
|
|
|
313
|
-
|
|
220
|
+
// Garbage bytes that aren't a valid binary frame
|
|
221
|
+
const garbage = new Uint8Array([0xff, 0xff, 0xff, 0xff])
|
|
222
|
+
const result = conn.handlePostBody(garbage)
|
|
314
223
|
|
|
315
224
|
expect(result.type).toBe("error")
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
225
|
+
expect(result.response.status).toBe(400)
|
|
226
|
+
conn.dispose()
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("returns error (not pending) on malformed CBOR payload", () => {
|
|
230
|
+
const conn = new SseConnection("peer-err", 1)
|
|
231
|
+
// Construct a structurally valid binary frame wrapping garbage CBOR.
|
|
232
|
+
// The frame parser accepts it (valid header), but CBOR decode fails.
|
|
233
|
+
const garbageCbor = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc])
|
|
234
|
+
const frame = encodeBinaryFrame(complete(WIRE_VERSION, garbageCbor))
|
|
235
|
+
|
|
236
|
+
const result = conn.handlePostBody(frame)
|
|
237
|
+
expect(result.type).toBe("error")
|
|
238
|
+
expect(result.response.status).toBe(400)
|
|
239
|
+
conn.dispose()
|
|
319
240
|
})
|
|
320
241
|
})
|
|
321
242
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// sse-asymmetric — tests for asymmetric text/binary Pipeline encoding.
|
|
2
|
+
//
|
|
3
|
+
// Verifies the key SSE invariant: server sends text, client sends binary,
|
|
4
|
+
// and a single AliasState covers both encoding directions because the
|
|
5
|
+
// alias transformer operates on WireMessage shape, not encoded bytes.
|
|
6
|
+
|
|
7
|
+
import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
|
|
8
|
+
import type {
|
|
9
|
+
ChannelMsg,
|
|
10
|
+
EstablishMsg,
|
|
11
|
+
InterestMsg,
|
|
12
|
+
PresentMsg,
|
|
13
|
+
} from "@kyneta/transport"
|
|
14
|
+
import { Pipeline } from "@kyneta/transport"
|
|
15
|
+
import type { Result, WireError } from "@kyneta/wire"
|
|
16
|
+
import { describe, expect, it } from "vitest"
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function unwrapAll<T>(results: readonly Result<T, WireError>[]): T[] {
|
|
23
|
+
const out: T[] = []
|
|
24
|
+
for (const r of results) {
|
|
25
|
+
if (!r.ok) throw new Error(`Unexpected error: ${JSON.stringify(r.error)}`)
|
|
26
|
+
out.push(r.value)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function recoverAll(
|
|
32
|
+
pipeline: Pipeline<any, any>,
|
|
33
|
+
frames: readonly unknown[],
|
|
34
|
+
): ChannelMsg[] {
|
|
35
|
+
const msgs: ChannelMsg[] = []
|
|
36
|
+
for (const frame of frames) {
|
|
37
|
+
for (const r of pipeline.receive(frame as any)) {
|
|
38
|
+
if (!r.ok) throw new Error(`receive error: ${JSON.stringify(r.error)}`)
|
|
39
|
+
msgs.push(r.value)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return msgs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Fixtures
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const serverEstablish: EstablishMsg = {
|
|
50
|
+
type: "establish",
|
|
51
|
+
identity: { peerId: "server", name: "Server", type: "service" },
|
|
52
|
+
features: { alias: true },
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const clientEstablish: EstablishMsg = {
|
|
56
|
+
type: "establish",
|
|
57
|
+
identity: { peerId: "client", name: "Client", type: "user" },
|
|
58
|
+
features: { alias: true },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Tests
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe("SSE asymmetric encoding", () => {
|
|
66
|
+
it("server text → client binary receive", () => {
|
|
67
|
+
// Server sends text, receives binary
|
|
68
|
+
const server = new Pipeline<"text", "binary">({
|
|
69
|
+
send: "text",
|
|
70
|
+
receive: "binary",
|
|
71
|
+
})
|
|
72
|
+
// Client sends binary, receives text
|
|
73
|
+
const client = new Pipeline<"binary", "text">({
|
|
74
|
+
send: "binary",
|
|
75
|
+
receive: "text",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const serverOut = unwrapAll(server.send(serverEstablish))
|
|
80
|
+
const recovered = recoverAll(client, serverOut)
|
|
81
|
+
|
|
82
|
+
expect(recovered).toHaveLength(1)
|
|
83
|
+
expect(recovered[0]).toEqual(serverEstablish)
|
|
84
|
+
} finally {
|
|
85
|
+
server.dispose()
|
|
86
|
+
client.dispose()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("client binary → server binary receive", () => {
|
|
91
|
+
const server = new Pipeline<"text", "binary">({
|
|
92
|
+
send: "text",
|
|
93
|
+
receive: "binary",
|
|
94
|
+
})
|
|
95
|
+
const client = new Pipeline<"binary", "text">({
|
|
96
|
+
send: "binary",
|
|
97
|
+
receive: "text",
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const clientOut = unwrapAll(client.send(clientEstablish))
|
|
102
|
+
const recovered = recoverAll(server, clientOut)
|
|
103
|
+
|
|
104
|
+
expect(recovered).toHaveLength(1)
|
|
105
|
+
expect(recovered[0]).toEqual(clientEstablish)
|
|
106
|
+
} finally {
|
|
107
|
+
server.dispose()
|
|
108
|
+
client.dispose()
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("alias state shared across encoding asymmetry", () => {
|
|
113
|
+
const server = new Pipeline<"text", "binary">({
|
|
114
|
+
send: "text",
|
|
115
|
+
receive: "binary",
|
|
116
|
+
})
|
|
117
|
+
const client = new Pipeline<"binary", "text">({
|
|
118
|
+
send: "binary",
|
|
119
|
+
receive: "text",
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
// Step 1: Server sends establish with alias:true → client receives
|
|
124
|
+
const serverEstFrames = unwrapAll(server.send(serverEstablish))
|
|
125
|
+
const clientGotEst = recoverAll(client, serverEstFrames)
|
|
126
|
+
expect(clientGotEst).toHaveLength(1)
|
|
127
|
+
expect(clientGotEst[0]).toEqual(serverEstablish)
|
|
128
|
+
|
|
129
|
+
// Step 2: Client sends establish with alias:true → server receives
|
|
130
|
+
const clientEstFrames = unwrapAll(client.send(clientEstablish))
|
|
131
|
+
const serverGotEst = recoverAll(server, clientEstFrames)
|
|
132
|
+
expect(serverGotEst).toHaveLength(1)
|
|
133
|
+
expect(serverGotEst[0]).toEqual(clientEstablish)
|
|
134
|
+
|
|
135
|
+
// Both pipelines now have mutualAlias enabled.
|
|
136
|
+
|
|
137
|
+
// Step 3: Server sends present with alias info (text) → client receives
|
|
138
|
+
const presentMsg: PresentMsg = {
|
|
139
|
+
type: "present",
|
|
140
|
+
docs: [
|
|
141
|
+
{
|
|
142
|
+
docId: "doc-1",
|
|
143
|
+
schemaHash: "h-1",
|
|
144
|
+
replicaType: ["plain", 1, 0],
|
|
145
|
+
syncProtocol: SYNC_AUTHORITATIVE,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const presentFrames = unwrapAll(server.send(presentMsg))
|
|
151
|
+
const clientGotPresent = recoverAll(client, presentFrames)
|
|
152
|
+
expect(clientGotPresent).toHaveLength(1)
|
|
153
|
+
const gotPresent = clientGotPresent[0]
|
|
154
|
+
if (gotPresent === undefined) throw new Error("unreachable")
|
|
155
|
+
expect(gotPresent.type).toBe("present")
|
|
156
|
+
if (gotPresent.type !== "present") throw new Error("unreachable")
|
|
157
|
+
expect(gotPresent.docs).toHaveLength(1)
|
|
158
|
+
expect(gotPresent.docs[0]?.docId).toBe("doc-1")
|
|
159
|
+
|
|
160
|
+
// Step 4: Client sends its own present for doc-1 (binary) → server receives.
|
|
161
|
+
// This assigns an outbound alias in the client's alias table.
|
|
162
|
+
const clientPresent: PresentMsg = {
|
|
163
|
+
type: "present",
|
|
164
|
+
docs: [
|
|
165
|
+
{
|
|
166
|
+
docId: "doc-1",
|
|
167
|
+
schemaHash: "h-1",
|
|
168
|
+
replicaType: ["plain", 1, 0],
|
|
169
|
+
syncProtocol: SYNC_AUTHORITATIVE,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const clientPresentFrames = unwrapAll(client.send(clientPresent))
|
|
175
|
+
const serverGotPresent = recoverAll(server, clientPresentFrames)
|
|
176
|
+
expect(serverGotPresent).toHaveLength(1)
|
|
177
|
+
const gotClientPresent = serverGotPresent[0]
|
|
178
|
+
if (gotClientPresent === undefined) throw new Error("unreachable")
|
|
179
|
+
expect(gotClientPresent.type).toBe("present")
|
|
180
|
+
if (gotClientPresent.type !== "present") throw new Error("unreachable")
|
|
181
|
+
expect(gotClientPresent.docs[0]?.docId).toBe("doc-1")
|
|
182
|
+
|
|
183
|
+
// Step 5: Client sends interest using the alias (dx instead of doc) → server receives
|
|
184
|
+
// The client's outbound alias table now has doc-1 (from step 4),
|
|
185
|
+
// and mutualAlias is true, so the interest will use dx.
|
|
186
|
+
const interestMsg: InterestMsg = {
|
|
187
|
+
type: "interest",
|
|
188
|
+
docId: "doc-1",
|
|
189
|
+
version: "v1",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const interestFrames = unwrapAll(client.send(interestMsg))
|
|
193
|
+
const serverGotInterest = recoverAll(server, interestFrames)
|
|
194
|
+
expect(serverGotInterest).toHaveLength(1)
|
|
195
|
+
const gotInterest = serverGotInterest[0]
|
|
196
|
+
if (gotInterest === undefined) throw new Error("unreachable")
|
|
197
|
+
expect(gotInterest.type).toBe("interest")
|
|
198
|
+
if (gotInterest.type !== "interest") throw new Error("unreachable")
|
|
199
|
+
expect(gotInterest.docId).toBe("doc-1")
|
|
200
|
+
} finally {
|
|
201
|
+
server.dispose()
|
|
202
|
+
client.dispose()
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
})
|
package/src/client-program.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Program } from "@kyneta/machine"
|
|
11
11
|
import type { ReconnectOptions } from "@kyneta/transport"
|
|
12
|
-
import {
|
|
12
|
+
import { DEFAULT_RECONNECT, shouldReconnect } from "@kyneta/transport"
|
|
13
13
|
|
|
14
14
|
import type { DisconnectReason, SseClientState } from "./types.js"
|
|
15
15
|
|
|
@@ -44,8 +44,8 @@ export type SseClientEffect =
|
|
|
44
44
|
export interface SseClientProgramOptions {
|
|
45
45
|
url: string
|
|
46
46
|
reconnect?: Partial<ReconnectOptions>
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/** Source of `[0, 1)` random values for jitter. Default: `Math.random` */
|
|
48
|
+
randomFn?: () => number
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
@@ -58,7 +58,7 @@ export interface SseClientProgramOptions {
|
|
|
58
58
|
export function createSseClientProgram(
|
|
59
59
|
options: SseClientProgramOptions,
|
|
60
60
|
): Program<SseClientMsg, SseClientState, SseClientEffect> {
|
|
61
|
-
const { url,
|
|
61
|
+
const { url, randomFn = Math.random } = options
|
|
62
62
|
const reconnect: ReconnectOptions = {
|
|
63
63
|
...DEFAULT_RECONNECT,
|
|
64
64
|
...options.reconnect,
|
|
@@ -67,44 +67,31 @@ export function createSseClientProgram(
|
|
|
67
67
|
/**
|
|
68
68
|
* Attempt to transition into reconnecting, or give up and disconnect.
|
|
69
69
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* Wraps the pure `shouldReconnect` decision and builds the SSE-specific
|
|
71
|
+
* state/effect tuple. Returns a tuple suitable for spreading into an
|
|
72
|
+
* `update` return.
|
|
73
73
|
*/
|
|
74
74
|
function tryReconnect(
|
|
75
75
|
currentAttempt: number,
|
|
76
76
|
reason: DisconnectReason,
|
|
77
77
|
...extraEffects: SseClientEffect[]
|
|
78
78
|
): [SseClientState, ...SseClientEffect[]] {
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const d = shouldReconnect(reconnect, currentAttempt, randomFn)
|
|
80
|
+
if (!d.reconnect) {
|
|
81
|
+
const finalReason: DisconnectReason =
|
|
82
|
+
d.cause === "max-attempts-exceeded"
|
|
83
|
+
? { type: "max-retries-exceeded", attempts: d.attempts }
|
|
84
|
+
: reason
|
|
85
|
+
return [{ status: "disconnected", reason: finalReason }, ...extraEffects]
|
|
81
86
|
}
|
|
82
|
-
|
|
83
|
-
if (currentAttempt >= reconnect.maxAttempts) {
|
|
84
|
-
return [
|
|
85
|
-
{
|
|
86
|
-
status: "disconnected",
|
|
87
|
-
reason: { type: "max-retries-exceeded", attempts: currentAttempt },
|
|
88
|
-
},
|
|
89
|
-
...extraEffects,
|
|
90
|
-
]
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const delay = computeBackoffDelay(
|
|
94
|
-
currentAttempt + 1,
|
|
95
|
-
reconnect.baseDelay,
|
|
96
|
-
reconnect.maxDelay,
|
|
97
|
-
jitterFn(),
|
|
98
|
-
)
|
|
99
|
-
|
|
100
87
|
return [
|
|
101
88
|
{
|
|
102
89
|
status: "reconnecting",
|
|
103
|
-
attempt:
|
|
104
|
-
nextAttemptMs:
|
|
90
|
+
attempt: d.attempt,
|
|
91
|
+
nextAttemptMs: d.delayMs,
|
|
105
92
|
},
|
|
106
93
|
...extraEffects,
|
|
107
|
-
{ type: "start-reconnect-timer", delayMs:
|
|
94
|
+
{ type: "start-reconnect-timer", delayMs: d.delayMs },
|
|
108
95
|
]
|
|
109
96
|
}
|
|
110
97
|
|