@livestore/webmesh 0.4.0-dev.9 → 0.5.0-dev.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.
Files changed (59) hide show
  1. package/README.md +5 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/direct-channel-internal.d.ts +2 -3
  4. package/dist/channel/direct-channel-internal.d.ts.map +1 -1
  5. package/dist/channel/direct-channel-internal.js +35 -38
  6. package/dist/channel/direct-channel-internal.js.map +1 -1
  7. package/dist/channel/direct-channel.d.ts.map +1 -1
  8. package/dist/channel/direct-channel.js +22 -23
  9. package/dist/channel/direct-channel.js.map +1 -1
  10. package/dist/channel/proxy-channel.d.ts +29 -5
  11. package/dist/channel/proxy-channel.d.ts.map +1 -1
  12. package/dist/channel/proxy-channel.js +87 -37
  13. package/dist/channel/proxy-channel.js.map +1 -1
  14. package/dist/common.d.ts +33 -35
  15. package/dist/common.d.ts.map +1 -1
  16. package/dist/common.js +7 -7
  17. package/dist/common.js.map +1 -1
  18. package/dist/mesh-schema.d.ts +262 -136
  19. package/dist/mesh-schema.d.ts.map +1 -1
  20. package/dist/mesh-schema.js +45 -44
  21. package/dist/mesh-schema.js.map +1 -1
  22. package/dist/node.d.ts +12 -6
  23. package/dist/node.d.ts.map +1 -1
  24. package/dist/node.js +47 -44
  25. package/dist/node.js.map +1 -1
  26. package/dist/node.test.js +253 -63
  27. package/dist/node.test.js.map +1 -1
  28. package/dist/utils.d.ts +1 -1
  29. package/dist/utils.d.ts.map +1 -1
  30. package/dist/utils.js.map +1 -1
  31. package/dist/websocket-edge.d.ts +20 -18
  32. package/dist/websocket-edge.d.ts.map +1 -1
  33. package/dist/websocket-edge.js +33 -41
  34. package/dist/websocket-edge.js.map +1 -1
  35. package/dist/websocket-edge.test.d.ts +7 -0
  36. package/dist/websocket-edge.test.d.ts.map +1 -0
  37. package/dist/websocket-edge.test.js +74 -0
  38. package/dist/websocket-edge.test.js.map +1 -0
  39. package/dist/worker/mod.d.ts +24 -0
  40. package/dist/worker/mod.d.ts.map +1 -0
  41. package/dist/worker/mod.js +44 -0
  42. package/dist/worker/mod.js.map +1 -0
  43. package/dist/worker/schema.d.ts +10 -0
  44. package/dist/worker/schema.d.ts.map +1 -0
  45. package/dist/worker/schema.js +7 -0
  46. package/dist/worker/schema.js.map +1 -0
  47. package/package.json +48 -12
  48. package/src/channel/direct-channel-internal.ts +41 -47
  49. package/src/channel/direct-channel.ts +28 -25
  50. package/src/channel/proxy-channel.ts +120 -57
  51. package/src/common.ts +9 -7
  52. package/src/mesh-schema.ts +34 -33
  53. package/src/node.test.ts +356 -80
  54. package/src/node.ts +63 -55
  55. package/src/utils.ts +3 -3
  56. package/src/websocket-edge.test.ts +98 -0
  57. package/src/websocket-edge.ts +52 -48
  58. package/src/worker/mod.ts +89 -0
  59. package/src/worker/schema.ts +8 -0
package/src/node.test.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { expect } from 'vitest'
2
+
1
3
  import { IS_CI, omitUndefineds } from '@livestore/utils'
2
- import { Chunk, Deferred, Effect, Exit, Schema, Scope, Stream, WebChannel } from '@livestore/utils/effect'
3
4
  import { Vitest } from '@livestore/utils-dev/node-vitest'
4
- import { expect } from 'vitest'
5
+ import { Deferred, Effect, Exit, Fiber, Schema, Scope, Stream, WebChannel } from '@livestore/utils/effect'
5
6
 
6
7
  import { Packet } from './mesh-schema.ts'
7
8
  import type { MeshNode } from './node.ts'
