@livestore/webmesh 0.3.0-dev.3 → 0.3.0-dev.31

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 (64) hide show
  1. package/README.md +26 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/message-channel-internal.d.ts +26 -0
  4. package/dist/channel/message-channel-internal.d.ts.map +1 -0
  5. package/dist/channel/message-channel-internal.js +217 -0
  6. package/dist/channel/message-channel-internal.js.map +1 -0
  7. package/dist/channel/message-channel.d.ts +21 -19
  8. package/dist/channel/message-channel.d.ts.map +1 -1
  9. package/dist/channel/message-channel.js +132 -162
  10. package/dist/channel/message-channel.js.map +1 -1
  11. package/dist/channel/proxy-channel.d.ts +3 -3
  12. package/dist/channel/proxy-channel.d.ts.map +1 -1
  13. package/dist/channel/proxy-channel.js +38 -19
  14. package/dist/channel/proxy-channel.js.map +1 -1
  15. package/dist/common.d.ts +36 -14
  16. package/dist/common.d.ts.map +1 -1
  17. package/dist/common.js +7 -4
  18. package/dist/common.js.map +1 -1
  19. package/dist/mesh-schema.d.ts +71 -5
  20. package/dist/mesh-schema.d.ts.map +1 -1
  21. package/dist/mesh-schema.js +55 -6
  22. package/dist/mesh-schema.js.map +1 -1
  23. package/dist/mod.d.ts +2 -2
  24. package/dist/mod.d.ts.map +1 -1
  25. package/dist/mod.js +2 -2
  26. package/dist/mod.js.map +1 -1
  27. package/dist/node.d.ts +43 -21
  28. package/dist/node.d.ts.map +1 -1
  29. package/dist/node.js +273 -100
  30. package/dist/node.js.map +1 -1
  31. package/dist/node.test.d.ts +1 -1
  32. package/dist/node.test.d.ts.map +1 -1
  33. package/dist/node.test.js +391 -156
  34. package/dist/node.test.js.map +1 -1
  35. package/dist/utils.d.ts +4 -4
  36. package/dist/utils.d.ts.map +1 -1
  37. package/dist/utils.js +7 -1
  38. package/dist/utils.js.map +1 -1
  39. package/dist/websocket-edge.d.ts +52 -0
  40. package/dist/websocket-edge.d.ts.map +1 -0
  41. package/dist/websocket-edge.js +85 -0
  42. package/dist/websocket-edge.js.map +1 -0
  43. package/package.json +5 -6
  44. package/src/channel/message-channel-internal.ts +356 -0
  45. package/src/channel/message-channel.ts +190 -310
  46. package/src/channel/proxy-channel.ts +259 -231
  47. package/src/common.ts +12 -13
  48. package/src/mesh-schema.ts +62 -6
  49. package/src/mod.ts +2 -2
  50. package/src/node.test.ts +554 -189
  51. package/src/node.ts +421 -138
  52. package/src/utils.ts +13 -2
  53. package/src/websocket-edge.ts +177 -0
  54. package/tmp/pack.tgz +0 -0
  55. package/dist/websocket-connection.d.ts +0 -51
  56. package/dist/websocket-connection.d.ts.map +0 -1
  57. package/dist/websocket-connection.js +0 -74
  58. package/dist/websocket-connection.js.map +0 -1
  59. package/dist/websocket-server.d.ts +0 -7
  60. package/dist/websocket-server.d.ts.map +0 -1
  61. package/dist/websocket-server.js +0 -24
  62. package/dist/websocket-server.js.map +0 -1
  63. package/src/websocket-connection.ts +0 -158
  64. package/src/websocket-server.ts +0 -40
package/src/node.ts CHANGED
@@ -1,14 +1,16 @@
1
- import { LS_DEV, shouldNeverHappen } from '@livestore/utils'
2
- import type { Scope } from '@livestore/utils/effect'
1
+ import { indent, LS_DEV, shouldNeverHappen } from '@livestore/utils'
3
2
  import {
4
3
  Cause,
4
+ Deferred,
5
5
  Duration,
6
6
  Effect,
7
+ Exit,
7
8
  Fiber,
8
9
  Option,
9
10
  PubSub,
10
11
  Queue,
11
12
  Schema,
13
+ Scope,
12
14
  Stream,
13
15
  WebChannel,
14
16
  } from '@livestore/utils/effect'
