@livestore/webmesh 0.3.0-dev.37 → 0.3.0-dev.39

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 (44) hide show
  1. package/README.md +19 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/{message-channel-internal.d.ts → direct-channel-internal.d.ts} +7 -7
  4. package/dist/channel/direct-channel-internal.d.ts.map +1 -0
  5. package/dist/channel/{message-channel-internal.js → direct-channel-internal.js} +22 -22
  6. package/dist/channel/direct-channel-internal.js.map +1 -0
  7. package/dist/channel/{message-channel.d.ts → direct-channel.d.ts} +3 -3
  8. package/dist/channel/direct-channel.d.ts.map +1 -0
  9. package/dist/channel/{message-channel.js → direct-channel.js} +17 -17
  10. package/dist/channel/direct-channel.js.map +1 -0
  11. package/dist/channel/proxy-channel.d.ts.map +1 -1
  12. package/dist/channel/proxy-channel.js +84 -21
  13. package/dist/channel/proxy-channel.js.map +1 -1
  14. package/dist/common.d.ts +11 -5
  15. package/dist/common.d.ts.map +1 -1
  16. package/dist/common.js +6 -1
  17. package/dist/common.js.map +1 -1
  18. package/dist/mesh-schema.d.ts +15 -15
  19. package/dist/mesh-schema.d.ts.map +1 -1
  20. package/dist/mesh-schema.js +9 -9
  21. package/dist/mesh-schema.js.map +1 -1
  22. package/dist/node.d.ts +10 -5
  23. package/dist/node.d.ts.map +1 -1
  24. package/dist/node.js +68 -30
  25. package/dist/node.js.map +1 -1
  26. package/dist/node.test.js +114 -17
  27. package/dist/node.test.js.map +1 -1
  28. package/dist/websocket-edge.d.ts +2 -1
  29. package/dist/websocket-edge.d.ts.map +1 -1
  30. package/dist/websocket-edge.js +6 -2
  31. package/dist/websocket-edge.js.map +1 -1
  32. package/package.json +3 -4
  33. package/src/channel/{message-channel-internal.ts → direct-channel-internal.ts} +29 -29
  34. package/src/channel/{message-channel.ts → direct-channel.ts} +20 -20
  35. package/src/channel/proxy-channel.ts +107 -25
  36. package/src/common.ts +12 -4
  37. package/src/mesh-schema.ts +16 -19
  38. package/src/node.test.ts +185 -17
  39. package/src/node.ts +97 -35
  40. package/src/websocket-edge.ts +7 -1
  41. package/dist/channel/message-channel-internal.d.ts.map +0 -1
  42. package/dist/channel/message-channel-internal.js.map +0 -1
  43. package/dist/channel/message-channel.d.ts.map +0 -1
  44. package/dist/channel/message-channel.js.map +0 -1
