@livestore/webmesh 0.3.0-dev.4 → 0.3.0-dev.41

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