starpc 0.49.10 → 0.49.13

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.
@@ -4,6 +4,7 @@ export interface ChannelStreamMessage<T> {
4
4
  ack?: true;
5
5
  opened?: true;
6
6
  closed?: true;
7
+ teardown?: true;
7
8
  error?: Error;
8
9
  data?: T;
9
10
  }
@@ -32,6 +33,8 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
32
33
  private keepAlive?;
33
34
  private idleWatchdog?;
34
35
  private closed;
36
+ private localWriteClosed;
37
+ private remoteWriteClosed;
35
38
  get isAcked(): boolean;
36
39
  get isOpen(): boolean;
37
40
  get isIdlePaused(): boolean;
@@ -40,6 +43,9 @@ export declare class ChannelStream<T = Uint8Array> implements Duplex<AsyncGenera
40
43
  private idleElapsed;
41
44
  private keepAliveElapsed;
42
45
  private finish;
46
+ private finishIfBothDirectionsClosed;
47
+ private clearKeepAlive;
48
+ private clearIdleWatchdog;
43
49
  close(error?: Error): void;
44
50
  pauseIdle(): void;
45
51
  resumeIdle(): void;
@@ -39,6 +39,10 @@ export class ChannelStream {
39
39
  idleWatchdog;
40
40
  // closed indicates the local channel has been torn down.
41
41
  closed = false;
42
+ // localWriteClosed indicates the local sink finished normally.
43
+ localWriteClosed = false;
44
+ // remoteWriteClosed indicates the remote sink finished normally.
45
+ remoteWriteClosed = false;
42
46
  // isAcked checks if the stream is acknowledged by the remote.
43
47
  get isAcked() {
44
48
  return this.remoteAck ?? false;
@@ -151,13 +155,15 @@ export class ChannelStream {
151
155
  }
152
156
  if (notifyRemote) {
153
157
  try {
154
- this.postMessage({ closed: true, error });
158
+ this.postMessage({ closed: true, error, teardown: true });
155
159
  }
156
160
  catch {
157
161
  // Ignore close races while tearing down the local channel.
158
162
  }
159
163
  }
160
164
  this.closed = true;
165
+ this.localWriteClosed = true;
166
+ this.remoteWriteClosed = true;
161
167
  // close channels
162
168
  if (this.channel instanceof MessagePort) {
163
169
  this.channel.onmessage = null;
@@ -186,6 +192,26 @@ export class ChannelStream {
186
192
  }
187
193
  this._source.end(error);
188
194
  }
195
+ // finishIfBothDirectionsClosed tears down after a clean bidirectional EOF.
196
+ finishIfBothDirectionsClosed() {
197
+ if (this.localWriteClosed && this.remoteWriteClosed) {
198
+ this.finish(undefined, false);
199
+ }
200
+ }
201
+ // clearKeepAlive stops local write keep-alives after the write side closes.
202
+ clearKeepAlive() {
203
+ if (this.keepAlive) {
204
+ this.keepAlive.clear();
205
+ delete this.keepAlive;
206
+ }
207
+ }
208
+ // clearIdleWatchdog stops receive watchdogs after the peer write side closes.
209
+ clearIdleWatchdog() {
210
+ if (this.idleWatchdog) {
211
+ this.idleWatchdog.clear();
212
+ delete this.idleWatchdog;
213
+ }
214
+ }
189
215
  // close closes the broadcast channels.
190
216
  close(error) {
191
217
  this.finish(error, true);
@@ -235,10 +261,13 @@ export class ChannelStream {
235
261
  for await (const msg of source) {
236
262
  this.postMessage({ data: msg });
237
263
  }
264
+ this.localWriteClosed = true;
265
+ this.clearKeepAlive();
238
266
  this.postMessage({ closed: true });
267
+ this.finishIfBothDirectionsClosed();
239
268
  }
240
269
  catch (error) {
241
- this.postMessage({ closed: true, error: error });
270
+ this.finish(error instanceof Error ? error : new Error(String(error)), true);
242
271
  }
243
272
  };
244
273
  }
@@ -254,16 +283,19 @@ export class ChannelStream {
254
283
  if (msg.opened) {
255
284
  this.onRemoteOpened();
256
285
  }
257
- const { data, closed, error: err } = msg;
258
- if (data) {
286
+ const { data, closed, error: err, teardown } = msg;
287
+ if (data !== undefined) {
259
288
  this._source.push(data);
260
289
  }
261
- if (err) {
290
+ if (err || teardown) {
262
291
  this.finish(err, false);
263
292
  return;
264
293
  }
265
294
  if (closed) {
266
- this.finish(undefined, false);
295
+ this.remoteWriteClosed = true;
296
+ this.clearIdleWatchdog();
297
+ this._source.end();
298
+ this.finishIfBothDirectionsClosed();
267
299
  }
268
300
  }
269
301
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pushable } from 'it-pushable';
3
+ import { ChannelStream } from './channel.js';
4
+ describe('ChannelStream', () => {
5
+ it('keeps MessagePort peer writes open after local source completes normally', async () => {
6
+ const { port1, port2 } = new MessageChannel();
7
+ const client = new ChannelStream('client', port1);
8
+ const server = new ChannelStream('server', port2);
9
+ await expectPeerWriteAfterLocalSourceCompletes(client, server);
10
+ });
11
+ it('keeps BroadcastChannel peer writes open after local source completes normally', async () => {
12
+ const channelName = `channel-stream-${Date.now()}-${Math.random()}`;
13
+ const clientToServer = `${channelName}-client-to-server`;
14
+ const serverToClient = `${channelName}-server-to-client`;
15
+ const client = new ChannelStream('client', {
16
+ tx: new BroadcastChannel(clientToServer),
17
+ rx: new BroadcastChannel(serverToClient),
18
+ });
19
+ const server = new ChannelStream('server', {
20
+ tx: new BroadcastChannel(serverToClient),
21
+ rx: new BroadcastChannel(clientToServer),
22
+ });
23
+ await expectPeerWriteAfterLocalSourceCompletes(client, server);
24
+ });
25
+ it('propagates explicit close errors as full teardown', async () => {
26
+ const { port1, port2 } = new MessageChannel();
27
+ const active = new ChannelStream('active', port1);
28
+ const passive = new ChannelStream('passive', port2);
29
+ const next = passive.source[Symbol.asyncIterator]().next();
30
+ try {
31
+ active.close(new Error('boom'));
32
+ await expect(next).rejects.toThrow('boom');
33
+ expect(passive.closed).toBe(true);
34
+ expect(port2.onmessage).toBe(null);
35
+ }
36
+ finally {
37
+ active.close();
38
+ passive.close();
39
+ }
40
+ });
41
+ });
42
+ async function expectPeerWriteAfterLocalSourceCompletes(client, server) {
43
+ const clientWrites = pushable({ objectMode: true });
44
+ const serverWrites = pushable({ objectMode: true });
45
+ const clientSink = client.sink(clientWrites);
46
+ const serverSink = server.sink(serverWrites);
47
+ const clientReads = client.source[Symbol.asyncIterator]();
48
+ const serverReads = server.source[Symbol.asyncIterator]();
49
+ try {
50
+ const request = new Uint8Array([1, 2, 3]);
51
+ clientWrites.push(request);
52
+ await expect(readNext(serverReads)).resolves.toEqual({
53
+ done: false,
54
+ value: request,
55
+ });
56
+ clientWrites.end();
57
+ await expect(readNext(serverReads)).resolves.toEqual({
58
+ done: true,
59
+ value: undefined,
60
+ });
61
+ const response = new Uint8Array([4, 5, 6]);
62
+ serverWrites.push(response);
63
+ await expect(readNext(clientReads)).resolves.toEqual({
64
+ done: false,
65
+ value: response,
66
+ });
67
+ serverWrites.end();
68
+ await expect(clientSink).resolves.toBeUndefined();
69
+ await expect(serverSink).resolves.toBeUndefined();
70
+ await expect(readNext(clientReads)).resolves.toEqual({
71
+ done: true,
72
+ value: undefined,
73
+ });
74
+ expect(client.closed).toBe(true);
75
+ expect(server.closed).toBe(true);
76
+ }
77
+ finally {
78
+ client.close();
79
+ server.close();
80
+ }
81
+ }
82
+ function readNext(source) {
83
+ return Promise.race([
84
+ source.next(),
85
+ new Promise((_, reject) => {
86
+ setTimeout(() => reject(new Error('timed out waiting for stream data')), 100);
87
+ }),
88
+ ]);
89
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, it, beforeEach, expect, vi } from 'vitest';
2
2
  import { pipe } from 'it-pipe';
3
- import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, } from '../srpc/index.js';
3
+ import { createHandler, createMux, Server, Client, StreamConn, ChannelStream, combineUint8ArrayListTransform, Packet, } from '../srpc/index.js';
4
4
  import { EchoerDefinition, EchoerServer, EchoerServiceName, runClientTest, } from '../echo/index.js';
5
5
  import { runAbortControllerTest, runRpcStreamTest, } from '../echo/client-test.js';
6
6
  describe('srpc server', () => {
@@ -39,6 +39,61 @@ describe('srpc server', () => {
39
39
  it('should pass rpc stream tests', async () => {
40
40
  await runRpcStreamTest(client);
41
41
  });
42
+ it('keeps detached server-streaming responses open after request source completes', async () => {
43
+ const mux = createMux();
44
+ const response = new TextEncoder().encode('delayed init');
45
+ mux.registerLookupMethod(async (service, method) => {
46
+ if (service !== 'test.ResourceService' || method !== 'ResourceClient') {
47
+ return null;
48
+ }
49
+ return async (_dataSource, dataSink) => {
50
+ await dataSink((async function* () {
51
+ await new Promise((resolve) => setTimeout(resolve, 10));
52
+ yield response;
53
+ })());
54
+ };
55
+ });
56
+ const server = new Server(mux.lookupMethod);
57
+ const firstResponse = new Promise((resolve, reject) => {
58
+ server.handlePacketStream({
59
+ source: (async function* () {
60
+ yield Packet.toBinary({
61
+ body: {
62
+ case: 'callStart',
63
+ value: {
64
+ rpcService: 'test.ResourceService',
65
+ rpcMethod: 'ResourceClient',
66
+ data: new Uint8Array(0),
67
+ dataIsZero: true,
68
+ },
69
+ },
70
+ });
71
+ })(),
72
+ sink: async (source) => {
73
+ try {
74
+ for await (const packetData of source) {
75
+ const packet = Packet.fromBinary(packetData);
76
+ if (packet.body?.case === 'callData') {
77
+ resolve(packet);
78
+ return;
79
+ }
80
+ }
81
+ reject(new Error('server response stream ended before call data'));
82
+ }
83
+ catch (err) {
84
+ reject(err);
85
+ }
86
+ },
87
+ });
88
+ });
89
+ const packet = await promiseWithTimeout(firstResponse, 'detached server-streaming response');
90
+ const body = packet.body;
91
+ expect(body?.case).toBe('callData');
92
+ if (body?.case !== 'callData' || !body.value?.data) {
93
+ throw new Error('expected callData packet');
94
+ }
95
+ expect([...body.value.data]).toEqual([...response]);
96
+ });
42
97
  it('removes abort listeners after a request completes', async () => {
43
98
  const controller = new AbortController();
44
99
  const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener');
@@ -60,3 +115,11 @@ describe('srpc server', () => {
60
115
  expect(port2.onmessage).toBe(null);
61
116
  });
62
117
  });
118
+ function promiseWithTimeout(promise, label) {
119
+ return Promise.race([
120
+ promise,
121
+ new Promise((_, reject) => {
122
+ setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 100);
123
+ }),
124
+ ]);
125
+ }
@@ -2,15 +2,19 @@ import { pipe } from 'it-pipe';
2
2
  import { combineUint8ArrayListTransform } from './array-list.js';
3
3
  import { parseLengthPrefixTransform, prependLengthPrefixTransform, } from './packet.js';
4
4
  // streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
5
- //
6
- // The stream is closed when the source writing to the sink ends.
7
5
  export function streamToPacketStream(stream) {
8
6
  return {
9
7
  source: pipe(stream, parseLengthPrefixTransform(), combineUint8ArrayListTransform()),
10
8
  sink: async (source) => {
11
- await pipe(source, prependLengthPrefixTransform(), stream)
12
- .catch((err) => stream.close(err))
13
- .then(() => stream.close());
9
+ try {
10
+ await pipe(source, prependLengthPrefixTransform(), stream);
11
+ await stream.closeWrite();
12
+ }
13
+ catch (err) {
14
+ const error = err instanceof Error ? err : new Error(String(err));
15
+ stream.abort(error);
16
+ throw error;
17
+ }
14
18
  },
15
19
  };
16
20
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { pipe } from 'it-pipe';
3
+ import { ChannelStream, combineUint8ArrayListTransform, StreamConn, } from '../srpc/index.js';
4
+ describe('StreamConn packet stream', () => {
5
+ it('keeps yamux peer writes open after local packet source completes normally', async () => {
6
+ const request = new TextEncoder().encode('request');
7
+ const response = new TextEncoder().encode('response');
8
+ let serverError;
9
+ const serverDone = new Promise((resolve, reject) => {
10
+ const { clientConn, cleanup } = connectStreamConns({
11
+ handlePacketStream(stream) {
12
+ void (async () => {
13
+ const packets = stream.source[Symbol.asyncIterator]();
14
+ const first = await nextWithTimeout(packets, 'server request packet');
15
+ expect(first.done).toBe(false);
16
+ expect([...first.value]).toEqual([...request]);
17
+ const done = await nextWithTimeout(packets, 'server request eof');
18
+ expect(done.done).toBe(true);
19
+ await stream.sink((async function* () {
20
+ yield response;
21
+ })());
22
+ })()
23
+ .then(resolve)
24
+ .catch((err) => {
25
+ serverError = err;
26
+ reject(err);
27
+ });
28
+ },
29
+ });
30
+ void (async () => {
31
+ try {
32
+ const clientStream = await clientConn.openStream();
33
+ await clientStream.sink((async function* () {
34
+ yield request;
35
+ })());
36
+ const packets = clientStream.source[Symbol.asyncIterator]();
37
+ const first = await nextWithTimeout(packets, 'client response packet');
38
+ expect(first.done).toBe(false);
39
+ expect([...first.value]).toEqual([...response]);
40
+ const done = await nextWithTimeout(packets, 'client response eof');
41
+ expect(done.done).toBe(true);
42
+ }
43
+ finally {
44
+ cleanup();
45
+ }
46
+ })().catch(reject);
47
+ });
48
+ await serverDone;
49
+ expect(serverError).toBeUndefined();
50
+ });
51
+ it('aborts the yamux stream when the packet source errors', async () => {
52
+ const request = new TextEncoder().encode('request');
53
+ const sourceError = new Error('source failed');
54
+ let resolveReset = () => { };
55
+ const resetSeen = new Promise((resolve) => {
56
+ resolveReset = resolve;
57
+ });
58
+ const { clientConn, cleanup } = connectStreamConns({
59
+ handlePacketStream(stream) {
60
+ void (async () => {
61
+ try {
62
+ for await (const _packet of stream.source) {
63
+ // Drain until the reset arrives.
64
+ }
65
+ resolveReset(new Error('server stream ended without reset'));
66
+ }
67
+ catch (err) {
68
+ resolveReset(err);
69
+ }
70
+ })();
71
+ },
72
+ });
73
+ try {
74
+ const clientStream = await clientConn.openStream();
75
+ await expect(clientStream.sink((async function* () {
76
+ yield request;
77
+ throw sourceError;
78
+ })())).rejects.toThrow('source failed');
79
+ const resetErr = await promiseWithTimeout(resetSeen, 'server reset');
80
+ expect(resetErr).toBeInstanceOf(Error);
81
+ expect(resetErr.message).toBe('stream reset');
82
+ }
83
+ finally {
84
+ cleanup();
85
+ }
86
+ });
87
+ });
88
+ function connectStreamConns(server) {
89
+ const clientConn = new StreamConn();
90
+ const serverConn = new StreamConn(server, { direction: 'inbound' });
91
+ const { port1: clientPort, port2: serverPort } = new MessageChannel();
92
+ const opts = {};
93
+ const clientChannelStream = new ChannelStream('client', clientPort, opts);
94
+ const serverChannelStream = new ChannelStream('server', serverPort, opts);
95
+ pipe(clientChannelStream, clientConn, combineUint8ArrayListTransform(), clientChannelStream)
96
+ .catch((err) => clientConn.close(err))
97
+ .then(() => clientConn.close());
98
+ pipe(serverChannelStream, serverConn, combineUint8ArrayListTransform(), serverChannelStream)
99
+ .catch((err) => serverConn.close(err))
100
+ .then(() => serverConn.close());
101
+ return {
102
+ clientConn,
103
+ cleanup() {
104
+ clientConn.close();
105
+ serverConn.close();
106
+ clientChannelStream.close();
107
+ serverChannelStream.close();
108
+ },
109
+ };
110
+ }
111
+ async function nextWithTimeout(source, label) {
112
+ return promiseWithTimeout(source.next(), label);
113
+ }
114
+ async function promiseWithTimeout(promise, label) {
115
+ return Promise.race([
116
+ promise,
117
+ new Promise((_, reject) => {
118
+ setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 500);
119
+ }),
120
+ ]);
121
+ }
package/go.mod CHANGED
@@ -3,9 +3,9 @@ module github.com/aperturerobotics/starpc
3
3
  go 1.25.0
4
4
 
5
5
  require (
6
- github.com/aperturerobotics/common v0.33.0 // latest
6
+ github.com/aperturerobotics/common v0.33.1-0.20260516193515-675cfc5a0c12 // latest
7
7
  github.com/aperturerobotics/protobuf-go-lite v0.13.0 // latest
8
- github.com/aperturerobotics/util v1.34.5-0.20260516103104-cbfc6d6a0589 // latest
8
+ github.com/aperturerobotics/util v1.34.5 // latest
9
9
  )
10
10
 
11
11
  require (
package/go.sum CHANGED
@@ -2,8 +2,8 @@ github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017 h1:3U7
2
2
  github.com/aperturerobotics/abseil-cpp v0.0.0-20260131110040-4bb56e2f9017/go.mod h1:lNSJTKECIUFAnfeSqy01kXYTYe1BHubW7198jNX3nEw=
3
3
  github.com/aperturerobotics/cli v1.1.0 h1:7a+YRC+EY3npAnTzhHV5gLCiw91KS0Ts3XwLILGOsT8=
4
4
  github.com/aperturerobotics/cli v1.1.0/go.mod h1:M7BFP9wow5ytTzMyJQOOO991fGfsUqdTI7gGEsHfTQ8=
5
- github.com/aperturerobotics/common v0.33.0 h1:IheETbaQPmvUpkm6Z+/1jbuAQOXZF5REnRRMXTaIeVk=
6
- github.com/aperturerobotics/common v0.33.0/go.mod h1:xabIJydWovkzjs5YZD8ru/BgFTAXekgHwV8DrTl3R2w=
5
+ github.com/aperturerobotics/common v0.33.1-0.20260516193515-675cfc5a0c12 h1:KQiFEDyu5//G8JZ3yTN92kF2P3F+LHjSKsQikgROncU=
6
+ github.com/aperturerobotics/common v0.33.1-0.20260516193515-675cfc5a0c12/go.mod h1:pW7BhBpKzVrTxN8XIz6jb3MJERZ15GDxaGQ7jcIl0Xg=
7
7
  github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0 h1:6/3RSSlPEQ6LeidslB1ZCJkxW+MnfYDkvdWMDklDXw4=
8
8
  github.com/aperturerobotics/go-protoc-gen-prost v0.0.0-20260329113538-218ccd8f20e0/go.mod h1:OBb/beWmr/pDIZAUfi86j/4tBh2v5ctTxKMqSnh9c/4=
9
9
  github.com/aperturerobotics/go-protoc-wasi v0.0.0-20260329113540-600516012db3 h1:lp+V8RYcBwTX1p81swkpZn5fhw1wn2xLorzETIxRyZQ=
@@ -16,10 +16,8 @@ github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4 h1:4Dy3B
16
16
  github.com/aperturerobotics/protobuf v0.0.0-20260203024654-8201686529c4/go.mod h1:tMgO7y6SJo/d9ZcvrpNqIQtdYT9de+QmYaHOZ4KnhOg=
17
17
  github.com/aperturerobotics/protobuf-go-lite v0.13.0 h1:jEvCJhHaJEikDY/va2AUnS0DOb/0n82aISLAqxSh4Sk=
18
18
  github.com/aperturerobotics/protobuf-go-lite v0.13.0/go.mod h1:lGH3s5ArCTXKI4wJdlNpaybUtwSjfAG0vdWjxOfMcF8=
19
- github.com/aperturerobotics/util v1.34.5-0.20260515183346-68f9eac1d69f h1:xISFLs00h441uZcMVxhZbLIZsMRcjOM5Yont18i7WjA=
20
- github.com/aperturerobotics/util v1.34.5-0.20260515183346-68f9eac1d69f/go.mod h1:mDe7WnncVuV7yjeeVSsagyfrw4xfncu7d+f0+d70niY=
21
- github.com/aperturerobotics/util v1.34.5-0.20260516103104-cbfc6d6a0589 h1:8B9O13He1sz8Spr2pc+RL3hBzAMveLgUCXT7BpAfvEY=
22
- github.com/aperturerobotics/util v1.34.5-0.20260516103104-cbfc6d6a0589/go.mod h1:mDe7WnncVuV7yjeeVSsagyfrw4xfncu7d+f0+d70niY=
19
+ github.com/aperturerobotics/util v1.34.5 h1:007MaOJrrsiGm5o1c8Tt7p8nVwUAxkM6pmGflrBww/U=
20
+ github.com/aperturerobotics/util v1.34.5/go.mod h1:mDe7WnncVuV7yjeeVSsagyfrw4xfncu7d+f0+d70niY=
23
21
  github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
24
22
  github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
25
23
  github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "starpc",
3
- "version": "0.49.10",
3
+ "version": "0.49.13",
4
4
  "description": "Streaming protobuf RPC service protocol over any two-way channel.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pushable } from 'it-pushable'
3
+
4
+ import { ChannelStream } from './channel.js'
5
+
6
+ describe('ChannelStream', () => {
7
+ it('keeps MessagePort peer writes open after local source completes normally', async () => {
8
+ const { port1, port2 } = new MessageChannel()
9
+ const client = new ChannelStream<Uint8Array>('client', port1)
10
+ const server = new ChannelStream<Uint8Array>('server', port2)
11
+ await expectPeerWriteAfterLocalSourceCompletes(client, server)
12
+ })
13
+
14
+ it('keeps BroadcastChannel peer writes open after local source completes normally', async () => {
15
+ const channelName = `channel-stream-${Date.now()}-${Math.random()}`
16
+ const clientToServer = `${channelName}-client-to-server`
17
+ const serverToClient = `${channelName}-server-to-client`
18
+ const client = new ChannelStream<Uint8Array>('client', {
19
+ tx: new BroadcastChannel(clientToServer),
20
+ rx: new BroadcastChannel(serverToClient),
21
+ })
22
+ const server = new ChannelStream<Uint8Array>('server', {
23
+ tx: new BroadcastChannel(serverToClient),
24
+ rx: new BroadcastChannel(clientToServer),
25
+ })
26
+ await expectPeerWriteAfterLocalSourceCompletes(client, server)
27
+ })
28
+
29
+ it('propagates explicit close errors as full teardown', async () => {
30
+ const { port1, port2 } = new MessageChannel()
31
+ const active = new ChannelStream<Uint8Array>('active', port1)
32
+ const passive = new ChannelStream<Uint8Array>('passive', port2)
33
+ const next = passive.source[Symbol.asyncIterator]().next()
34
+
35
+ try {
36
+ active.close(new Error('boom'))
37
+
38
+ await expect(next).rejects.toThrow('boom')
39
+ expect((passive as any).closed).toBe(true)
40
+ expect(port2.onmessage).toBe(null)
41
+ } finally {
42
+ active.close()
43
+ passive.close()
44
+ }
45
+ })
46
+ })
47
+
48
+ async function expectPeerWriteAfterLocalSourceCompletes(
49
+ client: ChannelStream<Uint8Array>,
50
+ server: ChannelStream<Uint8Array>,
51
+ ) {
52
+ const clientWrites = pushable<Uint8Array>({ objectMode: true })
53
+ const serverWrites = pushable<Uint8Array>({ objectMode: true })
54
+ const clientSink = client.sink(clientWrites)
55
+ const serverSink = server.sink(serverWrites)
56
+ const clientReads = client.source[Symbol.asyncIterator]()
57
+ const serverReads = server.source[Symbol.asyncIterator]()
58
+
59
+ try {
60
+ const request = new Uint8Array([1, 2, 3])
61
+ clientWrites.push(request)
62
+
63
+ await expect(readNext(serverReads)).resolves.toEqual({
64
+ done: false,
65
+ value: request,
66
+ })
67
+
68
+ clientWrites.end()
69
+ await expect(readNext(serverReads)).resolves.toEqual({
70
+ done: true,
71
+ value: undefined,
72
+ })
73
+
74
+ const response = new Uint8Array([4, 5, 6])
75
+ serverWrites.push(response)
76
+ await expect(readNext(clientReads)).resolves.toEqual({
77
+ done: false,
78
+ value: response,
79
+ })
80
+
81
+ serverWrites.end()
82
+ await expect(clientSink).resolves.toBeUndefined()
83
+ await expect(serverSink).resolves.toBeUndefined()
84
+ await expect(readNext(clientReads)).resolves.toEqual({
85
+ done: true,
86
+ value: undefined,
87
+ })
88
+ expect((client as any).closed).toBe(true)
89
+ expect((server as any).closed).toBe(true)
90
+ } finally {
91
+ client.close()
92
+ server.close()
93
+ }
94
+ }
95
+
96
+ function readNext<T>(
97
+ source: AsyncIterator<T>,
98
+ ): Promise<IteratorResult<T, undefined>> {
99
+ return Promise.race([
100
+ source.next(),
101
+ new Promise<IteratorResult<T, undefined>>((_, reject) => {
102
+ setTimeout(
103
+ () => reject(new Error('timed out waiting for stream data')),
104
+ 100,
105
+ )
106
+ }),
107
+ ])
108
+ }