@livestore/webmesh 0.4.0-dev.22 → 0.4.0-dev.24

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.
Files changed (37) hide show
  1. package/README.md +5 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/direct-channel-internal.d.ts.map +1 -1
  4. package/dist/channel/direct-channel-internal.js +15 -23
  5. package/dist/channel/direct-channel-internal.js.map +1 -1
  6. package/dist/channel/direct-channel.js +4 -4
  7. package/dist/channel/direct-channel.js.map +1 -1
  8. package/dist/channel/proxy-channel.d.ts +26 -1
  9. package/dist/channel/proxy-channel.d.ts.map +1 -1
  10. package/dist/channel/proxy-channel.js +64 -18
  11. package/dist/channel/proxy-channel.js.map +1 -1
  12. package/dist/common.d.ts.map +1 -1
  13. package/dist/common.js +5 -6
  14. package/dist/common.js.map +1 -1
  15. package/dist/node.d.ts +6 -0
  16. package/dist/node.d.ts.map +1 -1
  17. package/dist/node.js +25 -23
  18. package/dist/node.js.map +1 -1
  19. package/dist/node.test.js +192 -5
  20. package/dist/node.test.js.map +1 -1
  21. package/dist/websocket-edge.d.ts +1 -1
  22. package/dist/websocket-edge.d.ts.map +1 -1
  23. package/dist/websocket-edge.js +5 -7
  24. package/dist/websocket-edge.js.map +1 -1
  25. package/dist/websocket-edge.test.d.ts +7 -0
  26. package/dist/websocket-edge.test.d.ts.map +1 -0
  27. package/dist/websocket-edge.test.js +74 -0
  28. package/dist/websocket-edge.test.js.map +1 -0
  29. package/package.json +65 -12
  30. package/src/channel/direct-channel-internal.ts +17 -40
  31. package/src/channel/direct-channel.ts +4 -4
  32. package/src/channel/proxy-channel.ts +85 -25
  33. package/src/common.ts +5 -6
  34. package/src/node.test.ts +270 -7
  35. package/src/node.ts +31 -23
  36. package/src/websocket-edge.test.ts +98 -0
  37. package/src/websocket-edge.ts +7 -9
package/src/node.test.ts CHANGED
@@ -85,8 +85,8 @@ const maybeDelay =
85
85
  ? effect
86
86
  : Effect.sleep(delay).pipe(Effect.withSpan(`${label}:delay(${delay})`), Effect.andThen(effect))
87
87
 
88
- const testTimeout = IS_CI ? 30_000 : 1000
89
- const propTestTimeout = IS_CI ? 60_000 : 20_000
88
+ const testTimeout = IS_CI === true ? 30_000 : 1000
89
+ const propTestTimeout = IS_CI === true ? 60_000 : 20_000
90
90
 
91
91
  // TODO also make work without `Vitest.scopedLive` (i.e. with `Vitest.scoped`)
92
92
  // probably requires controlling the clocks
@@ -200,7 +200,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
200
200
  yield* Effect.promise(() => nodeX.debug.requestTopology(100))
201
201
  }).pipe(
202
202
  Vitest.withTestCtx(test, {
203
- suffix: `delayX=${delayX} delayY=${delayY} connectDelay=${connectDelay} channelType=${channelType} nodeNames=${nodeNames}`,
203
+ suffix: `delayX=${delayX} delayY=${delayY} connectDelay=${connectDelay} channelType=${channelType} nodeNames=${nodeNames.join(',')}`,
204
204
  }),
205
205
  ),
206
206
  // { fastCheck: { numRuns: 20 } },
@@ -231,8 +231,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
231
231
  // TODO also optionally delay the edge
232
232
  yield* connectNodes(nodeA, nodeB)
233
233
 
234
- const waitForBToBeOffline =
235
- waitForOfflineDelay === undefined ? undefined : yield* Deferred.make<void, never>()
234
+ const waitForBToBeOffline = waitForOfflineDelay === undefined ? undefined : yield* Deferred.make<void>()
236
235
 
