@livestore/webmesh 0.3.0-dev.2 → 0.3.0-dev.22

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 (58) hide show
  1. package/README.md +26 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/message-channel-internal.d.ts +26 -0
  4. package/dist/channel/message-channel-internal.d.ts.map +1 -0
  5. package/dist/channel/message-channel-internal.js +217 -0
  6. package/dist/channel/message-channel-internal.js.map +1 -0
  7. package/dist/channel/message-channel.d.ts +21 -19
  8. package/dist/channel/message-channel.d.ts.map +1 -1
  9. package/dist/channel/message-channel.js +132 -162
  10. package/dist/channel/message-channel.js.map +1 -1
  11. package/dist/channel/proxy-channel.d.ts +3 -3
  12. package/dist/channel/proxy-channel.d.ts.map +1 -1
  13. package/dist/channel/proxy-channel.js +38 -19
  14. package/dist/channel/proxy-channel.js.map +1 -1
  15. package/dist/common.d.ts +36 -14
  16. package/dist/common.d.ts.map +1 -1
  17. package/dist/common.js +7 -4
  18. package/dist/common.js.map +1 -1
  19. package/dist/mesh-schema.d.ts +71 -5
  20. package/dist/mesh-schema.d.ts.map +1 -1
  21. package/dist/mesh-schema.js +55 -6
  22. package/dist/mesh-schema.js.map +1 -1
  23. package/dist/mod.d.ts +2 -2
  24. package/dist/mod.d.ts.map +1 -1
  25. package/dist/mod.js +2 -2
  26. package/dist/mod.js.map +1 -1
  27. package/dist/node.d.ts +43 -21
  28. package/dist/node.d.ts.map +1 -1
  29. package/dist/node.js +271 -95
  30. package/dist/node.js.map +1 -1
  31. package/dist/node.test.d.ts +1 -1
  32. package/dist/node.test.d.ts.map +1 -1
  33. package/dist/node.test.js +391 -156
  34. package/dist/node.test.js.map +1 -1
  35. package/dist/websocket-connection.d.ts +6 -7
  36. package/dist/websocket-connection.d.ts.map +1 -1
  37. package/dist/websocket-connection.js +21 -26
  38. package/dist/websocket-connection.js.map +1 -1
  39. package/dist/websocket-edge.d.ts +50 -0
  40. package/dist/websocket-edge.d.ts.map +1 -0
  41. package/dist/websocket-edge.js +69 -0
  42. package/dist/websocket-edge.js.map +1 -0
  43. package/dist/websocket-server.d.ts.map +1 -1
  44. package/dist/websocket-server.js +23 -9
  45. package/dist/websocket-server.js.map +1 -1
  46. package/package.json +7 -6
  47. package/src/channel/message-channel-internal.ts +356 -0
  48. package/src/channel/message-channel.ts +190 -310
  49. package/src/channel/proxy-channel.ts +259 -231
  50. package/src/common.ts +12 -13
  51. package/src/mesh-schema.ts +62 -6
  52. package/src/mod.ts +2 -2
  53. package/src/node.test.ts +554 -189
  54. package/src/node.ts +417 -134
  55. package/src/websocket-edge.ts +159 -0
  56. package/src/websocket-server.ts +26 -9
  57. package/tmp/pack.tgz +0 -0
  58. package/src/websocket-connection.ts +0 -158
@@ -1,354 +1,234 @@
1
- import { casesHandled, shouldNeverHappen } from '@livestore/utils'
2
- import type { PubSub, Schema, Scope } from '@livestore/utils/effect'
3
1
  import {
2
+ Cause,
4
3
  Deferred,
5
4
  Effect,
6
5
  Either,
7
- Fiber,
8
- FiberHandle,
6
+ Exit,
7
+ Option,
9
8
  Queue,
10
- Schedule,
9
+ Schema,
10
+ Scope,
11
11
  Stream,
12
- SubscriptionRef,
12
+ TQueue,
13
13
  WebChannel,
14
14
  } from '@livestore/utils/effect'
