@livestore/webmesh 0.3.0-dev.10 → 0.3.0-dev.12

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 (51) hide show
  1. package/README.md +20 -1
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/channel/message-channel copy.d.ts +9 -0
  4. package/dist/channel/message-channel copy.d.ts.map +1 -0
  5. package/dist/channel/message-channel copy.js +137 -0
  6. package/dist/channel/message-channel copy.js.map +1 -0
  7. package/dist/channel/message-channel-internal copy.d.ts +42 -0
  8. package/dist/channel/message-channel-internal copy.d.ts.map +1 -0
  9. package/dist/channel/message-channel-internal copy.js +239 -0
  10. package/dist/channel/message-channel-internal copy.js.map +1 -0
  11. package/dist/channel/message-channel-internal.d.ts +26 -0
  12. package/dist/channel/message-channel-internal.d.ts.map +1 -0
  13. package/dist/channel/message-channel-internal.js +217 -0
  14. package/dist/channel/message-channel-internal.js.map +1 -0
  15. package/dist/channel/message-channel.d.ts +21 -19
  16. package/dist/channel/message-channel.d.ts.map +1 -1
  17. package/dist/channel/message-channel.js +128 -162
  18. package/dist/channel/message-channel.js.map +1 -1
  19. package/dist/channel/proxy-channel.d.ts +2 -2
  20. package/dist/channel/proxy-channel.d.ts.map +1 -1
  21. package/dist/channel/proxy-channel.js +7 -5
  22. package/dist/channel/proxy-channel.js.map +1 -1
  23. package/dist/common.d.ts +8 -4
  24. package/dist/common.d.ts.map +1 -1
  25. package/dist/common.js +2 -1
  26. package/dist/common.js.map +1 -1
  27. package/dist/mesh-schema.d.ts +23 -1
  28. package/dist/mesh-schema.d.ts.map +1 -1
  29. package/dist/mesh-schema.js +21 -2
  30. package/dist/mesh-schema.js.map +1 -1
  31. package/dist/node.d.ts +12 -1
  32. package/dist/node.d.ts.map +1 -1
  33. package/dist/node.js +40 -9
  34. package/dist/node.js.map +1 -1
  35. package/dist/node.test.d.ts +1 -1
  36. package/dist/node.test.d.ts.map +1 -1
  37. package/dist/node.test.js +300 -147
  38. package/dist/node.test.js.map +1 -1
  39. package/dist/websocket-connection.d.ts +1 -2
  40. package/dist/websocket-connection.d.ts.map +1 -1
  41. package/dist/websocket-connection.js +5 -4
  42. package/dist/websocket-connection.js.map +1 -1
  43. package/package.json +3 -3
  44. package/src/channel/message-channel-internal.ts +356 -0
  45. package/src/channel/message-channel.ts +183 -311
  46. package/src/channel/proxy-channel.ts +238 -230
  47. package/src/common.ts +3 -1
  48. package/src/mesh-schema.ts +20 -2
  49. package/src/node.test.ts +426 -177
  50. package/src/node.ts +70 -12
  51. package/src/websocket-connection.ts +83 -79
@@ -1,354 +1,226 @@
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,
9
7
  Queue,
10
- Schedule,
8
+ Schema,
9
+ Scope,
11
10
  Stream,
12
- SubscriptionRef,
11
+ TQueue,
13
12
  WebChannel,
14
13
  } 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
