@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.
- package/README.md +26 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/channel/message-channel-internal.d.ts +26 -0
- package/dist/channel/message-channel-internal.d.ts.map +1 -0
- package/dist/channel/message-channel-internal.js +217 -0
- package/dist/channel/message-channel-internal.js.map +1 -0
- package/dist/channel/message-channel.d.ts +21 -19
- package/dist/channel/message-channel.d.ts.map +1 -1
- package/dist/channel/message-channel.js +132 -162
- package/dist/channel/message-channel.js.map +1 -1
- package/dist/channel/proxy-channel.d.ts +3 -3
- package/dist/channel/proxy-channel.d.ts.map +1 -1
- package/dist/channel/proxy-channel.js +38 -19
- package/dist/channel/proxy-channel.js.map +1 -1
- package/dist/common.d.ts +36 -14
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +7 -4
- package/dist/common.js.map +1 -1
- package/dist/mesh-schema.d.ts +71 -5
- package/dist/mesh-schema.d.ts.map +1 -1
- package/dist/mesh-schema.js +55 -6
- package/dist/mesh-schema.js.map +1 -1
- package/dist/mod.d.ts +2 -2
- package/dist/mod.d.ts.map +1 -1
- package/dist/mod.js +2 -2
- package/dist/mod.js.map +1 -1
- package/dist/node.d.ts +43 -21
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +273 -100
- package/dist/node.js.map +1 -1
- package/dist/node.test.d.ts +1 -1
- package/dist/node.test.d.ts.map +1 -1
- package/dist/node.test.js +391 -156
- package/dist/node.test.js.map +1 -1
- package/dist/utils.d.ts +4 -4
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +7 -1
- package/dist/utils.js.map +1 -1
- package/dist/websocket-edge.d.ts +52 -0
- package/dist/websocket-edge.d.ts.map +1 -0
- package/dist/websocket-edge.js +85 -0
- package/dist/websocket-edge.js.map +1 -0
- package/package.json +5 -6
- package/src/channel/message-channel-internal.ts +356 -0
- package/src/channel/message-channel.ts +190 -310
- package/src/channel/proxy-channel.ts +259 -231
- package/src/common.ts +12 -13
- package/src/mesh-schema.ts +62 -6
- package/src/mod.ts +2 -2
- package/src/node.test.ts +554 -189
- package/src/node.ts +421 -138
- package/src/utils.ts +13 -2
- package/src/websocket-edge.ts +177 -0
- package/tmp/pack.tgz +0 -0
- package/dist/websocket-connection.d.ts +0 -51
- package/dist/websocket-connection.d.ts.map +0 -1
- package/dist/websocket-connection.js +0 -74
- package/dist/websocket-connection.js.map +0 -1
- package/dist/websocket-server.d.ts +0 -7
- package/dist/websocket-server.d.ts.map +0 -1
- package/dist/websocket-server.js +0 -24
- package/dist/websocket-server.js.map +0 -1
- package/src/websocket-connection.ts +0 -158
- 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 {
|
|
20
|
-
import * as
|
|
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
|
|
25
|
+
type EdgeChannel = WebChannel.WebChannel<typeof WebmeshSchema.Packet.Type, typeof WebmeshSchema.Packet.Type>
|
|
24
26
|
|
|
25
|
-
export interface MeshNode {
|
|
26
|
-
nodeName:
|
|
27
|
+
export interface MeshNode<TName extends MeshNodeName = MeshNodeName> {
|
|
28
|
+
nodeName: TName
|
|
27
29
|
|
|
28
|
-
|
|
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
|
|
43
|
+
* Manually adds a edge to get connected to the network of nodes with an existing WebChannel.
|
|
36
44
|
*
|
|
37
|
-
* Assumptions about the WebChannel
|
|
38
|
-
* - 1:1
|
|
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
|
-
|
|
51
|
+
addEdge: {
|
|
44
52
|
(options: {
|
|
45
53
|
target: MeshNodeName
|
|
46
|
-
|
|
54
|
+
edgeChannel: EdgeChannel
|
|
47
55
|
replaceIfExists: true
|
|
48
56
|
}): Effect.Effect<void, never, Scope.Scope>
|
|
49
57
|
(options: {
|
|
50
58
|
target: MeshNodeName
|
|
51
|
-
|
|
59
|
+
edgeChannel: EdgeChannel
|
|
52
60
|
replaceIfExists?: boolean
|
|
53
|
-
}): Effect.Effect<void,
|
|
61
|
+
}): Effect.Effect<void, EdgeAlreadyExistsError, Scope.Scope>
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
|
|
64
|
+
removeEdge: (targetNodeName: MeshNodeName) => Effect.Effect<void, Cause.NoSuchElementException>
|
|
57
65
|
|
|
58
66
|
/**
|
|
59
|
-
* Tries to broker a MessageChannel
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
122
|
+
const handledPacketIds = yield* TimeoutSet.make(Duration.minutes(1))
|
|
98
123
|
|
|
99
|
-
const
|
|
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<
|
|
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
|
|
151
|
+
const checkTransferableEdges = (packet: typeof WebmeshSchema.MessageChannelPacket.Type) => {
|
|
111
152
|
if (
|
|
112
153
|
(packet._tag === 'MessageChannelRequest' &&
|
|
113
|
-
(
|
|
114
|
-
// Either if direct
|
|
115
|
-
|
|
116
|
-
// ... or if no forward-
|
|
117
|
-
![...
|
|
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
|
|
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
|
|
174
|
+
const sendPacket = (packet: typeof WebmeshSchema.Packet.Type) =>
|
|
135
175
|
Effect.gen(function* () {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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*
|
|
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
|
|
159
|
-
const
|
|
283
|
+
packet.remainingHops.at(-1) ?? shouldNeverHappen(`${nodeName}: Expected remaining hops for packet`, packet)
|
|
284
|
+
const edgeChannel = edgeChannels.get(hopTarget)?.channel
|
|
160
285
|
|
|
161
|
-
if (
|
|
286
|
+
if (edgeChannel === undefined) {
|
|
162
287
|
yield* Effect.logWarning(
|
|
163
|
-
`${nodeName}: Expected to find hop target ${hopTarget} in
|
|
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*
|
|
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
|
|
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
|
|
180
|
-
const
|
|
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 &&
|
|
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(
|
|
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
|
|
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 (
|
|
329
|
+
if (edgeChannels.has(targetNodeName)) {
|
|
208
330
|
if (replaceIfExists) {
|
|
209
|
-
yield*
|
|
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
|
|
333
|
+
return yield* new EdgeAlreadyExistsError({ target: targetNodeName })
|
|
214
334
|
}
|
|
215
335
|
}
|
|
216
336
|
|
|
217
|
-
// TODO use a priority queue instead to prioritize network-changes/
|
|
218
|
-
const listenFiber = yield*
|
|
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(
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
349
|
+
switch (packet._tag) {
|
|
350
|
+
case 'NetworkEdgeAdded':
|
|
351
|
+
case 'NetworkTopologyRequest':
|
|
352
|
+
case 'NetworkTopologyResponse': {
|
|
353
|
+
yield* sendPacket(packet)
|
|
233
354
|
|
|
234
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
412
|
+
edgeChannels.set(targetNodeName, { channel: edgeChannel, listenFiber })
|
|
283
413
|
|
|
284
|
-
const
|
|
414
|
+
const edgeAddedPacket = WebmeshSchema.NetworkEdgeAdded.make({
|
|
285
415
|
source: nodeName,
|
|
286
416
|
target: targetNodeName,
|
|
287
417
|
})
|
|
288
|
-
yield* sendPacket(
|
|
418
|
+
yield* sendPacket(edgeAddedPacket).pipe(Effect.orDie)
|
|
289
419
|
}).pipe(
|
|
290
|
-
Effect.
|
|
291
|
-
|
|
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
|
|
426
|
+
const removeEdge: MeshNode['removeEdge'] = (targetNodeName) =>
|
|
296
427
|
Effect.gen(function* () {
|
|
297
|
-
if (!
|
|
298
|
-
yield* new Cause.NoSuchElementException(`No
|
|
428
|
+
if (!edgeChannels.has(targetNodeName)) {
|
|
429
|
+
yield* new Cause.NoSuchElementException(`No edge found for ${targetNodeName}`)
|
|
299
430
|
}
|
|
300
431
|
|
|
301
|
-
yield* Fiber.interrupt(
|
|
432
|
+
yield* Fiber.interrupt(edgeChannels.get(targetNodeName)!.listenFiber)
|
|
302
433
|
|
|
303
|
-
|
|
434
|
+
edgeChannels.delete(targetNodeName)
|
|
304
435
|
})
|
|
305
436
|
|
|
306
|
-
// TODO add heartbeat to detect dead
|
|
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 =
|
|
449
|
+
const channelKey = `target:${target}, channelName:${channelName}` satisfies ChannelKey
|
|
319
450
|
|
|
320
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
483
|
+
const { webChannel, initialEdgeDeferred } = yield* makeMessageChannel({
|
|
336
484
|
nodeName,
|
|
337
|
-
|
|
338
|
-
|
|
485
|
+
incomingPacketsQueue,
|
|
486
|
+
newEdgeAvailablePubSub,
|
|
339
487
|
target,
|
|
340
488
|
channelName,
|
|
341
489
|
schema,
|
|
342
490
|
sendPacket,
|
|
343
|
-
|
|
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
|
-
|
|
500
|
+
const channel = yield* makeProxyChannel({
|
|
347
501
|
nodeName,
|
|
348
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
375
|
-
|
|
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 }))
|