@@ -71,7 +72,7 @@ const createChannel = (source: MeshNode, target: string, options?: Partial<Param
71
72
 
72
73
  const getFirstMessage = <T1, T2>(channel: WebChannel.WebChannel<T1, T2>) =>
73
74
  channel.listen.pipe(
74
- Stream.flatten(),
75
+ Stream.mapEffect(Effect.fromResult),
75
76
  Stream.take(1),
76
77
  Stream.runCollect,
77
78
  Effect.map(([message]) => message),
@@ -85,19 +86,19 @@ const maybeDelay =
85
86
  ? effect
86
87
  : Effect.sleep(delay).pipe(Effect.withSpan(`${label}:delay(${delay})`), Effect.andThen(effect))
87
88
 
88
- const testTimeout = IS_CI ? 30_000 : 1000
89
- const propTestTimeout = IS_CI ? 60_000 : 20_000
89
+ const testTimeout = IS_CI === true ? 30_000 : 1000
90
+ const propTestTimeout = IS_CI === true ? 60_000 : 20_000
90
91
 
91
- // TODO also make work without `Vitest.scopedLive` (i.e. with `Vitest.scoped`)
92
+ // TODO also make work without `Vitest.live` (i.e. with `Vitest.effect`)
92
93
  // probably requires controlling the clocks
93
94
  Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
94
- const Delay = Schema.UndefinedOr(Schema.Literal(0, 1, 10, 50))
95
+ const Delay = Schema.UndefinedOr(Schema.Literals([0, 1, 10, 50]))
95
96
  // NOTE for message channels, we test both with and without transferables (i.e. proxying)
96
- const ChannelType = Schema.Literal('direct', 'proxy(via-messagechannel-edge)', 'proxy')
97
- const NodeNames = Schema.Union(
98
- Schema.Tuple(Schema.Literal('A'), Schema.Literal('B')),
99
- Schema.Tuple(Schema.Literal('B'), Schema.Literal('A')),
100
- )
97
+ const ChannelType = Schema.Literals(['direct', 'proxy(via-messagechannel-edge)', 'proxy'])
98
+ const NodeNames = Schema.Union([
99
+ Schema.Tuple([Schema.Literal('A'), Schema.Literal('B')]),
100
+ Schema.Tuple([Schema.Literal('B'), Schema.Literal('A')]),
101
+ ])
101
102
 
102
103
  const fromChannelType = (
103
104
  channelType: typeof ChannelType.Type,
@@ -170,10 +171,10 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
170
171
  // const connectDelay = undefined
171
172
  // const channelType = 'direct'
172
173
  // const nodeNames = ['B', 'A'] as const
173
- // Vitest.scopedLive(
174
+ // Vitest.live(
174
175
  // 'a / b connect at different times with different channel types',
175
176
  // (test) =>
176
- Vitest.scopedLive.prop(
177
+ Vitest.live.prop(
177
178
  'a / b connect at different times with different channel types',
178
179
  [Delay, Delay, Delay, ChannelType, NodeNames],
179
180
  ([delayX, delayY, connectDelay, channelType, nodeNames], test) =>
@@ -200,7 +201,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
200
201
  yield* Effect.promise(() => nodeX.debug.requestTopology(100))
201
202
  }).pipe(
202
203
  Vitest.withTestCtx(test, {
203
- suffix: `delayX=${delayX} delayY=${delayY} connectDelay=${connectDelay} channelType=${channelType} nodeNames=${nodeNames}`,
204
+ suffix: `delayX=${delayX} delayY=${delayY} connectDelay=${connectDelay} channelType=${channelType} nodeNames=${nodeNames.join(',')}`,
204
205
  }),
205
206
  ),
206
207
  // { fastCheck: { numRuns: 20 } },
@@ -208,10 +209,10 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
208
209
  // const waitForOfflineDelay = undefined
209
210
  // const sleepDelay = 0
210
211
  // const channelType = 'direct'
211
- // Vitest.scopedLive(
212
+ // Vitest.live(
212
213
  // 'b reconnects',
213
214
  // (test) =>
214
- Vitest.scopedLive.prop(
215
+ Vitest.live.prop(
215
216
  'b reconnects',
216
217
  [Delay, Delay, ChannelType],
217
218
  ([waitForOfflineDelay, sleepDelay, channelType], test) =>
@@ -231,8 +232,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
231
232
  // TODO also optionally delay the edge
232
233
  yield* connectNodes(nodeA, nodeB)
233
234
 
234
- const waitForBToBeOffline =
235
- waitForOfflineDelay === undefined ? undefined : yield* Deferred.make<void, never>()
235
+ const waitForBToBeOffline = waitForOfflineDelay === undefined ? undefined : yield* Deferred.make<void>()
236
236
 
237
237
  const nodeACode = Effect.gen(function* () {
238
238
  const channelAToB = yield* createChannel(nodeA, 'B', { mode })
@@ -241,7 +241,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
241
241
 
242
242
  console.log('nodeACode:waiting for B to be offline')
243
243
  if (waitForBToBeOffline !== undefined) {
244
- yield* waitForBToBeOffline
244
+ yield* Deferred.await(waitForBToBeOffline)
245
245
  }
246
246
 
247
247
  yield* channelAToB.send({ message: 'A2' })
@@ -286,14 +286,14 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
286
286
  { fastCheck: { numRuns: 20 } },
287
287
  )
288
288
 
289
- Vitest.scopedLive('reconnect with re-created node', (test) =>
289
+ Vitest.live('reconnect with re-created node', (test) =>
290
290
  Effect.gen(function* () {
291
291
  const nodeBgen1Scope = yield* Scope.make()
292
292
 
293
293
  const nodeA = yield* makeMeshNode('A')
294
- const nodeBgen1 = yield* makeMeshNode('B').pipe(Scope.extend(nodeBgen1Scope))
294
+ const nodeBgen1 = yield* makeMeshNode('B').pipe(Scope.provide(nodeBgen1Scope))
295
295
 
296
- yield* connectNodesViaMessageChannel(nodeA, nodeBgen1).pipe(Scope.extend(nodeBgen1Scope))
296
+ yield* connectNodesViaMessageChannel(nodeA, nodeBgen1).pipe(Scope.provide(nodeBgen1Scope))
297
297
 
298
298
  // yield* Effect.sleep(100)
299
299
 
@@ -314,7 +314,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
314
314
  // expect(channelBToA.debugInfo.connectCounter).toBe(1)
315
315
  })
316
316
 
317
- yield* Effect.all([nodeACode, nodeBCode(nodeBgen1).pipe(Scope.extend(nodeBgen1Scope))], {
317
+ yield* Effect.all([nodeACode, nodeBCode(nodeBgen1).pipe(Scope.provide(nodeBgen1Scope))], {
318
318
  concurrency: 'unbounded',
319
319
  }).pipe(Effect.withSpan('test1'))
320
320
 
@@ -329,7 +329,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
329
329
  }).pipe(Vitest.withTestCtx(test)),
330
330
  )
331
331
 
332
- const ChannelTypeWithoutMessageChannelProxy = Schema.Literal('proxy', 'direct')
332
+ const ChannelTypeWithoutMessageChannelProxy = Schema.Literals(['proxy', 'direct'])
333
333
  // TODO there seems to be a flaky case here which gets hit sometimes (e.g. 2025-02-28-17:11)
334
334
  // Log output:
335
335
  // test: { seed: -964670352, path: "1", endOnFailure: true }
@@ -345,7 +345,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
345
345
  // test: Error: Property failed after 2 tests
346
346
  // test: { seed: -964670352, path: "1", endOnFailure: true }
347
347
  // test: Counterexample: ["direct",["A","B"]]
348
- Vitest.scopedLive.prop(
348
+ Vitest.live.prop(
349
349
  'replace edge while keeping the channel',
350
350
  [ChannelTypeWithoutMessageChannelProxy, NodeNames],
351
351
  ([channelType, nodeNames], test) =>
@@ -367,7 +367,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
367
367
  yield* channelXToY.send({ message: `${nodeLabel.x}1` })
368
368
  expect(yield* getFirstMessage(channelXToY)).toEqual({ message: `${nodeLabel.y}1` })
369
369
 
370
- yield* waitForEdgeReplacement
370
+ yield* Deferred.await(waitForEdgeReplacement)
371
371
 
372
372
  yield* channelXToY.send({ message: `${nodeLabel.x}2` })
373
373
  expect(yield* getFirstMessage(channelXToY)).toEqual({ message: `${nodeLabel.y}2` })
@@ -392,7 +392,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
392
392
  yield* Effect.all([nodeXCode, nodeYCode], { concurrency: 'unbounded' })
393
393
  }).pipe(
394
394
  Vitest.withTestCtx(test, {
395
- suffix: `channelType=${channelType} nodeNames=${nodeNames}`,
395
+ suffix: `channelType=${channelType} nodeNames=${nodeNames.join(',')}`,
396
396
  }),
397
397
  ),
398
398
  { fastCheck: { numRuns: 10 } },
@@ -400,9 +400,9 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
400
400
 
401
401
  Vitest.describe('TODO improve latency', () => {
402
402
  // TODO we need to improve latency when sending messages concurrently
403
- Vitest.scopedLive.prop(
403
+ Vitest.live.prop(
404
404
  'concurrent messages',
405
- [ChannelType, Schema.Int.pipe(Schema.between(1, 50))],
405
+ [ChannelType, Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))],
406
406
  ([channelType, count], test) =>
407
407
  Effect.gen(function* () {
408
408
  const nodeA = yield* makeMeshNode('A')
@@ -416,14 +416,18 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
416
416
 
417
417
  // send 10 times A1
418
418
  yield* Effect.forEach(
419
- Chunk.makeBy(count, (i) => ({ message: `A${i}` })),
419
+ Array.from({ length: count }, (_, i) => ({ message: `A${i}` })),
420
420
  channelAToB.send,
421
421
  { concurrency: 'unbounded' },
422
422
  )
423
423
 
424
- expect(yield* channelAToB.listen.pipe(Stream.flatten(), Stream.take(count), Stream.runCollect)).toEqual(
425
- Chunk.makeBy(count, (i) => ({ message: `B${i}` })),
426
- )
424
+ expect(
425
+ yield* channelAToB.listen.pipe(
426
+ Stream.mapEffect(Effect.fromResult),
427
+ Stream.take(count),
428
+ Stream.runCollect,
429
+ ),
430
+ ).toEqual(Array.from({ length: count }, (_, i) => ({ message: `B${i}` })))
427
431
  // expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'A2' })
428
432
  })
429
433
 
@@ -432,14 +436,18 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
432
436
 
433
437
  // send 10 times B1
434
438
  yield* Effect.forEach(
435
- Chunk.makeBy(count, (i) => ({ message: `B${i}` })),
439
+ Array.from({ length: count }, (_, i) => ({ message: `B${i}` })),
436
440
  channelBToA.send,
437
441
  { concurrency: 'unbounded' },
438
442
  )
439
443
 
440
- expect(yield* channelBToA.listen.pipe(Stream.flatten(), Stream.take(count), Stream.runCollect)).toEqual(
441
- Chunk.makeBy(count, (i) => ({ message: `A${i}` })),
442
- )
444
+ expect(
445
+ yield* channelBToA.listen.pipe(
446
+ Stream.mapEffect(Effect.fromResult),
447
+ Stream.take(count),
448
+ Stream.runCollect,
449
+ ),
450
+ ).toEqual(Array.from({ length: count }, (_, i) => ({ message: `A${i}` })))
443
451
  })
444
452
 
445
453
  yield* Effect.all([nodeACode, nodeBCode, connectNodes(nodeA, nodeB).pipe(Effect.delay(100))], {
@@ -457,7 +465,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
457
465
  })
458
466
 
459
467
  Vitest.describe('message channel specific tests', () => {
460
- Vitest.scopedLive('differing initial edge counter', (test) =>
468
+ Vitest.live('differing initial edge counter', (test) =>
461
469
  Effect.gen(function* () {
462
470
  const nodeA = yield* makeMeshNode('A')
463
471
  const nodeB = yield* makeMeshNode('B')
@@ -469,28 +477,32 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
469
477
  const bFiber = yield* Effect.gen(function* () {
470
478
  const channelBToA = yield* createChannel(nodeB, 'A')
471
479
  yield* channelBToA.listen.pipe(
472
- Stream.flatten(),
480
+ Stream.mapEffect(Effect.fromResult),
473
481
  Stream.tap((msg) => channelBToA.send({ message: `resp:${msg.message}` })),
474
482
  Stream.take(messageCount),
475
483
  Stream.runDrain,
476
484
  )
477
- }).pipe(Effect.scoped, Effect.fork)
485
+ }).pipe(Effect.scoped, Effect.forkChild)
478
486
 
479
487
  // yield* createChannel(nodeA, 'B').pipe(Effect.andThen(WebChannel.shutdown))
480
488
  // // yield* createChannel(nodeA, 'B').pipe(Effect.andThen(WebChannel.shutdown))
481
489
  // // yield* createChannel(nodeA, 'B').pipe(Effect.andThen(WebChannel.shutdown))
482
- yield* Effect.gen(function* () {
483
- const channelAToB = yield* createChannel(nodeA, 'B')
484
- yield* channelAToB.send({ message: 'A' })
485
- expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'resp:A' })
486
- }).pipe(Effect.scoped, Effect.repeatN(messageCount))
490
+ yield* Effect.replicateEffect(
491
+ Effect.gen(function* () {
492
+ const channelAToB = yield* createChannel(nodeA, 'B')
493
+ yield* channelAToB.send({ message: 'A' })
494
+ expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'resp:A' })
495
+ }).pipe(Effect.scoped),
496
+ messageCount,
497
+ { discard: true },
498
+ )
487
499
 
488
- yield* bFiber
500
+ yield* Fiber.join(bFiber)
489
501
  }).pipe(Vitest.withTestCtx(test)),
490
502
  )
491
503
  })
492
504
 
493
- Vitest.scopedLive('manual debug test', (test) =>
505
+ Vitest.live('manual debug test', (test) =>
494
506
  Effect.gen(function* () {
495
507
  const nodeA = yield* makeMeshNode('A')
496
508
  const nodeB = yield* makeMeshNode('B')
@@ -518,7 +530,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
518
530
  }).pipe(Vitest.withTestCtx(test)),
519
531
  )
520
532
 
521
- Vitest.scopedLive('broadcast edge with message channel', (test) =>
533
+ Vitest.live('broadcast edge with message channel', (test) =>
522
534
  Effect.gen(function* () {
523
535
  const nodeA = yield* makeMeshNode('A')
524
536
  const nodeB = yield* makeMeshNode('B')
@@ -526,13 +538,13 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
526
538
  yield* connectNodesViaBroadcastChannel(nodeA, nodeB)
527
539
 
528
540
  const err = yield* createChannel(nodeA, 'B', { mode: 'direct' }).pipe(Effect.timeout(200), Effect.flip)
529
- expect(err._tag).toBe('TimeoutException')
541
+ expect(err._tag).toBe('TimeoutError')
530
542
  }).pipe(Vitest.withTestCtx(test)),
531
543
  )
532
544
  })
533
545
 
534
546
  Vitest.describe('A <> B <> C', () => {
535
- Vitest.scopedLive('should work', (test) =>
547
+ Vitest.live('should work', (test) =>
536
548
  Effect.gen(function* () {
537
549
  const nodeA = yield* makeMeshNode('A')
538
550
  const nodeB = yield* makeMeshNode('B')
@@ -562,7 +574,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
562
574
  }).pipe(Vitest.withTestCtx(test)),
563
575
  )
564
576
 
565
- Vitest.scopedLive('should work - delayed edge', (test) =>
577
+ Vitest.live('should work - delayed edge', (test) =>
566
578
  Effect.gen(function* () {
567
579
  const nodeA = yield* makeMeshNode('A')
568
580
  const nodeB = yield* makeMeshNode('B')
@@ -597,7 +609,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
597
609
  }).pipe(Vitest.withTestCtx(test)),
598
610
  )
599
611
 
600
- Vitest.scopedLive('proxy channel', (test) =>
612
+ Vitest.live('proxy channel', (test) =>
601
613
  Effect.gen(function* () {
602
614
  const nodeA = yield* makeMeshNode('A')
603
615
  const nodeB = yield* makeMeshNode('B')
@@ -622,7 +634,271 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
622
634
  }).pipe(Vitest.withTestCtx(test)),
623
635
  )
624
636
 
625
- Vitest.scopedLive('should fail with timeout due to missing edge', (test) =>
637
+ /**
638
+ * Pattern test: ACK sending must not block message processing.
639
+ *
640
+ * This test documents the pattern where ACKs are sent fire-and-forget using `Effect.forkScoped`
641
+ * to avoid blocking the message processing loop.
642
+ *
643
+ * Background: Before the fix, ACKs were sent with `await` which blocked message processing.
644
+ * This caused heartbeat timeouts in devtools after ~30 seconds ("Connection to app lost").
645
+ *
646
+ * Note: This unit test passes both with and without the fix because in-memory channels
647
+ * complete ACK sends instantly. The actual regression test is the Playwright test
648
+ * `node-adapter-timeout.play.ts` which uses real network conditions.
649
+ *
650
+ * This test exists to:
651
+ * 1. Document the expected pattern (high message throughput via proxy channels)
652
+ * 2. Verify the pattern works correctly with forked ACKs
653
+ * 3. Serve as a reference for the fix in proxy-channel.ts
654
+ */
655
+ Vitest.live('ACK sending should not block message processing', (test) =>
656
+ Effect.gen(function* () {
657
+ const nodeA = yield* makeMeshNode('A')
658
+ const nodeB = yield* makeMeshNode('B')
659
+ const nodeC = yield* makeMeshNode('C')
660
+
661
+ yield* connectNodesViaBroadcastChannel(nodeA, nodeB)
662
+ yield* connectNodesViaBroadcastChannel(nodeB, nodeC)
663
+
664
+ // Send many messages to stress the ACK handling
665
+ const messageCount = 10
666
+
667
+ const nodeACode = Effect.gen(function* () {
668
+ const channelAToC = yield* createChannel(nodeA, 'C', { mode: 'proxy' })
669
+ // Send multiple messages concurrently
670
+ yield* Effect.forEach(
671
+ Array.from({ length: messageCount }, (_, i) => ({ message: `A${i}` })),
672
+ channelAToC.send,
673
+ { concurrency: 'unbounded' },
674
+ )
675
+ // Receive responses
676
+ const responses = yield* channelAToC.listen.pipe(
677
+ Stream.mapEffect(Effect.fromResult),
678
+ Stream.take(messageCount),
679
+ Stream.runCollect,
680
+ )
681
+ expect(responses.length).toBe(messageCount)
682
+ })
683
+
684
+ const nodeCCode = Effect.gen(function* () {
685
+ const channelCToA = yield* createChannel(nodeC, 'A', { mode: 'proxy' })
686
+ // Send multiple messages concurrently
687
+ yield* Effect.forEach(
688
+ Array.from({ length: messageCount }, (_, i) => ({ message: `C${i}` })),
689
+ channelCToA.send,
690
+ { concurrency: 'unbounded' },
691
+ )
692
+ // Receive responses
693
+ const responses = yield* channelCToA.listen.pipe(
694
+ Stream.mapEffect(Effect.fromResult),
695
+ Stream.take(messageCount),
696
+ Stream.runCollect,
697
+ )
698
+ expect(responses.length).toBe(messageCount)
699
+ })
700
+
701
+ yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' })
702
+ }).pipe(Vitest.withTestCtx(test)),
703
+ )
704
+
705
+ Vitest.describe('ACK forkScoped regression tests', { timeout: 10_000 }, () => {
706
+ /**
707
+ * REGRESSION TEST: ACK sends must be forked (non-blocking) to allow message processing to continue.
708
+ *
709
+ * This test uses simulation parameters to inject a delay BEFORE the ACK send.
710
+ * It then measures when messages arrive at the receiver's listen queue.
711
+ *
712
+ * With the fix (Effect.forkScoped):
713
+ * - ACK send is forked in the background
714
+ * - Message is added to listen queue IMMEDIATELY (before ACK send completes)
715
+ * - Multiple messages arrive close together regardless of ACK delay
716
+ *
717
+ * Without the fix (blocking yield*):
718
+ * - ACK send blocks the processing loop
719
+ * - Message is added to listen queue AFTER ACK send completes
720
+ * - Messages arrive spread out by the ACK delay
721
+ *
722
+ * To verify this test catches the regression:
723
+ * 1. Temporarily change `Effect.forkScoped` to `yield*` in proxy-channel.ts:319
724
+ * 2. Run this test - it should FAIL because messages arrive too slowly
725
+ * 3. Revert the change - test should PASS
726
+ */
727
+ /**
728
+ * This test verifies the fix works: messages should arrive at the listen queue
729
+ * without being blocked by slow ACK sends. We measure how long it takes to receive
730
+ * all messages - with forked ACKs it should be fast, with blocking ACKs it would be slow.
731
+ */
732
+ Vitest.live('messages arrive in listen queue without waiting for ACK send', (test) =>
733
+ Effect.gen(function* () {
734
+ const ACK_DELAY_MS = 50
735
+ const MESSAGE_COUNT = 5
736
+
737
+ const nodeA = yield* makeMeshNode('A')
738
+ const nodeB = yield* makeMeshNode('B')
739
+
740
+ yield* connectNodesViaMessageChannel(nodeA, nodeB)
741
+
742
+ const receivedMessages: string[] = []
743
+
744
+ const senderCode = Effect.gen(function* () {
745
+ const channelAToB = yield* nodeA.makeChannel({
746
+ target: 'B',
747
+ channelName: 'test-ack-timing',
748
+ schema: ExampleSchema,
749
+ mode: 'proxy',
750
+ timeout: 3000,
751
+ })
752
+
753
+ // Send all messages concurrently - this is key!
754
+ // With forked ACKs, all messages get processed immediately at receiver
755
+ // With blocking ACKs, messages would be processed one at a time with delays
756
+ yield* Effect.forEach(
757
+ Array.from({ length: MESSAGE_COUNT }, (_, i) => ({ message: `msg${i}` })),
758
+ channelAToB.send,
759
+ { concurrency: 'unbounded' },
760
+ )
761
+ })
762
+
763
+ const receiverCode = Effect.gen(function* () {
764
+ const channelBToA = yield* nodeB.makeChannel({
765
+ target: 'A',
766
+ channelName: 'test-ack-timing',
767
+ schema: ExampleSchema,
768
+ mode: 'proxy',
769
+ timeout: 3000,
770
+ // KEY: Inject delay BEFORE ACK send
771
+ // With forkScoped: message arrives in queue immediately, then ACK is sent in background
772
+ // Without forkScoped: message waits for ACK delay before being added to queue
773
+ simulation: {
774
+ onPayload: {
775
+ beforeAckSend: ACK_DELAY_MS,
776
+ afterAckFork: 0,
777
+ afterListenQueueOffer: 0,
778
+ },
779
+ },
780
+ })
781
+
782
+ yield* channelBToA.listen.pipe(
783
+ Stream.mapEffect(Effect.fromResult),
784
+ Stream.tap((msg) =>
785
+ Effect.sync(() => {
786
+ receivedMessages.push(msg.message)
787
+ }),
788
+ ),
789
+ Stream.take(MESSAGE_COUNT),
790
+ Stream.runDrain,
791
+ )
792
+ })
793
+
794
+ const startTime = Date.now()
795
+ yield* Effect.all([senderCode, receiverCode], { concurrency: 'unbounded' })
796
+ const elapsed = Date.now() - startTime
797
+
798
+ console.log(`[regression-test-1] Received ${receivedMessages.length} messages in ${elapsed}ms`)
799
+ console.log(`[regression-test-1] Messages: ${receivedMessages.join(', ')}`)
800
+
801
+ expect(receivedMessages.length).toBe(MESSAGE_COUNT)
802
+
803
+ // With forked ACKs, elapsed time should be much less than MESSAGE_COUNT * ACK_DELAY_MS
804
+ // because ACKs don't block message processing
805
+ const blockingTime = MESSAGE_COUNT * ACK_DELAY_MS
806
+ console.log(`[regression-test-1] Elapsed: ${elapsed}ms, Blocking estimate: ${blockingTime}ms`)
807
+
808
+ // The test passes if messages arrive faster than they would with blocking ACKs
809
+ // Allow some margin for test overhead (2x faster than blocking)
810
+ if (elapsed > blockingTime / 2) {
811
+ throw new Error(
812
+ `REGRESSION DETECTED: Processing took ${elapsed}ms, blocking estimate: ${blockingTime}ms. ` +
813
+ `With forked ACKs, processing should be much faster. ` +
814
+ `Check that proxy-channel.ts uses Effect.forkScoped for ACK sends.`,
815
+ )
816
+ }
817
+ }).pipe(Vitest.withTestCtx(test)),
818
+ )
819
+
820
+ /**
821
+ * Additional test: Verify message processing continues during slow ACK sends.
822
+ *
823
+ * This test sends multiple messages concurrently and verifies they're all
824
+ * processed even when ACK sends are slow.
825
+ */
826
+ Vitest.live('concurrent messages processed despite slow ACK sends', (test) =>
827
+ Effect.gen(function* () {
828
+ const ACK_DELAY_MS = 50
829
+ const MESSAGE_COUNT = 5
830
+
831
+ const nodeA = yield* makeMeshNode('A')
832
+ const nodeB = yield* makeMeshNode('B')
833
+
834
+ yield* connectNodesViaMessageChannel(nodeA, nodeB)
835
+
836
+ const receivedMessages: string[] = []
837
+
838
+ const senderCode = Effect.gen(function* () {
839
+ const channelAToB = yield* nodeA.makeChannel({
840
+ target: 'B',
841
+ channelName: 'test-concurrent',
842
+ schema: ExampleSchema,
843
+ mode: 'proxy',
844
+ timeout: 3000,
845
+ })
846
+
847
+ // Send all messages concurrently
848
+ yield* Effect.forEach(
849
+ Array.from({ length: MESSAGE_COUNT }, (_, i) => ({ message: `msg${i}` })),
850
+ channelAToB.send,
851
+ { concurrency: 'unbounded' },
852
+ )
853
+ })
854
+
855
+ const receiverCode = Effect.gen(function* () {
856
+ const channelBToA = yield* nodeB.makeChannel({
857
+ target: 'A',
858
+ channelName: 'test-concurrent',
859
+ schema: ExampleSchema,
860
+ mode: 'proxy',
861
+ timeout: 3000,
862
+ simulation: {
863
+ onPayload: {
864
+ beforeAckSend: ACK_DELAY_MS,
865
+ afterAckFork: 0,
866
+ afterListenQueueOffer: 0,
867
+ },
868
+ },
869
+ })
870
+
871
+ yield* channelBToA.listen.pipe(
872
+ Stream.mapEffect(Effect.fromResult),
873
+ Stream.tap((msg) =>
874
+ Effect.sync(() => {
875
+ receivedMessages.push(msg.message)
876
+ }),
877
+ ),
878
+ Stream.take(MESSAGE_COUNT),
879
+ Stream.runDrain,
880
+ )
881
+ })
882
+
883
+ const startTime = Date.now()
884
+ yield* Effect.all([senderCode, receiverCode], { concurrency: 'unbounded' })
885
+ const elapsed = Date.now() - startTime
886
+
887
+ console.log(`[regression-test] Received ${receivedMessages.length} messages in ${elapsed}ms`)
888
+ console.log(`[regression-test] Messages: ${receivedMessages.join(', ')}`)
889
+
890
+ // All messages should be received
891
+ expect(receivedMessages.length).toBe(MESSAGE_COUNT)
892
+
893
+ // With forked ACKs, elapsed time should be much less than MESSAGE_COUNT * ACK_DELAY_MS
894
+ // because ACKs don't block message processing
895
+ const blockingTime = MESSAGE_COUNT * ACK_DELAY_MS
896
+ console.log(`[regression-test] Elapsed: ${elapsed}ms, Blocking estimate: ${blockingTime}ms`)
897
+ }).pipe(Vitest.withTestCtx(test)),
898
+ )
899
+ })
900
+
901
+ Vitest.live('should fail with timeout due to missing edge', (test) =>
626
902
  Effect.gen(function* () {
627
903
  const nodeA = yield* makeMeshNode('A')
628
904
  const nodeB = yield* makeMeshNode('B')
@@ -633,19 +909,19 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
633
909
 
634
910
  const nodeACode = Effect.gen(function* () {
635
911
  const err = yield* createChannel(nodeA, 'C').pipe(Effect.timeout(200), Effect.flip)
636
- expect(err._tag).toBe('TimeoutException')
912
+ expect(err._tag).toBe('TimeoutError')
637
913
  })
638
914
 
639
915
  const nodeCCode = Effect.gen(function* () {
640
916
  const err = yield* createChannel(nodeC, 'A').pipe(Effect.timeout(200), Effect.flip)
641
- expect(err._tag).toBe('TimeoutException')
917
+ expect(err._tag).toBe('TimeoutError')
642
918
  })
643
919
 
644
920
  yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' })
645
921
  }).pipe(Vitest.withTestCtx(test)),
646
922
  )
647
923
 
648
- Vitest.scopedLive('should fail with timeout due no transferable', (test) =>
924
+ Vitest.live('should fail with timeout due no transferable', (test) =>
649
925
  Effect.gen(function* () {
650
926
  const nodeA = yield* makeMeshNode('A')
651
927
  const nodeB = yield* makeMeshNode('B')
@@ -654,28 +930,28 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
654
930
 
655
931
  const nodeACode = Effect.gen(function* () {
656
932
  const err = yield* createChannel(nodeA, 'B').pipe(Effect.timeout(200), Effect.flip)
657
- expect(err._tag).toBe('TimeoutException')
933
+ expect(err._tag).toBe('TimeoutError')
658
934
  })
659
935
 
660
936
  const nodeBCode = Effect.gen(function* () {
661
937
  const err = yield* createChannel(nodeB, 'A').pipe(Effect.timeout(200), Effect.flip)
662
- expect(err._tag).toBe('TimeoutException')
938
+ expect(err._tag).toBe('TimeoutError')
663
939
  })
664
940
 
665
941
  yield* Effect.all([nodeACode, nodeBCode], { concurrency: 'unbounded' })
666
942
  }).pipe(Vitest.withTestCtx(test)),
667
943
  )
668
944
 
669
- Vitest.scopedLive('reconnect with re-created node', (test) =>
945
+ Vitest.live('reconnect with re-created node', (test) =>
670
946
  Effect.gen(function* () {
671
947
  const nodeCgen1Scope = yield* Scope.make()
672
948
 
673
949
  const nodeA = yield* makeMeshNode('A')
674
950
  const nodeB = yield* makeMeshNode('B')
675
- const nodeCgen1 = yield* makeMeshNode('C').pipe(Scope.extend(nodeCgen1Scope))
951
+ const nodeCgen1 = yield* makeMeshNode('C').pipe(Scope.provide(nodeCgen1Scope))
676
952
 
677
953
  yield* connectNodesViaMessageChannel(nodeA, nodeB)
678
- yield* connectNodesViaMessageChannel(nodeB, nodeCgen1).pipe(Scope.extend(nodeCgen1Scope))
954
+ yield* connectNodesViaMessageChannel(nodeB, nodeCgen1).pipe(Scope.provide(nodeCgen1Scope))
679
955
 
680
956
  const nodeACode = Effect.gen(function* () {
681
957
  const channelAToB = yield* createChannel(nodeA, 'C')
@@ -694,7 +970,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
694
970
 
695
971
  yield* Effect.all([nodeACode, nodeCCode(nodeCgen1)], { concurrency: 'unbounded' }).pipe(
696
972
  Effect.withSpan('test1'),
697
- Scope.extend(nodeCgen1Scope),
973
+ Scope.provide(nodeCgen1Scope),
698
974
  )
699
975
 
700
976
  yield* Scope.close(nodeCgen1Scope, Exit.void)
@@ -717,7 +993,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
717
993
  * D
718
994
  */
719
995
  Vitest.describe('diamond topology', () => {
720
- Vitest.scopedLive('should work', (test) =>
996
+ Vitest.live('should work', (test) =>
721
997
  Effect.gen(function* () {
722
998
  const nodeA = yield* makeMeshNode('A')
723
999
  const nodeB = yield* makeMeshNode('B')
@@ -756,7 +1032,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
756
1032
  * Topology: Butterfly topology with two connected hubs (C-D) each serving multiple nodes
757
1033
  */
758
1034
  Vitest.describe('butterfly topology', () => {
759
- Vitest.scopedLive('should work', (test) =>
1035
+ Vitest.live('should work', (test) =>
760
1036
  Effect.gen(function* () {
761
1037
  const nodeA = yield* makeMeshNode('A')
762
1038
  const nodeB = yield* makeMeshNode('B')
@@ -793,7 +1069,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
793
1069
  Vitest.describe('mixture of direct and proxy edge connections', () => {
794
1070
  // TODO test case to better guard against case where side A tries to create a proxy channel to B
795
1071
  // and side B tries to create a direct to A
796
- Vitest.scopedLive('should work for proxy channels', (test) =>
1072
+ Vitest.live('should work for proxy channels', (test) =>
797
1073
  Effect.gen(function* () {
798
1074
  const nodeA = yield* makeMeshNode('A')
799
1075
  const nodeB = yield* makeMeshNode('B')
@@ -805,7 +1081,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
805
1081
  }).pipe(Vitest.withTestCtx(test)),
806
1082
  )
807
1083
 
808
- Vitest.scopedLive('should work for directs', (test) =>
1084
+ Vitest.live('should work for directs', (test) =>
809
1085
  Effect.gen(function* () {
810
1086
  const nodeA = yield* makeMeshNode('A')
811
1087
  const nodeB = yield* makeMeshNode('B')
@@ -832,7 +1108,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
832
1108
  })
833
1109
 
834
1110
  Vitest.describe('listenForChannel', () => {
835
- Vitest.scopedLive('connect later', (test) =>
1111
+ Vitest.live('connect later', (test) =>
836
1112
  Effect.gen(function* () {
837
1113
  const nodeA = yield* makeMeshNode('A')
838
1114
 
@@ -871,7 +1147,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
871
1147
  )
872
1148
 
873
1149
  // TODO provide a way to allow for reconnecting in the `listenForChannel` case
874
- Vitest.scopedLive.skip('reconnect', (test) =>
1150
+ Vitest.live.skip('reconnect', (test) =>
875
1151
  Effect.gen(function* () {
876
1152
  const nodeA = yield* makeMeshNode('A')
877
1153
 
@@ -935,7 +1211,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
935
1211
  )
936
1212
 
937
1213
  Vitest.describe('prop tests', { timeout: propTestTimeout }, () => {
938
- Vitest.scopedLive.prop(
1214
+ Vitest.live.prop(
939
1215
  'listenForChannel A <> B <> C',
940
1216
  [Delay, Delay, Delay, Delay, ChannelType],
941
1217
  ([delayNodeA, delayNodeC, delayConnectAB, delayConnectBC, channelType], test) =>
@@ -944,7 +1220,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
944
1220
  const nodeB = yield* makeMeshNode('B')
945
1221
  const nodeC = yield* makeMeshNode('C')
946
1222
 
947
- const mode = channelType.includes('proxy') ? 'proxy' : 'direct'
1223
+ const mode = channelType.includes('proxy') === true ? 'proxy' : 'direct'
948
1224
  const connect = channelType === 'direct' ? connectNodesViaMessageChannel : connectNodesViaBroadcastChannel
949
1225
  yield* connect(nodeA, nodeB).pipe(maybeDelay(delayConnectAB, 'delayConnectAB'))
950
1226
  yield* connect(nodeB, nodeC).pipe(maybeDelay(delayConnectBC, 'delayConnectBC'))
@@ -996,7 +1272,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
996
1272
  })
997
1273
 
998
1274
  Vitest.describe('broadcast channel', () => {
999
- Vitest.scopedLive('should work', (test) =>
1275
+ Vitest.live('should work', (test) =>
1000
1276
  Effect.gen(function* () {
1001
1277
  const nodeA = yield* makeMeshNode('A')
1002
1278
  const nodeB = yield* makeMeshNode('B')
@@ -1009,23 +1285,23 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
1009
1285
  const channelOnC = yield* nodeC.makeBroadcastChannel({ channelName: 'test', schema: Schema.String })
1010
1286
 
1011
1287
  const listenOnAFiber = yield* channelOnA.listen.pipe(
1012
- Stream.flatten(),
1288
+ Stream.mapEffect(Effect.fromResult),
1013
1289
  Stream.runHead,
1014
- Effect.flatten,
1015
- Effect.fork,
1290
+ Effect.flatMap(Effect.fromOption),
1291
+ Effect.forkChild,
1016
1292
  )
1017
1293
  const listenOnCFiber = yield* channelOnC.listen.pipe(
1018
- Stream.flatten(),
1294
+ Stream.mapEffect(Effect.fromResult),
1019
1295
  Stream.runHead,
1020
- Effect.flatten,
1021
- Effect.fork,
1296
+ Effect.flatMap(Effect.fromOption),
1297
+ Effect.forkChild,
1022
1298
  )
1023
1299
 
1024
1300
  yield* channelOnA.send('A1')
1025
1301
  yield* channelOnC.send('C1')
1026
1302
 
1027
- expect(yield* listenOnAFiber).toEqual('C1')
1028
- expect(yield* listenOnCFiber).toEqual('A1')
1303
+ expect(yield* Fiber.join(listenOnAFiber)).toEqual('C1')
1304
+ expect(yield* Fiber.join(listenOnCFiber)).toEqual('A1')
1029
1305
  }).pipe(Vitest.withTestCtx(test)),
1030
1306
  )
1031
1307
  })