@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.
Files changed (54) hide show
  1. package/README.md +5 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/channel/message-channel.d.ts +20 -0
  4. package/dist/channel/message-channel.d.ts.map +1 -0
  5. package/dist/channel/message-channel.js +183 -0
  6. package/dist/channel/message-channel.js.map +1 -0
  7. package/dist/channel/proxy-channel.d.ts +19 -0
  8. package/dist/channel/proxy-channel.d.ts.map +1 -0
  9. package/dist/channel/proxy-channel.js +179 -0
  10. package/dist/channel/proxy-channel.js.map +1 -0
  11. package/dist/common.d.ts +83 -0
  12. package/dist/common.d.ts.map +1 -0
  13. package/dist/common.js +13 -0
  14. package/dist/common.js.map +1 -0
  15. package/dist/mesh-schema.d.ts +104 -0
  16. package/dist/mesh-schema.d.ts.map +1 -0
  17. package/dist/mesh-schema.js +77 -0
  18. package/dist/mesh-schema.js.map +1 -0
  19. package/dist/mod.d.ts +5 -0
  20. package/dist/mod.d.ts.map +1 -0
  21. package/dist/mod.js +5 -0
  22. package/dist/mod.js.map +1 -0
  23. package/dist/node.d.ts +65 -0
  24. package/dist/node.d.ts.map +1 -0
  25. package/dist/node.js +216 -0
  26. package/dist/node.js.map +1 -0
  27. package/dist/node.test.d.ts +2 -0
  28. package/dist/node.test.d.ts.map +1 -0
  29. package/dist/node.test.js +351 -0
  30. package/dist/node.test.js.map +1 -0
  31. package/dist/utils.d.ts +19 -0
  32. package/dist/utils.d.ts.map +1 -0
  33. package/dist/utils.js +41 -0
  34. package/dist/utils.js.map +1 -0
  35. package/dist/websocket-connection.d.ts +51 -0
  36. package/dist/websocket-connection.d.ts.map +1 -0
  37. package/dist/websocket-connection.js +74 -0
  38. package/dist/websocket-connection.js.map +1 -0
  39. package/dist/websocket-server.d.ts +7 -0
  40. package/dist/websocket-server.d.ts.map +1 -0
  41. package/dist/websocket-server.js +24 -0
  42. package/dist/websocket-server.js.map +1 -0
  43. package/package.json +32 -0
  44. package/src/channel/message-channel.ts +354 -0
  45. package/src/channel/proxy-channel.ts +332 -0
  46. package/src/common.ts +36 -0
  47. package/src/mesh-schema.ts +94 -0
  48. package/src/mod.ts +4 -0
  49. package/src/node.test.ts +533 -0
  50. package/src/node.ts +408 -0
  51. package/src/utils.ts +47 -0
  52. package/src/websocket-connection.ts +158 -0
  53. package/src/websocket-server.ts +40 -0
  54. 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
+ }