@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/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
- return new Channel(await dial(options.signal), dial, options.onStateChange);
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(dialed, dial, onStateChange) {
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's WebTransport
72
- * `datagrams` object, or undefined if the session has none (QMux over WebSocket). Calls that may
73
- * use datagrams open their stream here, so their datagram flow belongs to the session that
74
- * carries them even if the channel reconnects meanwhile.
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
- createBidirectionalStreamWithDatagrams() {
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: root.lookupType(method.inputType),
17
- responseType: root.lookupType(method.outputType),
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.datagrams,
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.datagrams,
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.datagrams,
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
- const EarlyOk = "the server completed the call before the request stream ended";
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 available before the
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 response frame
167
- * is read, and `onResponseMetadata` with the trailing metadata when the final status is OK. A
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 response frame
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 ?? cancelled(EarlyOk));
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 response
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 frame;
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 response frame
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
+ }