@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/zstd.js ADDED
@@ -0,0 +1,407 @@
1
+ // The zstd codec of compressed messages, the `@trevrpc/trevrpc-js/zstd` entry point. It runs the
2
+ // reference libzstd as WebAssembly (about 85 KB gzipped, built from trevrpc-js/zstd-wasm), so it
3
+ // is a separate entry point that applications without compression never load.
4
+ import { byteView } from "./bytes.js";
5
+ import wasmBase64 from "./zstd-wasm.js";
6
+
7
+ /** The magic number of zstd-format dictionaries (RFC 8878, section 5). */
8
+ const DictionaryMagic = 0xec30a437;
9
+ const DefaultLevel = 1;
10
+ const FullName = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
11
+ /** The smallest scratch buffer the codec allocates. */
12
+ const MinScratchSize = 4096;
13
+ // How many bytes of messages a stream frame takes before the encoder ends it and starts the next.
14
+ // libzstd's streaming buffer wraps once a 64 KiB window has filled, and from then on matches its
15
+ // history in two pieces, which costs a quarter more CPU per message. Ending the frame first keeps
16
+ // the history in one piece and makes messages less than 1% larger, since the next frame starts
17
+ // afresh (or from the dictionary again).
18
+ const FrameLimit = 60 << 10;
19
+
20
+ /**
21
+ * Creates the compression settings of a channel: the dictionaries it holds, each with the
22
+ * protobuf message types it compresses, and the zstd level of outgoing messages. Settings without
23
+ * dictionaries still enable stream compression, and compression without a dictionary, on the
24
+ * methods that allow them.
25
+ */
26
+ export async function createCompression({ dictionaries = [], level = DefaultLevel } = {}) {
27
+ if (!Number.isInteger(level)) {
28
+ throw new TypeError("compression level must be an integer");
29
+ }
30
+ const parsed = dictionaries.map(parseDictionary);
31
+ const ids = new Set();
32
+ const types = new Set();
33
+ for (const { id, messages } of parsed) {
34
+ if (ids.has(id)) {
35
+ throw new TypeError(`compression dictionaries share ID ${id}`);
36
+ }
37
+ ids.add(id);
38
+ for (const name of messages) {
39
+ if (types.has(name)) {
40
+ throw new TypeError(`message type ${name} is in two compression dictionaries`);
41
+ }
42
+ types.add(name);
43
+ }
44
+ }
45
+ const fingerprints = await Promise.all(parsed.map(({ data }) => fingerprintOf(data)));
46
+ const codec = await Codec.create(level === 0 ? DefaultLevel : level);
47
+ return new Compression(
48
+ parsed.map((dictionary, index) => codec.dictionary(dictionary, fingerprints[index])),
49
+ codec,
50
+ );
51
+ }
52
+
53
+ /** Validates one dictionary option as `{ id, data, messages }`. */
54
+ function parseDictionary({ data, messages = [] }, index) {
55
+ const bytes = byteView(data);
56
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
57
+ if (bytes.byteLength < 8 || view.getUint32(0, true) !== DictionaryMagic) {
58
+ throw new TypeError(`compression dictionary ${index} is not a zstd-format dictionary`);
59
+ }
60
+ const id = view.getUint32(4, true);
61
+ if (id === 0) {
62
+ throw new TypeError(`compression dictionary ${index} has dictionary ID 0`);
63
+ }
64
+ const names = [...messages];
65
+ for (const name of names) {
66
+ if (typeof name !== "string" || !FullName.test(name)) {
67
+ throw new TypeError(
68
+ `compression dictionary ${index}: invalid message type ${JSON.stringify(name)}`,
69
+ );
70
+ }
71
+ }
72
+ return { id, data: bytes, messages: names };
73
+ }
74
+
75
+ /** A dictionary's fingerprint: the first 8 bytes of its SHA-256 digest. */
76
+ async function fingerprintOf(data) {
77
+ return new Uint8Array(await crypto.subtle.digest("SHA-256", data), 0, 8);
78
+ }
79
+
80
+ /** Compression settings; see createCompression. Channels use them through these methods only. */
81
+ class Compression {
82
+ #byId = new Map();
83
+ #byType = new Map();
84
+ #codec;
85
+
86
+ constructor(dictionaries, codec) {
87
+ this.#codec = codec;
88
+ for (const dictionary of dictionaries) {
89
+ this.#byId.set(dictionary.id, dictionary);
90
+ for (const name of dictionary.messages) {
91
+ this.#byType.set(name, dictionary);
92
+ }
93
+ }
94
+ /**
95
+ * Compression without a dictionary, as the dictionary with ID 0 the negotiation treats it as:
96
+ * `compress(body)` and `decompress(payload, size)` like a dictionary's.
97
+ */
98
+ this.plain = codec.plain();
99
+ this.#byId.set(0, this.plain);
100
+ /**
101
+ * A client's dictionary_query: every dictionary as `{ id, fingerprint }`, then `{}`, the
102
+ * query for compression without a dictionary (ID 0 without a fingerprint, which encodes
103
+ * as an empty entry).
104
+ */
105
+ this.query = Object.freeze([
106
+ ...dictionaries.map(({ id, fingerprint }) => ({ id, fingerprint })),
107
+ Object.freeze({}),
108
+ ]);
109
+ Object.freeze(this);
110
+ }
111
+
112
+ /** The dictionary with Dictionary_ID id, `plain` for 0, or undefined. */
113
+ dictionary(id) {
114
+ return this.#byId.get(id);
115
+ }
116
+
117
+ /** The dictionary that compresses messages of the full protobuf type name, or undefined. */
118
+ dictionaryFor(name) {
119
+ return this.#byType.get(name);
120
+ }
121
+
122
+ /**
123
+ * Creates the compressing context of one direction of a call, with a window of 2^windowLog
124
+ * bytes, starting from `dictionary` unless it is undefined. Its `encode(body)` returns the
125
+ * chunk of one message and throws if the context failed; `close()` frees it.
126
+ */
127
+ streamEncoder(dictionary, windowLog) {
128
+ return this.#codec.streamEncoder(dictionary, windowLog);
129
+ }
130
+
131
+ /**
132
+ * Creates the decompressing context of one direction of a call, for windows of at most
133
+ * 2^windowLogMax bytes. Its `decode(chunk, size, frameDictionary)` decompresses a validated chunk
134
+ * into exactly size bytes or throws; `frameDictionary` is undefined for a chunk inside a frame,
135
+ * and the frame's dictionary, or null for none, for one that starts a frame. `close()` frees it.
136
+ */
137
+ streamDecoder(windowLogMax) {
138
+ return this.#codec.streamDecoder(windowLogMax);
139
+ }
140
+
141
+ /** libzstd's estimate of the memory of one stream compressing context. */
142
+ streamEncoderCost(windowLog) {
143
+ return this.#codec.streamEncoderCost(windowLog);
144
+ }
145
+
146
+ /** libzstd's estimate of the memory of one stream decompressing context. */
147
+ streamDecoderCost(windowLogMax) {
148
+ return this.#codec.streamDecoderCost(windowLogMax);
149
+ }
150
+ }
151
+
152
+ let compiled;
153
+
154
+ /** Compiles the module once per page; every Compression instantiates its own copy. */
155
+ function compileModule() {
156
+ compiled ??= WebAssembly.compile(decodeBase64(wasmBase64));
157
+ return compiled;
158
+ }
159
+
160
+ function decodeBase64(text) {
161
+ if (typeof Uint8Array.fromBase64 === "function") {
162
+ return Uint8Array.fromBase64(text);
163
+ }
164
+ const binary = atob(text);
165
+ const bytes = new Uint8Array(binary.length);
166
+ for (let index = 0; index < binary.length; index += 1) {
167
+ bytes[index] = binary.charCodeAt(index);
168
+ }
169
+ return bytes;
170
+ }
171
+
172
+ /**
173
+ * One instance of the libzstd module: a compression and a decompression context for per-message
174
+ * frames, the digested dictionaries, the stream contexts of open calls, and two scratch buffers in
175
+ * its memory. JavaScript runs one call at a time, so the per-message contexts and the buffers are
176
+ * shared by every message; they and the dictionaries live as long as the instance.
177
+ */
178
+ class Codec {
179
+ #exports;
180
+ #level;
181
+ #cctx;
182
+ #dctx;
183
+ /** The libzstd handles of each digested dictionary, `{ cdict, ddict }`. */
184
+ #handles = new WeakMap();
185
+ #input = { pointer: 0, size: 0 };
186
+ #output = { pointer: 0, size: 0 };
187
+
188
+ static async create(level) {
189
+ const instance = await WebAssembly.instantiate(await compileModule(), {});
190
+ return new Codec(instance.exports, level);
191
+ }
192
+
193
+ constructor(exports, level) {
194
+ this.#exports = exports;
195
+ this.#level = level;
196
+ this.#cctx = exports.zs_cctx_new(level);
197
+ this.#dctx = exports.zs_dctx_new();
198
+ if (this.#cctx === 0 || this.#dctx === 0) {
199
+ throw new Error("zstd: cannot create contexts");
200
+ }
201
+ }
202
+
203
+ /** Digests a parsed dictionary for compression at the codec's level and for decompression. */
204
+ dictionary({ id, data, messages }, fingerprint) {
205
+ const pointer = this.#copyIn(this.#input, data);
206
+ const cdict = this.#exports.zs_cdict_new(pointer, data.byteLength, this.#level);
207
+ const ddict = this.#exports.zs_ddict_new(pointer, data.byteLength);
208
+ if (cdict === 0 || ddict === 0) {
209
+ throw new TypeError(`compression dictionary ${id} cannot be loaded`);
210
+ }
211
+ const dictionary = Object.freeze({
212
+ id,
213
+ fingerprint,
214
+ messages: Object.freeze(messages),
215
+ compress: (body) => this.#compress(cdict, body),
216
+ decompress: (payload, size) => this.#decompress(ddict, payload, size),
217
+ });
218
+ this.#handles.set(dictionary, { cdict, ddict });
219
+ return dictionary;
220
+ }
221
+
222
+ /** Compression without a dictionary, as Compression.plain. */
223
+ plain() {
224
+ return Object.freeze({
225
+ id: 0,
226
+ fingerprint: new Uint8Array(0),
227
+ messages: Object.freeze([]),
228
+ compress: (body) => this.#compress(0, body),
229
+ decompress: (payload, size) => this.#decompress(0, payload, size),
230
+ });
231
+ }
232
+
233
+ /** See Compression.streamEncoder. */
234
+ streamEncoder(dictionary, windowLog) {
235
+ const cdict = dictionary === undefined ? 0 : this.#handles.get(dictionary).cdict;
236
+ const cctx = this.#exports.zs_stream_encoder_new(this.#level, windowLog, cdict);
237
+ if (cctx === 0) {
238
+ throw new Error("zstd: cannot create a stream context");
239
+ }
240
+ let open = true;
241
+ let inFrame = 0;
242
+ return {
243
+ encode: (body) => {
244
+ // An empty message never ends a frame, so it stays an empty chunk.
245
+ const end = inFrame + body.byteLength >= FrameLimit;
246
+ inFrame = end ? 0 : inFrame + body.byteLength;
247
+ return this.#streamCompress(cctx, body, end);
248
+ },
249
+ close: () => {
250
+ if (open) {
251
+ open = false;
252
+ this.#exports.zs_stream_encoder_free(cctx);
253
+ }
254
+ },
255
+ };
256
+ }
257
+
258
+ /** See Compression.streamDecoder. */
259
+ streamDecoder(windowLogMax) {
260
+ const dctx = this.#exports.zs_stream_decoder_new(windowLogMax);
261
+ if (dctx === 0) {
262
+ throw new Error("zstd: cannot create a stream context");
263
+ }
264
+ let open = true;
265
+ return {
266
+ decode: (chunk, size, frameDictionary) =>
267
+ this.#streamDecompress(dctx, chunk, size, frameDictionary),
268
+ close: () => {
269
+ if (open) {
270
+ open = false;
271
+ this.#exports.zs_stream_decoder_free(dctx);
272
+ }
273
+ },
274
+ };
275
+ }
276
+
277
+ streamEncoderCost(windowLog) {
278
+ return this.#exports.zs_stream_encoder_cost(this.#level, windowLog) >>> 0;
279
+ }
280
+
281
+ streamDecoderCost(windowLogMax) {
282
+ return this.#exports.zs_stream_decoder_cost(windowLogMax) >>> 0;
283
+ }
284
+
285
+ /** Compresses one message through a stream context into its chunk, or throws. */
286
+ #streamCompress(cctx, body, end) {
287
+ // Room for the frame header and block headers on top of the bound.
288
+ const capacity = this.#exports.zs_compress_bound(body.byteLength) + 64;
289
+ const output = this.#reserve(this.#output, capacity);
290
+ const input = this.#copyIn(this.#input, body);
291
+ const size = this.#exports.zs_stream_compress(
292
+ cctx,
293
+ output,
294
+ capacity,
295
+ input,
296
+ body.byteLength,
297
+ end ? 1 : 0,
298
+ );
299
+ if (size < 0) {
300
+ throw new Error("zstd: stream compression failed");
301
+ }
302
+ return this.#view(output, size).slice();
303
+ }
304
+
305
+ /** Decompresses one validated chunk through a stream context into size bytes, or throws. */
306
+ #streamDecompress(dctx, chunk, size, frameDictionary) {
307
+ if (frameDictionary !== undefined) {
308
+ const ddict = frameDictionary === null ? 0 : this.#handles.get(frameDictionary).ddict;
309
+ if (this.#exports.zs_stream_decoder_ref(dctx, ddict) < 0) {
310
+ throw new Error("zstd: cannot reference the dictionary");
311
+ }
312
+ }
313
+ if (chunk.byteLength === 0) {
314
+ if (size !== 0) {
315
+ throw new Error("zstd: empty chunk for a non-empty message");
316
+ }
317
+ return new Uint8Array(0);
318
+ }
319
+ // One spare byte: libzstd holds back output that does not fit, so a message longer than its
320
+ // declared size only shows as that byte.
321
+ const capacity = size + 1;
322
+ const output = this.#reserve(this.#output, capacity);
323
+ const input = this.#copyIn(this.#input, chunk);
324
+ const written = this.#exports.zs_stream_decompress(
325
+ dctx,
326
+ output,
327
+ capacity,
328
+ input,
329
+ chunk.byteLength,
330
+ );
331
+ if (written !== size) {
332
+ throw new Error("zstd: stream decompression failed");
333
+ }
334
+ return this.#view(output, size).slice();
335
+ }
336
+
337
+ /**
338
+ * Compresses body into a new payload, or returns undefined if that fails, and the message then
339
+ * travels uncompressed.
340
+ */
341
+ #compress(cdict, body) {
342
+ const bound = this.#exports.zs_compress_bound(body.byteLength);
343
+ let output;
344
+ let input;
345
+ try {
346
+ output = this.#reserve(this.#output, bound);
347
+ input = this.#copyIn(this.#input, body);
348
+ } catch {
349
+ return undefined;
350
+ }
351
+ const size = this.#exports.zs_compress(
352
+ this.#cctx,
353
+ cdict,
354
+ output,
355
+ bound,
356
+ input,
357
+ body.byteLength,
358
+ );
359
+ return size < 0 ? undefined : this.#view(output, size).slice();
360
+ }
361
+
362
+ /** Decompresses a validated payload into a new message of exactly size bytes, or throws. */
363
+ #decompress(ddict, payload, size) {
364
+ const output = this.#reserve(this.#output, size);
365
+ const input = this.#copyIn(this.#input, payload);
366
+ const written = this.#exports.zs_decompress(
367
+ this.#dctx,
368
+ ddict,
369
+ output,
370
+ size,
371
+ input,
372
+ payload.byteLength,
373
+ );
374
+ if (written !== size) {
375
+ throw new Error("zstd: decompression failed");
376
+ }
377
+ return this.#view(output, size).slice();
378
+ }
379
+
380
+ /** Copies bytes into buffer, growing it as needed, and returns its pointer. */
381
+ #copyIn(buffer, bytes) {
382
+ const pointer = this.#reserve(buffer, bytes.byteLength);
383
+ this.#view(pointer, bytes.byteLength).set(bytes);
384
+ return pointer;
385
+ }
386
+
387
+ /** Returns the pointer of buffer after growing it to at least size bytes. */
388
+ #reserve(buffer, size) {
389
+ if (buffer.size < size) {
390
+ if (buffer.size > 0) {
391
+ this.#exports.zs_free(buffer.pointer, buffer.size);
392
+ }
393
+ const grown = Math.max(size, buffer.size * 2, MinScratchSize);
394
+ buffer.pointer = this.#exports.zs_alloc(grown) >>> 0;
395
+ buffer.size = buffer.pointer === 0 ? 0 : grown;
396
+ if (buffer.pointer === 0) {
397
+ throw new RangeError(`zstd: cannot allocate ${grown} bytes`);
398
+ }
399
+ }
400
+ return buffer.pointer;
401
+ }
402
+
403
+ /** A view of linear memory; growing the memory detaches earlier views. */
404
+ #view(pointer, size) {
405
+ return new Uint8Array(this.#exports.memory.buffer, pointer, size);
406
+ }
407
+ }