@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/dist/node.test.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { expect } from 'vitest';
1
2
  import { IS_CI, omitUndefineds } from '@livestore/utils';
2
- import { Chunk, Deferred, Effect, Exit, Schema, Scope, Stream, WebChannel } from '@livestore/utils/effect';
3
3
  import { Vitest } from '@livestore/utils-dev/node-vitest';
4
- import { expect } from 'vitest';
4
+ import { Deferred, Effect, Exit, Fiber, Schema, Scope, Stream, WebChannel } from '@livestore/utils/effect';
5
5
  import { Packet } from "./mesh-schema.js";
6
6
  import { makeMeshNode } from "./node.js";
7
7
  // TODO test cases where in-between node only comes online later
@@ -55,20 +55,23 @@ const createChannel = (source, target, options) => source.makeChannel({
55
55
  mode: options?.mode ?? 'direct',
56
56
  timeout: options?.timeout ?? 200,
57
57
  });
58
- const getFirstMessage = (channel) => channel.listen.pipe(Stream.flatten(), Stream.take(1), Stream.runCollect, Effect.map(([message]) => message));
58
+ const getFirstMessage = (channel) => channel.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.take(1), Stream.runCollect, Effect.map(([message]) => message));
59
59
  // NOTE we distinguish between undefined and 0 delays as it changes the fiber execution
60
60
  const maybeDelay = (delay, label) => (effect) => delay === undefined
61
61
  ? effect
62
62
  : Effect.sleep(delay).pipe(Effect.withSpan(`${label}:delay(${delay})`), Effect.andThen(effect));
63
- const testTimeout = IS_CI ? 30_000 : 1000;
64
- const propTestTimeout = IS_CI ? 60_000 : 20_000;
65
- // TODO also make work without `Vitest.scopedLive` (i.e. with `Vitest.scoped`)
63
+ const testTimeout = IS_CI === true ? 30_000 : 1000;
64
+ const propTestTimeout = IS_CI === true ? 60_000 : 20_000;
65
+ // TODO also make work without `Vitest.live` (i.e. with `Vitest.effect`)
66
66
  // probably requires controlling the clocks
67
67
  Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
68
- const Delay = Schema.UndefinedOr(Schema.Literal(0, 1, 10, 50));
68
+ const Delay = Schema.UndefinedOr(Schema.Literals([0, 1, 10, 50]));
69
69
  // NOTE for message channels, we test both with and without transferables (i.e. proxying)
