@trevrpc/trevrpc-js 1.0.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.
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ // Connection metadata is request metadata a client sets once for all its calls, such as a bearer
2
+ // token, so that it crosses each connection once instead of in every REQUEST. A call offers the
3
+ // set (RpcRequest.connection_metadata with entries) until an ANSWER confirms that the server
4
+ // stored it for the session, then refers to it by ID. Calls on different streams reach the server
5
+ // in any order, so a reference only follows a confirmation. The server merges the set into each
6
+ // call's metadata, the call's own entries winning, so the client just sends both; see the wiki's
7
+ // Connection Metadata section. This module holds the channel's set and what each session
8
+ // confirmed; call.js puts them on the REQUEST and learns the confirmation from the ANSWER.
9
+ import { normalizeMetadata } from "./metadata.js";
10
+
11
+ /** The property of a transport that holds its ConnectionMetadataSource. */
12
+ export const ConnectionMetadataKey = Symbol("trevrpc.connectionMetadata");
13
+
14
+ /** The connection metadata of a transport, or undefined without a source. */
15
+ export function transportConnectionMetadata(transport) {
16
+ return transport[ConnectionMetadataKey];
17
+ }
18
+
19
+ /** A request that carries no connection metadata. */
20
+ const NoConnectionMetadata = Object.freeze({ field: undefined, offered: 0 });
21
+
22
+ /**
23
+ * A channel's connection metadata, shared by its sessions: the current set, whose ID changes with
24
+ * it, and the state of each session.
25
+ */
26
+ export class ConnectionMetadataSource {
27
+ /** The current set, `{ id, metadata }`, or undefined without connection metadata. */
28
+ #current;
29
+ #ids = 0;
30
+ #sessions = new WeakMap();
31
+
32
+ /** Throws InvalidArgument if `metadata` is invalid, like call metadata. */
33
+ constructor(metadata) {
34
+ this.set(metadata);
35
+ }
36
+
37
+ /** The current set, or undefined. */
38
+ get current() {
39
+ return this.#current;
40
+ }
41
+
42
+ /**
43
+ * Replaces the connection metadata, with keys normalized like a call's; empty or missing
44
+ * metadata removes it. The new set has a new ID, so each session offers it again.
45
+ */
46
+ set(metadata) {
47
+ const normalized = metadata == null ? {} : normalizeMetadata(metadata);
48
+ const keys = Object.keys(normalized);
49
+ if (keys.length === 0) {
50
+ this.#current = undefined;
51
+ return;
52
+ }
53
+ // Copied, so a caller changing its bytes later does not change what is sent.
54
+ for (const key of keys) {
55
+ normalized[key] = normalized[key].slice();
56
+ }
57
+ // IDs are uint32 on the wire and 0 means none: they run from 1 to 2^32 - 1 and wrap.
58
+ this.#ids = (this.#ids % 0xffffffff) + 1;
59
+ this.#current = { id: this.#ids, metadata: normalized };
60
+ }
61
+
62
+ /**
63
+ * The state of one session, keyed like the compression state: what the server confirmed on the
64
+ * connection that `key` identifies, dropped with it.
65
+ */
66
+ session(key) {
67
+ let state = this.#sessions.get(key);
68
+ if (state === undefined) {
69
+ state = new SessionConnectionMetadata(this);
70
+ this.#sessions.set(key, state);
71
+ }
72
+ return state;
73
+ }
74
+ }
75
+
76
+ class SessionConnectionMetadata {
77
+ #source;
78
+ /** The ID of the set the server confirmed on this session, or zero. */
79
+ #confirmed = 0;
80
+
81
+ constructor(source) {
82
+ this.#source = source;
83
+ }
84
+
85
+ /**
86
+ * The connection metadata of one REQUEST as `{ field, offered }`: `field` is the value of
87
+ * `connectionMetadata`, a reference to the current set once the session confirmed it and an
88
+ * offer of it otherwise, or undefined without a set; `offered` is the ID the REQUEST offers,
89
+ * which `learn` checks the ANSWER against, or zero.
90
+ */
91
+ prepare() {
92
+ const set = this.#source.current;
93
+ if (set === undefined) {
94
+ return NoConnectionMetadata;
95
+ }
96
+ if (this.#confirmed === set.id) {
97
+ return { field: { id: set.id }, offered: 0 };
98
+ }
99
+ return { field: { id: set.id, metadata: set.metadata }, offered: set.id };
100
+ }
101
+
102
+ /** Records that the server stored the `offered` set, once the call's ANSWER confirms it. */
103
+ learn(offered, answer) {
104
+ // Only the current set counts: a late confirmation of one since replaced would make calls
105
+ // offer the current one again.
106
+ if (
107
+ offered !== 0 &&
108
+ answer.connectionMetadata === offered &&
109
+ this.#source.current?.id === offered
110
+ ) {
111
+ this.#confirmed = offered;
112
+ }
113
+ }
114
+ }