@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.
@@ -2,20 +2,8 @@
2
2
 
3
3
  import { SYNC_AUTHORITATIVE } from "@kyneta/schema"
4
4
  import type { ChannelMsg } from "@kyneta/transport"
5
- import type { WireInterestMsg } from "@kyneta/wire"
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 a text frame string via the alias-aware pipeline.
30
+ * Encode a ChannelMsg into binary frame bytes via Pipeline<"binary">.
31
+ * This simulates what the client sends over POST.
43
32
  */
44
- function encodeToTextFrame(msg: ChannelMsg): string {
45
- const { wire } = applyOutboundAliasing(emptyAliasState(), msg)
46
- const payload = JSON.stringify(encodeTextWireMessage(wire))
47
- return encodeTextFrame(complete(TEXT_WIRE_VERSION, payload))
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 frame = decodeTextFrame(sentFrame)
86
- expect(frame.content.kind).toBe("complete")
87
- const parsed = JSON.parse(frame.content.payload)
88
- const wire = decodeTextWireMessage(parsed)
89
- const decoded = applyInboundAliasing(emptyAliasState(), wire)
90
- expect(decoded.error).toBeUndefined()
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 textFrame = encodeToTextFrame(presentMsg)
207
- const result = conn.handlePostBody(textFrame)
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
- const { wire: presentWire } = applyOutboundAliasing(
273
- emptyAliasState(),
274
- presentMsg,
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
- const docAlias = (presentWire as { docs: Array<{ a?: number }> }).docs[0]?.a
287
- if (docAlias === undefined) throw new Error("expected doc alias assignment")
288
-
289
- const interestWire: WireInterestMsg = {
290
- t: MessageType.Interest,
291
- dx: docAlias,
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 interestPayload = JSON.stringify(encodeTextWireMessage(interestWire))
294
- const interestFrame = encodeTextFrame(
295
- complete(TEXT_WIRE_VERSION, interestPayload),
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 interestMsg = interestResult.messages[0]
304
- if (!interestMsg) throw new Error("expected interest message")
305
- expect(interestMsg.type).toBe("interest")
306
- if (interestMsg.type !== "interest") throw new Error("expected interest")
307
- expect(interestMsg.docId).toBe("doc-1")
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 JSON input", () => {
217
+ it("returns error on malformed binary input", () => {
311
218
  const conn = createConnection()
312
219
 
313
- const result = conn.handlePostBody("not json")
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
- if (result.type === "error") {
317
- expect(result.response.status).toBe(400)
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
+ })
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { Program } from "@kyneta/machine"
11
11
  import type { ReconnectOptions } from "@kyneta/transport"
12
- import { computeBackoffDelay, DEFAULT_RECONNECT } from "@kyneta/transport"
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
- /** Inject jitter source for deterministic testing. Default: () => Math.random() * 1000 */
48
- jitterFn?: () => number
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, jitterFn = () => Math.random() * 1000 } = options
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
- * Pure computes the next state and effects from the current attempt
71
- * count and reconnect configuration. Returns a tuple suitable for
72
- * spreading into an `update` return.
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
- if (!reconnect.enabled) {
80
- return [{ status: "disconnected", reason }, ...extraEffects]
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: currentAttempt + 1,
104
- nextAttemptMs: delay,
90
+ attempt: d.attempt,
91
+ nextAttemptMs: d.delayMs,
105
92
  },
106
93
  ...extraEffects,
107
- { type: "start-reconnect-timer", delayMs: delay },
94
+ { type: "start-reconnect-timer", delayMs: d.delayMs },
108
95
  ]
109
96
  }
110
97