@@ -16,30 +16,30 @@ import {
16
16
  import { type ChannelName, type MeshNodeName, type MessageQueueItem, packetAsOtelAttributes } from '../common.js'
17
17
  import * as MeshSchema from '../mesh-schema.js'
18
18
 
19
- export interface MakeMessageChannelArgs {
19
+ export interface MakeDirectChannelArgs {
20
20
  nodeName: MeshNodeName
21
21
  /** Queue of incoming messages for this channel */
22
22
  incomingPacketsQueue: Queue.Queue<MessageQueueItem>
23
23
  newEdgeAvailablePubSub: PubSub.PubSub<MeshNodeName>
24
24
  channelName: ChannelName
25
25
  target: MeshNodeName
26
- sendPacket: (packet: typeof MeshSchema.MessageChannelPacket.Type) => Effect.Effect<void>
26
+ sendPacket: (packet: typeof MeshSchema.DirectChannelPacket.Type) => Effect.Effect<void>
27
27
  checkTransferableEdges: (
28
- packet: typeof MeshSchema.MessageChannelPacket.Type,
29
- ) => typeof MeshSchema.MessageChannelResponseNoTransferables.Type | undefined
28
+ packet: typeof MeshSchema.DirectChannelPacket.Type,
29
+ ) => typeof MeshSchema.DirectChannelResponseNoTransferables.Type | undefined
30
30
  schema: WebChannel.OutputSchema<any, any, any, any>
31
31
  }
32
32
 
33
33
  const makeDeferredResult = Deferred.make<
34
34
  WebChannel.WebChannel<any, any>,
35
- typeof MeshSchema.MessageChannelResponseNoTransferables.Type
35
+ typeof MeshSchema.DirectChannelResponseNoTransferables.Type
36
36
  >
37
37
 
38
38
  /**
39
39
  * The channel version is important here, as a channel will only be established once both sides have the same version.
40
40
  * The version is used to avoid concurrency issues where both sides have different incompatible message ports.
41
41
  */
42
- export const makeMessageChannelInternal = ({
42
+ export const makeDirectChannelInternal = ({
43
43
  nodeName,
44
44
  incomingPacketsQueue,
45
45
  target,
@@ -50,14 +50,14 @@ export const makeMessageChannelInternal = ({
50
50
  channelVersion,
51
51
  scope,
52
52
  sourceId,
53
- }: MakeMessageChannelArgs & {
53
+ }: MakeDirectChannelArgs & {
54
54
  channelVersion: number
55
- /** We're passing in the closeable scope from the wrapping message channel */
55
+ /** We're passing in the closeable scope from the wrapping direct channel */
56
56
  scope: Scope.CloseableScope
57
57
  sourceId: string
58
58
  }): Effect.Effect<
59
59
  WebChannel.WebChannel<any, any>,
60
- typeof MeshSchema.MessageChannelResponseNoTransferables.Type,
60
+ typeof MeshSchema.DirectChannelResponseNoTransferables.Type,
61
61
  Scope.Scope
62
62
  > =>
63
63
  Effect.gen(function* () {
@@ -95,8 +95,8 @@ export const makeMessageChannelInternal = ({
95
95
  // }
96
96
 
97
97
  const schema = {
98
- send: Schema.Union(schema_.send, MeshSchema.MessageChannelPing, MeshSchema.MessageChannelPong),
99
- listen: Schema.Union(schema_.listen, MeshSchema.MessageChannelPing, MeshSchema.MessageChannelPong),
98
+ send: Schema.Union(schema_.send, MeshSchema.DirectChannelPing, MeshSchema.DirectChannelPong),
99
+ listen: Schema.Union(schema_.listen, MeshSchema.DirectChannelPing, MeshSchema.DirectChannelPong),
100
100
  }
101
101
 
102
102
  const channelStateRef: { current: ChannelState } = {
@@ -122,7 +122,7 @@ export const makeMessageChannelInternal = ({
122
122
 
123
123
  if (channelState._tag === 'Initial') return shouldNeverHappen()
124
124
 
125
- if (packet._tag === 'MessageChannelResponseNoTransferables') {
125
+ if (packet._tag === 'DirectChannelResponseNoTransferables') {
126
126
  yield* Deferred.fail(deferred, packet)
127
127
  return 'close'
128
128
  }
@@ -139,7 +139,7 @@ export const makeMessageChannelInternal = ({
139
139
  // If this channel has a higher version, we need to signal the other side to close
140
140
  // and recreate the channel with the new version
141
141
  if (packet.channelVersion < channelVersion) {
142
- const newPacket = MeshSchema.MessageChannelRequest.make({
142
+ const newPacket = MeshSchema.DirectChannelRequest.make({
143
143
  source: nodeName,
144
144
  sourceId,
145
145
  target,
@@ -158,7 +158,7 @@ export const makeMessageChannelInternal = ({
158
158
  return
159
159
  }
160
160
 
161
- if (channelState._tag === 'Established' && packet._tag === 'MessageChannelRequest') {
161
+ if (channelState._tag === 'Established' && packet._tag === 'DirectChannelRequest') {
162
162
  if (packet.sourceId === channelState.otherSourceId) {
163
163
  return
164
164
  } else {
@@ -172,7 +172,7 @@ export const makeMessageChannelInternal = ({
172
172
 
173
173
  switch (packet._tag) {
174
174
  // Assumption: Each side has sent an initial request and another request as a response for an incoming request
175
- case 'MessageChannelRequest': {
175
+ case 'DirectChannelRequest': {
176
176
  if (channelState._tag !== 'RequestSent') {
177
177
  // We can safely ignore further incoming requests as we're already creating a channel
178
178
  return
@@ -181,7 +181,7 @@ export const makeMessageChannelInternal = ({
181
181
  if (packet.reqId === channelState.reqPacketId) {
182
182
  // Circuit-breaker: We've already sent a request so we don't need to send another one
183
183
  } else {
184
- const newRequestPacket = MeshSchema.MessageChannelRequest.make({
184
+ const newRequestPacket = MeshSchema.DirectChannelRequest.make({
185
185
  source: nodeName,
186
186
  sourceId,
187
187
  target,
@@ -199,10 +199,10 @@ export const makeMessageChannelInternal = ({
199
199
  const isWinner = nodeName > target
200
200
 
201
201
  if (isWinner) {
202
- span?.addEvent(`winner side: creating message channel and sending response`)
202
+ span?.addEvent(`winner side: creating direct channel and sending response`)
203
203
  const mc = new MessageChannel()
204
204
 
205
- // We're using a message channel with acks here to make sure messages are not lost
205
+ // We're using a direct channel with acks here to make sure messages are not lost
206
206
  // which might happen during re-edge scenarios.
207
207
  // Also we need to eagerly start listening since we're using the channel "ourselves"
208
208
  // for the initial ping-pong sequence.
@@ -213,7 +213,7 @@ export const makeMessageChannelInternal = ({
213
213
  }).pipe(Effect.andThen(WebChannel.toOpenChannel))
214
214
 
215
215
  yield* respondToSender(
216
- MeshSchema.MessageChannelResponseSuccess.make({
216
+ MeshSchema.DirectChannelResponseSuccess.make({
217
217
  reqId: packet.id,
218
218
  target,
219
219
  source: nodeName,
@@ -232,14 +232,14 @@ export const makeMessageChannelInternal = ({
232
232
  // Now we wait for the other side to respond via the channel
233
233
  yield* channel.listen.pipe(
234
234
  Stream.flatten(),
235
- Stream.filter(Schema.is(MeshSchema.MessageChannelPing)),
235
+ Stream.filter(Schema.is(MeshSchema.DirectChannelPing)),
236
236
  Stream.take(1),
237
237
  Stream.runDrain,
238
238
  )
239
239
 
240
240
  // span?.addEvent(`winner side: sending pong`)
241
241
 
242
- yield* channel.send(MeshSchema.MessageChannelPong.make({}))
242
+ yield* channel.send(MeshSchema.DirectChannelPong.make({}))
243
243
 
244
244
  span?.addEvent(`winner side: established`)
245
245
  channelStateRef.current = { _tag: 'Established', otherSourceId: packet.sourceId }
@@ -247,20 +247,20 @@ export const makeMessageChannelInternal = ({
247
247
  yield* Deferred.succeed(deferred, channel)
248
248
  } else {
249
249
  span?.addEvent(`loser side: waiting for response`)
250
- // Wait for `MessageChannelResponseSuccess` packet
250
+ // Wait for `DirectChannelResponseSuccess` packet
251
251
  channelStateRef.current = { _tag: 'loser:WaitingForResponse', otherSourceId: packet.sourceId }
252
252
  }
253
253
 
254
254
  break
255
255
  }
256
- case 'MessageChannelResponseSuccess': {
256
+ case 'DirectChannelResponseSuccess': {
257
257
  if (channelState._tag !== 'loser:WaitingForResponse') {
258
258
  return shouldNeverHappen(
259
- `Expected to find message channel response from ${target}, but was in ${channelState._tag} state`,
259
+ `Expected to find direct channel response from ${target}, but was in ${channelState._tag} state`,
260
260
  )
261
261
  }
262
262
 
263
- // See message-channel notes above
263
+ // See direct-channel notes above
264
264
  const channel = yield* WebChannel.messagePortChannelWithAck({
265
265
  port: packet.port,
266
266
  schema,
@@ -269,7 +269,7 @@ export const makeMessageChannelInternal = ({
269
269
 
270
270
  const waitForPongFiber = yield* channel.listen.pipe(
271
271
  Stream.flatten(),
272
- Stream.filter(Schema.is(MeshSchema.MessageChannelPong)),
272
+ Stream.filter(Schema.is(MeshSchema.DirectChannelPong)),
273
273
  Stream.take(1),
274
274
  Stream.runDrain,
275
275
  Effect.fork,
@@ -282,7 +282,7 @@ export const makeMessageChannelInternal = ({
282
282
  // TODO write a test that reproduces this issue and fix the root cause ()
283
283
  // https://github.com/livestorejs/livestore/issues/262
284
284
  yield* channel
285
- .send(MeshSchema.MessageChannelPing.make({}))
285
+ .send(MeshSchema.DirectChannelPing.make({}))
286
286
  .pipe(Effect.timeout(10), Effect.retry({ times: 2 }))
287
287
 
288
288
  // span?.addEvent(`loser side: waiting for pong`)
@@ -324,7 +324,7 @@ export const makeMessageChannelInternal = ({
324
324
  }
325
325
 
326
326
  const edgeRequest = Effect.gen(function* () {
327
- const packet = MeshSchema.MessageChannelRequest.make({
327
+ const packet = MeshSchema.DirectChannelRequest.make({
328
328
  source: nodeName,
329
329
  sourceId,
330
330
  target,
@@ -353,4 +353,4 @@ export const makeMessageChannelInternal = ({
353
353
  const channel = yield* deferred
354
354
 
355
355
  return channel
356
- }).pipe(Effect.withSpanScoped(`makeMessageChannel:${channelVersion}`))
356
+ }).pipe(Effect.withSpanScoped(`makeDirectChannel:${channelVersion}`))
@@ -15,8 +15,8 @@ import {
15
15
  import { nanoid } from '@livestore/utils/nanoid'
16
16
 
17
17
  import * as WebmeshSchema from '../mesh-schema.js'
18
- import type { MakeMessageChannelArgs } from './message-channel-internal.js'
19
- import { makeMessageChannelInternal } from './message-channel-internal.js'
18
+ import type { MakeDirectChannelArgs } from './direct-channel-internal.js'
19
+ import { makeDirectChannelInternal } from './direct-channel-internal.js'
20
20
 
21
21
  /**
22
22
  * Behaviour:
@@ -33,7 +33,7 @@ import { makeMessageChannelInternal } from './message-channel-internal.js'
33
33
  *
34
34
  * If needed we can also implement further functionality (like heartbeat) in this wrapper channel.
35
35
  */
36
- export const makeMessageChannel = ({
36
+ export const makeDirectChannel = ({
37
37
  schema,
38
38
  newEdgeAvailablePubSub,
39
39
  channelName,
@@ -42,7 +42,7 @@ export const makeMessageChannel = ({
42
42
  incomingPacketsQueue,
43
43
  target,
44
44
  sendPacket,
45
- }: MakeMessageChannelArgs) =>
45
+ }: MakeDirectChannelArgs) =>
46
46
  Effect.scopeWithCloseable((scope) =>
47
47
  Effect.gen(function* () {
48
48
  /** Only used to identify whether a source is the same instance to know when to reconnect */
@@ -66,7 +66,7 @@ export const makeMessageChannel = ({
66
66
  const resultDeferred = yield* Deferred.make<{
67
67
  channel: WebChannel.WebChannel<any, any>
68
68
  channelVersion: number
69
- makeMessageChannelScope: Scope.CloseableScope
69
+ makeDirectChannelScope: Scope.CloseableScope
70
70
  }>()
71
71
 
72
72
  while (true) {
@@ -75,9 +75,9 @@ export const makeMessageChannel = ({
75
75
 
76
76
  yield* Effect.spanEvent(`Connecting#${channelVersion}`)
77
77
 
78
- const makeMessageChannelScope = yield* Scope.make()
78
+ const makeDirectChannelScope = yield* Scope.make()
79
79
  // Attach the new scope to the parent scope
80
- yield* Effect.addFinalizer((ex) => Scope.close(makeMessageChannelScope, ex))
80
+ yield* Effect.addFinalizer((ex) => Scope.close(makeDirectChannelScope, ex))
81
81
 
82
82
  /**
83
83
  * Expected concurrency behaviour:
@@ -86,7 +86,7 @@ export const makeMessageChannel = ({
86
86
  * - The edge setup succeeds and we can interrupt the waitForNewEdgeFiber
87
87
  * - Tricky paths:
88
88
  * - While a edge is still being setup, we want to re-try when there is a new edge
89
- * - If the edge setup returns a `MessageChannelResponseNoTransferables` error,
89
+ * - If the edge setup returns a `DirectChannelResponseNoTransferables` error,
90
90
  * we want to wait for a new edge and then re-try
91
91
  * - Further notes:
92
92
  * - If the parent scope closes, we want to also interrupt both the edge setup and the waitForNewEdgeFiber
@@ -102,7 +102,7 @@ export const makeMessageChannel = ({
102
102
  Effect.fork,
103
103
  )
104
104
 
105
- const makeChannel = makeMessageChannelInternal({
105
+ const makeChannel = makeDirectChannelInternal({
106
106
  nodeName,
107
107
  sourceId,
108
108
  incomingPacketsQueue,
@@ -113,10 +113,10 @@ export const makeMessageChannel = ({
113
113
  channelVersion,
114
114
  newEdgeAvailablePubSub,
115
115
  sendPacket,
116
- scope: makeMessageChannelScope,
116
+ scope: makeDirectChannelScope,
117
117
  }).pipe(
118
- Scope.extend(makeMessageChannelScope),
119
- Effect.forkIn(makeMessageChannelScope),
118
+ Scope.extend(makeDirectChannelScope),
119
+ Effect.forkIn(makeDirectChannelScope),
120
120
  // Given we only call `Effect.exit` later when joining the fiber,
121
121
  // we don't want Effect to produce a "unhandled error" log message
122
122
  Effect.withUnhandledErrorLogLevel(Option.none()),
@@ -125,16 +125,16 @@ export const makeMessageChannel = ({
125
125
  const raceResult = yield* Effect.raceFirst(makeChannel, waitForNewEdgeFiber.pipe(Effect.disconnect))
126
126
 
127
127
  if (raceResult === 'new-edge') {
128
- yield* Scope.close(makeMessageChannelScope, Exit.fail('new-edge'))
128
+ yield* Scope.close(makeDirectChannelScope, Exit.fail('new-edge'))
129
129
  // We'll try again
130
130
  } else {
131
131
  const channelExit = yield* raceResult.pipe(Effect.exit)
132
132
  if (channelExit._tag === 'Failure') {
133
- yield* Scope.close(makeMessageChannelScope, channelExit)
133
+ yield* Scope.close(makeDirectChannelScope, channelExit)
134
134
 
135
135
  if (
136
136
  Cause.isFailType(channelExit.cause) &&
137
- Schema.is(WebmeshSchema.MessageChannelResponseNoTransferables)(channelExit.cause.error)
137
+ Schema.is(WebmeshSchema.DirectChannelResponseNoTransferables)(channelExit.cause.error)
138
138
  ) {
139
139
  // Only retry when there is a new edge available
140
140
  yield* waitForNewEdgeFiber.pipe(Effect.exit)
@@ -142,14 +142,14 @@ export const makeMessageChannel = ({
142
142
  } else {
143
143
  const channel = channelExit.value
144
144
 
145
- yield* Deferred.succeed(resultDeferred, { channel, makeMessageChannelScope, channelVersion })
145
+ yield* Deferred.succeed(resultDeferred, { channel, makeDirectChannelScope, channelVersion })
146
146
  break
147
147
  }
148
148
  }
149
149
  }
150
150
 
151
151
  // Now we wait until the first channel is established
152
- const { channel, makeMessageChannelScope, channelVersion } = yield* resultDeferred
152
+ const { channel, makeDirectChannelScope, channelVersion } = yield* resultDeferred
153
153
 
154
154
  yield* Effect.spanEvent(`Connected#${channelVersion}`)
155
155
  debugInfo.isConnected = true
@@ -164,7 +164,7 @@ export const makeMessageChannel = ({
164
164
  Stream.tapChunk((chunk) => Queue.offerAll(listenQueue, chunk)),
165
165
  Stream.runDrain,
166
166
  Effect.tapCauseLogPretty,
167
- Effect.forkIn(makeMessageChannelScope),
167
+ Effect.forkIn(makeDirectChannelScope),
168
168
  )
169
169
 
170
170
  yield* Effect.gen(function* () {
@@ -177,12 +177,12 @@ export const makeMessageChannel = ({
177
177
  yield* Deferred.succeed(deferred, void 0)
178
178
  yield* TQueue.take(sendQueue) // Remove the message from the queue
179
179
  }
180
- }).pipe(Effect.forkIn(makeMessageChannelScope))
180
+ }).pipe(Effect.forkIn(makeDirectChannelScope))
181
181
 
182
182
  // Wait until the channel is closed and then try to reconnect
183
183
  yield* channel.closedDeferred
184
184
 
185
- yield* Scope.close(makeMessageChannelScope, Exit.succeed('channel-closed'))
185
+ yield* Scope.close(makeDirectChannelScope, Exit.succeed('channel-closed'))
186
186
 
187
187
  yield* Effect.spanEvent(`Disconnected#${channelVersion}`)
188
188
  debugInfo.isConnected = false
@@ -71,6 +71,7 @@ export const makeProxyChannel = ({
71
71
  const channelStateRef = { current: { _tag: 'Initial' } as ProxiedChannelState }
72
72
 
73
73
  const debugInfo = {
74
+ kind: 'proxy-channel',
74
75
  pendingSends: 0,
75
76
  totalSends: 0,
76
77
  connectCounter: 0,
@@ -118,9 +119,15 @@ export const makeProxyChannel = ({
118
119
  const getCombinedChannelId = (otherSideChannelIdCandidate: string) =>
119
120
  [channelIdCandidate, otherSideChannelIdCandidate].sort().join('_')
120
121
 
122
+ const earlyPayloadBuffer = yield* Queue.unbounded<typeof MeshSchema.ProxyChannelPayload.Type>().pipe(
123
+ Effect.acquireRelease(Queue.shutdown),
124
+ )
125
+
121
126
  const processProxyPacket = ({ packet, respondToSender }: ProxyQueueItem) =>
122
127
  Effect.gen(function* () {
123
- // yield* Effect.log(`${nodeName}:processing packet ${packet._tag} from ${packet.source}`)
128
+ // yield* Effect.logDebug(
129
+ // `[${nodeName}] processProxyPacket received: ${packet._tag} from ${packet.source} (reqId: ${packet.id})`,
130
+ // )
124
131
 
125
132
  const otherSideName = packet.source
126
133
  const channelKey = `target:${otherSideName}, channelName:${packet.channelName}` satisfies ChannelKey
@@ -130,19 +137,46 @@ export const makeProxyChannel = ({
130
137
  case 'ProxyChannelRequest': {
131
138
  const combinedChannelId = getCombinedChannelId(packet.channelIdCandidate)
132
139
 
133
- if (channelState._tag === 'Initial' || channelState._tag === 'Established') {
134
- yield* SubscriptionRef.set(connectedStateRef, false)
135
- channelStateRef.current = { _tag: 'Pending', initiatedVia: 'incoming-request' }
136
- yield* Effect.spanEvent(`Reconnecting`).pipe(Effect.withParentSpan(channelSpan))
137
- debugInfo.isConnected = false
138
- debugInfo.connectCounter++
139
-
140
- // If we're already connected, we need to re-establish the edge
141
- if (channelState._tag === 'Established' && channelState.combinedChannelId !== combinedChannelId) {
140
+ // Handle Established state explicitly
141
+ if (channelState._tag === 'Established') {
142
+ // Check if the incoming request is for the *same* channel instance
143
+ if (channelState.combinedChannelId === combinedChannelId) {
144
+ // Already established with the same ID, likely a redundant request.
145
+ // Just respond and stay established.
146
+ // yield* Effect.logDebug(
147
+ // `[${nodeName}] Received redundant ProxyChannelRequest for already established channel instance ${combinedChannelId}. Responding.`,
148
+ // )
149
+ } else {
150
+ // Established, but the incoming request has a different ID.
151
+ // This implies a reconnect scenario where IDs don't match. Reset to Pending and re-initiate.
152
+ yield* Effect.logWarning(
153
+ `[${nodeName}] Received ProxyChannelRequest with different channel ID (${combinedChannelId}) while established with ${channelState.combinedChannelId}. Re-establishing.`,
154
+ )
155
+ yield* SubscriptionRef.set(connectedStateRef, false)
156
+ channelStateRef.current = { _tag: 'Pending', initiatedVia: 'incoming-request' }
157
+ yield* Effect.spanEvent(`Reconnecting (received conflicting ProxyChannelRequest)`).pipe(
158
+ Effect.withParentSpan(channelSpan),
159
+ )
160
+ debugInfo.isConnected = false
161
+ debugInfo.connectCounter++
162
+ // We need to send our own request as well to complete the handshake for the new ID
142
163
  yield* edgeRequest
143
164
  }
165
+ } else if (channelState._tag === 'Initial') {
166
+ // Standard initial connection: set to Pending
167
+ yield* SubscriptionRef.set(connectedStateRef, false) // Ensure connectedStateRef is false if we were somehow Initial but it wasn't false
168
+ channelStateRef.current = { _tag: 'Pending', initiatedVia: 'incoming-request' }
169
+ yield* Effect.spanEvent(`Connecting (received ProxyChannelRequest)`).pipe(
170
+ Effect.withParentSpan(channelSpan),
171
+ )
172
+ debugInfo.isConnected = false // Should be false already, but ensure consistency
173
+ debugInfo.connectCounter++
174
+ // No need to send edgeRequest here, the response acts as our part of the handshake for the incoming request's ID
144
175
  }
176
+ // If state is 'Pending', we are already trying to connect.
177
+ // Just let the response go out, don't change state.
145
178
 
179
+ // Send the response regardless of the initial state (unless an error occurred)
146
180
  yield* respondToSender(
147
181
  MeshSchema.ProxyChannelResponseSuccess.make({
148
182
  reqId: packet.id,
@@ -160,7 +194,6 @@ export const makeProxyChannel = ({
160
194
  }
161
195
  case 'ProxyChannelResponseSuccess': {
162
196
  if (channelState._tag !== 'Pending') {
163
- // return shouldNeverHappen(`Expected proxy channel to be pending but got ${channelState._tag}`)
164
197
  if (
165
198
  channelState._tag === 'Established' &&
166
199
  channelState.combinedChannelId !== packet.combinedChannelId
@@ -168,8 +201,14 @@ export const makeProxyChannel = ({
168
201
  return shouldNeverHappen(
169
202
  `ProxyChannel[${channelKey}]: Expected proxy channel to have the same combinedChannelId as the packet:\n${channelState.combinedChannelId} (channel) === ${packet.combinedChannelId} (packet)`,
170
203
  )
204
+ } else if (channelState._tag === 'Established') {
205
+ // yield* Effect.logDebug(`[${nodeName}] Ignoring redundant ResponseSuccess with same ID ${packet.id}`)
206
+ return
171
207
  } else {
172
- // for now just ignore it but should be looked into (there seems to be some kind of race condition/inefficiency)
208
+ yield* Effect.logWarning(
209
+ `[${nodeName}] Ignoring ResponseSuccess ${packet.id} received in unexpected state ${channelState._tag}`,
210
+ )
211
+ return
173
212
  }
174
213
  }
175
214
 
@@ -182,21 +221,41 @@ export const makeProxyChannel = ({
182
221
 
183
222
  yield* setStateToEstablished(packet.combinedChannelId)
184
223
 
224
+ const establishedState = channelStateRef.current
225
+ if (establishedState._tag === 'Established') {
226
+ //
227
+ const bufferedPackets = yield* Queue.takeAll(earlyPayloadBuffer)
228
+ // yield* Effect.logDebug(
229
+ // `[${nodeName}] Draining early payload buffer (${bufferedPackets.length}) after ResponseSuccess`,
230
+ // )
231
+ for (const bufferedPacket of bufferedPackets) {
232
+ if (establishedState.combinedChannelId !== bufferedPacket.combinedChannelId) {
233
+ yield* Effect.logWarning(
234
+ `[${nodeName}] Discarding buffered payload ${bufferedPacket.id}: Combined channel ID mismatch during drain. Expected ${establishedState.combinedChannelId}, got ${bufferedPacket.combinedChannelId}`,
235
+ )
236
+ continue
237
+ }
238
+ const decodedMessage = yield* Schema.decodeUnknown(establishedState.listenSchema)(
239
+ bufferedPacket.payload,
240
+ )
241
+ yield* establishedState.listenQueue.pipe(Queue.offer(decodedMessage))
242
+ }
243
+ } else {
244
+ yield* Effect.logError(
245
+ `[${nodeName}] State is not Established immediately after setStateToEstablished was called. Cannot drain buffer. State: ${establishedState._tag}`,
246
+ )
247
+ }
248
+
185
249
  return
186
250
  }
187
251
  case 'ProxyChannelPayload': {
188
- if (channelState._tag !== 'Established') {
189
- // return yield* Effect.die(`Not yet connected to ${target}. dropping message`)
190
- yield* Effect.spanEvent(`Not yet connected to ${target}. dropping message`, { packet })
191
- return
192
- }
193
-
194
- if (channelState.combinedChannelId !== packet.combinedChannelId) {
252
+ if (channelState._tag === 'Established' && channelState.combinedChannelId !== packet.combinedChannelId) {
195
253
  return yield* Effect.die(
196
254
  `ProxyChannel[${channelKey}]: Expected proxy channel to have the same combinedChannelId as the packet:\n${channelState.combinedChannelId} (channel) === ${packet.combinedChannelId} (packet)`,
197
255
  )
198
256
  }
199
257
 
258
+ // yield* Effect.logDebug(`[${nodeName}] Received payload reqId: ${packet.id}. Sending Ack.`)
200
259
  yield* respondToSender(
201
260
  MeshSchema.ProxyChannelPayloadAck.make({
202
261
  reqId: packet.id,
@@ -205,25 +264,36 @@ export const makeProxyChannel = ({
205
264
  target,
206
265
  source: nodeName,
207
266
  channelName,
208
- combinedChannelId: channelState.combinedChannelId,
267
+ combinedChannelId:
268
+ channelState._tag === 'Established' ? channelState.combinedChannelId : packet.combinedChannelId,
209
269
  }),
210
270
  )
211
271
 
212
- const decodedMessage = yield* Schema.decodeUnknown(channelState.listenSchema)(packet.payload)
213
- yield* channelState.listenQueue.pipe(Queue.offer(decodedMessage))
214
-
272
+ if (channelState._tag === 'Established') {
273
+ const decodedMessage = yield* Schema.decodeUnknown(channelState.listenSchema)(packet.payload)
274
+ yield* channelState.listenQueue.pipe(Queue.offer(decodedMessage))
275
+ } else {
276
+ // yield* Effect.logDebug(
277
+ // `[${nodeName}] Buffering early payload reqId: ${packet.id} (state: ${channelState._tag})`,
278
+ // )
279
+ yield* Queue.offer(earlyPayloadBuffer, packet)
280
+ }
215
281
  return
216
282
  }
217
283
  case 'ProxyChannelPayloadAck': {
284
+ // yield* Effect.logDebug(`[${nodeName}] Received Ack for reqId: ${packet.reqId}`)
285
+
218
286
  if (channelState._tag !== 'Established') {
219
287
  yield* Effect.spanEvent(`Not yet connected to ${target}. dropping message`)
288
+ yield* Effect.logWarning(
289
+ `[${nodeName}] Received Ack but not established (State: ${channelState._tag}). Dropping Ack for ${packet.reqId}`,
290
+ )
220
291
  return
221
292
  }
222
293
 
223
294
  const ack =
224
295
  channelState.ackMap.get(packet.reqId) ??
225
296
  shouldNeverHappen(`[ProxyChannel[${channelKey}]] Expected ack for ${packet.reqId}`)
226
-
227
297
  yield* Deferred.succeed(ack, void 0)
228
298
 
229
299
  channelState.ackMap.delete(packet.reqId)
@@ -344,15 +414,27 @@ export const makeProxyChannel = ({
344
414
 
345
415
  const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
346
416
 
417
+ const runtime = yield* Effect.runtime()
418
+
347
419
  const webChannel = {
348
420
  [WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
349
421
  send,
350
422
  listen,
351
423
  closedDeferred,
352
- supportsTransferables: true,
424
+ supportsTransferables: false,
353
425
  schema,
354
426
  shutdown: Scope.close(scope, Exit.void),
355
427
  debugInfo,
428
+ ...({
429
+ debug: {
430
+ ping: (message: string = 'ping') =>
431
+ send(WebChannel.DebugPingMessage.make({ message })).pipe(
432
+ Effect.provide(runtime),
433
+ Effect.tapCauseLogPretty,
434
+ Effect.runFork,
435
+ ),
436
+ },
437
+ } as {}),
356
438
  } satisfies WebChannel.WebChannel<any, any>
357
439
 
358
440
  return webChannel as WebChannel.WebChannel<any, any>
package/src/common.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type Effect, Predicate, Schema } from '@livestore/utils/effect'
2
2
 
3
- import type { MessageChannelPacket, Packet, ProxyChannelPacket } from './mesh-schema.js'
3
+ import type { DirectChannelPacket, Packet, ProxyChannelPacket } from './mesh-schema.js'
4
4
 
5
5
  export type ProxyQueueItem = {
6
6
  packet: typeof ProxyChannelPacket.Type
@@ -8,8 +8,8 @@ export type ProxyQueueItem = {
8
8
  }
9
9
 
10
10
  export type MessageQueueItem = {
11
- packet: typeof MessageChannelPacket.Type
12
- respondToSender: (msg: typeof MessageChannelPacket.Type) => Effect.Effect<void>
11
+ packet: typeof DirectChannelPacket.Type
12
+ respondToSender: (msg: typeof DirectChannelPacket.Type) => Effect.Effect<void>
13
13
  }
14
14
 
15
15
  export type MeshNodeName = string
@@ -31,5 +31,13 @@ export const packetAsOtelAttributes = (packet: typeof Packet.Type) => ({
31
31
  packetId: packet.id,
32
32
  'span.label':
33
33
  packet.id + (Predicate.hasProperty(packet, 'reqId') && packet.reqId !== undefined ? ` for ${packet.reqId}` : ''),
34
- ...(packet._tag !== 'MessageChannelResponseSuccess' && packet._tag !== 'ProxyChannelPayload' ? { packet } : {}),
34
+ ...(packet._tag !== 'DirectChannelResponseSuccess' && packet._tag !== 'ProxyChannelPayload' ? { packet } : {}),
35
35
  })
36
+
37
+ export const ListenForChannelResult = Schema.Struct({
38
+ channelName: Schema.String,
39
+ source: Schema.String,
40
+ mode: Schema.Union(Schema.Literal('proxy'), Schema.Literal('direct')),
41
+ })
42
+
43
+ export type ListenForChannelResult = typeof ListenForChannelResult.Type