@trevrpc/trevrpc-js 1.1.0 → 1.2.0
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/README.md +30 -1
- package/package.json +12 -4
- package/src/call.js +401 -58
- package/src/channel.js +47 -10
- package/src/client.js +93 -22
- package/src/compression.js +330 -0
- package/src/connection-metadata.js +114 -0
- package/src/datagram.js +48 -27
- package/src/framing.js +23 -6
- package/src/generator.js +107 -10
- package/src/index.d.ts +75 -14
- package/src/qmux/session.js +2 -1
- package/src/stream-compression.js +298 -0
- package/src/wire.js +43 -11
- package/src/zstd.d.ts +35 -0
- package/src/zstd.js +407 -0
package/src/channel.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { ConnectionMetadataKey, ConnectionMetadataSource } from "./connection-metadata.js";
|
|
1
2
|
import { QMuxWebSocketSession } from "./qmux/session.js";
|
|
2
3
|
import { dialRace } from "./race.js";
|
|
3
4
|
import { cancelled, failedPrecondition, statusFromTransportError, unavailable } from "./status.js";
|
|
5
|
+
import { StreamBudget, StreamBudgetKey } from "./stream-compression.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The WebTransport protocol that names wire version 1, offered through the `protocols` constructor
|
|
@@ -30,6 +32,7 @@ const DefaultReprobeIntervalMs = 5 * 60_000;
|
|
|
30
32
|
*/
|
|
31
33
|
export class Channel {
|
|
32
34
|
#dial;
|
|
35
|
+
#compression;
|
|
33
36
|
#onStateChange;
|
|
34
37
|
#session;
|
|
35
38
|
#transport;
|
|
@@ -40,11 +43,32 @@ export class Channel {
|
|
|
40
43
|
/** Opens a channel and waits for its first session; use connect() instead. */
|
|
41
44
|
static async connect(url, options = {}) {
|
|
42
45
|
const dial = sessionDialer(url, options);
|
|
43
|
-
|
|
46
|
+
// Checked before dialing, so an invalid option never leaves a session behind.
|
|
47
|
+
const streamBudget = new StreamBudget(options.streamCompressionMemory);
|
|
48
|
+
const connectionMetadata = new ConnectionMetadataSource(options.connectionMetadata);
|
|
49
|
+
return new Channel(await dial(options.signal), dial, {
|
|
50
|
+
...options,
|
|
51
|
+
streamBudget,
|
|
52
|
+
connectionMetadata,
|
|
53
|
+
});
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
constructor(
|
|
56
|
+
constructor(
|
|
57
|
+
dialed,
|
|
58
|
+
dial,
|
|
59
|
+
{
|
|
60
|
+
compression,
|
|
61
|
+
streamBudget = new StreamBudget(),
|
|
62
|
+
connectionMetadata = new ConnectionMetadataSource(),
|
|
63
|
+
onStateChange,
|
|
64
|
+
} = {},
|
|
65
|
+
) {
|
|
47
66
|
this.#dial = dial;
|
|
67
|
+
this.#compression = compression;
|
|
68
|
+
// The memory budget of the stream compression contexts of this channel's calls.
|
|
69
|
+
this[StreamBudgetKey] = streamBudget;
|
|
70
|
+
// The connection metadata every call of this channel carries.
|
|
71
|
+
this[ConnectionMetadataKey] = connectionMetadata;
|
|
48
72
|
this.#onStateChange = onStateChange;
|
|
49
73
|
this.#adopt(dialed);
|
|
50
74
|
}
|
|
@@ -59,6 +83,20 @@ export class Channel {
|
|
|
59
83
|
return this.#transport;
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/** The compression settings from the connect options, or undefined. */
|
|
87
|
+
get compression() {
|
|
88
|
+
return this.#compression;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Replaces the channel's connection metadata, which every call carries; empty or missing
|
|
93
|
+
* metadata removes it. Calls started after this use the new metadata. Throws InvalidArgument if
|
|
94
|
+
* it is invalid, and then keeps the old.
|
|
95
|
+
*/
|
|
96
|
+
setConnectionMetadata(metadata) {
|
|
97
|
+
this[ConnectionMetadataKey].set(metadata);
|
|
98
|
+
}
|
|
99
|
+
|
|
62
100
|
/** Opens a bidirectional stream on the current session. */
|
|
63
101
|
createBidirectionalStream() {
|
|
64
102
|
if (this.#state !== "ready") {
|
|
@@ -68,19 +106,18 @@ export class Channel {
|
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/**
|
|
71
|
-
* Opens a bidirectional stream on the current session, together with that session
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* carries them even if the channel reconnects
|
|
109
|
+
* Opens a bidirectional stream on the current session, together with that session: the
|
|
110
|
+
* WebTransport or QMux over WebSocket session, whose `datagrams` are undefined for QMux. Calls
|
|
111
|
+
* whose setup depends on their session (dictionary negotiation, datagram flows) open their stream
|
|
112
|
+
* here, so that setup belongs to the session that carries them even if the channel reconnects
|
|
113
|
+
* meanwhile.
|
|
75
114
|
*/
|
|
76
|
-
|
|
115
|
+
createBidirectionalStreamWithSession() {
|
|
77
116
|
if (this.#state !== "ready") {
|
|
78
117
|
return Promise.reject(unavailable(`channel is ${this.#state}`));
|
|
79
118
|
}
|
|
80
119
|
const session = this.#session;
|
|
81
|
-
return session
|
|
82
|
-
.createBidirectionalStream()
|
|
83
|
-
.then((stream) => ({ stream, datagrams: session.datagrams }));
|
|
120
|
+
return session.createBidirectionalStream().then((stream) => ({ stream, session }));
|
|
84
121
|
}
|
|
85
122
|
|
|
86
123
|
/** Resolves once the channel is ready, immediately if it already is. */
|
package/src/client.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Call, defaultCallOptions, mergeCallOptions } from "./call.js";
|
|
1
|
+
import { Call, EarlyOk, defaultCallOptions, mergeCallOptions } from "./call.js";
|
|
2
2
|
import { FrameType, marshalMessage, unmarshalMessage } from "./framing.js";
|
|
3
3
|
import { validateMetadata } from "./metadata.js";
|
|
4
4
|
import { Code, cancelled, internal, statusError } from "./status.js";
|
|
5
|
-
import { RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
|
|
5
|
+
import { RpcAnswer, RpcHeaders, RpcKind, RpcStatus } from "./wire.js";
|
|
6
6
|
|
|
7
7
|
/** Creates a service client from a generated service descriptor. */
|
|
8
8
|
export function createServiceClient(transport, service, root, options = {}) {
|
|
@@ -10,12 +10,18 @@ export function createServiceClient(transport, service, root, options = {}) {
|
|
|
10
10
|
const client = {};
|
|
11
11
|
|
|
12
12
|
for (const [jsName, method] of Object.entries(service.methods)) {
|
|
13
|
+
const requestType = root.lookupType(method.inputType);
|
|
14
|
+
const responseType = root.lookupType(method.outputType);
|
|
13
15
|
const rpc = {
|
|
14
16
|
// The wire method path, computed once per method rather than per call.
|
|
15
17
|
path: `/${service.fullName}/${method.name}`,
|
|
16
|
-
requestType
|
|
17
|
-
responseType
|
|
18
|
+
requestType,
|
|
19
|
+
responseType,
|
|
20
|
+
// Full protobuf names, which pick the compression dictionaries.
|
|
21
|
+
types: { request: fullName(requestType), response: fullName(responseType) },
|
|
18
22
|
datagrams: datagramUse(method),
|
|
23
|
+
compressStream: streamCompressionUse(method),
|
|
24
|
+
compress: plainCompressionUse(method),
|
|
19
25
|
};
|
|
20
26
|
const resolve = (override) => mergeCallOptions(defaults, override);
|
|
21
27
|
|
|
@@ -46,7 +52,7 @@ export function createServiceClient(transport, service, root, options = {}) {
|
|
|
46
52
|
/** Calls a unary RPC and returns the decoded response. */
|
|
47
53
|
export async function unaryCall(transport, rpc, request, options) {
|
|
48
54
|
const body = marshalMessage(rpc.requestType, request);
|
|
49
|
-
const call = await Call.open(transport, requestFields(rpc, RpcKind.Unary), body, options);
|
|
55
|
+
const call = await Call.open(transport, requestFields(rpc, RpcKind.Unary), body, options, rpc);
|
|
50
56
|
const stream = new BidirectionalStreamingCall(call, rpc, { options });
|
|
51
57
|
// The response decides the outcome, so the request side finishes in the background.
|
|
52
58
|
stream.closeSend().catch(() => {});
|
|
@@ -61,7 +67,7 @@ export async function serverStreamingCall(transport, rpc, request, options) {
|
|
|
61
67
|
requestFields(rpc, RpcKind.ServerStreaming),
|
|
62
68
|
body,
|
|
63
69
|
options,
|
|
64
|
-
rpc
|
|
70
|
+
rpc,
|
|
65
71
|
);
|
|
66
72
|
const stream = new BidirectionalStreamingCall(call, rpc, { options });
|
|
67
73
|
stream.closeSend().catch(() => {});
|
|
@@ -75,7 +81,7 @@ export async function clientStreamingCall(transport, rpc, options) {
|
|
|
75
81
|
requestFields(rpc, RpcKind.ClientStreaming),
|
|
76
82
|
undefined,
|
|
77
83
|
options,
|
|
78
|
-
rpc
|
|
84
|
+
rpc,
|
|
79
85
|
);
|
|
80
86
|
return new ClientStreamingCall(
|
|
81
87
|
new BidirectionalStreamingCall(call, rpc, { streamsRequest: true, options }),
|
|
@@ -89,7 +95,7 @@ export async function bidirectionalStreamingCall(transport, rpc, options) {
|
|
|
89
95
|
requestFields(rpc, RpcKind.BidirectionalStreaming),
|
|
90
96
|
undefined,
|
|
91
97
|
options,
|
|
92
|
-
rpc
|
|
98
|
+
rpc,
|
|
93
99
|
);
|
|
94
100
|
return new BidirectionalStreamingCall(call, rpc, { streamsRequest: true, options });
|
|
95
101
|
}
|
|
@@ -146,14 +152,35 @@ function datagramUse({ kind, unreliableRequests, unreliableResponses }) {
|
|
|
146
152
|
return { send: Boolean(unreliableRequests) && streamsRequest, receive: streamsResponse };
|
|
147
153
|
}
|
|
148
154
|
|
|
149
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The directions a method may stream-compress, `{ requests, responses }`, or undefined for none:
|
|
157
|
+
* those its descriptor marks (the generator leaves out directions that do not stream and message
|
|
158
|
+
* types with redacted fields), except directions marked unreliable, whose messages mostly travel
|
|
159
|
+
* as datagrams.
|
|
160
|
+
*/
|
|
161
|
+
function streamCompressionUse({ compressStreamRequests, compressStreamResponses, ...method }) {
|
|
162
|
+
const requests = Boolean(compressStreamRequests) && !method.unreliableRequests;
|
|
163
|
+
const responses = Boolean(compressStreamResponses) && !method.unreliableResponses;
|
|
164
|
+
return requests || responses ? { requests, responses } : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The directions whose messages a method may compress without a dictionary, `{ requests,
|
|
169
|
+
* responses }`, or undefined for none: those its descriptor marks (the generator marks both for
|
|
170
|
+
* `COMPRESSION_MESSAGE` and `COMPRESSION_STREAM`, except message types with redacted fields).
|
|
171
|
+
*/
|
|
172
|
+
function plainCompressionUse({ compressRequests, compressResponses }) {
|
|
173
|
+
const requests = Boolean(compressRequests);
|
|
174
|
+
const responses = Boolean(compressResponses);
|
|
175
|
+
return requests || responses ? { requests, responses } : undefined;
|
|
176
|
+
}
|
|
150
177
|
|
|
151
178
|
/**
|
|
152
179
|
* A bidirectional-streaming RPC. Messages are received with recv() or async iteration. The call
|
|
153
180
|
* ends with the server's final status: a non-OK status makes recv() throw it, and `status`
|
|
154
181
|
* settles with the final status or the error that ended the call. `headers` resolves once the
|
|
155
|
-
* first response frame arrived; reading it starts that read, so headers are
|
|
156
|
-
* first message.
|
|
182
|
+
* first response frame after any ANSWER arrived; reading it starts that read, so headers are
|
|
183
|
+
* available before the first message.
|
|
157
184
|
*
|
|
158
185
|
* While the request side is open, the response is read ahead of recv() (see ReadAheadFrames), so
|
|
159
186
|
* a final status reaches a caller that is only sending. It stops the upload at once: pending and
|
|
@@ -163,9 +190,13 @@ const EarlyOk = "the server completed the call before the request stream ended";
|
|
|
163
190
|
* The final status settles the outcome as soon as it is read. Responses read before it are still
|
|
164
191
|
* delivered in order, and the call's deadline and abort signal no longer apply to it.
|
|
165
192
|
*
|
|
166
|
-
* The call options' `onResponseHeaders` is called with the headers when the first
|
|
167
|
-
* is read, and `onResponseMetadata` with the trailing metadata when the final status is
|
|
168
|
-
* callback that throws fails the call.
|
|
193
|
+
* The call options' `onResponseHeaders` is called with the headers when the first frame after
|
|
194
|
+
* ANSWER is read, and `onResponseMetadata` with the trailing metadata when the final status is
|
|
195
|
+
* OK. A callback that throws fails the call.
|
|
196
|
+
*
|
|
197
|
+
* A call that stream-compresses its requests optimistically and is refused by the server is
|
|
198
|
+
* replayed without that, transparently: the caller's sends and the response continue on the
|
|
199
|
+
* replay.
|
|
169
200
|
*/
|
|
170
201
|
export class BidirectionalStreamingCall {
|
|
171
202
|
#call;
|
|
@@ -195,6 +226,7 @@ export class BidirectionalStreamingCall {
|
|
|
195
226
|
/** Receivers waiting for a frame. */
|
|
196
227
|
#demand = 0;
|
|
197
228
|
#headersWanted = false;
|
|
229
|
+
#sawAnswer = false;
|
|
198
230
|
#sawHeaders = false;
|
|
199
231
|
#sawMessage = false;
|
|
200
232
|
/** Settles on every change a waiter may care about. */
|
|
@@ -245,8 +277,8 @@ export class BidirectionalStreamingCall {
|
|
|
245
277
|
}
|
|
246
278
|
|
|
247
279
|
/**
|
|
248
|
-
* The response headers, empty when the server sent none. Settles when the first
|
|
249
|
-
* arrives, or rejects with the error that ended the call first.
|
|
280
|
+
* The response headers, empty when the server sent none. Settles when the first frame after
|
|
281
|
+
* ANSWER arrives, or rejects with the error that ended the call first.
|
|
250
282
|
*/
|
|
251
283
|
get headers() {
|
|
252
284
|
this.#headersWanted = true;
|
|
@@ -454,7 +486,7 @@ export class BidirectionalStreamingCall {
|
|
|
454
486
|
}
|
|
455
487
|
const error = code === Code.Ok ? undefined : statusError(code, message, metadata);
|
|
456
488
|
this.#terminal = { status: { code, message, metadata }, error };
|
|
457
|
-
this.#call.finish(error
|
|
489
|
+
this.#call.finish(error);
|
|
458
490
|
if (error === undefined) {
|
|
459
491
|
this.#outcomeSettled = true;
|
|
460
492
|
this.#resolveStatus({ code, message, metadata });
|
|
@@ -490,8 +522,10 @@ export class BidirectionalStreamingCall {
|
|
|
490
522
|
}
|
|
491
523
|
|
|
492
524
|
/**
|
|
493
|
-
* Reads the next MESSAGE or STATUS event, consuming HEADERS on the way. Enforces the
|
|
494
|
-
* grammar `[HEADERS] MESSAGE* STATUS
|
|
525
|
+
* Reads the next MESSAGE or STATUS event, consuming ANSWER and HEADERS on the way. Enforces the
|
|
526
|
+
* response grammar `[ANSWER] [HEADERS] MESSAGE* STATUS`, and that an optimistic call's response
|
|
527
|
+
* starts with ANSWER or STATUS. A ZSTD_MESSAGE or ZSTD_STREAM_MESSAGE is a MESSAGE,
|
|
528
|
+
* decompressed. ANSWER is not the headers: they settle with the first frame after it.
|
|
495
529
|
*/
|
|
496
530
|
async #readEvent() {
|
|
497
531
|
for (;;) {
|
|
@@ -500,7 +534,26 @@ export class BidirectionalStreamingCall {
|
|
|
500
534
|
throw this.#call.uploadFailure ?? internal("response stream ended before final status");
|
|
501
535
|
}
|
|
502
536
|
|
|
537
|
+
if (
|
|
538
|
+
frame.type !== FrameType.Status &&
|
|
539
|
+
frame.type !== FrameType.Answer &&
|
|
540
|
+
this.#call.replayable
|
|
541
|
+
) {
|
|
542
|
+
throw internal("optimistic call answered without an ANSWER frame");
|
|
543
|
+
}
|
|
503
544
|
switch (frame.type) {
|
|
545
|
+
case FrameType.Answer: {
|
|
546
|
+
if (this.#sawAnswer || this.#sawHeaders || this.#sawMessage) {
|
|
547
|
+
throw internal(
|
|
548
|
+
this.#sawAnswer
|
|
549
|
+
? "duplicate ANSWER frame"
|
|
550
|
+
: `ANSWER frame after ${this.#sawMessage ? "MESSAGE" : "HEADERS"}`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
this.#sawAnswer = true;
|
|
554
|
+
this.#call.learnAnswers(unmarshalMessage(RpcAnswer, frame.payload));
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
504
557
|
case FrameType.Headers: {
|
|
505
558
|
if (this.#sawHeaders || this.#sawMessage) {
|
|
506
559
|
throw internal(
|
|
@@ -515,12 +568,25 @@ export class BidirectionalStreamingCall {
|
|
|
515
568
|
continue;
|
|
516
569
|
}
|
|
517
570
|
case FrameType.Message:
|
|
571
|
+
case FrameType.ZstdMessage:
|
|
572
|
+
case FrameType.ZstdStreamMessage: {
|
|
573
|
+
const payload = this.#call.receivedMessage(frame);
|
|
518
574
|
this.#sawMessage = true;
|
|
519
575
|
this.#settleHeaders({});
|
|
520
576
|
this.#flow?.connect(this.#onDatagram);
|
|
521
|
-
return
|
|
577
|
+
return { type: FrameType.Message, payload };
|
|
578
|
+
}
|
|
522
579
|
case FrameType.Status: {
|
|
523
580
|
const status = unmarshalMessage(RpcStatus, frame.payload);
|
|
581
|
+
if (status.refusedStreamCompression && this.#call.replayable) {
|
|
582
|
+
// The server refused the optimistic call before running anything for it: the call
|
|
583
|
+
// continues on a replay, and its response is read from there.
|
|
584
|
+
// A call with a datagram flow is never optimistic, but its replay, on a session
|
|
585
|
+
// that may differ after a reconnect, can get one.
|
|
586
|
+
await this.#call.replay();
|
|
587
|
+
this.#flow = this.#rpc.datagrams?.receive ? this.#call.flow : undefined;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
524
590
|
validateResponseMetadata(status.metadata, "trailing metadata");
|
|
525
591
|
this.#settleHeaders({});
|
|
526
592
|
this.#flow?.connect(this.#onDatagram);
|
|
@@ -648,8 +714,8 @@ export class ResponseStream {
|
|
|
648
714
|
}
|
|
649
715
|
|
|
650
716
|
/**
|
|
651
|
-
* The response headers, empty when the server sent none. Settles when the first
|
|
652
|
-
* arrives, even before the first message is received, or rejects with the call error.
|
|
717
|
+
* The response headers, empty when the server sent none. Settles when the first frame after
|
|
718
|
+
* ANSWER arrives, even before the first message is received, or rejects with the call error.
|
|
653
719
|
*/
|
|
654
720
|
get headers() {
|
|
655
721
|
return this.#call.headers;
|
|
@@ -677,6 +743,11 @@ function requestFields(rpc, kind) {
|
|
|
677
743
|
return { method: rpc.path, kind };
|
|
678
744
|
}
|
|
679
745
|
|
|
746
|
+
/** The full name of a protobuf.js message type, without the leading dot. */
|
|
747
|
+
function fullName(type) {
|
|
748
|
+
return type.fullName.replace(/^\./u, "");
|
|
749
|
+
}
|
|
750
|
+
|
|
680
751
|
function validateResponseMetadata(metadata, what) {
|
|
681
752
|
try {
|
|
682
753
|
validateMetadata(metadata);
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Compressed messages: ZSTD_MESSAGE frames, whose payload is a magicless zstd frame compressed with
|
|
2
|
+
// a dictionary both peers hold or, on methods that allow it, without one, and the chunks of
|
|
3
|
+
// ZSTD_STREAM_MESSAGE frames (see the "Compressed Messages" section of the wire format). The codec itself lives in the `@trevrpc/trevrpc-js/zstd`
|
|
4
|
+
// subpath, so the runtime never loads it unless compression is configured; this module negotiates
|
|
5
|
+
// dictionaries per session, validates payloads, and enforces limits before handing a payload to
|
|
6
|
+
// the codec. Stream compression's per-call state is in stream-compression.js.
|
|
7
|
+
import { byteView } from "./bytes.js";
|
|
8
|
+
import { FrameType } from "./framing.js";
|
|
9
|
+
import { internal, resourceExhausted } from "./status.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The compression configuration of a transport, or undefined without one. A Channel carries the
|
|
13
|
+
* one from its connect options. A configuration without dictionaries still enables stream
|
|
14
|
+
* compression.
|
|
15
|
+
*/
|
|
16
|
+
export function transportCompression(transport) {
|
|
17
|
+
return transport.compression ?? undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The smallest message compressed without a dictionary: a smaller one rarely has anything to
|
|
22
|
+
* compress against within itself.
|
|
23
|
+
*/
|
|
24
|
+
export const PlainMinSize = 256;
|
|
25
|
+
|
|
26
|
+
const sessions = new WeakMap();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The compression state of one session (one connection) for a configuration. The server's answer
|
|
30
|
+
* to a query holds for the whole session, so it is cached here and dropped with the session.
|
|
31
|
+
*/
|
|
32
|
+
export function sessionCompression(compression, session) {
|
|
33
|
+
let bySession = sessions.get(compression);
|
|
34
|
+
if (bySession === undefined) {
|
|
35
|
+
bySession = new WeakMap();
|
|
36
|
+
sessions.set(compression, bySession);
|
|
37
|
+
}
|
|
38
|
+
let state = bySession.get(session);
|
|
39
|
+
if (state === undefined) {
|
|
40
|
+
state = new SessionCompression(compression);
|
|
41
|
+
bySession.set(session, state);
|
|
42
|
+
}
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class SessionCompression {
|
|
47
|
+
#compression;
|
|
48
|
+
/** The session's shared set, from the server's answer to a query; undefined until one arrives. */
|
|
49
|
+
#shared;
|
|
50
|
+
/**
|
|
51
|
+
* Whether calls on the session stream-compress requests optimistically: from the start, until
|
|
52
|
+
* the server refuses one, and again once a later call's ANSWER accepts `stream_compression`.
|
|
53
|
+
*/
|
|
54
|
+
optimisticStreams = true;
|
|
55
|
+
|
|
56
|
+
constructor(compression) {
|
|
57
|
+
this.#compression = compression;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The negotiation of one call whose response messages have type `responseType`: the REQUEST
|
|
62
|
+
* fields it sets, what it accepts on its responses (a Map from dictionary ID to dictionary, 0
|
|
63
|
+
* for compression without one, which `plainResponses` allows), and whether it queries the
|
|
64
|
+
* server.
|
|
65
|
+
*/
|
|
66
|
+
prepare(responseType, plainResponses = false) {
|
|
67
|
+
const shared = this.#shared;
|
|
68
|
+
const fields = {};
|
|
69
|
+
const queried = shared === undefined;
|
|
70
|
+
if (queried) {
|
|
71
|
+
fields.dictionaryQuery = this.#compression.query;
|
|
72
|
+
}
|
|
73
|
+
// A call that queries may already accept the dictionary it asks about: the server compresses
|
|
74
|
+
// with it only if its answer confirms it. Compression without a dictionary needs no
|
|
75
|
+
// confirmation: a server that does not decode it does not send it either.
|
|
76
|
+
const accept = new Map();
|
|
77
|
+
const dictionary = this.#compression.dictionaryFor(responseType);
|
|
78
|
+
if (dictionary !== undefined && (shared === undefined || shared.has(dictionary))) {
|
|
79
|
+
accept.set(dictionary.id, dictionary);
|
|
80
|
+
}
|
|
81
|
+
if (plainResponses) {
|
|
82
|
+
accept.set(0, this.#compression.plain);
|
|
83
|
+
}
|
|
84
|
+
if (accept.size > 0) {
|
|
85
|
+
fields.acceptDictionaries = [...accept.keys()];
|
|
86
|
+
}
|
|
87
|
+
return { fields, accept, queried };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Records the server's answer to a query as the session's shared set. The first answer wins;
|
|
92
|
+
* they all come from one configuration.
|
|
93
|
+
*/
|
|
94
|
+
learn(answer = []) {
|
|
95
|
+
if (this.#shared !== undefined) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const shared = new Set();
|
|
99
|
+
for (const { id = 0, fingerprint } of answer) {
|
|
100
|
+
// ID 0, which an entry without fields decodes to, is compression without a dictionary.
|
|
101
|
+
const dictionary = this.#compression.dictionary(id);
|
|
102
|
+
if (dictionary !== undefined && sameBytes(dictionary.fingerprint, byteView(fingerprint))) {
|
|
103
|
+
shared.add(dictionary);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.#shared = shared;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The dictionary request messages of type `requestType` may use now, or undefined. */
|
|
110
|
+
requestDictionary(requestType) {
|
|
111
|
+
const dictionary = this.#compression.dictionaryFor(requestType);
|
|
112
|
+
return dictionary !== undefined && this.#shared?.has(dictionary) ? dictionary : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What request messages of type `requestType` are compressed with now: their dictionary, or,
|
|
117
|
+
* with `plain`, `Compression.plain` once the server has confirmed it decodes compression
|
|
118
|
+
* without a dictionary; undefined for neither.
|
|
119
|
+
*/
|
|
120
|
+
requestCompressor(requestType, plain) {
|
|
121
|
+
const dictionary = this.requestDictionary(requestType);
|
|
122
|
+
if (dictionary !== undefined || !plain) {
|
|
123
|
+
return dictionary;
|
|
124
|
+
}
|
|
125
|
+
const compressor = this.#compression.plain;
|
|
126
|
+
return this.#shared?.has(compressor) ? compressor : undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Encodes a marshalled message as an outgoing message, `{ type, payload, size }`: a ZSTD_MESSAGE
|
|
132
|
+
* compressed with `dictionary` when that is shorter, and a MESSAGE otherwise, as without one.
|
|
133
|
+
* Compression without a dictionary (ID 0) applies from PlainMinSize. `size` is the message size,
|
|
134
|
+
* which limits apply to.
|
|
135
|
+
*/
|
|
136
|
+
export function encodeMessage(body, dictionary) {
|
|
137
|
+
const worth =
|
|
138
|
+
dictionary !== undefined && (dictionary.id !== 0 || body.byteLength >= PlainMinSize);
|
|
139
|
+
const compressed = worth ? dictionary.compress(body) : undefined;
|
|
140
|
+
return compressed !== undefined && compressed.byteLength < body.byteLength
|
|
141
|
+
? { type: FrameType.ZstdMessage, payload: compressed, size: body.byteLength }
|
|
142
|
+
: { type: FrameType.Message, payload: body, size: body.byteLength };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The message of a received MESSAGE or ZSTD_MESSAGE frame, `{ type, payload }`, decompressed with
|
|
147
|
+
* what `accept` holds (see inflateMessage).
|
|
148
|
+
*/
|
|
149
|
+
export function receivedMessage({ type, payload }, accept, maxSize) {
|
|
150
|
+
return type === FrameType.ZstdMessage ? inflateMessage(accept, payload, maxSize) : payload;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Decompresses the payload of a ZSTD_MESSAGE frame received on a response. `accept` maps the
|
|
155
|
+
* dictionary IDs the call accepts to their dictionaries, 0 for compression without one, or is
|
|
156
|
+
* undefined for none. A message above maxSize is ResourceExhausted, found before decompressing;
|
|
157
|
+
* every other fault is Internal.
|
|
158
|
+
*/
|
|
159
|
+
export function inflateMessage(accept, payload, maxSize) {
|
|
160
|
+
if (accept === undefined || accept.size === 0) {
|
|
161
|
+
throw internal("compressed message where no dictionary is accepted");
|
|
162
|
+
}
|
|
163
|
+
const header = parseZstdHeader(payload);
|
|
164
|
+
if (header.contentSize > maxSize) {
|
|
165
|
+
throw resourceExhausted(
|
|
166
|
+
`compressed message of ${header.contentSize} bytes exceeds maximum ${maxSize}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const dictionary = accept.get(header.dictionaryId);
|
|
170
|
+
if (dictionary === undefined) {
|
|
171
|
+
throw internal(
|
|
172
|
+
header.dictionaryId === 0
|
|
173
|
+
? "compressed message without a dictionary, which is not accepted"
|
|
174
|
+
: `compressed message uses dictionary ${header.dictionaryId}, which is not accepted`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
let message;
|
|
178
|
+
try {
|
|
179
|
+
message = dictionary.decompress(payload, header.contentSize);
|
|
180
|
+
} catch {
|
|
181
|
+
// Caught below as a failure; the codec's own error says nothing more.
|
|
182
|
+
}
|
|
183
|
+
if (message?.byteLength !== header.contentSize) {
|
|
184
|
+
throw internal("invalid ZSTD_MESSAGE payload");
|
|
185
|
+
}
|
|
186
|
+
return message;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const FcsSizes = [0, 2, 4, 8];
|
|
190
|
+
const DidSizes = [0, 1, 2, 4];
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validates that payload is exactly one magicless zstd frame with a Frame_Content_Size, and
|
|
194
|
+
* returns its Dictionary_ID (0 if absent or zero: compressed without a dictionary) and content
|
|
195
|
+
* size as `{ dictionaryId, contentSize }`. It walks the block
|
|
196
|
+
* headers to find the frame's end, so trailing bytes, a second frame included, are rejected. Block
|
|
197
|
+
* contents are not checked; decompression does that. A content size beyond 2^53 is approximate,
|
|
198
|
+
* which only matters for limits it exceeds anyway.
|
|
199
|
+
*/
|
|
200
|
+
export function parseZstdHeader(payload) {
|
|
201
|
+
const bytes = byteView(payload);
|
|
202
|
+
const invalid = () => internal("invalid ZSTD_MESSAGE payload");
|
|
203
|
+
if (bytes.byteLength === 0) {
|
|
204
|
+
throw invalid();
|
|
205
|
+
}
|
|
206
|
+
const descriptor = bytes[0];
|
|
207
|
+
const fcsFlag = descriptor >> 6;
|
|
208
|
+
const singleSegment = (descriptor & 0x20) !== 0;
|
|
209
|
+
const checksum = (descriptor & 0x04) !== 0;
|
|
210
|
+
const didSize = DidSizes[descriptor & 0x03];
|
|
211
|
+
const fcsSize = fcsFlag === 0 && singleSegment ? 1 : FcsSizes[fcsFlag];
|
|
212
|
+
// The reserved bit must be unset.
|
|
213
|
+
if ((descriptor & 0x08) !== 0 || fcsSize === 0) {
|
|
214
|
+
throw invalid();
|
|
215
|
+
}
|
|
216
|
+
let position = singleSegment ? 1 : 2; // after the Window_Descriptor, if present
|
|
217
|
+
if (bytes.byteLength < position + didSize + fcsSize) {
|
|
218
|
+
throw invalid();
|
|
219
|
+
}
|
|
220
|
+
const dictionaryId = readLittleEndian(bytes, position, didSize);
|
|
221
|
+
position += didSize;
|
|
222
|
+
let contentSize = readLittleEndian(bytes, position, fcsSize);
|
|
223
|
+
if (fcsSize === 2) {
|
|
224
|
+
contentSize += 256;
|
|
225
|
+
}
|
|
226
|
+
position += fcsSize;
|
|
227
|
+
for (;;) {
|
|
228
|
+
const block = nextBlock(bytes, position);
|
|
229
|
+
if (block === undefined) {
|
|
230
|
+
throw invalid();
|
|
231
|
+
}
|
|
232
|
+
position = block.next;
|
|
233
|
+
if (block.last) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (checksum) {
|
|
238
|
+
position += 4;
|
|
239
|
+
}
|
|
240
|
+
if (position !== bytes.byteLength) {
|
|
241
|
+
throw invalid();
|
|
242
|
+
}
|
|
243
|
+
return { dictionaryId, contentSize };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Validates `chunk`, the zstd data of a ZSTD_STREAM_MESSAGE, and returns
|
|
248
|
+
* `{ startsFrame, dictionaryId, endsFrame }`. `atFrameStart` says that no frame is open, so a
|
|
249
|
+
* non-empty chunk must start with a frame header: one without Single_Segment_flag,
|
|
250
|
+
* Frame_Content_Size_flag, Content_Checksum_flag or the reserved bit, whose window is at most
|
|
251
|
+
* 2^maxWindowLog. It then walks the block headers: a chunk is whole blocks, and a Last_Block ends
|
|
252
|
+
* both the chunk and the frame. An empty chunk is an empty message and changes nothing. Block
|
|
253
|
+
* contents are not checked; decompression does that.
|
|
254
|
+
*/
|
|
255
|
+
export function parseStreamChunk(chunk, atFrameStart, maxWindowLog) {
|
|
256
|
+
const bytes = byteView(chunk);
|
|
257
|
+
const invalid = () => internal("invalid ZSTD_STREAM_MESSAGE payload");
|
|
258
|
+
const result = { startsFrame: false, dictionaryId: 0, endsFrame: false };
|
|
259
|
+
if (bytes.byteLength === 0) {
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
let position = 0;
|
|
263
|
+
if (atFrameStart) {
|
|
264
|
+
const descriptor = bytes[0];
|
|
265
|
+
// Frame_Content_Size_flag (bits 7-6), Single_Segment_flag (bit 5), the reserved bit (bit 3) and
|
|
266
|
+
// Content_Checksum_flag (bit 2) must be unset.
|
|
267
|
+
if ((descriptor & 0xec) !== 0 || bytes.byteLength < 2) {
|
|
268
|
+
throw invalid();
|
|
269
|
+
}
|
|
270
|
+
const windowDescriptor = bytes[1];
|
|
271
|
+
const base = 2 ** (10 + (windowDescriptor >> 3));
|
|
272
|
+
if (base + (base / 8) * (windowDescriptor & 7) > 2 ** maxWindowLog) {
|
|
273
|
+
throw invalid();
|
|
274
|
+
}
|
|
275
|
+
const didSize = DidSizes[descriptor & 0x03];
|
|
276
|
+
position = 2 + didSize;
|
|
277
|
+
if (bytes.byteLength < position) {
|
|
278
|
+
throw invalid();
|
|
279
|
+
}
|
|
280
|
+
result.dictionaryId = readLittleEndian(bytes, 2, didSize);
|
|
281
|
+
result.startsFrame = true;
|
|
282
|
+
}
|
|
283
|
+
while (position < bytes.byteLength) {
|
|
284
|
+
const block = nextBlock(bytes, position);
|
|
285
|
+
if (block === undefined || (block.last && block.next !== bytes.byteLength)) {
|
|
286
|
+
throw invalid();
|
|
287
|
+
}
|
|
288
|
+
position = block.next;
|
|
289
|
+
result.endsFrame = block.last;
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Walks the zstd block whose header starts at `position` and returns `{ next, last }`: where the
|
|
296
|
+
* next block starts and whether this one is the Last_Block. Returns undefined for a reserved block
|
|
297
|
+
* type or a block that does not fit. Block contents are not checked.
|
|
298
|
+
*/
|
|
299
|
+
function nextBlock(bytes, position) {
|
|
300
|
+
if (bytes.byteLength - position < 3) {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const header = bytes[position] | (bytes[position + 1] << 8) | (bytes[position + 2] << 16);
|
|
304
|
+
let size = header >>> 3;
|
|
305
|
+
switch ((header >>> 1) & 0x03) {
|
|
306
|
+
case 0: // Raw_Block
|
|
307
|
+
case 2: // Compressed_Block
|
|
308
|
+
break;
|
|
309
|
+
case 1: // RLE_Block
|
|
310
|
+
size = 1;
|
|
311
|
+
break;
|
|
312
|
+
default:
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
const next = position + 3 + size;
|
|
316
|
+
return next > bytes.byteLength ? undefined : { next, last: (header & 1) !== 0 };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Reads an unsigned little-endian integer of size bytes as a number. */
|
|
320
|
+
function readLittleEndian(bytes, offset, size) {
|
|
321
|
+
let value = 0;
|
|
322
|
+
for (let index = size - 1; index >= 0; index -= 1) {
|
|
323
|
+
value = value * 256 + bytes[offset + index];
|
|
324
|
+
}
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function sameBytes(a, b) {
|
|
329
|
+
return a.byteLength === b.byteLength && a.every((byte, index) => byte === b[index]);
|
|
330
|
+
}
|