70
- const ChannelType = Schema.Literal('direct', 'proxy(via-messagechannel-edge)', 'proxy');
71
- const NodeNames = Schema.Union(Schema.Tuple(Schema.Literal('A'), Schema.Literal('B')), Schema.Tuple(Schema.Literal('B'), Schema.Literal('A')));
70
+ const ChannelType = Schema.Literals(['direct', 'proxy(via-messagechannel-edge)', 'proxy']);
71
+ const NodeNames = Schema.Union([
72
+ Schema.Tuple([Schema.Literal('A'), Schema.Literal('B')]),
73
+ Schema.Tuple([Schema.Literal('B'), Schema.Literal('A')]),
74
+ ]);
72
75
  const fromChannelType = (channelType) => {
73
76
  switch (channelType) {
74
77
  case 'proxy': {
@@ -114,10 +117,10 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
114
117
  // const connectDelay = undefined
115
118
  // const channelType = 'direct'
116
119
  // const nodeNames = ['B', 'A'] as const
117
- // Vitest.scopedLive(
120
+ // Vitest.live(
118
121
  // 'a / b connect at different times with different channel types',
119
122
  // (test) =>
120
- Vitest.scopedLive.prop('a / b connect at different times with different channel types', [Delay, Delay, Delay, ChannelType, NodeNames], ([delayX, delayY, connectDelay, channelType, nodeNames], test) => Effect.gen(function* () {
123
+ Vitest.live.prop('a / b connect at different times with different channel types', [Delay, Delay, Delay, ChannelType, NodeNames], ([delayX, delayY, connectDelay, channelType, nodeNames], test) => Effect.gen(function* () {
121
124
  // console.log({ delayX, delayY, connectDelay, channelType, nodeNames })
122
125
  const [nodeNameX, nodeNameY] = nodeNames;
123
126
  const nodeX = yield* makeMeshNode(nodeNameX);
@@ -136,15 +139,15 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
136
139
  });
137
140
  yield* Effect.promise(() => nodeX.debug.requestTopology(100));
138
141
  }).pipe(Vitest.withTestCtx(test, {
139
- suffix: `delayX=${delayX} delayY=${delayY} connectDelay=${connectDelay} channelType=${channelType} nodeNames=${nodeNames}`,
142
+ suffix: `delayX=${delayX} delayY=${delayY} connectDelay=${connectDelay} channelType=${channelType} nodeNames=${nodeNames.join(',')}`,
140
143
  })));
141
144
  // const waitForOfflineDelay = undefined
142
145
  // const sleepDelay = 0
143
146
  // const channelType = 'direct'
144
- // Vitest.scopedLive(
147
+ // Vitest.live(
145
148
  // 'b reconnects',
146
149
  // (test) =>
147
- Vitest.scopedLive.prop('b reconnects', [Delay, Delay, ChannelType], ([waitForOfflineDelay, sleepDelay, channelType], test) => Effect.gen(function* () {
150
+ Vitest.live.prop('b reconnects', [Delay, Delay, ChannelType], ([waitForOfflineDelay, sleepDelay, channelType], test) => Effect.gen(function* () {
148
151
  // console.log({ waitForOfflineDelay, sleepDelay, channelType })
149
152
  if (waitForOfflineDelay === undefined) {
150
153
  // TODO we still need to fix this scenario but it shouldn't really be common in practice
@@ -162,7 +165,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
162
165
  expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'B1' });
163
166
  console.log('nodeACode:waiting for B to be offline');
164
167
  if (waitForBToBeOffline !== undefined) {
165
- yield* waitForBToBeOffline;
168
+ yield* Deferred.await(waitForBToBeOffline);
166
169
  }
167
170
  yield* channelAToB.send({ message: 'A2' });
168
171
  expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'B2' });
@@ -194,11 +197,11 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
194
197
  }).pipe(Vitest.withTestCtx(test, {
195
198
  suffix: `waitForOfflineDelay=${waitForOfflineDelay} sleepDelay=${sleepDelay} channelType=${channelType}`,
196
199
  })), { fastCheck: { numRuns: 20 } });
197
- Vitest.scopedLive('reconnect with re-created node', (test) => Effect.gen(function* () {
200
+ Vitest.live('reconnect with re-created node', (test) => Effect.gen(function* () {
198
201
  const nodeBgen1Scope = yield* Scope.make();
199
202
  const nodeA = yield* makeMeshNode('A');
200
- const nodeBgen1 = yield* makeMeshNode('B').pipe(Scope.extend(nodeBgen1Scope));
201
- yield* connectNodesViaMessageChannel(nodeA, nodeBgen1).pipe(Scope.extend(nodeBgen1Scope));
203
+ const nodeBgen1 = yield* makeMeshNode('B').pipe(Scope.provide(nodeBgen1Scope));
204
+ yield* connectNodesViaMessageChannel(nodeA, nodeBgen1).pipe(Scope.provide(nodeBgen1Scope));
202
205
  // yield* Effect.sleep(100)
203
206
  const channelAToBOnce = yield* Effect.cached(createChannel(nodeA, 'B'));
204
207
  const nodeACode = Effect.gen(function* () {
@@ -213,7 +216,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
213
216
  expect(yield* getFirstMessage(channelBToA)).toEqual({ message: 'A1' });
214
217
  // expect(channelBToA.debugInfo.connectCounter).toBe(1)
215
218
  });
216
- yield* Effect.all([nodeACode, nodeBCode(nodeBgen1).pipe(Scope.extend(nodeBgen1Scope))], {
219
+ yield* Effect.all([nodeACode, nodeBCode(nodeBgen1).pipe(Scope.provide(nodeBgen1Scope))], {
217
220
  concurrency: 'unbounded',
218
221
  }).pipe(Effect.withSpan('test1'));
219
222
  yield* Scope.close(nodeBgen1Scope, Exit.void);
@@ -221,7 +224,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
221
224
  yield* connectNodesViaMessageChannel(nodeA, nodeBgen2, { replaceIfExists: true });
222
225
  yield* Effect.all([nodeACode, nodeBCode(nodeBgen2)], { concurrency: 'unbounded' }).pipe(Effect.withSpan('test2'));
223
226
  }).pipe(Vitest.withTestCtx(test)));
224
- const ChannelTypeWithoutMessageChannelProxy = Schema.Literal('proxy', 'direct');
227
+ const ChannelTypeWithoutMessageChannelProxy = Schema.Literals(['proxy', 'direct']);
225
228
  // TODO there seems to be a flaky case here which gets hit sometimes (e.g. 2025-02-28-17:11)
226
229
  // Log output:
227
230
  // test: { seed: -964670352, path: "1", endOnFailure: true }
@@ -237,7 +240,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
237
240
  // test: Error: Property failed after 2 tests
238
241
  // test: { seed: -964670352, path: "1", endOnFailure: true }
239
242
  // test: Counterexample: ["direct",["A","B"]]
240
- Vitest.scopedLive.prop('replace edge while keeping the channel', [ChannelTypeWithoutMessageChannelProxy, NodeNames], ([channelType, nodeNames], test) => Effect.gen(function* () {
243
+ Vitest.live.prop('replace edge while keeping the channel', [ChannelTypeWithoutMessageChannelProxy, NodeNames], ([channelType, nodeNames], test) => Effect.gen(function* () {
241
244
  const [nodeNameX, nodeNameY] = nodeNames;
242
245
  const nodeX = yield* makeMeshNode(nodeNameX);
243
246
  const nodeY = yield* makeMeshNode(nodeNameY);
@@ -249,7 +252,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
249
252
  const channelXToY = yield* createChannel(nodeX, nodeLabel.y, { mode });
250
253
  yield* channelXToY.send({ message: `${nodeLabel.x}1` });
251
254
  expect(yield* getFirstMessage(channelXToY)).toEqual({ message: `${nodeLabel.y}1` });
252
- yield* waitForEdgeReplacement;
255
+ yield* Deferred.await(waitForEdgeReplacement);
253
256
  yield* channelXToY.send({ message: `${nodeLabel.x}2` });
254
257
  expect(yield* getFirstMessage(channelXToY)).toEqual({ message: `${nodeLabel.y}2` });
255
258
  });
@@ -267,11 +270,11 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
267
270
  });
268
271
  yield* Effect.all([nodeXCode, nodeYCode], { concurrency: 'unbounded' });
269
272
  }).pipe(Vitest.withTestCtx(test, {
270
- suffix: `channelType=${channelType} nodeNames=${nodeNames}`,
273
+ suffix: `channelType=${channelType} nodeNames=${nodeNames.join(',')}`,
271
274
  })), { fastCheck: { numRuns: 10 } });
272
275
  Vitest.describe('TODO improve latency', () => {
273
276
  // TODO we need to improve latency when sending messages concurrently
274
- Vitest.scopedLive.prop('concurrent messages', [ChannelType, Schema.Int.pipe(Schema.between(1, 50))], ([channelType, count], test) => Effect.gen(function* () {
277
+ Vitest.live.prop('concurrent messages', [ChannelType, Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))], ([channelType, count], test) => Effect.gen(function* () {
275
278
  const nodeA = yield* makeMeshNode('A');
276
279
  const nodeB = yield* makeMeshNode('B');
277
280
  const { mode, connectNodes } = fromChannelType(channelType);
@@ -279,15 +282,15 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
279
282
  const nodeACode = Effect.gen(function* () {
280
283
  const channelAToB = yield* createChannel(nodeA, 'B', { mode });
281
284
  // send 10 times A1
282
- yield* Effect.forEach(Chunk.makeBy(count, (i) => ({ message: `A${i}` })), channelAToB.send, { concurrency: 'unbounded' });
283
- expect(yield* channelAToB.listen.pipe(Stream.flatten(), Stream.take(count), Stream.runCollect)).toEqual(Chunk.makeBy(count, (i) => ({ message: `B${i}` })));
285
+ yield* Effect.forEach(Array.from({ length: count }, (_, i) => ({ message: `A${i}` })), channelAToB.send, { concurrency: 'unbounded' });
286
+ expect(yield* channelAToB.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.take(count), Stream.runCollect)).toEqual(Array.from({ length: count }, (_, i) => ({ message: `B${i}` })));
284
287
  // expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'A2' })
285
288
  });
286
289
  const nodeBCode = Effect.gen(function* () {
287
290
  const channelBToA = yield* createChannel(nodeB, 'A', { mode });
288
291
  // send 10 times B1
289
- yield* Effect.forEach(Chunk.makeBy(count, (i) => ({ message: `B${i}` })), channelBToA.send, { concurrency: 'unbounded' });
290
- expect(yield* channelBToA.listen.pipe(Stream.flatten(), Stream.take(count), Stream.runCollect)).toEqual(Chunk.makeBy(count, (i) => ({ message: `A${i}` })));
292
+ yield* Effect.forEach(Array.from({ length: count }, (_, i) => ({ message: `B${i}` })), channelBToA.send, { concurrency: 'unbounded' });
293
+ expect(yield* channelBToA.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.take(count), Stream.runCollect)).toEqual(Array.from({ length: count }, (_, i) => ({ message: `A${i}` })));
291
294
  });
292
295
  yield* Effect.all([nodeACode, nodeBCode, connectNodes(nodeA, nodeB).pipe(Effect.delay(100))], {
293
296
  concurrency: 'unbounded',
@@ -299,27 +302,27 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
299
302
  });
300
303
  });
301
304
  Vitest.describe('message channel specific tests', () => {
302
- Vitest.scopedLive('differing initial edge counter', (test) => Effect.gen(function* () {
305
+ Vitest.live('differing initial edge counter', (test) => Effect.gen(function* () {
303
306
  const nodeA = yield* makeMeshNode('A');
304
307
  const nodeB = yield* makeMeshNode('B');
305
308
  yield* connectNodesViaMessageChannel(nodeA, nodeB);
306
309
  const messageCount = 3;
307
310
  const bFiber = yield* Effect.gen(function* () {
308
311
  const channelBToA = yield* createChannel(nodeB, 'A');
309
- yield* channelBToA.listen.pipe(Stream.flatten(), Stream.tap((msg) => channelBToA.send({ message: `resp:${msg.message}` })), Stream.take(messageCount), Stream.runDrain);
310
- }).pipe(Effect.scoped, Effect.fork);
312
+ yield* channelBToA.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.tap((msg) => channelBToA.send({ message: `resp:${msg.message}` })), Stream.take(messageCount), Stream.runDrain);
313
+ }).pipe(Effect.scoped, Effect.forkChild);
311
314
  // yield* createChannel(nodeA, 'B').pipe(Effect.andThen(WebChannel.shutdown))
312
315
  // // yield* createChannel(nodeA, 'B').pipe(Effect.andThen(WebChannel.shutdown))
313
316
  // // yield* createChannel(nodeA, 'B').pipe(Effect.andThen(WebChannel.shutdown))
314
- yield* Effect.gen(function* () {
317
+ yield* Effect.replicateEffect(Effect.gen(function* () {
315
318
  const channelAToB = yield* createChannel(nodeA, 'B');
316
319
  yield* channelAToB.send({ message: 'A' });
317
320
  expect(yield* getFirstMessage(channelAToB)).toEqual({ message: 'resp:A' });
318
- }).pipe(Effect.scoped, Effect.repeatN(messageCount));
319
- yield* bFiber;
321
+ }).pipe(Effect.scoped), messageCount, { discard: true });
322
+ yield* Fiber.join(bFiber);
320
323
  }).pipe(Vitest.withTestCtx(test)));
321
324
  });
322
- Vitest.scopedLive('manual debug test', (test) => Effect.gen(function* () {
325
+ Vitest.live('manual debug test', (test) => Effect.gen(function* () {
323
326
  const nodeA = yield* makeMeshNode('A');
324
327
  const nodeB = yield* makeMeshNode('B');
325
328
  // const connectNodes = connectNodesViaBroadcastChannel
@@ -338,16 +341,16 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
338
341
  concurrency: 'unbounded',
339
342
  });
340
343
  }).pipe(Vitest.withTestCtx(test)));
341
- Vitest.scopedLive('broadcast edge with message channel', (test) => Effect.gen(function* () {
344
+ Vitest.live('broadcast edge with message channel', (test) => Effect.gen(function* () {
342
345
  const nodeA = yield* makeMeshNode('A');
343
346
  const nodeB = yield* makeMeshNode('B');
344
347
  yield* connectNodesViaBroadcastChannel(nodeA, nodeB);
345
348
  const err = yield* createChannel(nodeA, 'B', { mode: 'direct' }).pipe(Effect.timeout(200), Effect.flip);
346
- expect(err._tag).toBe('TimeoutException');
349
+ expect(err._tag).toBe('TimeoutError');
347
350
  }).pipe(Vitest.withTestCtx(test)));
348
351
  });
349
352
  Vitest.describe('A <> B <> C', () => {
350
- Vitest.scopedLive('should work', (test) => Effect.gen(function* () {
353
+ Vitest.live('should work', (test) => Effect.gen(function* () {
351
354
  const nodeA = yield* makeMeshNode('A');
352
355
  const nodeB = yield* makeMeshNode('B');
353
356
  const nodeC = yield* makeMeshNode('C');
@@ -369,7 +372,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
369
372
  });
370
373
  yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' });
371
374
  }).pipe(Vitest.withTestCtx(test)));
372
- Vitest.scopedLive('should work - delayed edge', (test) => Effect.gen(function* () {
375
+ Vitest.live('should work - delayed edge', (test) => Effect.gen(function* () {
373
376
  const nodeA = yield* makeMeshNode('A');
374
377
  const nodeB = yield* makeMeshNode('B');
375
378
  const nodeC = yield* makeMeshNode('C');
@@ -393,7 +396,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
393
396
  connectNodes(nodeB, nodeC).pipe(Effect.delay(100), Effect.withSpan('connect-nodeB-nodeC-delay(100)')),
394
397
  ], { concurrency: 'unbounded' });
395
398
  }).pipe(Vitest.withTestCtx(test)));
396
- Vitest.scopedLive('proxy channel', (test) => Effect.gen(function* () {
399
+ Vitest.live('proxy channel', (test) => Effect.gen(function* () {
397
400
  const nodeA = yield* makeMeshNode('A');
398
401
  const nodeB = yield* makeMeshNode('B');
399
402
  const nodeC = yield* makeMeshNode('C');
@@ -411,7 +414,194 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
411
414
  });
412
415
  yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' });
413
416
  }).pipe(Vitest.withTestCtx(test)));
414
- Vitest.scopedLive('should fail with timeout due to missing edge', (test) => Effect.gen(function* () {
417
+ /**
418
+ * Pattern test: ACK sending must not block message processing.
419
+ *
420
+ * This test documents the pattern where ACKs are sent fire-and-forget using `Effect.forkScoped`
421
+ * to avoid blocking the message processing loop.
422
+ *
423
+ * Background: Before the fix, ACKs were sent with `await` which blocked message processing.
424
+ * This caused heartbeat timeouts in devtools after ~30 seconds ("Connection to app lost").
425
+ *
426
+ * Note: This unit test passes both with and without the fix because in-memory channels
427
+ * complete ACK sends instantly. The actual regression test is the Playwright test
428
+ * `node-adapter-timeout.play.ts` which uses real network conditions.
429
+ *
430
+ * This test exists to:
431
+ * 1. Document the expected pattern (high message throughput via proxy channels)
432
+ * 2. Verify the pattern works correctly with forked ACKs
433
+ * 3. Serve as a reference for the fix in proxy-channel.ts
434
+ */
435
+ Vitest.live('ACK sending should not block message processing', (test) => Effect.gen(function* () {
436
+ const nodeA = yield* makeMeshNode('A');
437
+ const nodeB = yield* makeMeshNode('B');
438
+ const nodeC = yield* makeMeshNode('C');
439
+ yield* connectNodesViaBroadcastChannel(nodeA, nodeB);
440
+ yield* connectNodesViaBroadcastChannel(nodeB, nodeC);
441
+ // Send many messages to stress the ACK handling
442
+ const messageCount = 10;
443
+ const nodeACode = Effect.gen(function* () {
444
+ const channelAToC = yield* createChannel(nodeA, 'C', { mode: 'proxy' });
445
+ // Send multiple messages concurrently
446
+ yield* Effect.forEach(Array.from({ length: messageCount }, (_, i) => ({ message: `A${i}` })), channelAToC.send, { concurrency: 'unbounded' });
447
+ // Receive responses
448
+ const responses = yield* channelAToC.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.take(messageCount), Stream.runCollect);
449
+ expect(responses.length).toBe(messageCount);
450
+ });
451
+ const nodeCCode = Effect.gen(function* () {
452
+ const channelCToA = yield* createChannel(nodeC, 'A', { mode: 'proxy' });
453
+ // Send multiple messages concurrently
454
+ yield* Effect.forEach(Array.from({ length: messageCount }, (_, i) => ({ message: `C${i}` })), channelCToA.send, { concurrency: 'unbounded' });
455
+ // Receive responses
456
+ const responses = yield* channelCToA.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.take(messageCount), Stream.runCollect);
457
+ expect(responses.length).toBe(messageCount);
458
+ });
459
+ yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' });
460
+ }).pipe(Vitest.withTestCtx(test)));
461
+ Vitest.describe('ACK forkScoped regression tests', { timeout: 10_000 }, () => {
462
+ /**
463
+ * REGRESSION TEST: ACK sends must be forked (non-blocking) to allow message processing to continue.
464
+ *
465
+ * This test uses simulation parameters to inject a delay BEFORE the ACK send.
466
+ * It then measures when messages arrive at the receiver's listen queue.
467
+ *
468
+ * With the fix (Effect.forkScoped):
469
+ * - ACK send is forked in the background
470
+ * - Message is added to listen queue IMMEDIATELY (before ACK send completes)
471
+ * - Multiple messages arrive close together regardless of ACK delay
472
+ *
473
+ * Without the fix (blocking yield*):
474
+ * - ACK send blocks the processing loop
475
+ * - Message is added to listen queue AFTER ACK send completes
476
+ * - Messages arrive spread out by the ACK delay
477
+ *
478
+ * To verify this test catches the regression:
479
+ * 1. Temporarily change `Effect.forkScoped` to `yield*` in proxy-channel.ts:319
480
+ * 2. Run this test - it should FAIL because messages arrive too slowly
481
+ * 3. Revert the change - test should PASS
482
+ */
483
+ /**
484
+ * This test verifies the fix works: messages should arrive at the listen queue
485
+ * without being blocked by slow ACK sends. We measure how long it takes to receive
486
+ * all messages - with forked ACKs it should be fast, with blocking ACKs it would be slow.
487
+ */
488
+ Vitest.live('messages arrive in listen queue without waiting for ACK send', (test) => Effect.gen(function* () {
489
+ const ACK_DELAY_MS = 50;
490
+ const MESSAGE_COUNT = 5;
491
+ const nodeA = yield* makeMeshNode('A');
492
+ const nodeB = yield* makeMeshNode('B');
493
+ yield* connectNodesViaMessageChannel(nodeA, nodeB);
494
+ const receivedMessages = [];
495
+ const senderCode = Effect.gen(function* () {
496
+ const channelAToB = yield* nodeA.makeChannel({
497
+ target: 'B',
498
+ channelName: 'test-ack-timing',
499
+ schema: ExampleSchema,
500
+ mode: 'proxy',
501
+ timeout: 3000,
502
+ });
503
+ // Send all messages concurrently - this is key!
504
+ // With forked ACKs, all messages get processed immediately at receiver
505
+ // With blocking ACKs, messages would be processed one at a time with delays
506
+ yield* Effect.forEach(Array.from({ length: MESSAGE_COUNT }, (_, i) => ({ message: `msg${i}` })), channelAToB.send, { concurrency: 'unbounded' });
507
+ });
508
+ const receiverCode = Effect.gen(function* () {
509
+ const channelBToA = yield* nodeB.makeChannel({
510
+ target: 'A',
511
+ channelName: 'test-ack-timing',
512
+ schema: ExampleSchema,
513
+ mode: 'proxy',
514
+ timeout: 3000,
515
+ // KEY: Inject delay BEFORE ACK send
516
+ // With forkScoped: message arrives in queue immediately, then ACK is sent in background
517
+ // Without forkScoped: message waits for ACK delay before being added to queue
518
+ simulation: {
519
+ onPayload: {
520
+ beforeAckSend: ACK_DELAY_MS,
521
+ afterAckFork: 0,
522
+ afterListenQueueOffer: 0,
523
+ },
524
+ },
525
+ });
526
+ yield* channelBToA.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.tap((msg) => Effect.sync(() => {
527
+ receivedMessages.push(msg.message);
528
+ })), Stream.take(MESSAGE_COUNT), Stream.runDrain);
529
+ });
530
+ const startTime = Date.now();
531
+ yield* Effect.all([senderCode, receiverCode], { concurrency: 'unbounded' });
532
+ const elapsed = Date.now() - startTime;
533
+ console.log(`[regression-test-1] Received ${receivedMessages.length} messages in ${elapsed}ms`);
534
+ console.log(`[regression-test-1] Messages: ${receivedMessages.join(', ')}`);
535
+ expect(receivedMessages.length).toBe(MESSAGE_COUNT);
536
+ // With forked ACKs, elapsed time should be much less than MESSAGE_COUNT * ACK_DELAY_MS
537
+ // because ACKs don't block message processing
538
+ const blockingTime = MESSAGE_COUNT * ACK_DELAY_MS;
539
+ console.log(`[regression-test-1] Elapsed: ${elapsed}ms, Blocking estimate: ${blockingTime}ms`);
540
+ // The test passes if messages arrive faster than they would with blocking ACKs
541
+ // Allow some margin for test overhead (2x faster than blocking)
542
+ if (elapsed > blockingTime / 2) {
543
+ throw new Error(`REGRESSION DETECTED: Processing took ${elapsed}ms, blocking estimate: ${blockingTime}ms. ` +
544
+ `With forked ACKs, processing should be much faster. ` +
545
+ `Check that proxy-channel.ts uses Effect.forkScoped for ACK sends.`);
546
+ }
547
+ }).pipe(Vitest.withTestCtx(test)));
548
+ /**
549
+ * Additional test: Verify message processing continues during slow ACK sends.
550
+ *
551
+ * This test sends multiple messages concurrently and verifies they're all
552
+ * processed even when ACK sends are slow.
553
+ */
554
+ Vitest.live('concurrent messages processed despite slow ACK sends', (test) => Effect.gen(function* () {
555
+ const ACK_DELAY_MS = 50;
556
+ const MESSAGE_COUNT = 5;
557
+ const nodeA = yield* makeMeshNode('A');
558
+ const nodeB = yield* makeMeshNode('B');
559
+ yield* connectNodesViaMessageChannel(nodeA, nodeB);
560
+ const receivedMessages = [];
561
+ const senderCode = Effect.gen(function* () {
562
+ const channelAToB = yield* nodeA.makeChannel({
563
+ target: 'B',
564
+ channelName: 'test-concurrent',
565
+ schema: ExampleSchema,
566
+ mode: 'proxy',
567
+ timeout: 3000,
568
+ });
569
+ // Send all messages concurrently
570
+ yield* Effect.forEach(Array.from({ length: MESSAGE_COUNT }, (_, i) => ({ message: `msg${i}` })), channelAToB.send, { concurrency: 'unbounded' });
571
+ });
572
+ const receiverCode = Effect.gen(function* () {
573
+ const channelBToA = yield* nodeB.makeChannel({
574
+ target: 'A',
575
+ channelName: 'test-concurrent',
576
+ schema: ExampleSchema,
577
+ mode: 'proxy',
578
+ timeout: 3000,
579
+ simulation: {
580
+ onPayload: {
581
+ beforeAckSend: ACK_DELAY_MS,
582
+ afterAckFork: 0,
583
+ afterListenQueueOffer: 0,
584
+ },
585
+ },
586
+ });
587
+ yield* channelBToA.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.tap((msg) => Effect.sync(() => {
588
+ receivedMessages.push(msg.message);
589
+ })), Stream.take(MESSAGE_COUNT), Stream.runDrain);
590
+ });
591
+ const startTime = Date.now();
592
+ yield* Effect.all([senderCode, receiverCode], { concurrency: 'unbounded' });
593
+ const elapsed = Date.now() - startTime;
594
+ console.log(`[regression-test] Received ${receivedMessages.length} messages in ${elapsed}ms`);
595
+ console.log(`[regression-test] Messages: ${receivedMessages.join(', ')}`);
596
+ // All messages should be received
597
+ expect(receivedMessages.length).toBe(MESSAGE_COUNT);
598
+ // With forked ACKs, elapsed time should be much less than MESSAGE_COUNT * ACK_DELAY_MS
599
+ // because ACKs don't block message processing
600
+ const blockingTime = MESSAGE_COUNT * ACK_DELAY_MS;
601
+ console.log(`[regression-test] Elapsed: ${elapsed}ms, Blocking estimate: ${blockingTime}ms`);
602
+ }).pipe(Vitest.withTestCtx(test)));
603
+ });
604
+ Vitest.live('should fail with timeout due to missing edge', (test) => Effect.gen(function* () {
415
605
  const nodeA = yield* makeMeshNode('A');
416
606
  const nodeB = yield* makeMeshNode('B');
417
607
  const nodeC = yield* makeMeshNode('C');
@@ -419,35 +609,35 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
419
609
  // We're not connecting nodeB and nodeC, so this should fail
420
610
  const nodeACode = Effect.gen(function* () {
421
611
  const err = yield* createChannel(nodeA, 'C').pipe(Effect.timeout(200), Effect.flip);
422
- expect(err._tag).toBe('TimeoutException');
612
+ expect(err._tag).toBe('TimeoutError');
423
613
  });
424
614
  const nodeCCode = Effect.gen(function* () {
425
615
  const err = yield* createChannel(nodeC, 'A').pipe(Effect.timeout(200), Effect.flip);
426
- expect(err._tag).toBe('TimeoutException');
616
+ expect(err._tag).toBe('TimeoutError');
427
617
  });
428
618
  yield* Effect.all([nodeACode, nodeCCode], { concurrency: 'unbounded' });
429
619
  }).pipe(Vitest.withTestCtx(test)));
430
- Vitest.scopedLive('should fail with timeout due no transferable', (test) => Effect.gen(function* () {
620
+ Vitest.live('should fail with timeout due no transferable', (test) => Effect.gen(function* () {
431
621
  const nodeA = yield* makeMeshNode('A');
432
622
  const nodeB = yield* makeMeshNode('B');
433
623
  yield* connectNodesViaBroadcastChannel(nodeA, nodeB);
434
624
  const nodeACode = Effect.gen(function* () {
435
625
  const err = yield* createChannel(nodeA, 'B').pipe(Effect.timeout(200), Effect.flip);
436
- expect(err._tag).toBe('TimeoutException');
626
+ expect(err._tag).toBe('TimeoutError');
437
627
  });
438
628
  const nodeBCode = Effect.gen(function* () {
439
629
  const err = yield* createChannel(nodeB, 'A').pipe(Effect.timeout(200), Effect.flip);
440
- expect(err._tag).toBe('TimeoutException');
630
+ expect(err._tag).toBe('TimeoutError');
441
631
  });
442
632
  yield* Effect.all([nodeACode, nodeBCode], { concurrency: 'unbounded' });
443
633
  }).pipe(Vitest.withTestCtx(test)));
444
- Vitest.scopedLive('reconnect with re-created node', (test) => Effect.gen(function* () {
634
+ Vitest.live('reconnect with re-created node', (test) => Effect.gen(function* () {
445
635
  const nodeCgen1Scope = yield* Scope.make();
446
636
  const nodeA = yield* makeMeshNode('A');
447
637
  const nodeB = yield* makeMeshNode('B');
448
- const nodeCgen1 = yield* makeMeshNode('C').pipe(Scope.extend(nodeCgen1Scope));
638
+ const nodeCgen1 = yield* makeMeshNode('C').pipe(Scope.provide(nodeCgen1Scope));
449
639
  yield* connectNodesViaMessageChannel(nodeA, nodeB);
450
- yield* connectNodesViaMessageChannel(nodeB, nodeCgen1).pipe(Scope.extend(nodeCgen1Scope));
640
+ yield* connectNodesViaMessageChannel(nodeB, nodeCgen1).pipe(Scope.provide(nodeCgen1Scope));
451
641
  const nodeACode = Effect.gen(function* () {
452
642
  const channelAToB = yield* createChannel(nodeA, 'C');
453
643
  yield* channelAToB.send({ message: 'A1' });
@@ -458,7 +648,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
458
648
  yield* channelBToA.send({ message: 'C1' });
459
649
  expect(yield* getFirstMessage(channelBToA)).toEqual({ message: 'A1' });
460
650
  });
461
- yield* Effect.all([nodeACode, nodeCCode(nodeCgen1)], { concurrency: 'unbounded' }).pipe(Effect.withSpan('test1'), Scope.extend(nodeCgen1Scope));
651
+ yield* Effect.all([nodeACode, nodeCCode(nodeCgen1)], { concurrency: 'unbounded' }).pipe(Effect.withSpan('test1'), Scope.provide(nodeCgen1Scope));
462
652
  yield* Scope.close(nodeCgen1Scope, Exit.void);
463
653
  const nodeCgen2 = yield* makeMeshNode('C');
464
654
  yield* connectNodesViaMessageChannel(nodeB, nodeCgen2, { replaceIfExists: true });
@@ -473,7 +663,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
473
663
  * D
474
664
  */
475
665
  Vitest.describe('diamond topology', () => {
476
- Vitest.scopedLive('should work', (test) => Effect.gen(function* () {
666
+ Vitest.live('should work', (test) => Effect.gen(function* () {
477
667
  const nodeA = yield* makeMeshNode('A');
478
668
  const nodeB = yield* makeMeshNode('B');
479
669
  const nodeC = yield* makeMeshNode('C');
@@ -505,7 +695,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
505
695
  * Topology: Butterfly topology with two connected hubs (C-D) each serving multiple nodes
506
696
  */
507
697
  Vitest.describe('butterfly topology', () => {
508
- Vitest.scopedLive('should work', (test) => Effect.gen(function* () {
698
+ Vitest.live('should work', (test) => Effect.gen(function* () {
509
699
  const nodeA = yield* makeMeshNode('A');
510
700
  const nodeB = yield* makeMeshNode('B');
511
701
  const nodeC = yield* makeMeshNode('C');
@@ -534,14 +724,14 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
534
724
  Vitest.describe('mixture of direct and proxy edge connections', () => {
535
725
  // TODO test case to better guard against case where side A tries to create a proxy channel to B
536
726
  // and side B tries to create a direct to A
537
- Vitest.scopedLive('should work for proxy channels', (test) => Effect.gen(function* () {
727
+ Vitest.live('should work for proxy channels', (test) => Effect.gen(function* () {
538
728
  const nodeA = yield* makeMeshNode('A');
539
729
  const nodeB = yield* makeMeshNode('B');
540
730
  yield* connectNodesViaMessageChannel(nodeB, nodeA);
541
731
  const err = yield* connectNodesViaBroadcastChannel(nodeA, nodeB).pipe(Effect.flip);
542
732
  expect(err._tag).toBe('EdgeAlreadyExistsError');
543
733
  }).pipe(Vitest.withTestCtx(test)));
544
- Vitest.scopedLive('should work for directs', (test) => Effect.gen(function* () {
734
+ Vitest.live('should work for directs', (test) => Effect.gen(function* () {
545
735
  const nodeA = yield* makeMeshNode('A');
546
736
  const nodeB = yield* makeMeshNode('B');
547
737
  const nodeC = yield* makeMeshNode('C');
@@ -561,7 +751,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
561
751
  }).pipe(Vitest.withTestCtx(test)));
562
752
  });
563
753
  Vitest.describe('listenForChannel', () => {
564
- Vitest.scopedLive('connect later', (test) => Effect.gen(function* () {
754
+ Vitest.live('connect later', (test) => Effect.gen(function* () {
565
755
  const nodeA = yield* makeMeshNode('A');
566
756
  const mode = 'direct';
567
757
  const connect = mode === 'direct' ? connectNodesViaMessageChannel : connectNodesViaBroadcastChannel;
@@ -585,7 +775,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
585
775
  yield* Effect.all([nodeACode, nodeBCode.pipe(Effect.delay(500))], { concurrency: 'unbounded' });
586
776
  }).pipe(Vitest.withTestCtx(test)));
587
777
  // TODO provide a way to allow for reconnecting in the `listenForChannel` case
588
- Vitest.scopedLive.skip('reconnect', (test) => Effect.gen(function* () {
778
+ Vitest.live.skip('reconnect', (test) => Effect.gen(function* () {
589
779
  const nodeA = yield* makeMeshNode('A');
590
780
  const mode = 'direct';
591
781
  const connect = mode === 'direct' ? connectNodesViaMessageChannel : connectNodesViaBroadcastChannel;
@@ -621,11 +811,11 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
621
811
  yield* Effect.all([nodeACode, nodeBCode], { concurrency: 'unbounded' });
622
812
  }).pipe(Vitest.withTestCtx(test)));
623
813
  Vitest.describe('prop tests', { timeout: propTestTimeout }, () => {
624
- Vitest.scopedLive.prop('listenForChannel A <> B <> C', [Delay, Delay, Delay, Delay, ChannelType], ([delayNodeA, delayNodeC, delayConnectAB, delayConnectBC, channelType], test) => Effect.gen(function* () {
814
+ Vitest.live.prop('listenForChannel A <> B <> C', [Delay, Delay, Delay, Delay, ChannelType], ([delayNodeA, delayNodeC, delayConnectAB, delayConnectBC, channelType], test) => Effect.gen(function* () {
625
815
  const nodeA = yield* makeMeshNode('A');
626
816
  const nodeB = yield* makeMeshNode('B');
627
817
  const nodeC = yield* makeMeshNode('C');
628
- const mode = channelType.includes('proxy') ? 'proxy' : 'direct';
818
+ const mode = channelType.includes('proxy') === true ? 'proxy' : 'direct';
629
819
  const connect = channelType === 'direct' ? connectNodesViaMessageChannel : connectNodesViaBroadcastChannel;
630
820
  yield* connect(nodeA, nodeB).pipe(maybeDelay(delayConnectAB, 'delayConnectAB'));
631
821
  yield* connect(nodeB, nodeC).pipe(maybeDelay(delayConnectBC, 'delayConnectBC'));
@@ -657,7 +847,7 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
657
847
  });
658
848
  });
659
849
  Vitest.describe('broadcast channel', () => {
660
- Vitest.scopedLive('should work', (test) => Effect.gen(function* () {
850
+ Vitest.live('should work', (test) => Effect.gen(function* () {
661
851
  const nodeA = yield* makeMeshNode('A');
662
852
  const nodeB = yield* makeMeshNode('B');
663
853
  const nodeC = yield* makeMeshNode('C');
@@ -665,12 +855,12 @@ Vitest.describe('webmesh node', { timeout: testTimeout }, () => {
665
855
  yield* connectNodesViaMessageChannel(nodeB, nodeC);
666
856
  const channelOnA = yield* nodeA.makeBroadcastChannel({ channelName: 'test', schema: Schema.String });
667
857
  const channelOnC = yield* nodeC.makeBroadcastChannel({ channelName: 'test', schema: Schema.String });
668
- const listenOnAFiber = yield* channelOnA.listen.pipe(Stream.flatten(), Stream.runHead, Effect.flatten, Effect.fork);
669
- const listenOnCFiber = yield* channelOnC.listen.pipe(Stream.flatten(), Stream.runHead, Effect.flatten, Effect.fork);
858
+ const listenOnAFiber = yield* channelOnA.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.runHead, Effect.flatMap(Effect.fromOption), Effect.forkChild);
859
+ const listenOnCFiber = yield* channelOnC.listen.pipe(Stream.mapEffect(Effect.fromResult), Stream.runHead, Effect.flatMap(Effect.fromOption), Effect.forkChild);
670
860
  yield* channelOnA.send('A1');
671
861
  yield* channelOnC.send('C1');
672
- expect(yield* listenOnAFiber).toEqual('C1');
673
- expect(yield* listenOnCFiber).toEqual('A1');
862
+ expect(yield* Fiber.join(listenOnAFiber)).toEqual('C1');
863
+ expect(yield* Fiber.join(listenOnCFiber)).toEqual('A1');
674
864
  }).pipe(Vitest.withTestCtx(test)));
675
865
  });
676
866
  });