237
236
  const nodeACode = Effect.gen(function* () {
238
237
  const channelAToB = yield* createChannel(nodeA, 'B', { mode })
@@ -392,7 +391,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
392
391
  yield* Effect.all([nodeXCode, nodeYCode], { concurrency: 'unbounded' })
393
392
  }).pipe(
394
393
  Vitest.withTestCtx(test, {
395
- suffix: `channelType=${channelType} nodeNames=${nodeNames}`,
394
+ suffix: `channelType=${channelType} nodeNames=${nodeNames.join(',')}`,
396
395
  }),
397
396
  ),
398
397
  { fastCheck: { numRuns: 10 } },
@@ -622,6 +621,270 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
622
621
  }).pipe(Vitest.withTestCtx(test)),
623
622
  )
624
623
 
624
+ /**
625
+ * Pattern test: ACK sending must not block message processing.
626
+ *
627
+ * This test documents the pattern where ACKs are sent fire-and-forget using `Effect.forkScoped`
628
+ * to avoid blocking the message processing loop.
629
+ *
630
+ * Background: Before the fix, ACKs were sent with `await` which blocked message processing.
631
+ * This caused heartbeat timeouts in devtools after ~30 seconds ("Connection to app lost").
632
+ *
633
+ * Note: This unit test passes both with and without the fix because in-memory channels
634
+ * complete ACK sends instantly. The actual regression test is the Playwright test
635
+ * `node-adapter-timeout.play.ts` which uses real network conditions.
636
+ *
637
+ * This test exists to:
638
+ * 1. Document the expected pattern (high message throughput via proxy channels)
639
+ * 2. Verify the pattern works correctly with forked ACKs
640
+ * 3. Serve as a reference for the fix in proxy-channel.ts
641
+ */
642
+ Vitest.scopedLive('ACK sending should not block message processing', (test) =>
643
+ Effect.gen(function* () {
644
+ const nodeA = yield* makeMeshNode('A')
645
+ const nodeB = yield* makeMeshNode('B')
646
+ const nodeC = yield* makeMeshNode('C')
647
+
648
+ yield* connectNodesViaBroadcastChannel(nodeA, nodeB)
649
+ yield* connectNodesViaBroadcastChannel(nodeB, nodeC)
650
+
651
+ // Send many messages to stress the ACK handling
652
+ const messageCount = 10
653
+
654
+ const nodeACode = Effect.gen(function* () {
655
+ const channelAToC = yield* createChannel(nodeA, 'C', { mode: 'proxy' })
656
+ // Send multiple messages concurrently
657
+ yield* Effect.forEach(
658
+ Chunk.makeBy(messageCount, (i) => ({ message: `A${i}` })),
659
+ channelAToC.send,
660
+ { concurrency: 'unbounded' },
661
+ )
662
+ // Receive responses
663
+ const responses = yield* channelAToC.listen.pipe(
664
+ Stream.flatten(),
665
+ Stream.take(messageCount),
666
+ Stream.runCollect,
667
+ )
668
+ expect(Chunk.size(responses)).toBe(messageCount)
669
+ })
670
+
671
+ const nodeCCode = Effect.gen(function* () {
672
+ const channelCToA = yield* createChannel(nodeC, 'A', { mode: 'proxy' })
673
+ // Send multiple messages concurrently
674
+ yield* Effect.forEach(
675
+ Chunk.makeBy(messageCount, (i) => ({ message: `C${i}` })),
676
+ channelCToA.send,
677
+ { concurrency: 'unbounded' },
678
+ )
679
+ // Receive responses
680
+ const responses = yield* channelCToA.listen.pipe(
681
+ Stream.flatten(),
682
+ Stream.take(messageCount),
683
+ Stream.runCollect,
684
+ )
685
+ expect(Chunk.size(responses)).toBe(messageCount)
686
+ })
687
+
688
+ yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' })
689
+ }).pipe(Vitest.withTestCtx(test)),
690
+ )
691
+
692
+ Vitest.describe('ACK forkScoped regression tests', { timeout: 10_000 }, () => {
693
+ /**
694
+ * REGRESSION TEST: ACK sends must be forked (non-blocking) to allow message processing to continue.
695
+ *
696
+ * This test uses simulation parameters to inject a delay BEFORE the ACK send.
697
+ * It then measures when messages arrive at the receiver's listen queue.
698
+ *
699
+ * With the fix (Effect.forkScoped):
700
+ * - ACK send is forked in the background
701
+ * - Message is added to listen queue IMMEDIATELY (before ACK send completes)
702
+ * - Multiple messages arrive close together regardless of ACK delay
703
+ *
704
+ * Without the fix (blocking yield*):
705
+ * - ACK send blocks the processing loop
706
+ * - Message is added to listen queue AFTER ACK send completes
707
+ * - Messages arrive spread out by the ACK delay
708
+ *
709
+ * To verify this test catches the regression:
710
+ * 1. Temporarily change `Effect.forkScoped` to `yield*` in proxy-channel.ts:319
711
+ * 2. Run this test - it should FAIL because messages arrive too slowly
712
+ * 3. Revert the change - test should PASS
713
+ */
714
+ /**
715
+ * This test verifies the fix works: messages should arrive at the listen queue
716
+ * without being blocked by slow ACK sends. We measure how long it takes to receive
717
+ * all messages - with forked ACKs it should be fast, with blocking ACKs it would be slow.
718
+ */
719
+ Vitest.scopedLive('messages arrive in listen queue without waiting for ACK send', (test) =>
720
+ Effect.gen(function* () {
721
+ const ACK_DELAY_MS = 50
722
+ const MESSAGE_COUNT = 5
723
+
724
+ const nodeA = yield* makeMeshNode('A')
725
+ const nodeB = yield* makeMeshNode('B')
726
+
727
+ yield* connectNodesViaMessageChannel(nodeA, nodeB)
728
+
729
+ const receivedMessages: string[] = []
730
+
731
+ const senderCode = Effect.gen(function* () {
732
+ const channelAToB = yield* nodeA.makeChannel({
733
+ target: 'B',
734
+ channelName: 'test-ack-timing',
735
+ schema: ExampleSchema,
736
+ mode: 'proxy',
737
+ timeout: 3000,
738
+ })
739
+
740
+ // Send all messages concurrently - this is key!
741
+ // With forked ACKs, all messages get processed immediately at receiver
742
+ // With blocking ACKs, messages would be processed one at a time with delays
743
+ yield* Effect.forEach(
744
+ Chunk.makeBy(MESSAGE_COUNT, (i) => ({ message: `msg${i}` })),
745
+ channelAToB.send,
746
+ { concurrency: 'unbounded' },
747
+ )
748
+ })
749
+
750
+ const receiverCode = Effect.gen(function* () {
751
+ const channelBToA = yield* nodeB.makeChannel({
752
+ target: 'A',
753
+ channelName: 'test-ack-timing',
754
+ schema: ExampleSchema,
755
+ mode: 'proxy',
756
+ timeout: 3000,
757
+ // KEY: Inject delay BEFORE ACK send
758
+ // With forkScoped: message arrives in queue immediately, then ACK is sent in background
759
+ // Without forkScoped: message waits for ACK delay before being added to queue
760
+ simulation: {
761
+ onPayload: {
762
+ beforeAckSend: ACK_DELAY_MS,
763
+ afterAckFork: 0,
764
+ afterListenQueueOffer: 0,
765
+ },
766
+ },
767
+ })
768
+
769
+ yield* channelBToA.listen.pipe(
770
+ Stream.flatten(),
771
+ Stream.tap((msg) =>
772
+ Effect.sync(() => {
773
+ receivedMessages.push(msg.message)
774
+ }),
775
+ ),
776
+ Stream.take(MESSAGE_COUNT),
777
+ Stream.runDrain,
778
+ )
779
+ })
780
+
781
+ const startTime = Date.now()
782
+ yield* Effect.all([senderCode, receiverCode], { concurrency: 'unbounded' })
783
+ const elapsed = Date.now() - startTime
784
+
785
+ console.log(`[regression-test-1] Received ${receivedMessages.length} messages in ${elapsed}ms`)
786
+ console.log(`[regression-test-1] Messages: ${receivedMessages.join(', ')}`)
787
+
788
+ expect(receivedMessages.length).toBe(MESSAGE_COUNT)
789
+
790
+ // With forked ACKs, elapsed time should be much less than MESSAGE_COUNT * ACK_DELAY_MS
791
+ // because ACKs don't block message processing
792
+ const blockingTime = MESSAGE_COUNT * ACK_DELAY_MS
793
+ console.log(`[regression-test-1] Elapsed: ${elapsed}ms, Blocking estimate: ${blockingTime}ms`)
794
+
795
+ // The test passes if messages arrive faster than they would with blocking ACKs
796
+ // Allow some margin for test overhead (2x faster than blocking)
797
+ if (elapsed > blockingTime / 2) {
798
+ throw new Error(
799
+ `REGRESSION DETECTED: Processing took ${elapsed}ms, blocking estimate: ${blockingTime}ms. ` +
800
+ `With forked ACKs, processing should be much faster. ` +
801
+ `Check that proxy-channel.ts uses Effect.forkScoped for ACK sends.`,
802
+ )
803
+ }
804
+ }).pipe(Vitest.withTestCtx(test)),
805
+ )
806
+
807
+ /**
808
+ * Additional test: Verify message processing continues during slow ACK sends.
809
+ *
810
+ * This test sends multiple messages concurrently and verifies they're all
811
+ * processed even when ACK sends are slow.
812
+ */
813
+ Vitest.scopedLive('concurrent messages processed despite slow ACK sends', (test) =>
814
+ Effect.gen(function* () {
815
+ const ACK_DELAY_MS = 50
816
+ const MESSAGE_COUNT = 5
817
+
818
+ const nodeA = yield* makeMeshNode('A')
819
+ const nodeB = yield* makeMeshNode('B')
820
+
821
+ yield* connectNodesViaMessageChannel(nodeA, nodeB)
822
+
823
+ const receivedMessages: string[] = []
824
+
825
+ const senderCode = Effect.gen(function* () {
826
+ const channelAToB = yield* nodeA.makeChannel({
827
+ target: 'B',
828
+ channelName: 'test-concurrent',
829
+ schema: ExampleSchema,
830
+ mode: 'proxy',
831
+ timeout: 3000,
832
+ })
833
+
834
+ // Send all messages concurrently
835
+ yield* Effect.forEach(
836
+ Chunk.makeBy(MESSAGE_COUNT, (i) => ({ message: `msg${i}` })),
837
+ channelAToB.send,
838
+ { concurrency: 'unbounded' },
839
+ )
840
+ })
841
+
842
+ const receiverCode = Effect.gen(function* () {
843
+ const channelBToA = yield* nodeB.makeChannel({
844
+ target: 'A',
845
+ channelName: 'test-concurrent',
846
+ schema: ExampleSchema,
847
+ mode: 'proxy',
848
+ timeout: 3000,
849
+ simulation: {
850
+ onPayload: {
851
+ beforeAckSend: ACK_DELAY_MS,
852
+ afterAckFork: 0,
853
+ afterListenQueueOffer: 0,
854
+ },
855
+ },
856
+ })
857
+
858
+ yield* channelBToA.listen.pipe(
859
+ Stream.flatten(),
860
+ Stream.tap((msg) =>
861
+ Effect.sync(() => {
862
+ receivedMessages.push(msg.message)
863
+ }),
864
+ ),
865
+ Stream.take(MESSAGE_COUNT),
866
+ Stream.runDrain,
867
+ )
868
+ })
869
+
870
+ const startTime = Date.now()
871
+ yield* Effect.all([senderCode, receiverCode], { concurrency: 'unbounded' })
872
+ const elapsed = Date.now() - startTime
873
+
874
+ console.log(`[regression-test] Received ${receivedMessages.length} messages in ${elapsed}ms`)
875
+ console.log(`[regression-test] Messages: ${receivedMessages.join(', ')}`)
876
+
877
+ // All messages should be received
878
+ expect(receivedMessages.length).toBe(MESSAGE_COUNT)
879
+
880
+ // With forked ACKs, elapsed time should be much less than MESSAGE_COUNT * ACK_DELAY_MS
881
+ // because ACKs don't block message processing
882
+ const blockingTime = MESSAGE_COUNT * ACK_DELAY_MS
883
+ console.log(`[regression-test] Elapsed: ${elapsed}ms, Blocking estimate: ${blockingTime}ms`)
884
+ }).pipe(Vitest.withTestCtx(test)),
885
+ )
886
+ })
887
+
625
888
  Vitest.scopedLive('should fail with timeout due to missing edge', (test) =>
626
889
  Effect.gen(function* () {
627
890
  const nodeA = yield* makeMeshNode('A')
@@ -944,7 +1207,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
944
1207
  const nodeB = yield* makeMeshNode('B')
945
1208
  const nodeC = yield* makeMeshNode('C')
946
1209
 
947
- const mode = channelType.includes('proxy') ? 'proxy' : 'direct'
1210
+ const mode = channelType.includes('proxy') === true ? 'proxy' : 'direct'
948
1211
  const connect = channelType === 'direct' ? connectNodesViaMessageChannel : connectNodesViaBroadcastChannel
949
1212
  yield* connect(nodeA, nodeB).pipe(maybeDelay(delayConnectAB, 'delayConnectAB'))
950
1213
  yield* connect(nodeB, nodeC).pipe(maybeDelay(delayConnectBC, 'delayConnectBC'))
package/src/node.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  } from '@livestore/utils/effect'
17
17
 
18
18
  import { makeDirectChannel } from './channel/direct-channel.ts'
19
- import { makeProxyChannel } from './channel/proxy-channel.ts'
19
+ import { makeProxyChannel, type ProxyChannelSimulationParams } from './channel/proxy-channel.ts'
20
20
  import type { ChannelKey, ListenForChannelResult, MeshNodeName, MessageQueueItem, ProxyQueueItem } from './common.ts'
21
21
  import { EdgeAlreadyExistsError, packetAsOtelAttributes } from './common.ts'
22
22
  import * as WebmeshSchema from './mesh-schema.ts'
@@ -114,6 +114,11 @@ export interface MeshNode<TName extends MeshNodeName = MeshNodeName> {
114
114
  * @default false
115
115
  */
116
116
  closeExisting?: boolean
117
+ /**
118
+ * Optional simulation parameters for testing timing-sensitive behavior.
119
+ * Only used when mode is 'proxy'.
120
+ */
121
+ simulation?: ProxyChannelSimulationParams
117
122
  }) => Effect.Effect<WebChannel.WebChannel<MsgListen, MsgSend>, never, Scope.Scope>
118
123
 
119
124
  listenForChannel: Stream.Stream<ListenForChannelResult>
@@ -173,7 +178,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
173
178
  (packet._tag === 'DirectChannelRequest' &&
174
179
  (edgeChannels.size === 0 || // Either if direct edge does not support transferables ...
175
180
  edgeChannels.get(packet.target)?.channel.supportsTransferables === false)) || // ... or if no forward-edges support transferables
176
- ![...edgeChannels.values()].some((c) => c.channel.supportsTransferables === true)
181
+ [...edgeChannels.values()].some((c) => c.channel.supportsTransferables) === false
177
182
  ) {
178
183
  return WebmeshSchema.DirectChannelResponseNoTransferables.make({
179
184
  reqId: packet.id,
@@ -187,13 +192,14 @@ export const makeMeshNode = <TName extends MeshNodeName>(
187
192
  hops: [],
188
193
  })
189
194
  }
195
+ return undefined
190
196
  }