15
-
16
- import { type ChannelName, type MeshNodeName, type MessageQueueItem, packetAsOtelAttributes } from '../common.js'
17
- import * as MeshSchema from '../mesh-schema.js'
18
-
19
- interface MakeMessageChannelArgs {
20
- nodeName: MeshNodeName
21
- queue: Queue.Queue<MessageQueueItem>
22
- newConnectionAvailablePubSub: PubSub.PubSub<MeshNodeName>
23
- channelName: ChannelName
24
- target: MeshNodeName
25
- sendPacket: (packet: typeof MeshSchema.MessageChannelPacket.Type) => Effect.Effect<void>
26
- checkTransferableConnections: (
27
- packet: typeof MeshSchema.MessageChannelPacket.Type,
28
- ) => typeof MeshSchema.MessageChannelResponseNoTransferables.Type | undefined
29
- schema: {
30
- send: Schema.Schema<any, any>
31
- listen: Schema.Schema<any, any>
32
- }
33
- }
34
-
15
+ import { nanoid } from '@livestore/utils/nanoid'
16
+
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'
20
+
21
+ /**
22
+ * Behaviour:
23
+ * - Waits until there is an initial edge
24
+ * - Automatically reconnects on disconnect
25
+ *
26
+ * Implementation notes:
27
+ * - We've split up the functionality into a wrapper channel and an internal channel.
28
+ * - The wrapper channel is responsible for:
29
+ * - Forwarding send/listen messages to the internal channel (via a queue)
30
+ * - Establishing the initial channel and reconnecting on disconnect
31
+ * - Listening for new edges as a hint to reconnect if not already connected
32
+ * - The wrapper channel maintains a edge counter which is used as the channel version
33
+ *
34
+ * If needed we can also implement further functionality (like heartbeat) in this wrapper channel.
35
+ */
35
36
  export const makeMessageChannel = ({
37
+ schema,
38
+ newEdgeAvailablePubSub,
39
+ channelName,
40
+ checkTransferableEdges,
36
41
  nodeName,
37
- queue,
38
- newConnectionAvailablePubSub,
42
+ incomingPacketsQueue,
39
43
  target,
40
- checkTransferableConnections,
41
- channelName,
42
- schema,
43
44
  sendPacket,
44
45
  }: MakeMessageChannelArgs) =>