-
14
+ import { nanoid } from '@livestore/utils/nanoid'
15
+
16
+ import { WebmeshSchema } from '../mod.js'
17
+ import type { MakeMessageChannelArgs } from './message-channel-internal.js'
18
+ import { makeMessageChannelInternal } from './message-channel-internal.js'
19
+
20
+ /**
21
+ * Behaviour:
22
+ * - Waits until there is an initial connection
23
+ * - Automatically reconnects on disconnect
24
+ *
25
+ * Implementation notes:
26
+ * - We've split up the functionality into a wrapper channel and an internal channel.
27
+ * - The wrapper channel is responsible for:
28
+ * - Forwarding send/listen messages to the internal channel (via a queue)
29
+ * - Establishing the initial channel and reconnecting on disconnect
30
+ * - Listening for new connections as a hint to reconnect if not already connected
31
+ * - The wrapper channel maintains a connection counter which is used as the channel version
32
+ *
33
+ * If needed we can also implement further functionality (like heartbeat) in this wrapper channel.
34
+ */
35
35
  export const makeMessageChannel = ({
36
- nodeName,
37
- queue,
36
+ schema,
38
37
  newConnectionAvailablePubSub,
39
- target,
40
- checkTransferableConnections,
41
38
  channelName,
42
- schema,
39
+ checkTransferableConnections,
40
+ nodeName,
41
+ incomingPacketsQueue,
42
+ target,
43
43
  sendPacket,
44
44
  }: 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 }