@@ -16,49 +18,64 @@ import {
16
18
  import { makeMessageChannel } from './channel/message-channel.js'
17
19
  import { makeProxyChannel } from './channel/proxy-channel.js'
18
20
  import type { ChannelKey, MeshNodeName, MessageQueueItem, ProxyQueueItem } from './common.js'
19
- import { ConnectionAlreadyExistsError, packetAsOtelAttributes } from './common.js'
20
- import * as MeshSchema from './mesh-schema.js'
21
+ import { EdgeAlreadyExistsError, packetAsOtelAttributes } from './common.js'
22
+ import * as WebmeshSchema from './mesh-schema.js'
21
23
  import { TimeoutSet } from './utils.js'
22
24
 
23
- type ConnectionChannel = WebChannel.WebChannel<typeof MeshSchema.Packet.Type, typeof MeshSchema.Packet.Type>
25
+ type EdgeChannel = WebChannel.WebChannel<typeof WebmeshSchema.Packet.Type, typeof WebmeshSchema.Packet.Type>
24
26
 
25
- export interface MeshNode {
26
- nodeName: MeshNodeName
27
+ export interface MeshNode<TName extends MeshNodeName = MeshNodeName> {
28
+ nodeName: TName
27
29
 
28
- connectionKeys: Effect.Effect<Set<MeshNodeName>>
30
+ edgeKeys: Effect.Effect<Set<MeshNodeName>>
29
31
 
30
32
  debug: {
31
- printChannelQueues: () => void
33
+ print: () => void
34
+ /** Sends a ping message to all connected nodes and channels */
35
+ ping: (payload?: string) => void
36
+ /**
37
+ * Requests the topology of the network from all connected nodes
38
+ */
39
+ requestTopology: (timeoutMs?: number) => Promise<void>
32
40
  }
33
41
 
34
42
  /**
35
- * Manually adds a connection to get connected to the network of nodes with an existing WebChannel.
43
+ * Manually adds a edge to get connected to the network of nodes with an existing WebChannel.
36
44
  *
37
- * Assumptions about the WebChannel connection:
38
- * - 1:1 connection
45
+ * Assumptions about the WebChannel edge:
46
+ * - 1:1 edge
39
47
  * - Queues messages internally to never drop messages
40
48
  * - Automatically reconnects
41
49
  * - Ideally supports transferables
42
50
  */
43
- addConnection: {
51
+ addEdge: {
44
52
  (options: {
45
53
  target: MeshNodeName
46
- connectionChannel: ConnectionChannel
54
+ edgeChannel: EdgeChannel
47
55
  replaceIfExists: true
48
56
  }): Effect.Effect<void, never, Scope.Scope>
49
57
  (options: {
50
58
  target: MeshNodeName
51
- connectionChannel: ConnectionChannel
59
+ edgeChannel: EdgeChannel
52
60
  replaceIfExists?: boolean
53
- }): Effect.Effect<void, ConnectionAlreadyExistsError, Scope.Scope>
61
+ }): Effect.Effect<void, EdgeAlreadyExistsError, Scope.Scope>
54
62
  }
55
63
 
56
- removeConnection: (targetNodeName: MeshNodeName) => Effect.Effect<void, Cause.NoSuchElementException>
64
+ removeEdge: (targetNodeName: MeshNodeName) => Effect.Effect<void, Cause.NoSuchElementException>
57
65
 
58
66
  /**
59
- * Tries to broker a MessageChannel connection between the nodes, otherwise will proxy messages via hop-nodes
67
+ * Tries to broker a MessageChannel edge between the nodes, otherwise will proxy messages via hop-nodes
68
+ *
69
+ * For a channel to successfully open, both sides need to have a edge and call `makeChannel`.
60
70
  *
61
- * For a channel to successfully open, both sides need to have a connection and call `makeChannel`
71
+ * Example:
72
+ * ```ts
73
+ * // Code on node A
74
+ * const channel = nodeA.makeChannel({ target: 'B', channelName: 'my-channel', schema: ... })
75
+ *
76
+ * // Code on node B
77
+ * const channel = nodeB.makeChannel({ target: 'A', channelName: 'my-channel', schema: ... })
78
+ * ```
62
79
  */
63
80
  makeChannel: <MsgListen, MsgSend>(args: {
64
81
  target: MeshNodeName
@@ -78,52 +95,75 @@ export interface MeshNode {
78
95
  */
79
96
  mode: 'messagechannel' | 'proxy'
80
97
  /**
81
- * Amount of time before we consider a channel creation failed and retry when a new connection is available
98
+ * Amount of time before we consider a channel creation failed and retry when a new edge is available
82
99
  *
83
100
  * @default 1 second
84
101
  */
85
102
  timeout?: Duration.DurationInput
86
103
  }) => Effect.Effect<WebChannel.WebChannel<MsgListen, MsgSend>, never, Scope.Scope>
104
+
105
+ /**
106
+ * Creates a WebChannel that is broadcasted to all connected nodes.
107
+ * Messages won't be buffered for nodes that join the network after the broadcast channel has been created.
108
+ */
109
+ makeBroadcastChannel: <Msg>(args: {
110
+ channelName: string
111
+ schema: Schema.Schema<Msg, any>
112
+ }) => Effect.Effect<WebChannel.WebChannel<Msg, Msg>, never, Scope.Scope>
87
113
  }
88
114
 
89
- export const makeMeshNode = (nodeName: MeshNodeName): Effect.Effect<MeshNode, never, Scope.Scope> =>
115
+ export const makeMeshNode = <TName extends MeshNodeName>(
116
+ nodeName: TName,
117
+ ): Effect.Effect<MeshNode<TName>, never, Scope.Scope> =>
90
118
  Effect.gen(function* () {
91
- const connectionChannels = new Map<
92
- MeshNodeName,
93
- { channel: ConnectionChannel; listenFiber: Fiber.RuntimeFiber<void> }
94
- >()
119
+ const edgeChannels = new Map<MeshNodeName, { channel: EdgeChannel; listenFiber: Fiber.RuntimeFiber<void> }>()
95
120
 
96
121
  // To avoid unbounded memory growth, we automatically forget about packet ids after a while
97
- const handledPacketIds = new TimeoutSet<string>({ timeout: Duration.minutes(1) })
122
+ const handledPacketIds = yield* TimeoutSet.make(Duration.minutes(1))
98
123
 
99
- const newConnectionAvailablePubSub = yield* PubSub.unbounded<MeshNodeName>().pipe(
100
- Effect.acquireRelease(PubSub.shutdown),
101
- )
124
+ const newEdgeAvailablePubSub = yield* PubSub.unbounded<MeshNodeName>().pipe(Effect.acquireRelease(PubSub.shutdown))
102
125
 
103
126
  // const proxyPacketsToProcess = yield* Queue.unbounded<ProxyQueueItem>().pipe(Effect.acquireRelease(Queue.shutdown))
104
127
  // const messagePacketsToProcess = yield* Queue.unbounded<MessageQueueItem>().pipe(
105
128
  // Effect.acquireRelease(Queue.shutdown),
106
129
  // )
107
130
 
108
- const channelMap = new Map<ChannelKey, { queue: Queue.Queue<MessageQueueItem | ProxyQueueItem> }>()
131
+ const channelMap = new Map<
132
+ ChannelKey,
133
+ {
134
+ queue: Queue.Queue<MessageQueueItem | ProxyQueueItem>
135
+ /** This reference is only kept for debugging purposes */
136
+ debugInfo:
137
+ | {
138
+ channel: WebChannel.WebChannel<any, any>
139
+ target: MeshNodeName
140
+ }
141
+ | undefined
142
+ }
143
+ >()
144
+
145
+ type RequestId = string
146
+ const topologyRequestsMap = new Map<RequestId, Map<MeshNodeName, Set<MeshNodeName>>>()
147
+
148
+ type BroadcastChannelName = string
149
+ const broadcastChannelListenQueueMap = new Map<BroadcastChannelName, Queue.Queue<any>>()
109
150
 
110
- const checkTransferableConnections = (packet: typeof MeshSchema.MessageChannelPacket.Type) => {
151
+ const checkTransferableEdges = (packet: typeof WebmeshSchema.MessageChannelPacket.Type) => {
111
152
  if (
112
153
  (packet._tag === 'MessageChannelRequest' &&
113
- (connectionChannels.size === 0 ||
114
- // Either if direct connection does not support transferables ...
115
- connectionChannels.get(packet.target)?.channel.supportsTransferables === false)) ||
116
- // ... or if no forward-connections support transferables
117
- ![...connectionChannels.values()].some((c) => c.channel.supportsTransferables === true)
154
+ (edgeChannels.size === 0 ||
155
+ // Either if direct edge does not support transferables ...
156
+ edgeChannels.get(packet.target)?.channel.supportsTransferables === false)) ||
157
+ // ... or if no forward-edges support transferables
158
+ ![...edgeChannels.values()].some((c) => c.channel.supportsTransferables === true)
118
159
  ) {
119
- return MeshSchema.MessageChannelResponseNoTransferables.make({
160
+ return WebmeshSchema.MessageChannelResponseNoTransferables.make({
120
161
  reqId: packet.id,
121
162
  channelName: packet.channelName,
122
163
  // NOTE for now we're "pretending" that the message is coming from the target node
123
164
  // even though we're already handling it here.
124
165
  // TODO we should clean this up at some point
125
166
  source: packet.target,
126
- // source: nodeName,
127
167
  target: packet.source,
128
168
  remainingHops: packet.hops,
129
169
  hops: [],
@@ -131,65 +171,151 @@ export const makeMeshNode = (nodeName: MeshNodeName): Effect.Effect<MeshNode, ne
131
171
  }
132
172
  }
133
173
 
134
- const sendPacket = (packet: typeof MeshSchema.Packet.Type) =>
174
+ const sendPacket = (packet: typeof WebmeshSchema.Packet.Type) =>
135
175
  Effect.gen(function* () {
136
- if (Schema.is(MeshSchema.NetworkConnectionAdded)(packet)) {
137
- yield* Effect.spanEvent('NetworkConnectionAdded', { packet, nodeName })
138
- yield* PubSub.publish(newConnectionAvailablePubSub, packet.target)
176
+ // yield* Effect.log(`${nodeName}: sendPacket:${packet._tag} [${packet.id}]`)
177
+
178
+ if (Schema.is(WebmeshSchema.NetworkEdgeAdded)(packet)) {
179
+ yield* Effect.spanEvent('NetworkEdgeAdded', { packet, nodeName })
180
+ yield* PubSub.publish(newEdgeAvailablePubSub, packet.target)
139
181
 
140
- const connectionsToForwardTo = Array.from(connectionChannels)
182
+ const edgesToForwardTo = Array.from(edgeChannels)
141
183
  .filter(([name]) => name !== packet.source)
142
184
  .map(([_, con]) => con.channel)
143
185
 
144
- yield* Effect.forEach(connectionsToForwardTo, (con) => con.send(packet), { concurrency: 'unbounded' })
186
+ yield* Effect.forEach(edgesToForwardTo, (con) => con.send(packet), { concurrency: 'unbounded' })
187
+ return
188
+ }
189
+
190
+ if (Schema.is(WebmeshSchema.BroadcastChannelPacket)(packet)) {
191
+ const edgesToForwardTo = Array.from(edgeChannels)
192
+ .filter(([name]) => !packet.hops.includes(name))
193
+ .map(([_, con]) => con.channel)
194
+
195
+ const adjustedPacket = {
196
+ ...packet,
197
+ hops: [...packet.hops, nodeName],
198
+ }
199
+
200
+ yield* Effect.forEach(edgesToForwardTo, (con) => con.send(adjustedPacket), { concurrency: 'unbounded' })
201
+
202
+ // Don't emit the packet to the own node listen queue
203
+ if (packet.source === nodeName) {
204
+ return
205
+ }
206
+
207
+ const queue = broadcastChannelListenQueueMap.get(packet.channelName)
208
+ // In case this node is listening to this channel, add the packet to the listen queue
209
+ if (queue !== undefined) {
210
+ yield* Queue.offer(queue, packet)
211
+ }
212
+
213
+ return
214
+ }
215
+
216
+ if (Schema.is(WebmeshSchema.NetworkTopologyRequest)(packet)) {
217
+ if (packet.source !== nodeName) {
218
+ const backEdgeName =
219
+ packet.hops.at(-1) ?? shouldNeverHappen(`${nodeName}: Expected hops for packet`, packet)
220
+ const backEdgeChannel = edgeChannels.get(backEdgeName)!.channel
221
+
222
+ // Respond with own edge info
223
+ const response = WebmeshSchema.NetworkTopologyResponse.make({
224
+ reqId: packet.id,
225
+ source: packet.source,
226
+ target: packet.target,
227
+ remainingHops: packet.hops.slice(0, -1),
228
+ nodeName,
229
+ edges: Array.from(edgeChannels.keys()),
230
+ })
231
+
232
+ yield* backEdgeChannel.send(response)
233
+ }
234
+
235
+ // Forward the packet to all edges except the already visited ones
236
+ const edgesToForwardTo = Array.from(edgeChannels)
237
+ .filter(([name]) => !packet.hops.includes(name))
238
+ .map(([_, con]) => con.channel)
239
+
240
+ const adjustedPacket = {
241
+ ...packet,
242
+ hops: [...packet.hops, nodeName],
243
+ }
244
+
245
+ yield* Effect.forEach(edgesToForwardTo, (con) => con.send(adjustedPacket), { concurrency: 'unbounded' })
246
+
145
247
  return
146
248
  }
147
249
 
148
- // We have a direct connection to the target node
149
- if (connectionChannels.has(packet.target)) {
150
- const connectionChannel = connectionChannels.get(packet.target)!.channel
250
+ if (Schema.is(WebmeshSchema.NetworkTopologyResponse)(packet)) {
251
+ if (packet.source === nodeName) {
252
+ const topologyRequestItem = topologyRequestsMap.get(packet.reqId)!
253
+ topologyRequestItem.set(packet.nodeName, new Set(packet.edges))
254
+ } else {
255
+ const remainingHops = packet.remainingHops
256
+ // Forwarding the response to the original sender via the route back
257
+ const routeBack =
258
+ remainingHops.at(-1) ?? shouldNeverHappen(`${nodeName}: Expected remaining hops for packet`, packet)
259
+ const edgeChannel =
260
+ edgeChannels.get(routeBack)?.channel ??
261
+ shouldNeverHappen(
262
+ `${nodeName}: Expected edge channel (${routeBack}) for packet`,
263
+ packet,
264
+ 'Available edges:',
265
+ Array.from(edgeChannels.keys()),
266
+ )
267
+
268
+ yield* edgeChannel.send({ ...packet, remainingHops: packet.remainingHops.slice(0, -1) })
269
+ }
270
+ return
271
+ }
272
+
273
+ // We have a direct edge to the target node
274
+ if (edgeChannels.has(packet.target)) {
275
+ const edgeChannel = edgeChannels.get(packet.target)!.channel
151
276
  const hops = packet.source === nodeName ? [] : [...packet.hops, nodeName]
152
- yield* connectionChannel.send({ ...packet, hops })
277
+ yield* edgeChannel.send({ ...packet, hops })
153
278
  }
154
279
  // In this case we have an expected route back we should follow
155
280
  // eslint-disable-next-line unicorn/no-negated-condition
156
281
  else if (packet.remainingHops !== undefined) {
157
282
  const hopTarget =
158
- packet.remainingHops[0] ?? shouldNeverHappen(`${nodeName}: Expected remaining hops for packet`, packet)
159
- const connectionChannel = connectionChannels.get(hopTarget)?.channel
283
+ packet.remainingHops.at(-1) ?? shouldNeverHappen(`${nodeName}: Expected remaining hops for packet`, packet)
284
+ const edgeChannel = edgeChannels.get(hopTarget)?.channel
160
285
 
161
- if (connectionChannel === undefined) {
286
+ if (edgeChannel === undefined) {
162
287
  yield* Effect.logWarning(
163
- `${nodeName}: Expected to find hop target ${hopTarget} in connections. Dropping packet.`,
288
+ `${nodeName}: Expected to find hop target ${hopTarget} in edges. Dropping packet.`,
164
289
  packet,
165
290
  )
166
291
  return
167
292
  }
168
293
 
169
- yield* connectionChannel.send({
294
+ yield* edgeChannel.send({
170
295
  ...packet,
171
- remainingHops: packet.remainingHops.slice(1),
296
+ remainingHops: packet.remainingHops.slice(0, -1),
172
297
  hops: [...packet.hops, nodeName],
173
298
  })
174
299
  }
175
- // No route found, forward to all connections
300
+ // No route found, forward to all edges
176
301
  else {
177
302
  const hops = packet.source === nodeName ? [] : [...packet.hops, nodeName]
178
303
 
179
- // Optimization: filter out connection where packet just came from
180
- const connectionsToForwardTo = Array.from(connectionChannels)
304
+ // Optimization: filter out edge where packet just came from
305
+ const edgesToForwardTo = Array.from(edgeChannels)
181
306
  .filter(([name]) => name !== packet.source)
182
307
  .map(([_, con]) => con.channel)
183
308
 
184
309
  // TODO if hops-depth=0, we should fail right away with no route found
185
- if (hops.length === 0 && connectionsToForwardTo.length === 0 && LS_DEV) {
310
+ if (hops.length === 0 && edgesToForwardTo.length === 0 && LS_DEV) {
186
311
  console.log(nodeName, 'no route found', packet._tag, 'TODO handle better')
187
312
  // TODO return a expected failure
188
313
  }
189
314
 
190
315
  const packetToSend = { ...packet, hops }
316
+ // console.debug(nodeName, 'sendPacket:forwarding', packetToSend)
191
317
 
192
- yield* Effect.forEach(connectionsToForwardTo, (con) => con.send(packetToSend), { concurrency: 'unbounded' })
318
+ yield* Effect.forEach(edgesToForwardTo, (con) => con.send(packetToSend), { concurrency: 'unbounded' })
193
319
  }
194
320
  }).pipe(
195
321
  Effect.withSpan(`sendPacket:${packet._tag}:${packet.source}→${packet.target}`, {
@@ -198,112 +324,117 @@ export const makeMeshNode = (nodeName: MeshNodeName): Effect.Effect<MeshNode, ne
198
324
  Effect.orDie,
199
325
  )
200
326
 
201
- const addConnection: MeshNode['addConnection'] = ({
202
- target: targetNodeName,
203
- connectionChannel,
204
- replaceIfExists = false,
205
- }) =>
327
+ const addEdge: MeshNode['addEdge'] = ({ target: targetNodeName, edgeChannel, replaceIfExists = false }) =>
206
328
  Effect.gen(function* () {
207
- if (connectionChannels.has(targetNodeName)) {
329
+ if (edgeChannels.has(targetNodeName)) {
208
330
  if (replaceIfExists) {
209
- yield* removeConnection(targetNodeName).pipe(Effect.orDie)
210
- // console.log('interrupting', targetNodeName)
211
- // yield* Fiber.interrupt(connectionChannels.get(targetNodeName)!.listenFiber)
331
+ yield* removeEdge(targetNodeName).pipe(Effect.orDie)
212
332
  } else {
213
- return yield* new ConnectionAlreadyExistsError({ target: targetNodeName })
333
+ return yield* new EdgeAlreadyExistsError({ target: targetNodeName })
214
334
  }
215
335
  }
216
336
 
217
- // TODO use a priority queue instead to prioritize network-changes/connection-requests over payloads
218
- const listenFiber = yield* connectionChannel.listen.pipe(
337
+ // TODO use a priority queue instead to prioritize network-changes/edge-requests over payloads
338
+ const listenFiber = yield* edgeChannel.listen.pipe(
219
339
  Stream.flatten(),
220
340
  Stream.tap((message) =>
221
341
  Effect.gen(function* () {
222
- const packet = yield* Schema.decodeUnknown(MeshSchema.Packet)(message)
342
+ const packet = yield* Schema.decodeUnknown(WebmeshSchema.Packet)(message)
223
343
 
224
344
  // console.debug(nodeName, 'received', packet._tag, packet.source, packet.target)
225
345
 
226
346
  if (handledPacketIds.has(packet.id)) return
227
347
  handledPacketIds.add(packet.id)
228
348
 
229
- if (packet._tag === 'NetworkConnectionAdded') {
230
- yield* sendPacket(packet)
231
- } else if (packet.target === nodeName) {
232
- const channelKey = `${packet.source}-${packet.channelName}` satisfies ChannelKey
349
+ switch (packet._tag) {
350
+ case 'NetworkEdgeAdded':
351
+ case 'NetworkTopologyRequest':
352
+ case 'NetworkTopologyResponse': {
353
+ yield* sendPacket(packet)
233
354
 
234
- if (!channelMap.has(channelKey)) {
235
- const queue = yield* Queue.unbounded<MessageQueueItem | ProxyQueueItem>().pipe(
236
- Effect.acquireRelease(Queue.shutdown),
237
- )
238
- channelMap.set(channelKey, { queue })
355
+ break
239
356
  }
240
-
241
- const queue = channelMap.get(channelKey)!.queue
242
-
243
- const respondToSender = (outgoingPacket: typeof MeshSchema.Packet.Type) =>
244
- connectionChannel
245
- .send(outgoingPacket)
246
- .pipe(
247
- Effect.withSpan(
248
- `respondToSender:${outgoingPacket._tag}:${outgoingPacket.source}→${outgoingPacket.target}`,
249
- { attributes: packetAsOtelAttributes(outgoingPacket) },
250
- ),
251
- Effect.orDie,
252
- )
253
-
254
- if (Schema.is(MeshSchema.ProxyChannelPacket)(packet)) {
255
- yield* Queue.offer(queue, { packet, respondToSender })
256
- } else if (Schema.is(MeshSchema.MessageChannelPacket)(packet)) {
257
- yield* Queue.offer(queue, { packet, respondToSender })
258
- }
259
- } else {
260
- if (Schema.is(MeshSchema.MessageChannelPacket)(packet)) {
261
- const noTransferableResponse = checkTransferableConnections(packet)
262
- if (noTransferableResponse !== undefined) {
263
- yield* Effect.spanEvent(`No transferable connections found for ${packet.source}→${packet.target}`)
264
- return yield* connectionChannel.send(noTransferableResponse).pipe(
265
- Effect.withSpan(`sendNoTransferableResponse:${packet.source}→${packet.target}`, {
266
- attributes: packetAsOtelAttributes(noTransferableResponse),
267
- }),
268
- )
357
+ default: {
358
+ if (packet.target === nodeName) {
359
+ const channelKey = `target:${packet.source}, channelName:${packet.channelName}` satisfies ChannelKey
360
+
361
+ if (!channelMap.has(channelKey)) {
362
+ const queue = yield* Queue.unbounded<MessageQueueItem | ProxyQueueItem>().pipe(
363
+ Effect.acquireRelease(Queue.shutdown),
364
+ )
365
+ channelMap.set(channelKey, { queue, debugInfo: undefined })
366
+ }
367
+
368
+ const queue = channelMap.get(channelKey)!.queue
369
+
370
+ const respondToSender = (outgoingPacket: typeof WebmeshSchema.Packet.Type) =>
371
+ edgeChannel
372
+ .send(outgoingPacket)
373
+ .pipe(
374
+ Effect.withSpan(
375
+ `respondToSender:${outgoingPacket._tag}:${outgoingPacket.source}→${outgoingPacket.target}`,
376
+ { attributes: packetAsOtelAttributes(outgoingPacket) },
377
+ ),
378
+ Effect.orDie,
379
+ )
380
+
381
+ if (Schema.is(WebmeshSchema.ProxyChannelPacket)(packet)) {
382
+ yield* Queue.offer(queue, { packet, respondToSender })
383
+ } else if (Schema.is(WebmeshSchema.MessageChannelPacket)(packet)) {
384
+ yield* Queue.offer(queue, { packet, respondToSender })
385
+ }
386
+ } else {
387
+ if (Schema.is(WebmeshSchema.MessageChannelPacket)(packet)) {
388
+ const noTransferableResponse = checkTransferableEdges(packet)
389
+ if (noTransferableResponse !== undefined) {
390
+ yield* Effect.spanEvent(`No transferable edges found for ${packet.source}→${packet.target}`)
391
+ return yield* edgeChannel.send(noTransferableResponse).pipe(
392
+ Effect.withSpan(`sendNoTransferableResponse:${packet.source}→${packet.target}`, {
393
+ attributes: packetAsOtelAttributes(noTransferableResponse),
394
+ }),
395
+ )
396
+ }
397
+ }
398
+
399
+ yield* sendPacket(packet)
269
400
  }
270
401
  }
271
-
272
- yield* sendPacket(packet)
273
402
  }
274
403
  }),
275
404
  ),
276
405
  Stream.runDrain,
406
+ Effect.interruptible,
277
407
  Effect.orDie,
278
408
  Effect.tapCauseLogPretty,
279
409
  Effect.forkScoped,
280
410
  )
281
411
 
282
- connectionChannels.set(targetNodeName, { channel: connectionChannel, listenFiber })
412
+ edgeChannels.set(targetNodeName, { channel: edgeChannel, listenFiber })
283
413
 
284
- const connectionAddedPacket = MeshSchema.NetworkConnectionAdded.make({
414
+ const edgeAddedPacket = WebmeshSchema.NetworkEdgeAdded.make({
285
415
  source: nodeName,
286
416
  target: targetNodeName,
287
417
  })
288
- yield* sendPacket(connectionAddedPacket).pipe(Effect.ignoreLogged)
418
+ yield* sendPacket(edgeAddedPacket).pipe(Effect.orDie)
289
419
  }).pipe(
290
- Effect.withSpan(`addConnection:${nodeName}→${targetNodeName}`, {
291
- attributes: { supportsTransferables: connectionChannel.supportsTransferables },
420
+ Effect.annotateLogs({ 'addEdge:target': targetNodeName }),
421
+ Effect.withSpan(`addEdge:${nodeName}→${targetNodeName}`, {
422
+ attributes: { supportsTransferables: edgeChannel.supportsTransferables },
292
423
  }),
293
424
  ) as any // any-cast needed for error/never overload
294
425
 
295
- const removeConnection: MeshNode['removeConnection'] = (targetNodeName) =>
426
+ const removeEdge: MeshNode['removeEdge'] = (targetNodeName) =>
296
427
  Effect.gen(function* () {
297
- if (!connectionChannels.has(targetNodeName)) {
298
- yield* new Cause.NoSuchElementException(`No connection found for ${targetNodeName}`)
428
+ if (!edgeChannels.has(targetNodeName)) {
429
+ yield* new Cause.NoSuchElementException(`No edge found for ${targetNodeName}`)
299
430
  }
300
431
 
301
- yield* Fiber.interrupt(connectionChannels.get(targetNodeName)!.listenFiber)
432
+ yield* Fiber.interrupt(edgeChannels.get(targetNodeName)!.listenFiber)
302
433
 
303
- connectionChannels.delete(targetNodeName)
434
+ edgeChannels.delete(targetNodeName)
304
435
  })
305
436
 
306
- // TODO add heartbeat to detect dead connections (for both e2e and proxying)
437
+ // TODO add heartbeat to detect dead edges (for both e2e and proxying)
307
438
  // TODO when a channel is established in the same origin, we can use a weblock to detect disconnects
308
439
  const makeChannel: MeshNode['makeChannel'] = ({
309
440
  target,
@@ -315,13 +446,18 @@ export const makeMeshNode = (nodeName: MeshNodeName): Effect.Effect<MeshNode, ne
315
446
  }) =>
316
447
  Effect.gen(function* () {
317
448
  const schema = WebChannel.mapSchema(inputSchema)
318
- const channelKey = `${target}-${channelName}` satisfies ChannelKey
449
+ const channelKey = `target:${target}, channelName:${channelName}` satisfies ChannelKey
319
450
 
320
- if (!channelMap.has(channelKey)) {
451
+ if (channelMap.has(channelKey)) {
452
+ const existingChannel = channelMap.get(channelKey)!.debugInfo?.channel
453
+ if (existingChannel) {
454
+ shouldNeverHappen(`Channel ${channelKey} already exists`, existingChannel)
455
+ }
456
+ } else {
321
457
  const queue = yield* Queue.unbounded<MessageQueueItem | ProxyQueueItem>().pipe(
322
458
  Effect.acquireRelease(Queue.shutdown),
323
459
  )
324
- channelMap.set(channelKey, { queue })
460
+ channelMap.set(channelKey, { queue, debugInfo: undefined })
325
461
  }
326
462
 
327
463
  const queue = channelMap.get(channelKey)!.queue as Queue.Queue<any>
@@ -329,47 +465,194 @@ export const makeMeshNode = (nodeName: MeshNodeName): Effect.Effect<MeshNode, ne
329
465
  yield* Effect.addFinalizer(() => Effect.sync(() => channelMap.delete(channelKey)))
330
466
 
331
467
  if (mode === 'messagechannel') {
332
- // console.debug(nodeName, 'message mode', modeRef.current)
468
+ const incomingPacketsQueue = yield* Queue.unbounded<any>().pipe(Effect.acquireRelease(Queue.shutdown))
469
+
470
+ // We're we're draining the queue into another new queue.
471
+ // It's a bit of a mystery why this is needed, since the unit tests also work without it.
472
+ // But for the LiveStore devtools to actually work, we need to do this.
473
+ // We should figure out some day why this is needed and further simplify if possible.
474
+ yield* Queue.takeBetween(queue, 1, 10).pipe(
475
+ Effect.tap((_) => Queue.offerAll(incomingPacketsQueue, _)),
476
+ Effect.forever,
477
+ Effect.interruptible,
478
+ Effect.tapCauseLogPretty,
479
+ Effect.forkScoped,
480
+ )
333
481
 
334
482
  // NOTE already retries internally when transferables are required
335
- return yield* makeMessageChannel({
483
+ const { webChannel, initialEdgeDeferred } = yield* makeMessageChannel({
336
484
  nodeName,
337
- queue,
338
- newConnectionAvailablePubSub,
485
+ incomingPacketsQueue,
486
+ newEdgeAvailablePubSub,
339
487
  target,
340
488
  channelName,
341
489
  schema,
342
490
  sendPacket,
343
- checkTransferableConnections,
491
+ checkTransferableEdges,
344
492
  })
493
+
494
+ channelMap.set(channelKey, { queue, debugInfo: { channel: webChannel, target } })
495
+
496
+ yield* initialEdgeDeferred
497
+
498
+ return webChannel
345
499
  } else {
346
- return yield* makeProxyChannel({
500
+ const channel = yield* makeProxyChannel({
347
501
  nodeName,
348
- newConnectionAvailablePubSub,
502
+ newEdgeAvailablePubSub,
349
503
  target,
350
504
  channelName,
351
505
  schema,
352
506
  queue,
353
507
  sendPacket,
354
508
  })
509
+
510
+ channelMap.set(channelKey, { queue, debugInfo: { channel, target } })
511
+
512
+ return channel
355
513
  }
356
514
  }).pipe(
515
+ // Effect.timeout(timeout),
357
516
  Effect.withSpanScoped(`makeChannel:${nodeName}→${target}(${channelName})`, {
358
517
  attributes: { target, channelName, mode, timeout },
359
518
  }),
360
519
  Effect.annotateLogs({ nodeName }),
361
520
  )
362
521
 
363
- const connectionKeys: MeshNode['connectionKeys'] = Effect.sync(() => new Set(connectionChannels.keys()))
522
+ const makeBroadcastChannel: MeshNode['makeBroadcastChannel'] = ({ channelName, schema }) =>
523
+ Effect.scopeWithCloseable((scope) =>
524
+ Effect.gen(function* () {
525
+ if (broadcastChannelListenQueueMap.has(channelName)) {
526
+ return shouldNeverHappen(
527
+ `Broadcast channel ${channelName} already exists`,
528
+ broadcastChannelListenQueueMap.get(channelName),
529
+ )
530
+ }
531
+
532
+ const debugInfo = {}
533
+
534
+ const queue = yield* Queue.unbounded<any>().pipe(Effect.acquireRelease(Queue.shutdown))
535
+ broadcastChannelListenQueueMap.set(channelName, queue)
536
+
537
+ const send = (message: any) =>
538
+ Effect.gen(function* () {
539
+ const payload = yield* Schema.encode(schema)(message)
540
+ const packet = WebmeshSchema.BroadcastChannelPacket.make({
541
+ channelName,
542
+ payload,
543
+ source: nodeName,
544
+ target: '-',
545
+ hops: [],
546
+ })
547
+
548
+ yield* sendPacket(packet)
549
+ })
550
+
551
+ const listen = Stream.fromQueue(queue).pipe(
552
+ Stream.filter(Schema.is(WebmeshSchema.BroadcastChannelPacket)),
553
+ Stream.map((_) => Schema.decodeEither(schema)(_.payload)),
554
+ )
555
+
556
+ const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
557
+
558
+ return {
559
+ [WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
560
+ send,
561
+ listen,
562
+ closedDeferred,
563
+ supportsTransferables: true,
564
+ schema: { listen: schema, send: schema },
565
+ shutdown: Scope.close(scope, Exit.void),
566
+ debugInfo,
567
+ } satisfies WebChannel.WebChannel<any, any>
568
+ }),
569
+ )
570
+
571
+ const edgeKeys: MeshNode['edgeKeys'] = Effect.sync(() => new Set(edgeChannels.keys()))
572
+
573
+ const runtime = yield* Effect.runtime()
364
574
 
365
575
  const debug: MeshNode['debug'] = {
366
- printChannelQueues: () => {
576
+ print: () => {
577
+ console.log('Webmesh debug info for node:', nodeName)
578
+
579
+ console.log('Edges:', edgeChannels.size)
580
+ for (const [key, value] of edgeChannels) {
581
+ console.log(` ${key}: supportsTransferables=${value.channel.supportsTransferables}`)
582
+ }
583
+
367
584
  console.log('Channels:', channelMap.size)
368
585
  for (const [key, value] of channelMap) {
369
- console.log(`${key}: ${value.queue.unsafeSize().pipe(Option.getOrUndefined)}`, value.queue)
586
+ console.log(
587
+ indent(key, 2),
588
+ '\n',
589
+ Object.entries({
590
+ target: value.debugInfo?.target,
591
+ supportsTransferables: value.debugInfo?.channel.supportsTransferables,
592
+ ...value.debugInfo?.channel.debugInfo,
593
+ })
594
+ .map(([key, value]) => indent(`${key}=${value}`, 4))
595
+ .join('\n'),
596
+ ' ',
597
+ value.debugInfo?.channel,
598
+ '\n',
599
+ indent(`Queue: ${value.queue.unsafeSize().pipe(Option.getOrUndefined)}`, 4),
600
+ value.queue,
601
+ )
602
+ }
603
+
604
+ console.log('Broadcast channels:', broadcastChannelListenQueueMap.size)
605
+ for (const [key, _value] of broadcastChannelListenQueueMap) {
606
+ console.log(indent(key, 2))
370
607
  }
371
608
  },
609
+ ping: (payload) => {
610
+ Effect.gen(function* () {
611
+ const msg = (via: string) =>
612
+ WebChannel.DebugPingMessage.make({ message: `ping from ${nodeName} via edge ${via}`, payload })
613
+
614
+ for (const [channelName, con] of edgeChannels) {
615
+ yield* Effect.logDebug(`sending ping via edge ${channelName}`)
616
+ yield* con.channel.send(msg(`edge ${channelName}`) as any)
617
+ }
618
+
619
+ for (const [channelKey, channel] of channelMap) {
620
+ if (channel.debugInfo === undefined) continue
621
+ yield* Effect.logDebug(`sending ping via channel ${channelKey}`)
622
+ yield* channel.debugInfo.channel.send(msg(`channel ${channelKey}`) as any)
623
+ }
624
+ }).pipe(Effect.provide(runtime), Effect.tapCauseLogPretty, Effect.runFork)
625
+ },
626
+ requestTopology: (timeoutMs = 1000) =>
627
+ Effect.gen(function* () {
628
+ const packet = WebmeshSchema.NetworkTopologyRequest.make({
629
+ source: nodeName,
630
+ target: '-',
631
+ hops: [],
632
+ })
633
+
634
+ const item = new Map<MeshNodeName, Set<MeshNodeName>>()
635
+ item.set(nodeName, new Set(edgeChannels.keys()))
636
+ topologyRequestsMap.set(packet.id, item)
637
+
638
+ yield* sendPacket(packet)
639
+
640
+ yield* Effect.logDebug(`Waiting ${timeoutMs}ms for topology response`)
641
+ yield* Effect.sleep(timeoutMs)
642
+
643
+ for (const [key, value] of item) {
644
+ yield* Effect.logDebug(`node '${key}' is connected to: ${Array.from(value.values()).join(', ')}`)
645
+ }
646
+ }).pipe(Effect.provide(runtime), Effect.tapCauseLogPretty, Effect.runPromise),
372
647
  }
373
648
 
374
- return { nodeName, addConnection, removeConnection, makeChannel, connectionKeys, debug } satisfies MeshNode
375
- }).pipe(Effect.withSpan(`makeMeshNode:${nodeName}`))
649
+ return {
650
+ nodeName,
651
+ addEdge,
652
+ removeEdge,
653
+ makeChannel,
654
+ makeBroadcastChannel,
655
+ edgeKeys,
656
+ debug,
657
+ } satisfies MeshNode
658
+ }).pipe(Effect.withSpan(`makeMeshNode:${nodeName}`), Effect.annotateLogs({ 'makeMeshNode.nodeName': nodeName }))