@btoon/js 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/web.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.webFormat = webFormat;
4
+ exports.trustedDecompressionFormat = trustedDecompressionFormat;
5
+ exports.mayDecompressAsync = mayDecompressAsync;
4
6
  exports.webCompress = webCompress;
5
7
  exports.webDecompress = webDecompress;
6
8
  exports.subtle = subtle;
@@ -9,8 +11,10 @@ exports.webHmacVerify = webHmacVerify;
9
11
  // Asynchronous implementations on top of web platform APIs: Compression Streams and
10
12
  // WebCrypto. Used by encodeAsync/decodeAsync wherever the runtime provides them.
11
13
  const errors_js_1 = require("./errors.js");
12
- const inflate_js_1 = require("./inflate.js");
13
14
  const FORMATS = { zlib: 'deflate', brotli: 'brotli', zstd: 'zstd' };
15
+ // DecompressionStream has no way to bound a Zstandard window (SPEC §6), so it is never used
16
+ // for zstd.
17
+ const DECOMPRESSION_FORMATS = { zlib: 'deflate', brotli: 'brotli' };
14
18
  const CHUNK = 64 * 1024;
15
19
  const supported = new Map();
16
20
  function streamCtor(kind) {
@@ -48,6 +52,76 @@ function concat(chunks, total) {
48
52
  }
49
53
  return out;
50
54
  }
