@livestore/webmesh 0.0.0-snapshot-1d99fea7d2ce2c7a5d9ed0a3752f8a7bda6bc3db
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/channel/message-channel.d.ts +20 -0
- package/dist/channel/message-channel.d.ts.map +1 -0
- package/dist/channel/message-channel.js +183 -0
- package/dist/channel/message-channel.js.map +1 -0
- package/dist/channel/proxy-channel.d.ts +19 -0
- package/dist/channel/proxy-channel.d.ts.map +1 -0
- package/dist/channel/proxy-channel.js +179 -0
- package/dist/channel/proxy-channel.js.map +1 -0
- package/dist/common.d.ts +83 -0
- package/dist/common.d.ts.map +1 -0
- package/dist/common.js +13 -0
- package/dist/common.js.map +1 -0
- package/dist/mesh-schema.d.ts +104 -0
- package/dist/mesh-schema.d.ts.map +1 -0
- package/dist/mesh-schema.js +77 -0
- package/dist/mesh-schema.js.map +1 -0
- package/dist/mod.d.ts +5 -0
- package/dist/mod.d.ts.map +1 -0
- package/dist/mod.js +5 -0
- package/dist/mod.js.map +1 -0
- package/dist/node.d.ts +65 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +216 -0
- package/dist/node.js.map +1 -0
- package/dist/node.test.d.ts +2 -0
- package/dist/node.test.d.ts.map +1 -0
- package/dist/node.test.js +351 -0
- package/dist/node.test.js.map +1 -0
- package/dist/utils.d.ts +19 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +41 -0
- package/dist/utils.js.map +1 -0
- package/dist/websocket-connection.d.ts +51 -0
- package/dist/websocket-connection.d.ts.map +1 -0
- package/dist/websocket-connection.js +74 -0
- package/dist/websocket-connection.js.map +1 -0
- package/dist/websocket-server.d.ts +7 -0
- package/dist/websocket-server.d.ts.map +1 -0
- package/dist/websocket-server.js +24 -0
- package/dist/websocket-server.js.map +1 -0
- package/package.json +32 -0
- package/src/channel/message-channel.ts +354 -0
- package/src/channel/proxy-channel.ts +332 -0
- package/src/common.ts +36 -0
- package/src/mesh-schema.ts +94 -0
- package/src/mod.ts +4 -0
- package/src/node.test.ts +533 -0
- package/src/node.ts +408 -0
- package/src/utils.ts +47 -0
- package/src/websocket-connection.ts +158 -0
- package/src/websocket-server.ts +40 -0
- package/tsconfig.json +11 -0
package/src/node.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { LS_DEV, shouldNeverHappen } from '@livestore/utils'
|
|
2
|
+
import type { Scope } from '@livestore/utils/effect'
|
|
3
|
+
import {
|
|
4
|
+
Cause,
|
|
5
|
+
Duration,
|
|
6
|
+
Effect,
|
|
7
|
+
Fiber,
|
|
8
|
+
Option,
|
|
9
|
+
PubSub,
|
|
10
|
+
Queue,
|
|
11
|
+
Schema,
|
|
12
|
+
Stream,
|
|
13
|
+
WebChannel,
|
|
14
|
+
} from '@livestore/utils/effect'
|
|
15
|
+
|
|
16
|
+
import { makeMessageChannel } from './channel/message-channel.js'
|
|
17
|
+
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'
|
|
21
|
+
import { TimeoutSet } from './utils.js'
|
|
22
|
+
|
|
23
|
+
type ConnectionChannel = WebChannel.WebChannel<typeof MeshSchema.Packet.Type, typeof MeshSchema.Packet.Type>
|
|
24
|
+
|
|
25
|
+
export interface MeshNode {
|
|
26
|
+
nodeName: MeshNodeName
|
|
27
|
+
|
|
28
|
+
connectionKeys: Effect.Effect<Set<MeshNodeName>>
|
|
29
|
+
|
|
30
|
+
debug: {
|
|
31
|
+
print: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Manually adds a connection to get connected to the network of nodes with an existing WebChannel.
|
|
36
|
+
*
|
|
37
|
+
* Assumptions about the WebChannel connection:
|
|
38
|
+
* - 1:1 connection
|
|
39
|
+
* - Queues messages internally to never drop messages
|
|
40
|
+
* - Automatically reconnects
|
|
41
|
+
* - Ideally supports transferables
|
|
42
|
+
*/
|
|
43
|
+
addConnection: {
|
|
44
|
+
(options: {
|
|
45
|
+
target: MeshNodeName
|
|
46
|
+
connectionChannel: ConnectionChannel
|
|
47
|
+
replaceIfExists: true
|
|
48
|
+
}): Effect.Effect<void, never, Scope.Scope>
|
|
49
|
+
(options: {
|
|
50
|
+
target: MeshNodeName
|
|
51
|
+
connectionChannel: ConnectionChannel
|
|
52
|
+
replaceIfExists?: boolean
|
|
53
|
+
}): Effect.Effect<void, ConnectionAlreadyExistsError, Scope.Scope>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
removeConnection: (targetNodeName: MeshNodeName) => Effect.Effect<void, Cause.NoSuchElementException>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tries to broker a MessageChannel connection between the nodes, otherwise will proxy messages via hop-nodes
|
|
60
|
+
*
|
|
61
|
+
* For a channel to successfully open, both sides need to have a connection and call `makeChannel`
|
|
62
|
+
*/
|
|
63
|
+
makeChannel: <MsgListen, MsgSend>(args: {
|
|
64
|
+
target: MeshNodeName
|
|
65
|
+
/**
|
|
66
|
+
* A name for the channel (same from both sides).
|
|
67
|
+
* Needs to be unique in the context of the 2 connected nodes.
|
|
68
|
+
*/
|
|
69
|
+
channelName: string
|
|
70
|
+
schema:
|
|
71
|
+
| Schema.Schema<MsgListen | MsgSend, any>
|
|
72
|
+
| {
|
|
73
|
+
listen: Schema.Schema<MsgListen, any>
|
|
74
|
+
send: Schema.Schema<MsgSend, any>
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* If possible, prefer using a MessageChannel with transferables (i.e. transferring memory instead of copying it).
|
|
78
|
+
*/
|
|
79
|
+
mode: 'messagechannel' | 'proxy'
|
|
80
|
+
/**
|
|
81
|
+
* Amount of time before we consider a channel creation failed and retry when a new connection is available
|
|
82
|
+
*
|
|
83
|
+
* @default 1 second
|
|
84
|
+
*/
|
|
85
|
+
timeout?: Duration.DurationInput
|
|
86
|
+
}) => Effect.Effect<WebChannel.WebChannel<MsgListen, MsgSend>, never, Scope.Scope>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const makeMeshNode = (nodeName: MeshNodeName): Effect.Effect<MeshNode, never, Scope.Scope> =>
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
const connectionChannels = new Map<
|
|
92
|
+
MeshNodeName,
|
|
93
|
+
{ channel: ConnectionChannel; listenFiber: Fiber.RuntimeFiber<void> }
|
|
94
|
+
>()
|
|
95
|
+
|
|
96
|
+
// To avoid unbounded memory growth, we automatically forget about packet ids after a while
|
|
97
|
+
const handledPacketIds = new TimeoutSet<string>({ timeout: Duration.minutes(1) })
|
|
98
|
+
|
|
99
|
+
const newConnectionAvailablePubSub = yield* PubSub.unbounded<MeshNodeName>().pipe(
|
|
100
|
+
Effect.acquireRelease(PubSub.shutdown),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
// const proxyPacketsToProcess = yield* Queue.unbounded<ProxyQueueItem>().pipe(Effect.acquireRelease(Queue.shutdown))
|
|
104
|
+
// const messagePacketsToProcess = yield* Queue.unbounded<MessageQueueItem>().pipe(
|
|
105
|
+
// Effect.acquireRelease(Queue.shutdown),
|
|
106
|
+
// )
|
|
107
|
+
|
|
108
|
+
const channelMap = new Map<
|
|
109
|
+
ChannelKey,
|
|
110
|
+
{
|
|
111
|
+
queue: Queue.Queue<MessageQueueItem | ProxyQueueItem>
|
|
112
|
+
/** This reference is only kept for debugging purposes */
|
|
113
|
+
debugInfo:
|
|
114
|
+
| {
|
|
115
|
+
channel: WebChannel.WebChannel<any, any>
|
|
116
|
+
target: MeshNodeName
|
|
117
|
+
}
|
|
118
|
+
| undefined
|
|
119
|
+
}
|
|
120
|
+
>()
|
|
121
|
+
|
|
122
|
+
const checkTransferableConnections = (packet: typeof MeshSchema.MessageChannelPacket.Type) => {
|
|
123
|
+
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)
|
|
130
|
+
) {
|
|
131
|
+
return MeshSchema.MessageChannelResponseNoTransferables.make({
|
|
132
|
+
reqId: packet.id,
|
|
133
|
+
channelName: packet.channelName,
|
|
134
|
+
// NOTE for now we're "pretending" that the message is coming from the target node
|
|
135
|
+
// even though we're already handling it here.
|
|
136
|
+
// TODO we should clean this up at some point
|
|
137
|
+
source: packet.target,
|
|
138
|
+
// source: nodeName,
|
|
139
|
+
target: packet.source,
|
|
140
|
+
remainingHops: packet.hops,
|
|
141
|
+
hops: [],
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sendPacket = (packet: typeof MeshSchema.Packet.Type) =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
if (Schema.is(MeshSchema.NetworkConnectionAdded)(packet)) {
|
|
149
|
+
yield* Effect.spanEvent('NetworkConnectionAdded', { packet, nodeName })
|
|
150
|
+
yield* PubSub.publish(newConnectionAvailablePubSub, packet.target)
|
|
151
|
+
|
|
152
|
+
const connectionsToForwardTo = Array.from(connectionChannels)
|
|
153
|
+
.filter(([name]) => name !== packet.source)
|
|
154
|
+
.map(([_, con]) => con.channel)
|
|
155
|
+
|
|
156
|
+
yield* Effect.forEach(connectionsToForwardTo, (con) => con.send(packet), { concurrency: 'unbounded' })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// We have a direct connection to the target node
|
|
161
|
+
if (connectionChannels.has(packet.target)) {
|
|
162
|
+
const connectionChannel = connectionChannels.get(packet.target)!.channel
|
|
163
|
+
const hops = packet.source === nodeName ? [] : [...packet.hops, nodeName]
|
|
164
|
+
yield* connectionChannel.send({ ...packet, hops })
|
|
165
|
+
}
|
|
166
|
+
// In this case we have an expected route back we should follow
|
|
167
|
+
// eslint-disable-next-line unicorn/no-negated-condition
|
|
168
|
+
else if (packet.remainingHops !== undefined) {
|
|
169
|
+
const hopTarget =
|
|
170
|
+
packet.remainingHops[0] ?? shouldNeverHappen(`${nodeName}: Expected remaining hops for packet`, packet)
|
|
171
|
+
const connectionChannel = connectionChannels.get(hopTarget)?.channel
|
|
172
|
+
|
|
173
|
+
if (connectionChannel === undefined) {
|
|
174
|
+
yield* Effect.logWarning(
|
|
175
|
+
`${nodeName}: Expected to find hop target ${hopTarget} in connections. Dropping packet.`,
|
|
176
|
+
packet,
|
|
177
|
+
)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
yield* connectionChannel.send({
|
|
182
|
+
...packet,
|
|
183
|
+
remainingHops: packet.remainingHops.slice(1),
|
|
184
|
+
hops: [...packet.hops, nodeName],
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
// No route found, forward to all connections
|
|
188
|
+
else {
|
|
189
|
+
const hops = packet.source === nodeName ? [] : [...packet.hops, nodeName]
|
|
190
|
+
|
|
191
|
+
// Optimization: filter out connection where packet just came from
|
|
192
|
+
const connectionsToForwardTo = Array.from(connectionChannels)
|
|
193
|
+
.filter(([name]) => name !== packet.source)
|
|
194
|
+
.map(([_, con]) => con.channel)
|
|
195
|
+
|
|
196
|
+
// 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')
|
|
199
|
+
// TODO return a expected failure
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const packetToSend = { ...packet, hops }
|
|
203
|
+
// console.debug(nodeName, 'sendPacket:forwarding', packetToSend)
|
|
204
|
+
|
|
205
|
+
yield* Effect.forEach(connectionsToForwardTo, (con) => con.send(packetToSend), { concurrency: 'unbounded' })
|
|
206
|
+
}
|
|
207
|
+
}).pipe(
|
|
208
|
+
Effect.withSpan(`sendPacket:${packet._tag}:${packet.source}→${packet.target}`, {
|
|
209
|
+
attributes: packetAsOtelAttributes(packet),
|
|
210
|
+
}),
|
|
211
|
+
Effect.orDie,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const addConnection: MeshNode['addConnection'] = ({
|
|
215
|
+
target: targetNodeName,
|
|
216
|
+
connectionChannel,
|
|
217
|
+
replaceIfExists = false,
|
|
218
|
+
}) =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
if (connectionChannels.has(targetNodeName)) {
|
|
221
|
+
if (replaceIfExists) {
|
|
222
|
+
yield* removeConnection(targetNodeName).pipe(Effect.orDie)
|
|
223
|
+
// console.log('interrupting', targetNodeName)
|
|
224
|
+
// yield* Fiber.interrupt(connectionChannels.get(targetNodeName)!.listenFiber)
|
|
225
|
+
} else {
|
|
226
|
+
return yield* new ConnectionAlreadyExistsError({ target: targetNodeName })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// TODO use a priority queue instead to prioritize network-changes/connection-requests over payloads
|
|
231
|
+
const listenFiber = yield* connectionChannel.listen.pipe(
|
|
232
|
+
Stream.flatten(),
|
|
233
|
+
Stream.tap((message) =>
|
|
234
|
+
Effect.gen(function* () {
|
|
235
|
+
const packet = yield* Schema.decodeUnknown(MeshSchema.Packet)(message)
|
|
236
|
+
|
|
237
|
+
// console.debug(nodeName, 'received', packet._tag, packet.source, packet.target)
|
|
238
|
+
|
|
239
|
+
if (handledPacketIds.has(packet.id)) return
|
|
240
|
+
handledPacketIds.add(packet.id)
|
|
241
|
+
|
|
242
|
+
if (packet._tag === 'NetworkConnectionAdded') {
|
|
243
|
+
yield* sendPacket(packet)
|
|
244
|
+
} else if (packet.target === nodeName) {
|
|
245
|
+
const channelKey = `${packet.source}-${packet.channelName}` satisfies ChannelKey
|
|
246
|
+
|
|
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 })
|
|
252
|
+
}
|
|
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
|
+
)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
yield* sendPacket(packet)
|
|
286
|
+
}
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
Stream.runDrain,
|
|
290
|
+
Effect.orDie,
|
|
291
|
+
Effect.tapCauseLogPretty,
|
|
292
|
+
Effect.forkScoped,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
connectionChannels.set(targetNodeName, { channel: connectionChannel, listenFiber })
|
|
296
|
+
|
|
297
|
+
const connectionAddedPacket = MeshSchema.NetworkConnectionAdded.make({
|
|
298
|
+
source: nodeName,
|
|
299
|
+
target: targetNodeName,
|
|
300
|
+
})
|
|
301
|
+
yield* sendPacket(connectionAddedPacket).pipe(Effect.ignoreLogged)
|
|
302
|
+
}).pipe(
|
|
303
|
+
Effect.withSpan(`addConnection:${nodeName}→${targetNodeName}`, {
|
|
304
|
+
attributes: { supportsTransferables: connectionChannel.supportsTransferables },
|
|
305
|
+
}),
|
|
306
|
+
) as any // any-cast needed for error/never overload
|
|
307
|
+
|
|
308
|
+
const removeConnection: MeshNode['removeConnection'] = (targetNodeName) =>
|
|
309
|
+
Effect.gen(function* () {
|
|
310
|
+
if (!connectionChannels.has(targetNodeName)) {
|
|
311
|
+
yield* new Cause.NoSuchElementException(`No connection found for ${targetNodeName}`)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
yield* Fiber.interrupt(connectionChannels.get(targetNodeName)!.listenFiber)
|
|
315
|
+
|
|
316
|
+
connectionChannels.delete(targetNodeName)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
// TODO add heartbeat to detect dead connections (for both e2e and proxying)
|
|
320
|
+
// TODO when a channel is established in the same origin, we can use a weblock to detect disconnects
|
|
321
|
+
const makeChannel: MeshNode['makeChannel'] = ({
|
|
322
|
+
target,
|
|
323
|
+
channelName,
|
|
324
|
+
schema: inputSchema,
|
|
325
|
+
// TODO in the future we could have a mode that prefers messagechannels and then falls back to proxies if needed
|
|
326
|
+
mode,
|
|
327
|
+
timeout = Duration.seconds(1),
|
|
328
|
+
}) =>
|
|
329
|
+
Effect.gen(function* () {
|
|
330
|
+
const schema = WebChannel.mapSchema(inputSchema)
|
|
331
|
+
const channelKey = `${target}-${channelName}` satisfies ChannelKey
|
|
332
|
+
|
|
333
|
+
if (!channelMap.has(channelKey)) {
|
|
334
|
+
const queue = yield* Queue.unbounded<MessageQueueItem | ProxyQueueItem>().pipe(
|
|
335
|
+
Effect.acquireRelease(Queue.shutdown),
|
|
336
|
+
)
|
|
337
|
+
channelMap.set(channelKey, { queue, debugInfo: undefined })
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const queue = channelMap.get(channelKey)!.queue as Queue.Queue<any>
|
|
341
|
+
|
|
342
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => channelMap.delete(channelKey)))
|
|
343
|
+
|
|
344
|
+
if (mode === 'messagechannel') {
|
|
345
|
+
// console.debug(nodeName, 'message mode', modeRef.current)
|
|
346
|
+
|
|
347
|
+
// NOTE already retries internally when transferables are required
|
|
348
|
+
const channel = yield* makeMessageChannel({
|
|
349
|
+
nodeName,
|
|
350
|
+
queue,
|
|
351
|
+
newConnectionAvailablePubSub,
|
|
352
|
+
target,
|
|
353
|
+
channelName,
|
|
354
|
+
schema,
|
|
355
|
+
sendPacket,
|
|
356
|
+
checkTransferableConnections,
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
channelMap.set(channelKey, { queue, debugInfo: { channel, target } })
|
|
360
|
+
|
|
361
|
+
return channel
|
|
362
|
+
} else {
|
|
363
|
+
const channel = yield* makeProxyChannel({
|
|
364
|
+
nodeName,
|
|
365
|
+
newConnectionAvailablePubSub,
|
|
366
|
+
target,
|
|
367
|
+
channelName,
|
|
368
|
+
schema,
|
|
369
|
+
queue,
|
|
370
|
+
sendPacket,
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
channelMap.set(channelKey, { queue, debugInfo: { channel, target } })
|
|
374
|
+
|
|
375
|
+
return channel
|
|
376
|
+
}
|
|
377
|
+
}).pipe(
|
|
378
|
+
Effect.withSpanScoped(`makeChannel:${nodeName}→${target}(${channelName})`, {
|
|
379
|
+
attributes: { target, channelName, mode, timeout },
|
|
380
|
+
}),
|
|
381
|
+
Effect.annotateLogs({ nodeName }),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
const connectionKeys: MeshNode['connectionKeys'] = Effect.sync(() => new Set(connectionChannels.keys()))
|
|
385
|
+
|
|
386
|
+
const debug: MeshNode['debug'] = {
|
|
387
|
+
print: () => {
|
|
388
|
+
console.log('Connections:', connectionChannels.size)
|
|
389
|
+
for (const [key, value] of connectionChannels) {
|
|
390
|
+
console.log(` ${key}: supportsTransferables=${value.channel.supportsTransferables}`)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
console.log('Channels:', channelMap.size)
|
|
394
|
+
for (const [key, value] of channelMap) {
|
|
395
|
+
console.log(
|
|
396
|
+
` ${key}: \n`,
|
|
397
|
+
` Queue: ${value.queue.unsafeSize().pipe(Option.getOrUndefined)}`,
|
|
398
|
+
value.queue,
|
|
399
|
+
'\n',
|
|
400
|
+
` Channel: target=${value.debugInfo?.target} supportsTransferables=${value.debugInfo?.channel.supportsTransferables}`,
|
|
401
|
+
value.debugInfo?.channel,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { nodeName, addConnection, removeConnection, makeChannel, connectionKeys, debug } satisfies MeshNode
|
|
408
|
+
}).pipe(Effect.withSpan(`makeMeshNode:${nodeName}`))
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* eslint-disable prefer-arrow/prefer-arrow-functions */
|
|
2
|
+
import { Duration } from '@livestore/utils/effect'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A set of values that expire after a given timeout
|
|
6
|
+
* The timeout cleanup is performed in a batched way to avoid excessive setTimeout calls
|
|
7
|
+
*/
|
|
8
|
+
export class TimeoutSet<V> {
|
|
9
|
+
private values = new Map<V, number>()
|
|
10
|
+
private timeoutHandle: NodeJS.Timeout | undefined
|
|
11
|
+
private readonly timeoutMs: number
|
|
12
|
+
|
|
13
|
+
constructor({ timeout }: { timeout: Duration.DurationInput }) {
|
|
14
|
+
this.timeoutMs = Duration.toMillis(timeout)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
add(value: V): void {
|
|
18
|
+
this.values.set(value, Date.now())
|
|
19
|
+
this.scheduleCleanup()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
has(value: V): boolean {
|
|
23
|
+
return this.values.has(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
delete(value: V): void {
|
|
27
|
+
this.values.delete(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private scheduleCleanup(): void {
|
|
31
|
+
if (this.timeoutHandle === undefined) {
|
|
32
|
+
this.timeoutHandle = setTimeout(() => {
|
|
33
|
+
this.cleanup()
|
|
34
|
+
this.timeoutHandle = undefined
|
|
35
|
+
}, this.timeoutMs)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private cleanup(): void {
|
|
40
|
+
const now = Date.now()
|
|
41
|
+
for (const [value, timestamp] of this.values.entries()) {
|
|
42
|
+
if (now - timestamp >= this.timeoutMs) {
|
|
43
|
+
this.values.delete(value)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Scope } from '@livestore/utils/effect'
|
|
2
|
+
import {
|
|
3
|
+
Deferred,
|
|
4
|
+
Effect,
|
|
5
|
+
Either,
|
|
6
|
+
FiberHandle,
|
|
7
|
+
Queue,
|
|
8
|
+
Schedule,
|
|
9
|
+
Schema,
|
|
10
|
+
Stream,
|
|
11
|
+
WebChannel,
|
|
12
|
+
WebSocket,
|
|
13
|
+
} from '@livestore/utils/effect'
|
|
14
|
+
import type NodeWebSocket from 'ws'
|
|
15
|
+
|
|
16
|
+
import * as MeshSchema from './mesh-schema.js'
|
|
17
|
+
import type { MeshNode } from './node.js'
|
|
18
|
+
|
|
19
|
+
export class WSConnectionInit extends Schema.TaggedStruct('WSConnectionInit', {
|
|
20
|
+
from: Schema.String,
|
|
21
|
+
}) {}
|
|
22
|
+
|
|
23
|
+
export class WSConnectionPayload extends Schema.TaggedStruct('WSConnectionPayload', {
|
|
24
|
+
from: Schema.String,
|
|
25
|
+
payload: Schema.Any,
|
|
26
|
+
}) {}
|
|
27
|
+
|
|
28
|
+
export class WSConnectionMessage extends Schema.Union(WSConnectionInit, WSConnectionPayload) {}
|
|
29
|
+
|
|
30
|
+
export const MessageMsgPack = Schema.MsgPack(WSConnectionMessage)
|
|
31
|
+
|
|
32
|
+
export type SocketType =
|
|
33
|
+
| {
|
|
34
|
+
_tag: 'leaf'
|
|
35
|
+
from: string
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
_tag: 'relay'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const connectViaWebSocket = ({
|
|
42
|
+
node,
|
|
43
|
+
url,
|
|
44
|
+
reconnect = Schedule.exponential(100),
|
|
45
|
+
}: {
|
|
46
|
+
node: MeshNode
|
|
47
|
+
url: string
|
|
48
|
+
reconnect?: Schedule.Schedule<unknown> | false
|
|
49
|
+
}): Effect.Effect<void, never, Scope.Scope> =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const fiberHandle = yield* FiberHandle.make()
|
|
52
|
+
|
|
53
|
+
const connect = Effect.gen(function* () {
|
|
54
|
+
const socket = yield* WebSocket.makeWebSocket({ url, reconnect })
|
|
55
|
+
|
|
56
|
+
// NOTE we want to use `runFork` here so this Effect is not part of the fiber that will be interrupted
|
|
57
|
+
socket.addEventListener('close', () => FiberHandle.run(fiberHandle, connect).pipe(Effect.runFork))
|
|
58
|
+
|
|
59
|
+
const connection = yield* makeWebSocketConnection(socket, { _tag: 'leaf', from: node.nodeName })
|
|
60
|
+
|
|
61
|
+
yield* node.addConnection({ target: 'ws', connectionChannel: connection.webChannel, replaceIfExists: true })
|
|
62
|
+
|
|
63
|
+
yield* Effect.never
|
|
64
|
+
}).pipe(Effect.scoped, Effect.withSpan('@livestore/webmesh:websocket-connection:connect'))
|
|
65
|
+
|
|
66
|
+
yield* FiberHandle.run(fiberHandle, connect)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export const makeWebSocketConnection = (
|
|
70
|
+
socket: globalThis.WebSocket | NodeWebSocket.WebSocket,
|
|
71
|
+
socketType: SocketType,
|
|
72
|
+
): Effect.Effect<
|
|
73
|
+
{
|
|
74
|
+
webChannel: WebChannel.WebChannel<typeof MeshSchema.Packet.Type, typeof MeshSchema.Packet.Type>
|
|
75
|
+
from: string
|
|
76
|
+
},
|
|
77
|
+
never,
|
|
78
|
+
Scope.Scope
|
|
79
|
+
> =>
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
socket.binaryType = 'arraybuffer'
|
|
82
|
+
|
|
83
|
+
const fromDeferred = yield* Deferred.make<string>()
|
|
84
|
+
|
|
85
|
+
yield* Stream.fromEventListener<MessageEvent>(socket as any, 'message').pipe(
|
|
86
|
+
Stream.map((msg) => Schema.decodeUnknownEither(MessageMsgPack)(new Uint8Array(msg.data))),
|
|
87
|
+
Stream.flatten(),
|
|
88
|
+
Stream.tap((msg) =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
if (msg._tag === 'WSConnectionInit') {
|
|
91
|
+
yield* Deferred.succeed(fromDeferred, msg.from)
|
|
92
|
+
} else {
|
|
93
|
+
const decodedPayload = yield* Schema.decode(MeshSchema.Packet)(msg.payload)
|
|
94
|
+
yield* Queue.offer(listenQueue, decodedPayload)
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
Stream.runDrain,
|
|
99
|
+
Effect.tapCauseLogPretty,
|
|
100
|
+
Effect.forkScoped,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const listenQueue = yield* Queue.unbounded<typeof MeshSchema.Packet.Type>().pipe(
|
|
104
|
+
Effect.acquireRelease(Queue.shutdown),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const initHandshake = (from: string) =>
|
|
108
|
+
socket.send(Schema.encodeSync(MessageMsgPack)({ _tag: 'WSConnectionInit', from }))
|
|
109
|
+
|
|
110
|
+
if (socketType._tag === 'leaf') {
|
|
111
|
+
initHandshake(socketType.from)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const deferredResult = yield* fromDeferred
|
|
115
|
+
const from = socketType._tag === 'leaf' ? socketType.from : deferredResult
|
|
116
|
+
|
|
117
|
+
if (socketType._tag === 'relay') {
|
|
118
|
+
initHandshake(from)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const isConnectedLatch = yield* Effect.makeLatch(true)
|
|
122
|
+
|
|
123
|
+
const closedDeferred = yield* Deferred.make<void>()
|
|
124
|
+
|
|
125
|
+
yield* Effect.eventListener<any>(
|
|
126
|
+
socket,
|
|
127
|
+
'close',
|
|
128
|
+
() =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
yield* isConnectedLatch.close
|
|
131
|
+
yield* Deferred.succeed(closedDeferred, undefined)
|
|
132
|
+
}),
|
|
133
|
+
{ once: true },
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const send = (message: typeof MeshSchema.Packet.Type) =>
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
yield* isConnectedLatch.await
|
|
139
|
+
const payload = yield* Schema.encode(MeshSchema.Packet)(message)
|
|
140
|
+
socket.send(Schema.encodeSync(MessageMsgPack)({ _tag: 'WSConnectionPayload', payload, from }))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const listen = Stream.fromQueue(listenQueue).pipe(Stream.map(Either.right))
|
|
144
|
+
|
|
145
|
+
const webChannel = {
|
|
146
|
+
[WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
|
|
147
|
+
send,
|
|
148
|
+
listen,
|
|
149
|
+
closedDeferred,
|
|
150
|
+
schema: { listen: MeshSchema.Packet, send: MeshSchema.Packet },
|
|
151
|
+
supportsTransferables: false,
|
|
152
|
+
} satisfies WebChannel.WebChannel<typeof MeshSchema.Packet.Type, typeof MeshSchema.Packet.Type>
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
webChannel: webChannel as WebChannel.WebChannel<typeof MeshSchema.Packet.Type, typeof MeshSchema.Packet.Type>,
|
|
156
|
+
from,
|
|
157
|
+
}
|
|
158
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Scope } from '@livestore/utils/effect'
|
|
2
|
+
import { Effect } from '@livestore/utils/effect'
|
|
3
|
+
import * as WebSocket from 'ws'
|
|
4
|
+
|
|
5
|
+
import { makeMeshNode } from './node.js'
|
|
6
|
+
import { makeWebSocketConnection } from './websocket-connection.js'
|
|
7
|
+
|
|
8
|
+
export const makeWebSocketServer = ({
|
|
9
|
+
relayNodeName,
|
|
10
|
+
}: {
|
|
11
|
+
relayNodeName: string
|
|
12
|
+
}): Effect.Effect<WebSocket.WebSocketServer, never, Scope.Scope> =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const server = new WebSocket.WebSocketServer({ noServer: true })
|
|
15
|
+
|
|
16
|
+
const node = yield* makeMeshNode(relayNodeName)
|
|
17
|
+
|
|
18
|
+
const runtime = yield* Effect.runtime<never>()
|
|
19
|
+
|
|
20
|
+
// TODO handle node disconnects (i.e. remove respective connection)
|
|
21
|
+
server.on('connection', (socket) => {
|
|
22
|
+
Effect.gen(function* () {
|
|
23
|
+
const { webChannel, from } = yield* makeWebSocketConnection(socket, { _tag: 'relay' })
|
|
24
|
+
|
|
25
|
+
yield* node.addConnection({ target: from, connectionChannel: webChannel, replaceIfExists: true })
|
|
26
|
+
yield* Effect.log(`WS Relay ${relayNodeName}: added connection from '${from}'`)
|
|
27
|
+
|
|
28
|
+
socket.addEventListener('close', () =>
|
|
29
|
+
Effect.gen(function* () {
|
|
30
|
+
yield* node.removeConnection(from)
|
|
31
|
+
yield* Effect.log(`WS Relay ${relayNodeName}: removed connection from '${from}'`)
|
|
32
|
+
}).pipe(Effect.provide(runtime), Effect.runFork),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
yield* Effect.never
|
|
36
|
+
}).pipe(Effect.scoped, Effect.tapCauseLogPretty, Effect.provide(runtime), Effect.runFork)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return server
|
|
40
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"resolveJsonModule": true,
|
|
7
|
+
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
|
8
|
+
},
|
|
9
|
+
"include": ["./src"],
|
|
10
|
+
"references": [{ "path": "../common" }, { "path": "../utils" }]
|
|
11
|
+
}
|