191
197
 
192
198
  const sendPacket = (packet: typeof WebmeshSchema.Packet.Type) =>
193
199
  Effect.gen(function* () {
194
200
  // yield* Effect.log(`${nodeName}: sendPacket:${packet._tag} [${packet.id}]`)
195
201
 
196
- if (Schema.is(WebmeshSchema.NetworkEdgeAdded)(packet)) {
202
+ if (Schema.is(WebmeshSchema.NetworkEdgeAdded)(packet) === true) {
197
203
  yield* Effect.spanEvent('NetworkEdgeAdded', { packet, nodeName })
198
204
  yield* PubSub.publish(newEdgeAvailablePubSub, packet.target)
199
205
 
@@ -205,7 +211,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
205
211
  return
206
212
  }
207
213
 
208
- if (Schema.is(WebmeshSchema.BroadcastChannelPacket)(packet)) {
214
+ if (Schema.is(WebmeshSchema.BroadcastChannelPacket)(packet) === true) {
209
215
  const edgesToForwardTo = Array.from(edgeChannels)
210
216
  .filter(([name]) => !packet.hops.includes(name))
211
217
  .map(([_, con]) => con.channel)
@@ -231,7 +237,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
231
237
  return
232
238
  }
233
239
 
234
- if (Schema.is(WebmeshSchema.NetworkTopologyRequest)(packet)) {
240
+ if (Schema.is(WebmeshSchema.NetworkTopologyRequest)(packet) === true) {
235
241
  if (packet.source !== nodeName) {
236
242
  const backEdgeName =
237
243
  packet.hops.at(-1) ?? shouldNeverHappen(`${nodeName}: Expected hops for packet`, packet)
@@ -265,7 +271,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
265
271
  return
266
272
  }
267
273
 
268
- if (Schema.is(WebmeshSchema.NetworkTopologyResponse)(packet)) {
274
+ if (Schema.is(WebmeshSchema.NetworkTopologyResponse)(packet) === true) {
269
275
  if (packet.source === nodeName) {
270
276
  const topologyRequestItem = topologyRequestsMap.get(packet.reqId)!
271
277
  topologyRequestItem.set(packet.nodeName, new Set(packet.edges))
@@ -289,7 +295,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
289
295
  }
290
296
 
291
297
  // We have a direct edge to the target node
292
- if (edgeChannels.has(packet.target)) {
298
+ if (edgeChannels.has(packet.target) === true) {
293
299
  const edgeChannel = edgeChannels.get(packet.target)!.channel
294
300
  const hops = packet.source === nodeName ? [] : [...packet.hops, nodeName]
295
301
  yield* Effect.annotateCurrentSpan({ hasDirectEdge: true })
@@ -325,7 +331,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
325
331
  .map(([name, con]) => ({ name, channel: con.channel }))
326
332
 
327
333
  // TODO if hops-depth=0, we should fail right away with no route found
328
- if (hops.length === 0 && edgesToForwardTo.length === 0 && LS_DEV) {
334
+ if (hops.length === 0 && edgesToForwardTo.length === 0 && LS_DEV === true) {
329
335
  yield* Effect.logWarning(nodeName, 'no route found to', packet.target, packet._tag, 'TODO handle better')
330
336
  // TODO return a expected failure
331
337
  }
@@ -348,8 +354,8 @@ export const makeMeshNode = <TName extends MeshNodeName>(
348
354
 
349
355
  const addEdge: MeshNode['addEdge'] = ({ target: targetNodeName, edgeChannel, replaceIfExists = false }) =>
350
356
  Effect.gen(function* () {
351
- if (edgeChannels.has(targetNodeName)) {
352
- if (replaceIfExists) {
357
+ if (edgeChannels.has(targetNodeName) === true) {
358
+ if (replaceIfExists === true) {
353
359
  yield* removeEdge(targetNodeName).pipe(Effect.orDie)
354
360
  } else {
355
361
  return yield* new EdgeAlreadyExistsError({ target: targetNodeName })
@@ -365,7 +371,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
365
371
 
366
372
  // console.debug(nodeName, 'recv', packet._tag, packet.source, packet.target)
367
373
 
368
- if (handledPacketIds.has(packet.id)) return
374
+ if (handledPacketIds.has(packet.id) === true) return
369
375
  handledPacketIds.add(packet.id)
370
376
 
371
377
  switch (packet._tag) {
@@ -380,7 +386,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
380
386
  if (packet.target === nodeName) {
381
387
  const channelKey = `target:${packet.source}, channelName:${packet.channelName}` satisfies ChannelKey
382
388
 
383
- if (!channelMap.has(channelKey)) {
389
+ if (channelMap.has(channelKey) === false) {
384
390
  const channelQueue = yield* Queue.unbounded<MessageQueueItem | ProxyQueueItem>().pipe(
385
391
  Effect.acquireRelease(Queue.shutdown),
386
392
  )
@@ -400,9 +406,9 @@ export const makeMeshNode = <TName extends MeshNodeName>(
400
406
  Effect.orDie,
401
407
  )
402
408
 
403
- if (Schema.is(WebmeshSchema.ProxyChannelPacket)(packet)) {
409
+ if (Schema.is(WebmeshSchema.ProxyChannelPacket)(packet) === true) {
404
410
  yield* Queue.offer(channelQueue, { packet, respondToSender })
405
- } else if (Schema.is(WebmeshSchema.DirectChannelPacket)(packet)) {
411
+ } else if (Schema.is(WebmeshSchema.DirectChannelPacket)(packet) === true) {
406
412
  yield* Queue.offer(channelQueue, { packet, respondToSender })
407
413
  }
408
414
 
@@ -414,7 +420,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
414
420
  })
415
421
  }
416
422
  } else {
417
- if (Schema.is(WebmeshSchema.DirectChannelPacket)(packet)) {
423
+ if (Schema.is(WebmeshSchema.DirectChannelPacket)(packet) === true) {
418
424
  const noTransferableResponse = checkTransferableEdges(packet)
419
425
  if (noTransferableResponse !== undefined) {
420
426
  yield* Effect.spanEvent(`No transferable edges found for ${packet.source}→${packet.target}`)
@@ -455,7 +461,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
455
461
 
456
462
  const removeEdge: MeshNode['removeEdge'] = (targetNodeName) =>
457
463
  Effect.gen(function* () {
458
- if (!edgeChannels.has(targetNodeName)) {
464
+ if (edgeChannels.has(targetNodeName) === false) {
459
465
  return yield* new Cause.NoSuchElementException(`No edge found for ${targetNodeName}`)
460
466
  }
461
467
 
@@ -477,15 +483,16 @@ export const makeMeshNode = <TName extends MeshNodeName>(
477
483
  mode,
478
484
  timeout = Duration.seconds(1),
479
485
  closeExisting = false,
486
+ simulation,
480
487
  }) =>
481
488
  Effect.gen(function* () {
482
489
  const schema = WebChannel.mapSchema(inputSchema)
483
490
  const channelKey = `target:${target}, channelName:${channelName}` satisfies ChannelKey
484
491
 
485
- if (channelMap.has(channelKey)) {
492
+ if (channelMap.has(channelKey) === true) {
486
493
  const existingChannel = channelMap.get(channelKey)!.debugInfo?.channel
487
- if (existingChannel) {
488
- if (closeExisting) {
494
+ if (existingChannel !== undefined) {
495
+ if (closeExisting === true) {
489
496
  yield* existingChannel.shutdown
490
497
  channelMap.delete(channelKey)
491
498
  } else {
@@ -546,6 +553,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
546
553
  schema,
547
554
  queue: channelQueue,
548
555
  sendPacket,
556
+ ...(simulation !== undefined ? { simulation } : {}),
549
557
  })
550
558
 
551
559
  channelMap.set(channelKey, { queue: channelQueue, debugInfo: { channel, target } })
@@ -564,7 +572,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
564
572
  // TODO also provide a way to allow for reconnects
565
573
  let listenAlreadyStarted = false
566
574
  const listenForChannel: MeshNode['listenForChannel'] = Stream.suspend(() => {
567
- if (listenAlreadyStarted) {
575
+ if (listenAlreadyStarted === true) {
568
576
  return shouldNeverHappen('listenForChannel already started')
569
577
  }
570
578
  listenAlreadyStarted = true
@@ -576,7 +584,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
576
584
  return Stream.fromQueue(channelRequestsQueue).pipe(
577
585
  Stream.filter((res) => {
578
586
  const hashed = hash(res)
579
- if (seen.has(hashed)) {
587
+ if (seen.has(hashed) === true) {
580
588
  return false
581
589
  }
582
590
  seen.add(hashed)
@@ -588,7 +596,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
588
596
  const makeBroadcastChannel: MeshNode['makeBroadcastChannel'] = ({ channelName, schema }) =>
589
597
  Effect.scopeWithCloseable((scope) =>
590
598
  Effect.gen(function* () {
591
- if (broadcastChannelListenQueueMap.has(channelName)) {
599
+ if (broadcastChannelListenQueueMap.has(channelName) === true) {
592
600
  return shouldNeverHappen(
593
601
  `Broadcast channel ${channelName} already exists`,
594
602
  broadcastChannelListenQueueMap.get(channelName),
@@ -657,7 +665,7 @@ export const makeMeshNode = <TName extends MeshNodeName>(
657
665
  supportsTransferables: value.debugInfo?.channel.supportsTransferables,
658
666
  ...value.debugInfo?.channel.debugInfo,
659
667
  })
660
- .map(([key, value]) => indent(`${key}=${value}`, 4))
668
+ .map(([key, value]) => indent(`${key}=${String(value)}`, 4))
661
669
  .join('\n'),
662
670
  ' ',
663
671
  value.debugInfo?.channel,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for websocket-edge.ts
3
+ *
4
+ * These tests verify basic WebSocket edge functionality.
5
+ */
6
+
7
+ import { expect } from 'vitest'
8
+
9
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
10
+ import { Effect, Either, Schema, WebChannel } from '@livestore/utils/effect'
11
+
12
+ import * as MeshSchema from './mesh-schema.ts'
13
+ import { MessageMsgPack, WSEdgeInit, WSEdgePayload } from './websocket-edge.ts'
14
+
15
+ Vitest.describe('websocket-edge', () => {
16
+ /**
17
+ * Test that WSEdgeInit messages can be encoded/decoded via MessageMsgPack.
18
+ */
19
+ Vitest.scopedLive('should encode/decode WSEdgeInit', (test) =>
20
+ Effect.gen(function* () {
21
+ const initMessage = WSEdgeInit.make({ from: 'test-node' })
22
+
23
+ // Encode to msgpack
24
+ const encoded = yield* Schema.encode(MessageMsgPack)(initMessage)
25
+
26
+ // Decode back
27
+ const decoded = yield* Schema.decode(MessageMsgPack)(encoded)
28
+
29
+ expect(decoded._tag).toBe('WSEdgeInit')
30
+ if (decoded._tag === 'WSEdgeInit') {
31
+ expect(decoded.from).toBe('test-node')
32
+ }
33
+ }).pipe(Vitest.withTestCtx(test)),
34
+ )
35
+
36
+ /**
37
+ * Test that WSEdgePayload messages with valid Packet payloads work correctly.
38
+ */
39
+ Vitest.scopedLive('should encode/decode WSEdgePayload with Packet', (test) =>
40
+ Effect.gen(function* () {
41
+ const packet = {
42
+ _tag: 'NetworkEdgeAdded' as const,
43
+ id: 'test-id',
44
+ source: 'node-a',
45
+ target: 'node-b',
46
+ }
47
+ const wsMessage = WSEdgePayload.make({ from: 'test-node', payload: packet })
48
+
49
+ // Encode to msgpack
50
+ const encoded = yield* Schema.encode(MessageMsgPack)(wsMessage)
51
+
52
+ // Decode back
53
+ const decoded = yield* Schema.decode(MessageMsgPack)(encoded)
54
+
55
+ expect(decoded._tag).toBe('WSEdgePayload')
56
+ if (decoded._tag === 'WSEdgePayload') {
57
+ expect(decoded.from).toBe('test-node')
58
+ expect(decoded.payload).toEqual(packet)
59
+ }
60
+ }).pipe(Vitest.withTestCtx(test)),
61
+ )
62
+
63
+ /**
64
+ * Test that mapSchema(Packet) includes WebChannel internal messages.
65
+ * This is important because channels can send/receive WebChannel.Ping/Pong/DebugPing.
66
+ */
67
+ Vitest.scopedLive('mapSchema should include WebChannel messages in schema', (test) =>
68
+ Effect.gen(function* () {
69
+ const schema = WebChannel.mapSchema(MeshSchema.Packet)
70
+
71
+ // WebChannel.Ping should be decodable via the wrapped listen schema
72
+ const pingPayload = { _tag: 'WebChannel.Ping' as const, requestId: 'test-123' }
73
+ const result = Schema.decodeUnknownEither(schema.listen)(pingPayload)
74
+
75
+ // mapSchema adds WebChannel messages to the schema
76
+ expect(Either.isRight(result)).toBe(true)
77
+ }).pipe(Vitest.withTestCtx(test)),
78
+ )
79
+
80
+ /**
81
+ * Test that valid webmesh packets can be decoded.
82
+ */
83
+ Vitest.scopedLive('should decode valid webmesh packets', (test) =>
84
+ Effect.gen(function* () {
85
+ const schema = WebChannel.mapSchema(MeshSchema.Packet)
86
+
87
+ const packet = {
88
+ _tag: 'NetworkEdgeAdded' as const,
89
+ id: 'test-id',
90
+ source: 'node-a',
91
+ target: 'node-b',
92
+ }
93
+
94
+ const result = yield* Schema.decodeUnknown(schema.listen)(packet)
95
+ expect(result._tag).toBe('NetworkEdgeAdded')
96
+ }).pipe(Vitest.withTestCtx(test)),
97
+ )
98
+ })