55
+ function fromHex(h) {
56
+ const out = new Uint8Array(h.length / 2);
57
+ for (let i = 0; i < out.length; i++)
58
+ out[i] = parseInt(h.slice(2 * i, 2 * i + 2), 16);
59
+ return out;
60
+ }
61
+ // Streams of the five bytes "BTOON" (zlib level 9, Brotli quality 11) for the trust probe.
62
+ const PROBE_TEXT = 'BTOON';
63
+ const PROBE_STREAMS = { deflate: '78da730af1f7f7030004780183', brotli: '0b028042544f4f4e03' };
64
+ const trust = new Map();
65
+ /**
66
+ * Whether this runtime's DecompressionStream can be trusted with `format`: it must decode a
67
+ * valid stream and reject one followed by a byte (in the same write or in the next), or by a
68
+ * second stream. Node.js 18 to 23 (some later 22.x releases excepted) ignore bytes after a
69
+ * zlib stream, and Node.js 22 and 24 after a Brotli stream, while SPEC §6 requires
70
+ * `invalid_envelope`. Checked once per format.
71
+ */
72
+ function trusted(format) {
73
+ let p = trust.get(format);
74
+ if (p === undefined) {
75
+ p = probe(format);
76
+ trust.set(format, p);
77
+ }
78
+ return p;
79
+ }
80
+ async function probe(format) {
81
+ const hex = PROBE_STREAMS[format];
82
+ if (hex === undefined)
83
+ return false;
84
+ const valid = fromHex(hex);
85
+ const next = (extra) => {
86
+ const out = new Uint8Array(valid.length + extra.length);
87
+ out.set(valid);
88
+ out.set(extra, valid.length);
89
+ return out;
90
+ };
91
+ try {
92
+ const out = await decompressChunks(format, [valid], 64, format);
93
+ if (String.fromCharCode(...out) !== PROBE_TEXT)
94
+ return false;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ for (const chunks of [[next(Uint8Array.of(0))], [valid, Uint8Array.of(0)], [next(valid)]]) {
100
+ try {
101
+ await decompressChunks(format, chunks, 64, format);
102
+ return false; // trailing bytes accepted
103
+ }
104
+ catch {
105
+ // rejected, as it should be
106
+ }
107
+ }
108
+ return true;
109
+ }
110
+ /**
111
+ * The DecompressionStream format that decodeAsync may use for `alg` when no other
112
+ * decompressor is available, or undefined: never for zstd, and only once the runtime has
113
+ * shown that it rejects trailing bytes.
114
+ */
115
+ async function trustedDecompressionFormat(alg) {
116
+ const format = DECOMPRESSION_FORMATS[alg];
117
+ if (format === undefined || webFormat(alg, 'DecompressionStream') === undefined)
118
+ return undefined;
119
+ return (await trusted(format)) ? format : undefined;
120
+ }
121
+ /** Whether decodeAsync might decompress `alg` with a DecompressionStream here (for error messages). */
122
+ function mayDecompressAsync(alg) {
123
+ return DECOMPRESSION_FORMATS[alg] !== undefined && webFormat(alg, 'DecompressionStream') !== undefined;
124
+ }
51
125
  async function readAll(reader, limit, alg) {
52
126
  const chunks = [];
53
127
  let total = 0;
@@ -64,11 +138,18 @@ async function readAll(reader, limit, alg) {
64
138
  }
65
139
  return concat(chunks, total);
66
140
  }
67
- /** Feeds `data` in chunks, honouring backpressure, then closes the writer. */
68
- async function pump(writer, data) {
69
- for (let i = 0; i < data.length; i += CHUNK) {
141
+ /** Splits `data` into the chunks written to a stream. */
142
+ function chunked(data) {
143
+ const chunks = [];
144
+ for (let i = 0; i < data.length; i += CHUNK)
145
+ chunks.push(data.subarray(i, Math.min(i + CHUNK, data.length)));
146
+ return chunks;
147
+ }
148
+ /** Writes `chunks`, honouring backpressure, then closes the writer. */
149
+ async function pump(writer, chunks) {
150
+ for (const chunk of chunks) {
70
151
  await writer.ready;
71
- await writer.write(data.subarray(i, Math.min(i + CHUNK, data.length)));
152
+ await writer.write(chunk);
72
153
  }
73
154
  await writer.close();
74
155
  }
@@ -78,41 +159,45 @@ function messageOf(e) {
78
159
  async function webCompress(format, data) {
79
160
  const stream = new (streamCtor('CompressionStream'))(format);
80
161
  const writer = stream.writable.getWriter();
81
- const writing = pump(writer, data);
162
+ const writing = pump(writer, chunked(data));
82
163
  writing.catch(() => undefined);
83
164
  const out = await readAll(stream.readable.getReader(), Infinity, format);
84
165
  await writing;
85
166
  return out;
86
167
  }
87
168
  /**
88
- * Decompresses with a DecompressionStream, stopping as soon as the output exceeds
89
- * `limit` (the declared size). For zlib it also checks that the payload ends with the
90
- * Adler-32 of the output: older runtimes silently ignore bytes after the end of the
91
- * stream, which a strict decoder must reject.
169
+ * Runs `chunks` through a DecompressionStream. Output beyond `limit` bytes fails with
170
+ * `invalid_envelope` ("<label> payload decompresses to more than the declared size").
92
171
  */
93
- async function webDecompress(alg, format, data, limit) {
172
+ async function decompressChunks(format, chunks, limit, label) {
94
173
  const stream = new (streamCtor('DecompressionStream'))(format);
95
174
  const writer = stream.writable.getWriter();
96
- const writing = pump(writer, data);
175
+ const writing = pump(writer, chunks);
97
176
  writing.catch(() => undefined);
98
- let out;
99
177
  try {
100
- out = await readAll(stream.readable.getReader(), limit, alg);
178
+ const out = await readAll(stream.readable.getReader(), limit, label);
101
179
  await writing;
180
+ return out;
102
181
  }
103
182
  catch (e) {
104
183
  writer.abort(e).catch(() => undefined);
184
+ throw e;
185
+ }
186
+ }
187
+ /**
188
+ * Decompresses with a DecompressionStream, stopping as soon as the output exceeds `limit`
189
+ * (the declared size). Only for a format that trustedDecompressionFormat() returned, which
190
+ * rejects bytes after the end of the stream.
191
+ */
192
+ async function webDecompress(alg, format, data, limit) {
193
+ try {
194
+ return await decompressChunks(format, chunked(data), limit, alg);
195
+ }
196
+ catch (e) {
105
197
  if (e instanceof errors_js_1.BtoonError)
106
198
  throw e;
107
199
  throw (0, errors_js_1.fail)('invalid_envelope', `${alg} payload is corrupt: ${messageOf(e)}`, e);
108
200
  }
109
- if (alg === 'zlib') {
110
- const n = data.length;
111
- const trailer = n >= 4 ? ((data[n - 4] << 24) | (data[n - 3] << 16) | (data[n - 2] << 8) | data[n - 1]) >>> 0 : -1;
112
- if (trailer !== (0, inflate_js_1.adler32)(out))
113
- throw (0, errors_js_1.fail)('invalid_envelope', 'zlib payload has trailing bytes');
114
- }
115
- return out;
116
201
  }
117
202
  /** WebCrypto's SubtleCrypto, if available (absent in Node 18 and on insecure origins). */
118
203
  function subtle() {
@@ -1,5 +1,5 @@
1
1
  /** Version of this library. */
2
- export declare const VERSION: "0.1.1";
2
+ export declare const VERSION: "0.1.2";
3
3
  /** Version of the BTOON specification this library implements. */
4
4
  export declare const SPEC_VERSION: "0.1";
5
5
  export declare const EXT_TIMESTAMP = -1;
@@ -1,5 +1,5 @@
1
1
  /** Version of this library. */
2
- export const VERSION = '0.1.1';
2
+ export const VERSION = '0.1.2';
3
3
  /** Version of the BTOON specification this library implements. */
4
4
  export const SPEC_VERSION = '0.1';
5
5
  // Extension type ids (SPEC §5).
@@ -1,8 +1,13 @@
1
1
  import { type CompressionAlgorithm, type DecodeOptions, type EncodeOptions } from './options.js';
2
- /** Synchronous building blocks of one environment. */
2
+ /** Building blocks of one environment: its synchronous codecs and HMAC, and non-blocking decompressors. */
3
3
  export interface Platform {
4
4
  /** Decompressors; `size` is the declared uncompressed size (already checked against the limit). */
5
5
  readonly decompress: Readonly<Partial<Record<CompressionAlgorithm, (data: Uint8Array, size: number) => Uint8Array>>>;
6
+ /**
7
+ * Non-blocking forms of `decompress` that reach the same verdict (node:zlib on the thread
8
+ * pool). decodeAsync prefers them.
9
+ */
10
+ readonly decompressAsync?: Readonly<Partial<Record<CompressionAlgorithm, (data: Uint8Array, size: number) => Promise<Uint8Array>>>>;
6
11
  readonly compress: Readonly<Partial<Record<CompressionAlgorithm, (data: Uint8Array) => Uint8Array>>>;
7
12
  hmac(key: Uint8Array, parts: readonly Uint8Array[]): Uint8Array;
8
13
  equal(a: Uint8Array, b: Uint8Array): boolean;
package/dist/esm/core.js CHANGED
@@ -7,7 +7,7 @@ import { encodeDocument } from './encode.js';
7
7
  import { BtoonError, fail } from './errors.js';
8
8
  import { decodeConfig, encodeConfig, } from './options.js';
9
9
  import { isUint8Array } from './values.js';
10
- import { subtle, webCompress, webDecompress, webFormat, webHmacSign, webHmacVerify } from './web.js';
10
+ import { mayDecompressAsync, subtle, trustedDecompressionFormat, webCompress, webDecompress, webFormat, webHmacSign, webHmacVerify, } from './web.js';
11
11
  const COMPRESSED_MAGIC = [0x42, 0x54, 0x4f, 0x4e]; // "BTON"
12
12
  const SIGNED_MAGIC = [0x42, 0x54, 0x53, 0x47]; // "BTSG"
13
13
  const SIGNED_HEADER = new Uint8Array([0x42, 0x54, 0x53, 0x47, 1, 1, 0, 0]);
@@ -198,7 +198,7 @@ export function decodeWith(data, options, platform) {
198
198
  if (out === undefined) {
199
199
  const builtin = platform.decompress[alg];
200
200
  if (builtin === undefined)
201
- throw unavailable(alg, 'decompression', webFormat(alg, 'DecompressionStream') !== undefined);
201
+ throw unavailable(alg, 'decompression', mayDecompressAsync(alg));
202
202
  out = builtin(payload, size);
203
203
  }
204
204
  bytes = checkSize(out, size, alg);
@@ -253,17 +253,30 @@ export async function decodeAsyncWith(data, options, platform) {
253
253
  if (env !== null) {
254
254
  const { alg, payload, size } = env;
255
255
  let out = await callCodecAsync(cfg.codecs, alg, 'decompress', [payload, size]);
256
- if (out === undefined) {
257
- const format = webFormat(alg, 'DecompressionStream');
258
- const builtin = platform.decompress[alg];
259
- if (format !== undefined)
260
- out = await webDecompress(alg, format, payload, size);
261
- else if (builtin !== undefined)
262
- out = builtin(payload, size);
263
- else
264
- throw unavailable(alg, 'decompression', false);
265
- }
256
+ if (out === undefined)
257
+ out = await decompressAsync(platform, alg, payload, size);
266
258
  bytes = checkSize(out, size, alg);
267
259
  }
268
260
  return decodeDocument(bytes, cfg);
269
261
  }
262
+ /**
263
+ * decodeAsync's decompressor. It reaches the same verdict as decode's: first the same
264
+ * decompressor, in its non-blocking form where there is one (node:zlib), then the synchronous
265
+ * one (the pure-JS inflate). A DecompressionStream is the last resort, only for a format that
266
+ * the runtime has shown it handles strictly, and never for zstd.
267
+ */
268
+ async function decompressAsync(platform, alg, payload, size) {
269
+ const nonBlocking = platform.decompressAsync?.[alg];
270
+ if (nonBlocking !== undefined)
271
+ return nonBlocking(payload, size);
272
+ const builtin = platform.decompress[alg];
273
+ if (builtin !== undefined)
274
+ return builtin(payload, size);
275
+ const format = await trustedDecompressionFormat(alg);
276
+ if (format !== undefined)
277
+ return webDecompress(alg, format, payload, size);
278
+ if (mayDecompressAsync(alg)) {
279
+ throw fail('unsupported_compression', `${alg} decompression is not available in this environment: its DecompressionStream accepts bytes after the end of a stream; pass options.codecs.${alg}`);
280
+ }
281
+ throw unavailable(alg, 'decompression', false);
282
+ }
@@ -1,7 +1,8 @@
1
1
  // Hardened BTOON decoder (SPEC §7). Invariants:
2
2
  // * every read is bounds-checked against `end` (no reads past the input);
3
3
  // * containers are depth-limited (max_depth) — no unbounded recursion;
4
- // * nothing is allocated beyond what the remaining input could encode.
4
+ // * nothing is allocated beyond what the remaining input could encode, and what containers
5
+ // allocate before their elements are decoded is bounded for the whole document.
5
6
  import { DEFAULT_MAX_DEPTH, EXT_BIGINT, EXT_DATE, EXT_DATETIME, EXT_F32_VECTOR, EXT_F64_VECTOR, EXT_TABULAR, EXT_TIMESTAMP, MAX_DATE_MS, MAX_OFFSET_MINUTES, NANOS_PER_SECOND, TABULAR_VERSION, } from './constants.js';
6
7
  import { BtoonError, fail, isStackOverflow } from './errors.js';
7
8
  import { decodeUtf8 } from './utf8.js';
@@ -17,6 +18,15 @@ const fromCharCode = String.fromCharCode;
17
18
  const KEY_CACHE_SIZE = 4096;
18
19
  const KEY_CACHE_MAX_LEN = 16;
19
20
  const keyCache = new Array(KEY_CACHE_SIZE).fill(undefined);
21
+ // Limits on what containers allocate before their elements are decoded. A header may announce
22
+ // as many elements as there are bytes left, but nested containers announce the same bytes
23
+ // again at every level, so trusting the counts let a 1 MiB document of 64 nested tabulars
24
+ // build 4 GB of rows and exhaust the heap (review 2026-11-13, X1). An array, or the row list
25
+ // of a table, reserves at most MAX_RESERVE slots, and those open at any time at most
26
+ // RESERVE_BUDGET together; beyond that they grow as elements are decoded. Each tabular row is
27
+ // created once its first cell has been decoded.
28
+ const MAX_RESERVE = 1024;
29
+ const RESERVE_BUDGET = 64 * MAX_RESERVE;
20
30
  const HEX = [];
21
31
  for (let i = 0; i < 256; i++)
22
32
  HEX.push((i < 16 ? '0' : '') + i.toString(16));
@@ -45,6 +55,8 @@ export function plainBytes(b) {
45
55
  class Decoder {
46
56
  constructor(bytes, strict, maxDepth, tsObjects, bigAlways) {
47
57
  this.pos = 0;
58
+ /** Slots reserved by the arrays and row lists being decoded (see reserve). */
59
+ this.reserved = 0;
48
60
  this.bytes = plainBytes(bytes);
49
61
  this.view = new DataView(this.bytes.buffer, this.bytes.byteOffset, this.bytes.byteLength);
50
62
  this.end = this.bytes.length;
@@ -209,15 +221,26 @@ class Decoder {
209
221
  this.pos += len;
210
222
  return b;
211
223
  }
224
+ /**
225
+ * How many of the `n` elements that an array, or a table's row list, announces it may
226
+ * allocate up front. They are charged to the document's budget until it is complete.
227
+ */
228
+ reserve(n) {
229
+ const r = Math.min(n, MAX_RESERVE, RESERVE_BUDGET - this.reserved);
230
+ this.reserved += r;
231
+ return r;
232
+ }
212
233
  array(n, depth) {
213
234
  if (depth >= this.maxDepth)
214
235
  throw this.err('max_depth', 'maximum nesting depth exceeded');
215
236
  // Every element takes at least one byte.
216
237
  if (n > this.end - this.pos)
217
238
  throw this.err('truncated', 'array length exceeds the remaining input');
218
- const a = new Array(n);
239
+ const r = this.reserve(n);
240
+ const a = new Array(r); // grows as elements beyond the reservation arrive
219
241
  for (let i = 0; i < n; i++)
220
242
  a[i] = this.value(depth + 1);
243
+ this.reserved -= r;
221
244
  return a;
222
245
  }
223
246
  key() {
@@ -447,7 +470,7 @@ class Decoder {
447
470
  // Each column needs at least 9 bytes: name length, type code, column length.
448
471
  if (cols > (end - p) / 9)
449
472
  throw bad('tabular column count exceeds the payload');
450
- const names = new Array(cols);
473
+ const names = [];
451
474
  for (let c = 0; c < cols; c++) {
452
475
  if (end - p < 4)
453
476
  throw bad('tabular payload truncated');
@@ -458,7 +481,7 @@ class Decoder {
458
481
  const name = decodeUtf8(this.bytes, p, n, this.strict);
459
482
  if (name === undefined)
460
483
  throw this.err('invalid_utf8', 'column name is not valid UTF-8', p);
461
- names[c] = name;
484
+ names.push(name);
462
485
  p += n;
463
486
  }
464
487
  if (new Set(names).size !== cols)
@@ -469,9 +492,10 @@ class Decoder {
469
492
  // Every cell is at least one byte.
470
493
  if (rowCount * cols > end - p)
471
494
  throw bad('tabular row count exceeds the payload');
472
- const rows = new Array(rowCount);
473
- for (let r = 0; r < rowCount; r++)
474
- rows[r] = {};
495
+ // Each row is created once its first cell has been decoded, so the rows are backed by
496
+ // input actually consumed, however many the header announces.
497
+ const reserved = this.reserve(rowCount);
498
+ const rows = new Array(reserved);
475
499
  const savedEnd = this.end;
476
500
  for (let c = 0; c < cols; c++) {
477
501
  if (end - p < 4)
@@ -485,7 +509,15 @@ class Decoder {
485
509
  this.pos = p;
486
510
  this.end = colEnd;
487
511
  try {
488
- if (name === '__proto__') {
512
+ if (c === 0) {
513
+ for (let r = 0; r < rowCount; r++) {
514
+ const v = this.value(depth + 2);
515
+ const row = {};
516
+ setProperty(row, name, v);
517
+ rows[r] = row;
518
+ }
519
+ }
520
+ else if (name === '__proto__') {
489
521
  for (let r = 0; r < rowCount; r++)
490
522
  setProperty(rows[r], name, this.value(depth + 2));
491
523
  }
@@ -505,6 +537,7 @@ class Decoder {
505
537
  p = colEnd;
506
538
  }
507
539
  this.end = savedEnd;
540
+ this.reserved -= reserved;
508
541
  if (p !== end)
509
542
  throw bad('trailing bytes in tabular payload');
510
543
  this.pos = end;
@@ -60,9 +60,12 @@ export declare function decode(data: BinaryInput, options?: DecodeOptions): unkn
60
60
  */
61
61
  export declare function encodeAsync(value: unknown, options?: EncodeOptions): Promise<Uint8Array<ArrayBuffer>>;
62
62
  /**
63
- * Like {@link decode}, but decompresses with `DecompressionStream` (when the runtime
64
- * supports the algorithm) and verifies signatures with `crypto.subtle.verify`, falling back
65
- * to the synchronous implementations. The input is copied first, so the caller may reuse
66
- * its buffer immediately. Rejects with a {@link BtoonError}.
63
+ * Like {@link decode}, but asynchronous. In the Node.js entry, decompression runs on the
64
+ * `node:zlib` thread pool. In the portable entry, zlib uses the same pure-JS inflate as
65
+ * `decode`, and Brotli can also be decompressed with a `DecompressionStream` where the runtime
66
+ * has shown that it rejects bytes after the end of a stream (never Zstandard, whose window
67
+ * size it can't bound). Signatures are verified with `crypto.subtle.verify` where available.
68
+ * `decodeAsync` never accepts a document that `decode` rejects. The input is copied first, so
69
+ * the caller may reuse its buffer immediately. Rejects with a {@link BtoonError}.
67
70
  */
68
71
  export declare function decodeAsync(data: BinaryInput, options?: DecodeOptions): Promise<unknown>;
package/dist/esm/index.js CHANGED
@@ -64,10 +64,13 @@ export function encodeAsync(value, options) {
64
64
  return encodeAsyncWith(value, options, portablePlatform);
65
65
  }
66
66
  /**
67
- * Like {@link decode}, but decompresses with `DecompressionStream` (when the runtime
68
- * supports the algorithm) and verifies signatures with `crypto.subtle.verify`, falling back
69
- * to the synchronous implementations. The input is copied first, so the caller may reuse
70
- * its buffer immediately. Rejects with a {@link BtoonError}.
67
+ * Like {@link decode}, but asynchronous. In the Node.js entry, decompression runs on the
68
+ * `node:zlib` thread pool. In the portable entry, zlib uses the same pure-JS inflate as
69
+ * `decode`, and Brotli can also be decompressed with a `DecompressionStream` where the runtime
70
+ * has shown that it rejects bytes after the end of a stream (never Zstandard, whose window
71
+ * size it can't bound). Signatures are verified with `crypto.subtle.verify` where available.
72
+ * `decodeAsync` never accepts a document that `decode` rejects. The input is copied first, so
73
+ * the caller may reuse its buffer immediately. Rejects with a {@link BtoonError}.
71
74
  */
72
75
  export function decodeAsync(data, options) {
73
76
  return decodeAsyncWith(data, options, portablePlatform);
@@ -56,9 +56,12 @@ export declare function decode(data: BinaryInput, options?: DecodeOptions): unkn
56
56
  */
57
57
  export declare function encodeAsync(value: unknown, options?: EncodeOptions): Promise<Uint8Array<ArrayBuffer>>;
58
58
  /**
59
- * Like {@link decode}, but decompresses with `DecompressionStream` (when the runtime
60
- * supports the algorithm) and verifies signatures with `crypto.subtle.verify`, falling back
61
- * to the synchronous implementations. The input is copied first, so the caller may reuse
62
- * its buffer immediately. Rejects with a {@link BtoonError}.
59
+ * Like {@link decode}, but asynchronous. In the Node.js entry, decompression runs on the
60
+ * `node:zlib` thread pool. In the portable entry, zlib uses the same pure-JS inflate as
61
+ * `decode`, and Brotli can also be decompressed with a `DecompressionStream` where the runtime
62
+ * has shown that it rejects bytes after the end of a stream (never Zstandard, whose window
63
+ * size it can't bound). Signatures are verified with `crypto.subtle.verify` where available.
64
+ * `decodeAsync` never accepts a document that `decode` rejects. The input is copied first, so
65
+ * the caller may reuse its buffer immediately. Rejects with a {@link BtoonError}.
63
66
  */
64
67
  export declare function decodeAsync(data: BinaryInput, options?: DecodeOptions): Promise<unknown>;
package/dist/esm/node.js CHANGED
@@ -60,10 +60,13 @@ export function encodeAsync(value, options) {
60
60
  return encodeAsyncWith(value, options, nodePlatform);
61
61
  }
62
62
  /**
63
- * Like {@link decode}, but decompresses with `DecompressionStream` (when the runtime
64
- * supports the algorithm) and verifies signatures with `crypto.subtle.verify`, falling back
65
- * to the synchronous implementations. The input is copied first, so the caller may reuse
66
- * its buffer immediately. Rejects with a {@link BtoonError}.
63
+ * Like {@link decode}, but asynchronous. In the Node.js entry, decompression runs on the
64
+ * `node:zlib` thread pool. In the portable entry, zlib uses the same pure-JS inflate as
65
+ * `decode`, and Brotli can also be decompressed with a `DecompressionStream` where the runtime
66
+ * has shown that it rejects bytes after the end of a stream (never Zstandard, whose window
67
+ * size it can't bound). Signatures are verified with `crypto.subtle.verify` where available.
68
+ * `decodeAsync` never accepts a document that `decode` rejects. The input is copied first, so
69
+ * the caller may reuse its buffer immediately. Rejects with a {@link BtoonError}.
67
70
  */
68
71
  export function decodeAsync(data, options) {
69
72
  return decodeAsyncWith(data, options, nodePlatform);
@@ -1,7 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto';
2
2
  import * as zlib from 'node:zlib';
3
3
  import { fail } from './errors.js';
4
- import { adler32 } from './inflate.js';
4
+ import { inflateZlib } from './inflate.js';
5
5
  function nodeFn(name) {
6
6
  const f = zlib[name];
7
7
  return typeof f === 'function' ? f : undefined;
@@ -9,80 +9,115 @@ function nodeFn(name) {
9
9
  function plain(b) {
10
10
  return new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
11
11
  }
12
- /**
13
- * Wraps a node:zlib `*Sync` decompressor: output is capped at the declared size
14
- * (`maxOutputLength`), and input left over after the end of the stream is rejected.
15
- */
12
+ /** node:zlib options: output capped at the declared size, and `info` to learn how much input was consumed. */
13
+ function options(size, extra) {
14
+ return { ...extra?.(size), info: true, maxOutputLength: Math.max(1, size) };
15
+ }
16
+ function failure(alg, e) {
17
+ if (e.code === 'ERR_BUFFER_TOO_LARGE') {
18
+ return fail('invalid_envelope', `${alg} payload decompresses to more than the declared size`, e);
19
+ }
20
+ return fail('invalid_envelope', `${alg} payload is corrupt: ${e.message}`, e);
21
+ }
22
+ /** The output of a node:zlib decompressor, once it is clear that the stream used the whole payload. */
23
+ function output(alg, data, size, r) {
24
+ let out;
25
+ let consumed;
26
+ if (r instanceof Uint8Array) {
27
+ out = r;
28
+ }
29
+ else {
30
+ const info = r;
31
+ out = info.buffer;
32
+ consumed = info.engine?.bytesWritten;
33
+ }
34
+ if (typeof consumed !== 'number') {
35
+ // A runtime that doesn't report the input it consumed can't show that no bytes follow the
36
+ // stream (SPEC §6). zlib has an exact pure-JS decoder; the other algorithms don't.
37
+ if (alg === 'zlib')
38
+ return inflateZlib(data, size);
39
+ throw fail('unsupported_compression', `${alg} decompression in this runtime cannot detect trailing bytes; pass options.codecs.${alg}`);
40
+ }
41
+ if (consumed !== data.length)
42
+ throw fail('invalid_envelope', `${alg} payload has trailing bytes`);
43
+ return plain(out);
44
+ }
45
+ /** Wraps a node:zlib `*Sync` decompressor, for decode. */
16
46
  function decompressor(alg, fn, extra) {
17
47
  if (fn === undefined)
18
48
  return undefined;
19
49
  return (data, size) => {
20
50
  let r;
21
51
  try {
22
- r = fn(data, { ...extra?.(size), info: true, maxOutputLength: Math.max(1, size) });
52
+ r = fn(data, options(size, extra));
23
53
  }
24
54
  catch (e) {
25
- if (e.code === 'ERR_BUFFER_TOO_LARGE') {
26
- throw fail('invalid_envelope', `${alg} payload decompresses to more than the declared size`, e);
27
- }
28
- throw fail('invalid_envelope', `${alg} payload is corrupt: ${e.message}`, e);
29
- }
30
- let out;
31
- let consumed;
32
- if (r instanceof Uint8Array) {
33
- out = r;
34
- }
35
- else {
36
- const info = r;
37
- out = info.buffer;
38
- consumed = info.engine?.bytesWritten;
39
- }
40
- if (typeof consumed === 'number') {
41
- if (consumed !== data.length)
42
- throw fail('invalid_envelope', `${alg} payload has trailing bytes`);
43
- }
44
- else if (alg === 'zlib') {
45
- // A runtime that doesn't report consumed input: the stream must end in its Adler-32.
46
- const n = data.length;
47
- const trailer = n >= 4 ? ((data[n - 4] << 24) | (data[n - 3] << 16) | (data[n - 2] << 8) | data[n - 1]) >>> 0 : -1;
48
- if (trailer !== adler32(out))
49
- throw fail('invalid_envelope', 'zlib payload has trailing bytes');
55
+ throw failure(alg, e);
50
56
  }
51
- return plain(out);
57
+ return output(alg, data, size, r);
52
58
  };
53
59
  }
54
- function compressor(fn, options) {
60
+ /**
61
+ * Wraps the callback form of the same node:zlib decompressor, for decodeAsync: it runs on the
62
+ * thread pool, with the same limits and checks, so both give the same verdict.
63
+ */
64
+ function asyncDecompressor(alg, fn, extra) {
65
+ if (fn === undefined)
66
+ return undefined;
67
+ return (data, size) => new Promise((resolve, reject) => {
68
+ fn(data, options(size, extra), (e, r) => {
69
+ if (e) {
70
+ reject(failure(alg, e));
71
+ return;
72
+ }
73
+ try {
74
+ resolve(output(alg, data, size, r));
75
+ }
76
+ catch (err) {
77
+ reject(err);
78
+ }
79
+ });
80
+ });
81
+ }
82
+ function compressor(fn, opts) {
55
83
  if (fn === undefined)
56
84
  return undefined;
57
- return (data) => plain(fn(data, options));
85
+ return (data) => plain(fn(data, opts));
58
86
  }
59
87
  const decompress = {};
88
+ const decompressAsync = {};
60
89
  const compress = {};
61
90
  decompress.zlib = decompressor('zlib', nodeFn('inflateSync'));
91
+ decompressAsync.zlib = asyncDecompressor('zlib', nodeFn('inflate'));
62
92
  compress.zlib = compressor(nodeFn('deflateSync'), { level: 6 });
63
93
  const brotliQuality = zlib.constants?.BROTLI_PARAM_QUALITY;
64
94
  if (brotliQuality !== undefined) {
65
95
  decompress.brotli = decompressor('brotli', nodeFn('brotliDecompressSync'));
96
+ decompressAsync.brotli = asyncDecompressor('brotli', nodeFn('brotliDecompress'));
66
97
  compress.brotli = compressor(nodeFn('brotliCompressSync'), { params: { [brotliQuality]: 6 } });
67
98
  }
68
- // node:zlib has Zstandard from Node.js 22.15 / 23.8. SPEC §6: bound the decoder's memory
69
- // by rejecting frames whose window exceeds max(8 MiB, min(uncompressed_size, 128 MiB)).
99
+ // node:zlib has Zstandard from Node.js 22.15 / 23.8. SPEC §6: bound the decoder's memory by
100
+ // rejecting frames whose window exceeds max(8 MiB, min(uncompressed_size, 128 MiB)). Without
101
+ // the windowLogMax parameter there is no bound, so zstd decompression is not offered at all.
70
102
  const zstdWindowLogMax = zlib.constants?.ZSTD_d_windowLogMax;
71
- decompress.zstd = decompressor('zstd', nodeFn('zstdDecompressSync'), (size) => zstdWindowLogMax === undefined
72
- ? {}
73
- : { params: { [zstdWindowLogMax]: Math.ceil(Math.log2(Math.max(8 << 20, Math.min(size, 128 << 20)))) } });
74
- compress.zstd = compressor(nodeFn('zstdCompressSync'), {});
75
- for (const alg of Object.keys(decompress)) {
76
- if (decompress[alg] === undefined)
77
- delete decompress[alg];
103
+ if (zstdWindowLogMax !== undefined) {
104
+ const zstdWindow = (size) => ({
105
+ params: { [zstdWindowLogMax]: Math.ceil(Math.log2(Math.max(8 << 20, Math.min(size, 128 << 20)))) },
106
+ });
107
+ decompress.zstd = decompressor('zstd', nodeFn('zstdDecompressSync'), zstdWindow);
108
+ decompressAsync.zstd = asyncDecompressor('zstd', nodeFn('zstdDecompress'), zstdWindow);
78
109
  }
79
- for (const alg of Object.keys(compress)) {
80
- if (compress[alg] === undefined)
81
- delete compress[alg];
110
+ compress.zstd = compressor(nodeFn('zstdCompressSync'), {});
111
+ for (const table of [decompress, decompressAsync, compress]) {
112
+ for (const alg of Object.keys(table)) {
113
+ if (table[alg] === undefined)
114
+ delete table[alg];
115
+ }
82
116
  }
83
117
  /** Node.js (and Bun): node:zlib for zlib, Brotli and Zstandard; node:crypto for HMAC. */
84
118
  export const nodePlatform = {
85
119
  decompress,
120
+ decompressAsync,
86
121
  compress,
87
122
  hmac(key, parts) {
88
123
  const h = createHmac('sha256', key);