starpc 0.49.11 → 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.
- package/dist/srpc/channel.d.ts +6 -0
- package/dist/srpc/channel.js +38 -6
- package/dist/srpc/channel.test.d.ts +1 -0
- package/dist/srpc/channel.test.js +89 -0
- package/dist/srpc/server.test.js +64 -1
- package/dist/srpc/stream.js +9 -5
- package/dist/srpc/stream.test.d.ts +1 -0
- package/dist/srpc/stream.test.js +121 -0
- package/package.json +1 -1
- package/srpc/channel.test.ts +108 -0
- package/srpc/channel.ts +46 -6
- package/srpc/server.test.ts +72 -0
- package/srpc/stream.test.ts +174 -0
- package/srpc/stream.ts +8 -5
package/dist/srpc/channel.d.ts
CHANGED
|
@@ -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;
|
package/dist/srpc/channel.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|
package/dist/srpc/server.test.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/srpc/stream.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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/package.json
CHANGED
|
@@ -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
|
+
}
|
package/srpc/channel.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface ChannelStreamMessage<T> {
|
|
|
13
13
|
opened?: true
|
|
14
14
|
// closed indicates the stream is closed.
|
|
15
15
|
closed?: true
|
|
16
|
+
// teardown indicates the whole channel should be torn down.
|
|
17
|
+
teardown?: true
|
|
16
18
|
// error indicates the stream has an error.
|
|
17
19
|
error?: Error
|
|
18
20
|
// data is any message data.
|
|
@@ -82,6 +84,10 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
|
|
|
82
84
|
private idleWatchdog?: Watchdog
|
|
83
85
|
// closed indicates the local channel has been torn down.
|
|
84
86
|
private closed = false
|
|
87
|
+
// localWriteClosed indicates the local sink finished normally.
|
|
88
|
+
private localWriteClosed = false
|
|
89
|
+
// remoteWriteClosed indicates the remote sink finished normally.
|
|
90
|
+
private remoteWriteClosed = false
|
|
85
91
|
|
|
86
92
|
// isAcked checks if the stream is acknowledged by the remote.
|
|
87
93
|
public get isAcked() {
|
|
@@ -207,12 +213,14 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
|
|
|
207
213
|
}
|
|
208
214
|
if (notifyRemote) {
|
|
209
215
|
try {
|
|
210
|
-
this.postMessage({ closed: true, error })
|
|
216
|
+
this.postMessage({ closed: true, error, teardown: true })
|
|
211
217
|
} catch {
|
|
212
218
|
// Ignore close races while tearing down the local channel.
|
|
213
219
|
}
|
|
214
220
|
}
|
|
215
221
|
this.closed = true
|
|
222
|
+
this.localWriteClosed = true
|
|
223
|
+
this.remoteWriteClosed = true
|
|
216
224
|
// close channels
|
|
217
225
|
if (this.channel instanceof MessagePort) {
|
|
218
226
|
this.channel.onmessage = null
|
|
@@ -241,6 +249,29 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
|
|
|
241
249
|
this._source.end(error)
|
|
242
250
|
}
|
|
243
251
|
|
|
252
|
+
// finishIfBothDirectionsClosed tears down after a clean bidirectional EOF.
|
|
253
|
+
private finishIfBothDirectionsClosed() {
|
|
254
|
+
if (this.localWriteClosed && this.remoteWriteClosed) {
|
|
255
|
+
this.finish(undefined, false)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// clearKeepAlive stops local write keep-alives after the write side closes.
|
|
260
|
+
private clearKeepAlive() {
|
|
261
|
+
if (this.keepAlive) {
|
|
262
|
+
this.keepAlive.clear()
|
|
263
|
+
delete this.keepAlive
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// clearIdleWatchdog stops receive watchdogs after the peer write side closes.
|
|
268
|
+
private clearIdleWatchdog() {
|
|
269
|
+
if (this.idleWatchdog) {
|
|
270
|
+
this.idleWatchdog.clear()
|
|
271
|
+
delete this.idleWatchdog
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
244
275
|
// close closes the broadcast channels.
|
|
245
276
|
public close(error?: Error) {
|
|
246
277
|
this.finish(error, true)
|
|
@@ -297,9 +328,15 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
|
|
|
297
328
|
for await (const msg of source) {
|
|
298
329
|
this.postMessage({ data: msg })
|
|
299
330
|
}
|
|
331
|
+
this.localWriteClosed = true
|
|
332
|
+
this.clearKeepAlive()
|
|
300
333
|
this.postMessage({ closed: true })
|
|
334
|
+
this.finishIfBothDirectionsClosed()
|
|
301
335
|
} catch (error) {
|
|
302
|
-
this.
|
|
336
|
+
this.finish(
|
|
337
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
338
|
+
true,
|
|
339
|
+
)
|
|
303
340
|
}
|
|
304
341
|
}
|
|
305
342
|
}
|
|
@@ -316,16 +353,19 @@ export class ChannelStream<T = Uint8Array> implements Duplex<
|
|
|
316
353
|
if (msg.opened) {
|
|
317
354
|
this.onRemoteOpened()
|
|
318
355
|
}
|
|
319
|
-
const { data, closed, error: err } = msg
|
|
320
|
-
if (data) {
|
|
356
|
+
const { data, closed, error: err, teardown } = msg
|
|
357
|
+
if (data !== undefined) {
|
|
321
358
|
this._source.push(data)
|
|
322
359
|
}
|
|
323
|
-
if (err) {
|
|
360
|
+
if (err || teardown) {
|
|
324
361
|
this.finish(err, false)
|
|
325
362
|
return
|
|
326
363
|
}
|
|
327
364
|
if (closed) {
|
|
328
|
-
this.
|
|
365
|
+
this.remoteWriteClosed = true
|
|
366
|
+
this.clearIdleWatchdog()
|
|
367
|
+
this._source.end()
|
|
368
|
+
this.finishIfBothDirectionsClosed()
|
|
329
369
|
}
|
|
330
370
|
}
|
|
331
371
|
}
|
package/srpc/server.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ChannelStream,
|
|
10
10
|
combineUint8ArrayListTransform,
|
|
11
11
|
ChannelStreamOpts,
|
|
12
|
+
Packet,
|
|
12
13
|
} from '../srpc/index.js'
|
|
13
14
|
import {
|
|
14
15
|
EchoerDefinition,
|
|
@@ -77,6 +78,68 @@ describe('srpc server', () => {
|
|
|
77
78
|
await runRpcStreamTest(client)
|
|
78
79
|
})
|
|
79
80
|
|
|
81
|
+
it('keeps detached server-streaming responses open after request source completes', async () => {
|
|
82
|
+
const mux = createMux()
|
|
83
|
+
const response = new TextEncoder().encode('delayed init')
|
|
84
|
+
mux.registerLookupMethod(async (service, method) => {
|
|
85
|
+
if (service !== 'test.ResourceService' || method !== 'ResourceClient') {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
return async (_dataSource, dataSink) => {
|
|
89
|
+
await dataSink(
|
|
90
|
+
(async function* () {
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
92
|
+
yield response
|
|
93
|
+
})(),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const server = new Server(mux.lookupMethod)
|
|
99
|
+
const firstResponse = new Promise<Packet>((resolve, reject) => {
|
|
100
|
+
server.handlePacketStream({
|
|
101
|
+
source: (async function* () {
|
|
102
|
+
yield Packet.toBinary({
|
|
103
|
+
body: {
|
|
104
|
+
case: 'callStart',
|
|
105
|
+
value: {
|
|
106
|
+
rpcService: 'test.ResourceService',
|
|
107
|
+
rpcMethod: 'ResourceClient',
|
|
108
|
+
data: new Uint8Array(0),
|
|
109
|
+
dataIsZero: true,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
})(),
|
|
114
|
+
sink: async (source) => {
|
|
115
|
+
try {
|
|
116
|
+
for await (const packetData of source) {
|
|
117
|
+
const packet = Packet.fromBinary(packetData)
|
|
118
|
+
if (packet.body?.case === 'callData') {
|
|
119
|
+
resolve(packet)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
reject(new Error('server response stream ended before call data'))
|
|
124
|
+
} catch (err) {
|
|
125
|
+
reject(err as Error)
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const packet = await promiseWithTimeout(
|
|
132
|
+
firstResponse,
|
|
133
|
+
'detached server-streaming response',
|
|
134
|
+
)
|
|
135
|
+
const body = packet.body
|
|
136
|
+
expect(body?.case).toBe('callData')
|
|
137
|
+
if (body?.case !== 'callData' || !body.value?.data) {
|
|
138
|
+
throw new Error('expected callData packet')
|
|
139
|
+
}
|
|
140
|
+
expect([...body.value.data]).toEqual([...response])
|
|
141
|
+
})
|
|
142
|
+
|
|
80
143
|
it('removes abort listeners after a request completes', async () => {
|
|
81
144
|
const controller = new AbortController()
|
|
82
145
|
const removeEventListener = vi.spyOn(
|
|
@@ -114,3 +177,12 @@ describe('srpc server', () => {
|
|
|
114
177
|
expect(port2.onmessage).toBe(null)
|
|
115
178
|
})
|
|
116
179
|
})
|
|
180
|
+
|
|
181
|
+
function promiseWithTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
|
182
|
+
return Promise.race([
|
|
183
|
+
promise,
|
|
184
|
+
new Promise<T>((_, reject) => {
|
|
185
|
+
setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 100)
|
|
186
|
+
}),
|
|
187
|
+
])
|
|
188
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pipe } from 'it-pipe'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ChannelStream,
|
|
6
|
+
combineUint8ArrayListTransform,
|
|
7
|
+
StreamConn,
|
|
8
|
+
type ChannelStreamOpts,
|
|
9
|
+
type PacketStream,
|
|
10
|
+
type StreamHandler,
|
|
11
|
+
} from '../srpc/index.js'
|
|
12
|
+
|
|
13
|
+
describe('StreamConn packet stream', () => {
|
|
14
|
+
it('keeps yamux peer writes open after local packet source completes normally', async () => {
|
|
15
|
+
const request = new TextEncoder().encode('request')
|
|
16
|
+
const response = new TextEncoder().encode('response')
|
|
17
|
+
|
|
18
|
+
let serverError: unknown
|
|
19
|
+
const serverDone = new Promise<void>((resolve, reject) => {
|
|
20
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
21
|
+
handlePacketStream(stream: PacketStream) {
|
|
22
|
+
void (async () => {
|
|
23
|
+
const packets = stream.source[Symbol.asyncIterator]()
|
|
24
|
+
const first = await nextWithTimeout(
|
|
25
|
+
packets,
|
|
26
|
+
'server request packet',
|
|
27
|
+
)
|
|
28
|
+
expect(first.done).toBe(false)
|
|
29
|
+
expect([...first.value]).toEqual([...request])
|
|
30
|
+
|
|
31
|
+
const done = await nextWithTimeout(packets, 'server request eof')
|
|
32
|
+
expect(done.done).toBe(true)
|
|
33
|
+
|
|
34
|
+
await stream.sink(
|
|
35
|
+
(async function* () {
|
|
36
|
+
yield response
|
|
37
|
+
})(),
|
|
38
|
+
)
|
|
39
|
+
})()
|
|
40
|
+
.then(resolve)
|
|
41
|
+
.catch((err) => {
|
|
42
|
+
serverError = err
|
|
43
|
+
reject(err)
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
void (async () => {
|
|
49
|
+
try {
|
|
50
|
+
const clientStream = await clientConn.openStream()
|
|
51
|
+
await clientStream.sink(
|
|
52
|
+
(async function* () {
|
|
53
|
+
yield request
|
|
54
|
+
})(),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
const packets = clientStream.source[Symbol.asyncIterator]()
|
|
58
|
+
const first = await nextWithTimeout(packets, 'client response packet')
|
|
59
|
+
expect(first.done).toBe(false)
|
|
60
|
+
expect([...first.value]).toEqual([...response])
|
|
61
|
+
|
|
62
|
+
const done = await nextWithTimeout(packets, 'client response eof')
|
|
63
|
+
expect(done.done).toBe(true)
|
|
64
|
+
} finally {
|
|
65
|
+
cleanup()
|
|
66
|
+
}
|
|
67
|
+
})().catch(reject)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
await serverDone
|
|
71
|
+
expect(serverError).toBeUndefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('aborts the yamux stream when the packet source errors', async () => {
|
|
75
|
+
const request = new TextEncoder().encode('request')
|
|
76
|
+
const sourceError = new Error('source failed')
|
|
77
|
+
let resolveReset: (err: unknown) => void = () => {}
|
|
78
|
+
const resetSeen = new Promise<unknown>((resolve) => {
|
|
79
|
+
resolveReset = resolve
|
|
80
|
+
})
|
|
81
|
+
const { clientConn, cleanup } = connectStreamConns({
|
|
82
|
+
handlePacketStream(stream: PacketStream) {
|
|
83
|
+
void (async () => {
|
|
84
|
+
try {
|
|
85
|
+
for await (const _packet of stream.source) {
|
|
86
|
+
// Drain until the reset arrives.
|
|
87
|
+
}
|
|
88
|
+
resolveReset(new Error('server stream ended without reset'))
|
|
89
|
+
} catch (err) {
|
|
90
|
+
resolveReset(err)
|
|
91
|
+
}
|
|
92
|
+
})()
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const clientStream = await clientConn.openStream()
|
|
98
|
+
await expect(
|
|
99
|
+
clientStream.sink(
|
|
100
|
+
(async function* () {
|
|
101
|
+
yield request
|
|
102
|
+
throw sourceError
|
|
103
|
+
})(),
|
|
104
|
+
),
|
|
105
|
+
).rejects.toThrow('source failed')
|
|
106
|
+
|
|
107
|
+
const resetErr = await promiseWithTimeout(resetSeen, 'server reset')
|
|
108
|
+
expect(resetErr).toBeInstanceOf(Error)
|
|
109
|
+
expect((resetErr as Error).message).toBe('stream reset')
|
|
110
|
+
} finally {
|
|
111
|
+
cleanup()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
function connectStreamConns(server: StreamHandler): {
|
|
117
|
+
clientConn: StreamConn
|
|
118
|
+
cleanup: () => void
|
|
119
|
+
} {
|
|
120
|
+
const clientConn = new StreamConn()
|
|
121
|
+
const serverConn = new StreamConn(server, { direction: 'inbound' })
|
|
122
|
+
|
|
123
|
+
const { port1: clientPort, port2: serverPort } = new MessageChannel()
|
|
124
|
+
const opts: ChannelStreamOpts = {}
|
|
125
|
+
const clientChannelStream = new ChannelStream('client', clientPort, opts)
|
|
126
|
+
const serverChannelStream = new ChannelStream('server', serverPort, opts)
|
|
127
|
+
|
|
128
|
+
pipe(
|
|
129
|
+
clientChannelStream,
|
|
130
|
+
clientConn,
|
|
131
|
+
combineUint8ArrayListTransform(),
|
|
132
|
+
clientChannelStream,
|
|
133
|
+
)
|
|
134
|
+
.catch((err: Error) => clientConn.close(err))
|
|
135
|
+
.then(() => clientConn.close())
|
|
136
|
+
|
|
137
|
+
pipe(
|
|
138
|
+
serverChannelStream,
|
|
139
|
+
serverConn,
|
|
140
|
+
combineUint8ArrayListTransform(),
|
|
141
|
+
serverChannelStream,
|
|
142
|
+
)
|
|
143
|
+
.catch((err: Error) => serverConn.close(err))
|
|
144
|
+
.then(() => serverConn.close())
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
clientConn,
|
|
148
|
+
cleanup() {
|
|
149
|
+
clientConn.close()
|
|
150
|
+
serverConn.close()
|
|
151
|
+
clientChannelStream.close()
|
|
152
|
+
serverChannelStream.close()
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function nextWithTimeout<T>(
|
|
158
|
+
source: AsyncIterator<T>,
|
|
159
|
+
label: string,
|
|
160
|
+
): Promise<IteratorResult<T>> {
|
|
161
|
+
return promiseWithTimeout(source.next(), label)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function promiseWithTimeout<T>(
|
|
165
|
+
promise: Promise<T>,
|
|
166
|
+
label: string,
|
|
167
|
+
): Promise<T> {
|
|
168
|
+
return Promise.race([
|
|
169
|
+
promise,
|
|
170
|
+
new Promise<T>((_, reject) => {
|
|
171
|
+
setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 500)
|
|
172
|
+
}),
|
|
173
|
+
])
|
|
174
|
+
}
|
package/srpc/stream.ts
CHANGED
|
@@ -28,8 +28,6 @@ export type OpenStreamFunc = () => Promise<PacketStream>
|
|
|
28
28
|
export type HandleStreamFunc = (ch: PacketStream) => Promise<void>
|
|
29
29
|
|
|
30
30
|
// streamToPacketStream converts a Stream into a PacketStream using length-prefix framing.
|
|
31
|
-
//
|
|
32
|
-
// The stream is closed when the source writing to the sink ends.
|
|
33
31
|
export function streamToPacketStream(stream: Stream): PacketStream {
|
|
34
32
|
return {
|
|
35
33
|
source: pipe(
|
|
@@ -38,9 +36,14 @@ export function streamToPacketStream(stream: Stream): PacketStream {
|
|
|
38
36
|
combineUint8ArrayListTransform(),
|
|
39
37
|
),
|
|
40
38
|
sink: async (source: Source<Uint8Array>): Promise<void> => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
try {
|
|
40
|
+
await pipe(source, prependLengthPrefixTransform(), stream)
|
|
41
|
+
await stream.closeWrite()
|
|
42
|
+
} catch (err) {
|
|
43
|
+
const error = err instanceof Error ? err : new Error(String(err))
|
|
44
|
+
stream.abort(error)
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
44
47
|
},
|
|
45
48
|
}
|
|
46
49
|
}
|