@livestore/webmesh 0.4.0-dev.22 → 0.4.0-dev.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/channel/direct-channel-internal.d.ts.map +1 -1
- package/dist/channel/direct-channel-internal.js +15 -23
- package/dist/channel/direct-channel-internal.js.map +1 -1
- package/dist/channel/direct-channel.js +4 -4
- package/dist/channel/direct-channel.js.map +1 -1
- package/dist/channel/proxy-channel.d.ts +26 -1
- package/dist/channel/proxy-channel.d.ts.map +1 -1
- package/dist/channel/proxy-channel.js +64 -18
- package/dist/channel/proxy-channel.js.map +1 -1
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +5 -6
- package/dist/common.js.map +1 -1
- package/dist/node.d.ts +6 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +25 -23
- package/dist/node.js.map +1 -1
- package/dist/node.test.js +192 -5
- package/dist/node.test.js.map +1 -1
- package/dist/websocket-edge.d.ts +1 -1
- package/dist/websocket-edge.d.ts.map +1 -1
- package/dist/websocket-edge.js +5 -7
- package/dist/websocket-edge.js.map +1 -1
- package/dist/websocket-edge.test.d.ts +7 -0
- package/dist/websocket-edge.test.d.ts.map +1 -0
- package/dist/websocket-edge.test.js +74 -0
- package/dist/websocket-edge.test.js.map +1 -0
- package/package.json +65 -12
- package/src/channel/direct-channel-internal.ts +17 -40
- package/src/channel/direct-channel.ts +4 -4
- package/src/channel/proxy-channel.ts +85 -25
- package/src/common.ts +5 -6
- package/src/node.test.ts +270 -7
- package/src/node.ts +31 -23
- package/src/websocket-edge.test.ts +98 -0
- 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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
+
})
|