@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
@@ -0,0 +1,332 @@
1
+ import { casesHandled, shouldNeverHappen } from '@livestore/utils'
2
+ import type { PubSub } from '@livestore/utils/effect'
3
+ import {
4
+ Deferred,
5
+ Effect,
6
+ Either,
7
+ Fiber,
8
+ FiberHandle,
9
+ Queue,
10
+ Schedule,
11
+ Schema,
12
+ Stream,
13
+ SubscriptionRef,
14
+ WebChannel,
15
+ } from '@livestore/utils/effect'
16
+ import { nanoid } from '@livestore/utils/nanoid'
17
+
18
+ import {
19
+ type ChannelKey,
20
+ type ChannelName,
21
+ type MeshNodeName,
22
+ packetAsOtelAttributes,
23
+ type ProxyQueueItem,
24
+ } from '../common.js'
25
+ import * as MeshSchema from '../mesh-schema.js'
26
+
27
+ interface MakeProxyChannelArgs {
28
+ queue: Queue.Queue<ProxyQueueItem>
29
+ nodeName: MeshNodeName
30
+ newConnectionAvailablePubSub: PubSub.PubSub<MeshNodeName>
31
+ sendPacket: (packet: typeof MeshSchema.ProxyChannelPacket.Type) => Effect.Effect<void>
32
+ channelName: ChannelName
33
+ target: MeshNodeName
34
+ schema: {
35
+ send: Schema.Schema<any, any>
36
+ listen: Schema.Schema<any, any>
37
+ }
38
+ }
39
+
40
+ export const makeProxyChannel = ({
41
+ queue,
42
+ nodeName,
43
+ newConnectionAvailablePubSub,
44
+ sendPacket,
45
+ target,
46
+ channelName,
47
+ schema,
48
+ }: MakeProxyChannelArgs) =>
49
+ Effect.gen(function* () {
50
+ type ProxiedChannelState =
51
+ | {
52
+ _tag: 'Initial'
53
+ }
54
+ | {
55
+ _tag: 'Pending'
56
+ initiatedVia: 'outgoing-request' | 'incoming-request'
57
+ }
58
+ | ProxiedChannelStateEstablished
59
+
60
+ type ProxiedChannelStateEstablished = {
61
+ _tag: 'Established'
62
+ listenSchema: Schema.Schema<any, any>
63
+ listenQueue: Queue.Queue<any>
64
+ ackMap: Map<string, Deferred.Deferred<void, never>>
65
+ combinedChannelId: string
66
+ }
67
+
68
+ const channelStateRef = { current: { _tag: 'Initial' } as ProxiedChannelState }
69
+
70
+ /**
71
+ * We need to unique identify a channel as multiple channels might exist between the same two nodes.
72
+ * We do this by letting each channel end generate a unique id and then combining them in a deterministic way.
73
+ */
74
+ const channelIdCandidate = nanoid(5)
75
+ yield* Effect.annotateCurrentSpan({ channelIdCandidate })
76
+
77
+ const channelSpan = yield* Effect.currentSpan.pipe(Effect.orDie)
78
+
79
+ const connectedStateRef = yield* SubscriptionRef.make<ProxiedChannelStateEstablished | false>(false)
80
+
81
+ const waitForEstablished = Effect.gen(function* () {
82
+ const state = yield* SubscriptionRef.waitUntil(connectedStateRef, (state) => state !== false)
83
+
84
+ return state as ProxiedChannelStateEstablished
85
+ })
86
+
87
+ const setStateToEstablished = (channelId: string) =>
88
+ Effect.gen(function* () {
89
+ // TODO avoid "double" `Connected` events (we might call `setStateToEstablished` twice during initial connection)
90
+ yield* Effect.spanEvent(`Connected (${channelId})`).pipe(Effect.withParentSpan(channelSpan))
91
+ channelStateRef.current = {
92
+ _tag: 'Established',
93
+ listenSchema: schema.listen,
94
+ listenQueue,
95
+ ackMap,
96
+ combinedChannelId: channelId,
97
+ }
98
+ yield* SubscriptionRef.set(connectedStateRef, channelStateRef.current)
99
+ })
100
+
101
+ const connectionRequest = Effect.suspend(() =>
102
+ sendPacket(
103
+ MeshSchema.ProxyChannelRequest.make({ channelName, hops: [], source: nodeName, target, channelIdCandidate }),
104
+ ),
105
+ )
106
+
107
+ const getCombinedChannelId = (otherSideChannelIdCandidate: string) =>
108
+ [channelIdCandidate, otherSideChannelIdCandidate].sort().join('_')
109
+
110
+ const processProxyPacket = ({ packet, respondToSender }: ProxyQueueItem) =>
111
+ Effect.gen(function* () {
112
+ // yield* Effect.log(`${nodeName}:processing packet ${packet._tag} from ${packet.source}`)
113
+
114
+ const otherSideName = packet.source
115
+ const channelKey = `${otherSideName}-${packet.channelName}` satisfies ChannelKey
116
+ const channelState = channelStateRef.current
117
+
118
+ switch (packet._tag) {
119
+ case 'ProxyChannelRequest': {
120
+ const combinedChannelId = getCombinedChannelId(packet.channelIdCandidate)
121
+
122
+ if (channelState._tag === 'Initial' || channelState._tag === 'Established') {
123
+ yield* SubscriptionRef.set(connectedStateRef, false)
124
+ channelStateRef.current = { _tag: 'Pending', initiatedVia: 'incoming-request' }
125
+ yield* Effect.spanEvent(`Reconnecting`).pipe(Effect.withParentSpan(channelSpan))
126
+
127
+ // If we're already connected, we need to re-establish the connection
128
+ if (channelState._tag === 'Established' && channelState.combinedChannelId !== combinedChannelId) {
129
+ yield* connectionRequest
130
+ }
131
+ }
132
+
133
+ yield* respondToSender(
134
+ MeshSchema.ProxyChannelResponseSuccess.make({
135
+ reqId: packet.id,
136
+ remainingHops: packet.hops,
137
+ hops: [],
138
+ target,
139
+ source: nodeName,
140
+ channelName,
141
+ combinedChannelId,
142
+ channelIdCandidate,
143
+ }),
144
+ )
145
+
146
+ return
147
+ }
148
+ case 'ProxyChannelResponseSuccess': {
149
+ if (channelState._tag !== 'Pending') {
150
+ // return shouldNeverHappen(`Expected proxy channel to be pending but got ${channelState._tag}`)
151
+ if (channelState._tag === 'Established' && channelState.combinedChannelId !== packet.combinedChannelId) {
152
+ return shouldNeverHappen(
153
+ `Expected proxy channel to have the same combinedChannelId as the packet:\n${channelState.combinedChannelId} (channel) === ${packet.combinedChannelId} (packet)`,
154
+ )
155
+ } else {
156
+ // for now just ignore it but should be looked into (there seems to be some kind of race condition/inefficiency)
157
+ }
158
+ }
159
+
160
+ const combinedChannelId = getCombinedChannelId(packet.channelIdCandidate)
161
+ if (combinedChannelId !== packet.combinedChannelId) {
162
+ return yield* Effect.die(
163
+ `Expected proxy channel to have the same combinedChannelId as the packet:\n${combinedChannelId} (channel) === ${packet.combinedChannelId} (packet)`,
164
+ )
165
+ }
166
+
167
+ yield* setStateToEstablished(packet.combinedChannelId)
168
+
169
+ return
170
+ }
171
+ case 'ProxyChannelPayload': {
172
+ if (channelState._tag !== 'Established') {
173
+ // return yield* Effect.die(`Not yet connected to ${target}. dropping message`)
174
+ yield* Effect.spanEvent(`Not yet connected to ${target}. dropping message`, { packet })
175
+ return
176
+ }
177
+
178
+ if (channelState.combinedChannelId !== packet.combinedChannelId) {
179
+ return yield* Effect.die(
180
+ `Expected proxy channel to have the same combinedChannelId as the packet:\n${channelState.combinedChannelId} (channel) === ${packet.combinedChannelId} (packet)`,
181
+ )
182
+ }
183
+
184
+ yield* respondToSender(
185
+ MeshSchema.ProxyChannelPayloadAck.make({
186
+ reqId: packet.id,
187
+ remainingHops: packet.hops,
188
+ hops: [],
189
+ target,
190
+ source: nodeName,
191
+ channelName,
192
+ combinedChannelId: channelState.combinedChannelId,
193
+ }),
194
+ )
195
+
196
+ const decodedMessage = yield* Schema.decodeUnknown(channelState.listenSchema)(packet.payload)
197
+ yield* channelState.listenQueue.pipe(Queue.offer(decodedMessage))
198
+
199
+ return
200
+ }
201
+ case 'ProxyChannelPayloadAck': {
202
+ if (channelState._tag !== 'Established') {
203
+ yield* Effect.spanEvent(`Not yet connected to ${target}. dropping message`)
204
+ return
205
+ }
206
+
207
+ const ack =
208
+ channelState.ackMap.get(packet.reqId) ??
209
+ shouldNeverHappen(`Expected ack for ${packet.reqId} in proxy channel ${channelKey}`)
210
+
211
+ yield* Deferred.succeed(ack, void 0)
212
+
213
+ channelState.ackMap.delete(packet.reqId)
214
+
215
+ return
216
+ }
217
+ default: {
218
+ return casesHandled(packet)
219
+ }
220
+ }
221
+ }).pipe(
222
+ Effect.withSpan(`handleProxyPacket:${packet._tag}:${packet.source}->${packet.target}`, {
223
+ attributes: packetAsOtelAttributes(packet),
224
+ }),
225
+ )
226
+
227
+ yield* Stream.fromQueue(queue).pipe(
228
+ Stream.tap(processProxyPacket),
229
+ Stream.runDrain,
230
+ Effect.tapCauseLogPretty,
231
+ Effect.forkScoped,
232
+ )
233
+
234
+ const listenQueue = yield* Queue.unbounded<any>()
235
+
236
+ yield* Effect.spanEvent(`Connecting`)
237
+
238
+ const ackMap = new Map<string, Deferred.Deferred<void, never>>()
239
+
240
+ // check if already established via incoming `ProxyChannelRequest` from other side
241
+ // which indicates we already have a connection to the target node
242
+ // const channelState = channelStateRef.current
243
+ {
244
+ if (channelStateRef.current._tag !== 'Initial') {
245
+ return shouldNeverHappen('Expected proxy channel to be Initial')
246
+ }
247
+
248
+ channelStateRef.current = { _tag: 'Pending', initiatedVia: 'outgoing-request' }
249
+
250
+ yield* connectionRequest
251
+
252
+ const retryOnNewConnectionFiber = yield* Stream.fromPubSub(newConnectionAvailablePubSub).pipe(
253
+ Stream.tap(() => connectionRequest),
254
+ Stream.runDrain,
255
+ Effect.forkScoped,
256
+ )
257
+
258
+ const { combinedChannelId: channelId } = yield* waitForEstablished
259
+
260
+ yield* Fiber.interrupt(retryOnNewConnectionFiber)
261
+
262
+ yield* setStateToEstablished(channelId)
263
+ }
264
+
265
+ const send = (message: any) =>
266
+ Effect.gen(function* () {
267
+ const payload = yield* Schema.encodeUnknown(schema.send)(message)
268
+ const sendFiberHandle = yield* FiberHandle.make<void, never>()
269
+
270
+ const sentDeferred = yield* Deferred.make<void>()
271
+
272
+ const trySend = Effect.gen(function* () {
273
+ const { combinedChannelId } = (yield* SubscriptionRef.waitUntil(
274
+ connectedStateRef,
275
+ (channel) => channel !== false,
276
+ )) as ProxiedChannelStateEstablished
277
+
278
+ const innerSend = Effect.gen(function* () {
279
+ // Note we're re-creating new packets every time otherwise they will be skipped because of `handledIds`
280
+ const ack = yield* Deferred.make<void, never>()
281
+ const packet = MeshSchema.ProxyChannelPayload.make({
282
+ channelName,
283
+ payload,
284
+ hops: [],
285
+ source: nodeName,
286
+ target,
287
+ combinedChannelId,
288
+ })
289
+ ackMap.set(packet.id, ack)
290
+
291
+ yield* sendPacket(packet)
292
+
293
+ yield* ack
294
+ yield* Deferred.succeed(sentDeferred, void 0)
295
+ })
296
+
297
+ yield* innerSend.pipe(Effect.timeout(100), Effect.retry(Schedule.exponential(100)), Effect.orDie)
298
+ }).pipe(Effect.tapErrorCause(Effect.logError))
299
+
300
+ const rerunOnNewChannelFiber = yield* connectedStateRef.changes.pipe(
301
+ Stream.filter((_) => _ === false),
302
+ Stream.tap(() => FiberHandle.run(sendFiberHandle, trySend)),
303
+ Stream.runDrain,
304
+ Effect.fork,
305
+ )
306
+
307
+ yield* FiberHandle.run(sendFiberHandle, trySend)
308
+
309
+ yield* sentDeferred
310
+
311
+ yield* Fiber.interrupt(rerunOnNewChannelFiber)
312
+ }).pipe(
313
+ Effect.scoped,
314
+ Effect.withSpan(`sendAckWithRetry:ProxyChannelPayload`),
315
+ Effect.withParentSpan(channelSpan),
316
+ )
317
+
318
+ const listen = Stream.fromQueue(listenQueue).pipe(Stream.map(Either.right))
319
+
320
+ const closedDeferred = yield* Deferred.make<void>()
321
+
322
+ const webChannel = {
323
+ [WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
324
+ send,
325
+ listen,
326
+ closedDeferred,
327
+ supportsTransferables: true,
328
+ schema,
329
+ } satisfies WebChannel.WebChannel<any, any>
330
+
331
+ return webChannel as WebChannel.WebChannel<any, any>
332
+ }).pipe(Effect.withSpanScoped('makeProxyChannel'))
package/src/common.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { type Effect, Schema } from '@livestore/utils/effect'
2
+
3
+ import type { MessageChannelPacket, Packet, ProxyChannelPacket } from './mesh-schema.js'
4
+
5
+ export type ProxyQueueItem = {
6
+ packet: typeof ProxyChannelPacket.Type
7
+ respondToSender: (msg: typeof ProxyChannelPacket.Type) => Effect.Effect<void>
8
+ }
9
+
10
+ export type MessageQueueItem = {
11
+ packet: typeof MessageChannelPacket.Type
12
+ respondToSender: (msg: typeof MessageChannelPacket.Type) => Effect.Effect<void>
13
+ }
14
+
15
+ export type MeshNodeName = string
16
+
17
+ export type ChannelName = string
18
+ export type ChannelKey = `${MeshNodeName}-${ChannelName}`
19
+
20
+ // TODO actually use this to avoid timeouts in certain cases
21
+ export class NoConnectionRouteSignal extends Schema.TaggedError<NoConnectionRouteSignal>()(
22
+ 'NoConnectionRouteSignal',
23
+ {},
24
+ ) {}
25
+
26
+ export class ConnectionAlreadyExistsError extends Schema.TaggedError<ConnectionAlreadyExistsError>()(
27
+ 'ConnectionAlreadyExistsError',
28
+ {
29
+ target: Schema.String,
30
+ },
31
+ ) {}
32
+
33
+ export const packetAsOtelAttributes = (packet: typeof Packet.Type) => ({
34
+ packetId: packet.id,
35
+ ...(packet._tag !== 'MessageChannelResponseSuccess' && packet._tag !== 'ProxyChannelPayload' ? { packet } : {}),
36
+ })
@@ -0,0 +1,94 @@
1
+ import { Schema, Transferable } from '@livestore/utils/effect'
2
+ import { nanoid } from '@livestore/utils/nanoid'
3
+
4
+ const id = Schema.String.pipe(
5
+ Schema.optional,
6
+ Schema.withDefaults({ constructor: () => nanoid(10), decoding: () => nanoid(10) }),
7
+ )
8
+
9
+ const defaultPacketFields = {
10
+ id,
11
+ target: Schema.String,
12
+ source: Schema.String,
13
+ channelName: Schema.String,
14
+ hops: Schema.Array(Schema.String),
15
+ }
16
+
17
+ const remainingHopsUndefined = Schema.Undefined.pipe(Schema.optional)
18
+
19
+ // Needs to go through already existing MessageChannel connections, times out otherwise
20
+ export class MessageChannelRequest extends Schema.TaggedStruct('MessageChannelRequest', {
21
+ ...defaultPacketFields,
22
+ remainingHops: remainingHopsUndefined,
23
+ }) {}
24
+
25
+ export class MessageChannelResponseSuccess extends Schema.TaggedStruct('MessageChannelResponseSuccess', {
26
+ ...defaultPacketFields,
27
+ reqId: Schema.String,
28
+ port: Transferable.MessagePort,
29
+ // Since we can't copy this message, we need to follow the exact route back to the sender
30
+ remainingHops: Schema.Array(Schema.String),
31
+ }) {}
32
+
33
+ export class MessageChannelResponseNoTransferables extends Schema.TaggedStruct(
34
+ 'MessageChannelResponseNoTransferables',
35
+ {
36
+ ...defaultPacketFields,
37
+ reqId: Schema.String,
38
+ remainingHops: Schema.Array(Schema.String),
39
+ },
40
+ ) {}
41
+
42
+ export class ProxyChannelRequest extends Schema.TaggedStruct('ProxyChannelRequest', {
43
+ ...defaultPacketFields,
44
+ remainingHops: remainingHopsUndefined,
45
+ channelIdCandidate: Schema.String,
46
+ }) {}
47
+
48
+ export class ProxyChannelResponseSuccess extends Schema.TaggedStruct('ProxyChannelResponseSuccess', {
49
+ ...defaultPacketFields,
50
+ reqId: Schema.String,
51
+ remainingHops: Schema.Array(Schema.String),
52
+ combinedChannelId: Schema.String,
53
+ channelIdCandidate: Schema.String,
54
+ }) {}
55
+
56
+ export class ProxyChannelPayload extends Schema.TaggedStruct('ProxyChannelPayload', {
57
+ ...defaultPacketFields,
58
+ remainingHops: remainingHopsUndefined,
59
+ payload: Schema.Any,
60
+ combinedChannelId: Schema.String,
61
+ }) {}
62
+
63
+ export class ProxyChannelPayloadAck extends Schema.TaggedStruct('ProxyChannelPayloadAck', {
64
+ ...defaultPacketFields,
65
+ reqId: Schema.String,
66
+ remainingHops: Schema.Array(Schema.String),
67
+ combinedChannelId: Schema.String,
68
+ }) {}
69
+
70
+ /**
71
+ * Broadcast to all nodes when a new connection is added.
72
+ * Mostly used for auto-reconnect purposes.
73
+ */
74
+ // TODO actually use for this use case
75
+ export class NetworkConnectionAdded extends Schema.TaggedStruct('NetworkConnectionAdded', {
76
+ id,
77
+ source: Schema.String,
78
+ target: Schema.String,
79
+ }) {}
80
+
81
+ export class MessageChannelPacket extends Schema.Union(
82
+ MessageChannelRequest,
83
+ MessageChannelResponseSuccess,
84
+ MessageChannelResponseNoTransferables,
85
+ ) {}
86
+
87
+ export class ProxyChannelPacket extends Schema.Union(
88
+ ProxyChannelRequest,
89
+ ProxyChannelResponseSuccess,
90
+ ProxyChannelPayload,
91
+ ProxyChannelPayloadAck,
92
+ ) {}
93
+
94
+ export class Packet extends Schema.Union(MessageChannelPacket, ProxyChannelPacket, NetworkConnectionAdded) {}
package/src/mod.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './websocket-connection.js'
2
+ export * from './node.js'
3
+ export * as WebmeshSchema from './mesh-schema.js'
4
+ export { ConnectionAlreadyExistsError } from './common.js'