@livestore/webmesh 0.3.0-dev.9 → 0.3.1-dev.0

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