45
+ Effect.scopeWithCloseable((scope) =>
46
+ Effect.gen(function* () {
47
+ /** Only used to identify whether a source is the same instance to know when to reconnect */
48
+ const sourceId = nanoid()
49
+
50
+ const listenQueue = yield* Queue.unbounded<any>()
51
+ const sendQueue = yield* TQueue.unbounded<[msg: any, deferred: Deferred.Deferred<void>]>()
52
+
53
+ const initialConnectionDeferred = yield* Deferred.make<void>()
54
+
55
+ const debugInfo = {
56
+ pendingSends: 0,
57
+ totalSends: 0,
58
+ connectCounter: 0,
59
+ isConnected: false,
60
+ innerChannelRef: { current: undefined as WebChannel.WebChannel<any, any> | undefined },
61
+ }
105
62
 
106
- yield* reconnect
63
+ // #region reconnect-loop
64
+ yield* Effect.gen(function* () {
65
+ const resultDeferred = yield* Deferred.make<{
66
+ channel: WebChannel.WebChannel<any, any>
67
+ channelVersion: number
68
+ makeMessageChannelScope: Scope.CloseableScope
69
+ }>()
70
+
71
+ while (true) {
72
+ debugInfo.connectCounter++
73
+ const channelVersion = debugInfo.connectCounter
74
+
75
+ yield* Effect.spanEvent(`Connecting#${channelVersion}`)
76
+
77
+ const makeMessageChannelScope = yield* Scope.make()
78
+ // Attach the new scope to the parent scope
79
+ yield* Effect.addFinalizer((ex) => Scope.close(makeMessageChannelScope, ex))
80
+
81
+ /**
82
+ * Expected concurrency behaviour:
83
+ * - We're concurrently running the connection setup and the waitForNewConnectionFiber
84
+ * - Happy path:
85
+ * - The connection setup succeeds and we can interrupt the waitForNewConnectionFiber
86
+ * - Tricky paths:
87
+ * - While a connection is still being setup, we want to re-try when there is a new connection
88
+ * - If the connection setup returns a `MessageChannelResponseNoTransferables` error,
89
+ * we want to wait for a new connection and then re-try
90
+ * - Further notes:
91
+ * - If the parent scope closes, we want to also interrupt both the connection setup and the waitForNewConnectionFiber
92
+ * - We're creating a separate scope for each connection attempt, which
93
+ * - we'll use to fork the message channel in which allows us to interrupt it later
94
+ * - We need to make sure that "interruption" isn't "bubbling out"
95
+ */
96
+ const waitForNewConnectionFiber = yield* Stream.fromPubSub(newConnectionAvailablePubSub).pipe(
97
+ Stream.tap((connectionName) => Effect.spanEvent(`new-conn:${connectionName}`)),
98
+ Stream.take(1),
99
+ Stream.runDrain,
100
+ Effect.as('new-connection' as const),
101
+ Effect.fork,
102
+ )
107
103
 
108
- return
109
- }
110
-
111
- const thisSideAlsoResponded = channelState._tag === 'ResponseSent'
112
-
113
- const usePortFromThisSide = thisSideAlsoResponded && nodeName > target
114
- yield* Effect.annotateCurrentSpan({ usePortFromThisSide })
115
-
116
- const winnerPort = usePortFromThisSide ? channelState.locallyCreatedPort : packet.port
117
- yield* Deferred.succeed(channelState.deferred, winnerPort)
118
-
119
- return
120
- }
121
- case 'MessageChannelResponseNoTransferables': {
122
- if (channelState._tag === 'Established') return
123
-
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
104
+ const makeChannel = makeMessageChannelInternal({
105
+ nodeName,
106
+ sourceId,
107
+ incomingPacketsQueue,
108
+ target,
109
+ checkTransferableConnections,
110
+ channelName,
111
+ schema,
112
+ channelVersion,
113
+ newConnectionAvailablePubSub,
114
+ sendPacket,
115
+ scope: makeMessageChannelScope,
116
+ }).pipe(Scope.extend(makeMessageChannelScope), Effect.forkIn(makeMessageChannelScope))
117
+
118
+ const res = yield* Effect.raceFirst(makeChannel, waitForNewConnectionFiber.pipe(Effect.disconnect))
119
+
120
+ if (res === 'new-connection') {
121
+ yield* Scope.close(makeMessageChannelScope, Exit.fail('new-connection'))
122
+ // We'll try again
123
+ } else {
124
+ const result = yield* res.pipe(Effect.exit)
125
+ if (result._tag === 'Failure') {
126
+ yield* Scope.close(makeMessageChannelScope, result)
127
+
128
+ if (
129
+ Cause.isFailType(result.cause) &&
130
+ Schema.is(WebmeshSchema.MessageChannelResponseNoTransferables)(result.cause.error)
131
+ ) {
132
+ yield* waitForNewConnectionFiber.pipe(Effect.exit)
156
133
  }
134
+ } else {
135
+ const channel = result.value
157
136
 
137
+ yield* Deferred.succeed(resultDeferred, { channel, makeMessageChannelScope, channelVersion })
158
138
  break
159
139
  }
160
- default: {
161
- return casesHandled(packet)
162
- }
163
140
  }
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 }
198
-
199
- const connectionRequest = Effect.gen(function* () {
200
- const packet = MeshSchema.MessageChannelRequest.make({ source: nodeName, target, channelName, hops: [] })
141
+ }
201
142
 
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
- }
143
+ // Now we wait until the first channel is established
144
+ const { channel, makeMessageChannelScope, channelVersion } = yield* resultDeferred
208
145
 
209
- yield* sendPacket(packet)
210
- })
146
+ yield* Effect.spanEvent(`Connected#${channelVersion}`)
147
+ debugInfo.isConnected = true
148
+ debugInfo.innerChannelRef.current = channel
211
149
 
212
- yield* connectionRequest
150
+ yield* Deferred.succeed(initialConnectionDeferred, void 0)
213
151
 
214
- const retryOnNewConnectionFiber = yield* Stream.fromPubSub(newConnectionAvailablePubSub).pipe(
215
- Stream.tap(() => Effect.spanEvent(`RetryOnNewConnection`)),
216
- Stream.tap(() => connectionRequest),
152
+ // We'll now forward all incoming messages to the listen queue
153
+ yield* channel.listen.pipe(
154
+ Stream.flatten(),
155
+ // Stream.tap((msg) => Effect.log(`${target}→${channelName}→${nodeName}:message:${msg.message}`)),
156
+ Stream.tapChunk((chunk) => Queue.offerAll(listenQueue, chunk)),
217
157
  Stream.runDrain,
218
- Effect.forkScoped,
158
+ Effect.tapCauseLogPretty,
159
+ Effect.forkIn(makeMessageChannelScope),
219
160
  )
220
161
 
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
162
+ yield* Effect.gen(function* () {
163
+ while (true) {
164
+ const [msg, deferred] = yield* TQueue.peek(sendQueue)
165
+ // NOTE we don't need an explicit retry flow here since in case of the channel being closed,
166
+ // the send will never succeed. Meanwhile the send-loop fiber will be interrupted and
167
+ // given we only peeked at the queue, the message to send is still there.
168
+ yield* channel.send(msg)
169
+ yield* Deferred.succeed(deferred, void 0)
170
+ yield* TQueue.take(sendQueue) // Remove the message from the queue
171
+ }
172
+ }).pipe(Effect.forkIn(makeMessageChannelScope))
263
173
 
264
- yield* SubscriptionRef.set(internalChannelSref, internalChannel)
174
+ // Wait until the channel is closed and then try to reconnect
175
+ yield* channel.closedDeferred
265
176
 
266
- yield* Effect.spanEvent(`Connected#${connectCount}`)
177
+ yield* Scope.close(makeMessageChannelScope, Exit.succeed('channel-closed'))
267
178
 
268
- yield* internalChannel.listen.pipe(
269
- Stream.flatten(),
270
- Stream.tap((msg) => Queue.offer(listenQueue, msg)),
271
- Stream.runDrain,
179
+ yield* Effect.spanEvent(`Disconnected#${channelVersion}`)
180
+ debugInfo.isConnected = false
181
+ debugInfo.innerChannelRef.current = undefined
182
+ }).pipe(
183
+ Effect.scoped, // Additionally scoping here to clean up finalizers after each loop run
184
+ Effect.forever,
272
185
  Effect.tapCauseLogPretty,
273
186
  Effect.forkScoped,
274
187
  )
188
+ // #endregion reconnect-loop
275
189
 
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
- )
190
+ const parentSpan = yield* Effect.currentSpan.pipe(Effect.orDie)
303
191
 
304
- const parentSpan = yield* Effect.currentSpan.pipe(Effect.orDie)
305
-
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))
192
+ const send = (message: any) =>
193
+ Effect.gen(function* () {
194
+ const sentDeferred = yield* Deferred.make<void>()
325
195
 
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
- )
196
+ debugInfo.pendingSends++
197
+ debugInfo.totalSends++
332
198
 
333
- yield* FiberHandle.run(sendFiberHandle, trySend)
199
+ yield* TQueue.offer(sendQueue, [message, sentDeferred])
334
200
 
335
- yield* sentDeferred
201
+ yield* sentDeferred
336
202
 
337
- yield* Fiber.interrupt(rerunOnNewChannelFiber)
338
- }).pipe(Effect.scoped, Effect.withParentSpan(parentSpan))
203
+ debugInfo.pendingSends--
204
+ }).pipe(Effect.scoped, Effect.withParentSpan(parentSpan))
339
205
 
340
- const listen = Stream.fromQueue(listenQueue).pipe(Stream.map(Either.right))
206
+ const listen = Stream.fromQueue(listenQueue, { maxChunkSize: 1 }).pipe(Stream.map(Either.right))
341
207
 
342
- const closedDeferred = yield* Deferred.make<void>()
208
+ const closedDeferred = yield* Deferred.make<void>().pipe(Effect.acquireRelease(Deferred.done(Exit.void)))
343
209
 
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>
210
+ const webChannel = {
211
+ [WebChannel.WebChannelSymbol]: WebChannel.WebChannelSymbol,
212
+ send,
213
+ listen,
214
+ closedDeferred,
215
+ supportsTransferables: true,
216
+ schema,
217
+ debugInfo,
218
+ shutdown: Scope.close(scope, Exit.succeed('shutdown')),
219
+ } satisfies WebChannel.WebChannel<any, any>
352
220
 
353
- return webChannel as WebChannel.WebChannel<any, any>
354
- }).pipe(Effect.withSpanScoped('makeMessageChannel'))
221
+ return {
222
+ webChannel: webChannel as WebChannel.WebChannel<any, any>,
223
+ initialConnectionDeferred,
224
+ }
225
+ }),
226
+ )