45
- Effect.gen(function* () {
46
- const reconnectTriggerQueue = yield* Queue.unbounded<void>()
47
- const reconnect = Queue.offer(reconnectTriggerQueue, void 0)
48
-
49
- type ChannelState =
50
- | { _tag: 'Established' }
51
- | {
52
- _tag: 'Initial'
53
- deferred: Deferred.Deferred<MessagePort, typeof MeshSchema.MessageChannelResponseNoTransferables.Type>
54
- }
55
- | {
56
- _tag: 'RequestSent'
57
- deferred: Deferred.Deferred<MessagePort, typeof MeshSchema.MessageChannelResponseNoTransferables.Type>
58
- }
59
- | {
60
- _tag: 'ResponseSent'
61
- // Set in the case where this side already received a request, and created a port.
62
- // Might be used or discarded based on tie-breaking logic below.
63
- locallyCreatedPort: MessagePort
64
- deferred: Deferred.Deferred<MessagePort, typeof MeshSchema.MessageChannelResponseNoTransferables.Type>
65
- }
66
-
67
- const makeInitialState = Effect.gen(function* () {
68
- const deferred = yield* Deferred.make<MessagePort, typeof MeshSchema.MessageChannelResponseNoTransferables.Type>()
69
- return { _tag: 'Initial', deferred } as ChannelState
70
- })
71
-
72
- const channelStateRef = { current: yield* makeInitialState }
73
-
74
- const makeMessageChannelInternal: Effect.Effect<
75
- WebChannel.WebChannel<any, any, never>,
76
- never,
77
- Scope.Scope
78
- > = Effect.gen(function* () {
79
- const processMessagePacket = ({ packet, respondToSender }: MessageQueueItem) =>
80
- Effect.gen(function* () {
81
- const channelState = channelStateRef.current
82
-
83
- // yield* Effect.log(`${nodeName}:processing packet ${packet._tag}, channel state: ${channelState._tag}`)
84
-
85
- switch (packet._tag) {
86
- // Since there can be concurrent MessageChannel responses from both sides,
87
- // we need to decide which side's port we want to use and which side's port we want to ignore.
88
- // This is only relevant in the case where both sides already sent their responses.
89
- // In this case we're using the target name as a "tie breaker" to decide which side's port to use.
90
- // We do this by sorting the target names lexicographically and use the first one as the winner.
91
- case 'MessageChannelResponseSuccess': {
92
- if (channelState._tag === 'Initial') {
93
- return shouldNeverHappen(
94
- `Expected to find message channel request from ${target}, but was in ${channelState._tag} state`,
95
- )
96
- }
97
-
98
- if (channelState._tag === 'Established') {
99
- const deferred = yield* Deferred.make<
100
- MessagePort,
101
- typeof MeshSchema.MessageChannelResponseNoTransferables.Type
102
- >()
103
-
104
- channelStateRef.current = { _tag: 'RequestSent', deferred }
105
-
106
- yield* reconnect
107
-
108
- return
109
- }
110
-
111
- const thisSideAlsoResponded = channelState._tag === 'ResponseSent'
112
-
113
- const usePortFromThisSide = thisSideAlsoResponded && nodeName > target
114
- yield* Effect.annotateCurrentSpan({ usePortFromThisSide })
46
+ Effect.scopeWithCloseable((scope) =>
47
+ Effect.gen(function* () {
48
+ /** Only used to identify whether a source is the same instance to know when to reconnect */
49
+ const sourceId = nanoid()
50
+
51
+ const listenQueue = yield* Queue.unbounded<any>()
52
+ const sendQueue = yield* TQueue.unbounded<[msg: any, deferred: Deferred.Deferred<void>]>()
53
+
54
+ const initialEdgeDeferred = yield* Deferred.make<void>()
55
+
56
+ const debugInfo = {
57
+ pendingSends: 0,
58
+ totalSends: 0,
59
+ connectCounter: 0,
60
+ isConnected: false,
61
+ innerChannelRef: { current: undefined as WebChannel.WebChannel<any, any> | undefined },
62
+ }
115
63
 
116
- const winnerPort = usePortFromThisSide ? channelState.locallyCreatedPort : packet.port
117
- yield* Deferred.succeed(channelState.deferred, winnerPort)
64
+ // #region reconnect-loop
65
+ yield* Effect.gen(function* () {
66
+ const resultDeferred = yield* Deferred.make<{
67
+ channel: WebChannel.WebChannel<any, any>
68
+ channelVersion: number
69
+ makeMessageChannelScope: Scope.CloseableScope
70
+ }>()
71
+
72
+ while (true) {
73
+ debugInfo.connectCounter++
74
+ const channelVersion = debugInfo.connectCounter
75
+
76
+ yield* Effect.spanEvent(`Connecting#${channelVersion}`)
77
+
78
+ const makeMessageChannelScope = yield* Scope.make()
79
+ // Attach the new scope to the parent scope
80
+ yield* Effect.addFinalizer((ex) => Scope.close(makeMessageChannelScope, ex))
81
+
82
+ /**
83
+ * Expected concurrency behaviour:
84
+ * - We're concurrently running the edge setup and the waitForNewEdgeFiber
85
+ * - Happy path:
86
+ * - The edge setup succeeds and we can interrupt the waitForNewEdgeFiber
87
+ * - Tricky paths:
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,
90
+ * we want to wait for a new edge and then re-try
91
+ * - Further notes:
92
+ * - If the parent scope closes, we want to also interrupt both the edge setup and the waitForNewEdgeFiber
93
+ * - We're creating a separate scope for each edge attempt, which
94
+ * - we'll use to fork the message channel in which allows us to interrupt it later
95
+ * - We need to make sure that "interruption" isn't "bubbling out"
96
+ */
97
+ const waitForNewEdgeFiber = yield* Stream.fromPubSub(newEdgeAvailablePubSub).pipe(
98
+ Stream.tap((edgeName) => Effect.spanEvent(`new-conn:${edgeName}`)),
99
+ Stream.take(1),
100
+ Stream.runDrain,
101
+ Effect.as('new-edge' as const),
102
+ Effect.fork,
103
+ )
118
104
 
119
- return
120
- }
121
- case 'MessageChannelResponseNoTransferables': {
122
- if (channelState._tag === 'Established') return
105
+ const makeChannel = makeMessageChannelInternal({
106
+ nodeName,
107
+ sourceId,
108
+ incomingPacketsQueue,
109
+ target,
110
+ checkTransferableEdges,
111
+ channelName,
112
+ schema,
113
+ channelVersion,
114
+ newEdgeAvailablePubSub,
115
+ sendPacket,
116
+ scope: makeMessageChannelScope,
117
+ }).pipe(
118
+ Scope.extend(makeMessageChannelScope),
119
+ Effect.forkIn(makeMessageChannelScope),
120
+ // Given we only call `Effect.exit` later when joining the fiber,
121
+ // we don't want Effect to produce a "unhandled error" log message
122
+ Effect.withUnhandledErrorLogLevel(Option.none()),
123
+ )
123
124
 
124
- yield* Deferred.fail(channelState!.deferred, packet)
125
- channelStateRef.current = yield* makeInitialState
126
- return
127
- }
128
- case 'MessageChannelRequest': {
129
- const mc = new MessageChannel()
130
-
131
- const shouldReconnect = channelState._tag === 'Established'
132
-
133
- const deferred =
134
- channelState._tag === 'Established'
135
- ? yield* Deferred.make<MessagePort, typeof MeshSchema.MessageChannelResponseNoTransferables.Type>()
136
- : channelState.deferred
137
-
138
- channelStateRef.current = { _tag: 'ResponseSent', locallyCreatedPort: mc.port1, deferred }
139
-
140
- yield* respondToSender(
141
- MeshSchema.MessageChannelResponseSuccess.make({
142
- reqId: packet.id,
143
- target,
144
- source: nodeName,
145
- channelName: packet.channelName,
146
- hops: [],
147
- remainingHops: packet.hops,
148
- port: mc.port2,
149
- }),
150
- )
151
-
152
- // If there's an established channel, we use the new request as a signal
153
- // to drop the old channel and use the new one
154
- if (shouldReconnect) {
155
- yield* reconnect
125
+ const raceResult = yield* Effect.raceFirst(makeChannel, waitForNewEdgeFiber.pipe(Effect.disconnect))
126
+
127
+ if (raceResult === 'new-edge') {
128
+ yield* Scope.close(makeMessageChannelScope, Exit.fail('new-edge'))
129
+ // We'll try again
130
+ } else {
131
+ const channelExit = yield* raceResult.pipe(Effect.exit)
132
+ if (channelExit._tag === 'Failure') {
133
+ yield* Scope.close(makeMessageChannelScope, channelExit)
134
+
135
+ if (
136
+ Cause.isFailType(channelExit.cause) &&
137
+ Schema.is(WebmeshSchema.MessageChannelResponseNoTransferables)(channelExit.cause.error)
138
+ ) {
139
+ // Only retry when there is a new edge available
140
+ yield* waitForNewEdgeFiber.pipe(Effect.exit)
156
141
  }
142
+ } else {
143
+ const channel = channelExit.value
157
144
 
145
+ yield* Deferred.succeed(resultDeferred, { channel, makeMessageChannelScope, channelVersion })
158
146
  break
159
147
  }
160
- default: {
161
- return casesHandled(packet)
162
- }
163
148
  }
164
- }).pipe(
165
- Effect.withSpan(`handleMessagePacket:${packet._tag}:${packet.source}→${packet.target}`, {
166
- attributes: packetAsOtelAttributes(packet),
167
- }),
168
- )
169
-
170
- yield* Stream.fromQueue(queue).pipe(
171
- Stream.tap(processMessagePacket),
172
- Stream.runDrain,
173
- Effect.tapCauseLogPretty,
174
- Effect.forkScoped,
175
- )
176
-
177
- const channelFromPort = (port: MessagePort) =>
178
- Effect.gen(function* () {
179
- channelStateRef.current = { _tag: 'Established' }
180
-
181
- // NOTE to support re-connects we need to ack each message
182
- const channel = yield* WebChannel.messagePortChannelWithAck({ port, schema })
183
-
184
- return channel
185
- })
186
-
187
- const channelState = channelStateRef.current
188
-
189
- if (channelState._tag === 'Initial' || channelState._tag === 'RequestSent') {
190
- // Important to make a new deferred here as the old one might have been used already
191
- // TODO model this better
192
- const deferred =
193
- channelState._tag === 'RequestSent'
194
- ? yield* Deferred.make<MessagePort, typeof MeshSchema.MessageChannelResponseNoTransferables.Type>()
195
- : channelState.deferred
196
-
197
- channelStateRef.current = { _tag: 'RequestSent', deferred }
149
+ }
198
150
 
199
- const connectionRequest = Effect.gen(function* () {
200
- const packet = MeshSchema.MessageChannelRequest.make({ source: nodeName, target, channelName, hops: [] })
151
+ // Now we wait until the first channel is established
152
+ const { channel, makeMessageChannelScope, channelVersion } = yield* resultDeferred
201
153
 
202
- const noTransferableResponse = checkTransferableConnections(packet)
203
- if (noTransferableResponse !== undefined) {
204
- yield* Effect.spanEvent(`No transferable connections found for ${packet.source}→${packet.target}`)
205
- yield* Deferred.fail(deferred, noTransferableResponse)
206
- return
207
- }
154
+ yield* Effect.spanEvent(`Connected#${channelVersion}`)
155
+ debugInfo.isConnected = true
156
+ debugInfo.innerChannelRef.current = channel
208
157
 
209
- yield* sendPacket(packet)
210
- })
158
+ yield* Deferred.succeed(initialEdgeDeferred, void 0)
211
159
 
212
- yield* connectionRequest
213
-
214
- const retryOnNewConnectionFiber = yield* Stream.fromPubSub(newConnectionAvailablePubSub).pipe(
215
- Stream.tap(() => Effect.spanEvent(`RetryOnNewConnection`)),
216
- Stream.tap(() => connectionRequest),
160
+ // We'll now forward all incoming messages to the listen queue
161
+ yield* channel.listen.pipe(
162
+ Stream.flatten(),
163
+ // Stream.tap((msg) => Effect.log(`${target}→${channelName}→${nodeName}:message:${msg.message}`)),
164
+ Stream.tapChunk((chunk) => Queue.offerAll(listenQueue, chunk)),
217
165
  Stream.runDrain,
218
- Effect.forkScoped,
166
+ Effect.tapCauseLogPretty,
167
+ Effect.forkIn(makeMessageChannelScope),
219
168
  )
220
169
 
221
- const portResult = yield* deferred.pipe(Effect.either)
222
- yield* Fiber.interrupt(retryOnNewConnectionFiber)
223
-
224
- if (portResult._tag === 'Right') {
225
- return yield* channelFromPort(portResult.right)
226
- } else {
227
- // We'll keep retrying with a new connection
228
- yield* Stream.fromPubSub(newConnectionAvailablePubSub).pipe(Stream.take(1), Stream.runDrain)
229
-
230
- yield* reconnect
231
-
232
- return yield* Effect.interrupt
233
- }
234
- } else {
235
- // In this case we've already received a request from the other side (before we had a chance to send our request),
236
- // so we already created a MessageChannel,responded with one port
237
- // and are now using the other port to create the channel.
238
- if (channelState._tag === 'ResponseSent') {
239
- return yield* channelFromPort(channelState.locallyCreatedPort)
240
- } else {
241
- return shouldNeverHappen(
242
- `Expected pending message channel to be in ResponseSent state, but was in ${channelState._tag} state`,
243
- )
244
- }
245
- }
246
- })
247
-
248
- const internalChannelSref = yield* SubscriptionRef.make<WebChannel.WebChannel<any, any> | false>(false)
249
-
250
- const listenQueue = yield* Queue.unbounded<any>()
251
-
252
- let connectCounter = 0
253
-
254
- const connect = Effect.gen(function* () {
255
- const connectCount = ++connectCounter
256
- yield* Effect.spanEvent(`Connecting#${connectCount}`)
257
-
258
- yield* SubscriptionRef.set(internalChannelSref, false)
259
-
260
- yield* Effect.addFinalizer(() => Effect.spanEvent(`Disconnected#${connectCount}`))
261
-
262
- const internalChannel = yield* makeMessageChannelInternal
170
+ yield* Effect.gen(function* () {
171
+ while (true) {
172
+ const [msg, deferred] = yield* TQueue.peek(sendQueue)
173
+ // NOTE we don't need an explicit retry flow here since in case of the channel being closed,
174
+ // the send will never succeed. Meanwhile the send-loop fiber will be interrupted and
175
+ // given we only peeked at the queue, the message to send is still there.
176
+ yield* channel.send(msg)
177
+ yield* Deferred.succeed(deferred, void 0)
178
+ yield* TQueue.take(sendQueue) // Remove the message from the queue
179
+ }
180
+ }).pipe(Effect.forkIn(makeMessageChannelScope))
263
181
 
264
- yield* SubscriptionRef.set(internalChannelSref, internalChannel)
182
+ // Wait until the channel is closed and then try to reconnect
183
+ yield* channel.closedDeferred
265
184
 
266
- yield* Effect.spanEvent(`Connected#${connectCount}`)
185
+ yield* Scope.close(makeMessageChannelScope, Exit.succeed('channel-closed'))
267
186
 
268
- yield* internalChannel.listen.pipe(
269
- Stream.flatten(),
270
- Stream.tap((msg) => Queue.offer(listenQueue, msg)),
271
- Stream.runDrain,
187
+ yield* Effect.spanEvent(`Disconnected#${channelVersion}`)
188
+ debugInfo.isConnected = false
189
+ debugInfo.innerChannelRef.current = undefined
190
+ }).pipe(
191
+ Effect.scoped, // Additionally scoping here to clean up finalizers after each loop run
192
+ Effect.forever,
272
193
  Effect.tapCauseLogPretty,
273
194
  Effect.forkScoped,
274
195
  )
196
+ // #endregion reconnect-loop
275
197
 
276
- yield* Effect.never
277
- }).pipe(Effect.scoped)
278
-
279
- const fiberHandle = yield* FiberHandle.make<void, never>()
280
-
281
- const runConnect = Effect.gen(function* () {
282
- // Cleanly shutdown the previous connection first
283
- // Otherwise the old and new connection will "overlap"
284
- yield* FiberHandle.clear(fiberHandle)
285
- yield* FiberHandle.run(fiberHandle, connect)
286
- })
287
-
288
- yield* runConnect
289
-
290
- // Then listen for reconnects
291
- yield* Stream.fromQueue(reconnectTriggerQueue).pipe(
292
- Stream.tap(() => runConnect),
293
- Stream.runDrain,
294
- Effect.tapCauseLogPretty,
295
- Effect.forkScoped,
296
- )
297
-
298
- // Wait for the initial connection to be established or for an error to occur
299
- yield* Effect.raceFirst(
300
- SubscriptionRef.waitUntil(internalChannelSref, (channel) => channel !== false),
301
- FiberHandle.join(fiberHandle),
302
- )
303
-
304
- const parentSpan = yield* Effect.currentSpan.pipe(Effect.orDie)
198
+ const parentSpan = yield* Effect.currentSpan.pipe(Effect.orDie)
305
199
 
306
- const send = (message: any) =>
307
- Effect.gen(function* () {
308
- const sendFiberHandle = yield* FiberHandle.make<void, never>()
309
-
310
- const sentDeferred = yield* Deferred.make<void>()
311
-
312
- const trySend = Effect.gen(function* () {
313
- const channel = (yield* SubscriptionRef.waitUntil(
314
- internalChannelSref,
315
- (channel) => channel !== false,
316
- )) as WebChannel.WebChannel<any, any>
317
-
318
- const innerSend = Effect.gen(function* () {
319
- yield* channel.send(message)
320
- yield* Deferred.succeed(sentDeferred, void 0)
321
- })
322
-
323
- yield* innerSend.pipe(Effect.timeout(100), Effect.retry(Schedule.exponential(100)), Effect.orDie)
324
- }).pipe(Effect.tapErrorCause(Effect.logError))
200
+ const send = (message: any) =>
201
+ Effect.gen(function* () {
202
+ const sentDeferred = yield* Deferred.make<void>()
325
203
 
326
- const rerunOnNewChannelFiber = yield* internalChannelSref.changes.pipe(
327
- Stream.filter((_) => _ === false),
328
- Stream.tap(() => FiberHandle.run(sendFiberHandle, trySend)),
329
- Stream.runDrain,
330
- Effect.fork,
331
- )
204
+ debugInfo.pendingSends++
205
+ debugInfo.totalSends++
332
206
 
333
- yield* FiberHandle.run(sendFiberHandle, trySend)
207
+ yield* TQueue.offer(sendQueue, [message, sentDeferred])
334
208
 
335
- yield* sentDeferred
209
+ yield* sentDeferred
336
210
 
337
- yield* Fiber.interrupt(rerunOnNewChannelFiber)
338
- }).pipe(Effect.scoped, Effect.withParentSpan(parentSpan))
211
+ debugInfo.pendingSends--
212
+ }).pipe(Effect.scoped, Effect.withParentSpan(parentSpan))
339
213
 
340
- const listen = Stream.fromQueue(listenQueue).pipe(Stream.map(Either.right))
214
+ const listen = Stream.fromQueue(listenQueue, { maxChunkSize: 1 }).pipe(Stream.map(Either.right))
341
215
 
342
- const closedDeferred = yield* Deferred.make<void>()
216
+ const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
343
217
 
344
- const webChannel = {
345
- [WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
346
- send,
347
- listen,
348
- closedDeferred,
349
- supportsTransferables: true,
350
- schema,
351
- } satisfies WebChannel.WebChannel<any, any>
218
+ const webChannel = {
219
+ [WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
220
+ send,
221
+ listen,
222
+ closedDeferred,
223
+ supportsTransferables: true,
224
+ schema,
225
+ debugInfo,
226
+ shutdown: Scope.close(scope, Exit.succeed('shutdown')),
227
+ } satisfies WebChannel.WebChannel<any, any>
352
228
 
353
- return webChannel as WebChannel.WebChannel<any, any>
354
- }).pipe(Effect.withSpanScoped('makeMessageChannel'))
229
+ return {
230
+ webChannel: webChannel as WebChannel.WebChannel<any, any>,
231
+ initialEdgeDeferred,
232
+ }
233
+ }),
234
+ )