micro-packed
Version:
Define complex binary structures using composable primitives
1,306 lines (1,305 loc) • 78.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports._TEST = exports.ZeroPad = exports.magicBytes = exports.flag = exports.cstring = exports.string = exports.hex = exports.bytes = exports.bool = exports.F64LE = exports.F64BE = exports.F32LE = exports.F32BE = exports.I8 = exports.U8 = exports.I16BE = exports.I16LE = exports.U16BE = exports.U16LE = exports.I32BE = exports.I32LE = exports.U32BE = exports.U32LE = exports.int = exports.I64BE = exports.I64LE = exports.U64BE = exports.U64LE = exports.I128BE = exports.I128LE = exports.U128BE = exports.U128LE = exports.I256BE = exports.I256LE = exports.U256BE = exports.U256LE = exports.bigint = exports.bits = exports.coders = exports.wrap = exports.utils = exports.NULL = exports.EMPTY = void 0;
exports.validate = validate;
exports.isCoder = isCoder;
exports.prefix = prefix;
exports.apply = apply;
exports.lazy = lazy;
exports.flagged = flagged;
exports.optional = optional;
exports.magic = magic;
exports.constant = constant;
exports.struct = struct;
exports.tuple = tuple;
exports.array = array;
exports.map = map;
exports.tag = tag;
exports.mappedTag = mappedTag;
exports.bitset = bitset;
exports.padLeft = padLeft;
exports.padRight = padRight;
exports.pointer = pointer;
const base_1 = require("@scure/base");
/**
* Define complex binary structures using composable primitives.
* Main ideas:
* - Encode / decode can be chained, same as in `scure-base`
* - A complex structure can be created from an array and struct of primitive types
* - Strings / bytes are arrays with specific optimizations: we can just read bytes directly
* without creating plain array first and reading each byte separately.
* - Types are inferred from definition
* @module
* @example
* import * as P from 'micro-packed';
* const s = P.struct({
* field1: P.U32BE, // 32-bit unsigned big-endian integer
* field2: P.string(P.U8), // String with U8 length prefix
* field3: P.bytes(32), // 32 bytes
* field4: P.array(P.U16BE, P.struct({ // Array of structs with U16BE length
* subField1: P.U64BE, // 64-bit unsigned big-endian integer
* subField2: P.string(10) // 10-byte string
* }))
* });
*/
// TODO: remove dependency on scure-base & inline?
/*
Exports can be groupped like this:
- Primitive types: P.bytes, P.string, P.hex, P.constant, P.pointer
- Complex types: P.array, P.struct, P.tuple, P.map, P.tag, P.mappedTag
- Padding, prefix, magic: P.padLeft, P.padRight, P.prefix, P.magic, P.magicBytes
- Flags: P.flag, P.flagged, P.optional
- Wrappers: P.apply, P.wrap, P.lazy
- Bit fiddling: P.bits, P.bitset
- utils: P.validate, coders.decimal
- Debugger
*/
/** Shortcut to zero-length (empty) byte array */
exports.EMPTY = new Uint8Array();
/** Shortcut to one-element (element is 0) byte array */
exports.NULL = new Uint8Array([0]);
/** Checks if two Uint8Arrays are equal. Not constant-time. */
function equalBytes(a, b) {
if (a.length !== b.length)
return false;
for (let i = 0; i < a.length; i++)
if (a[i] !== b[i])
return false;
return true;
}
/** Checks if the given value is a Uint8Array. */
function isBytes(a) {
return a instanceof Uint8Array || (ArrayBuffer.isView(a) && a.constructor.name === 'Uint8Array');
}
/**
* Concatenates multiple Uint8Arrays.
* Engines limit functions to 65K+ arguments.
* @param arrays Array of Uint8Array elements
* @returns Concatenated Uint8Array
*/
function concatBytes(...arrays) {
let sum = 0;
for (let i = 0; i < arrays.length; i++) {
const a = arrays[i];
if (!isBytes(a))
throw new Error('Uint8Array expected');
sum += a.length;
}
const res = new Uint8Array(sum);
for (let i = 0, pad = 0; i < arrays.length; i++) {
const a = arrays[i];
res.set(a, pad);
pad += a.length;
}
return res;
}
/**
* Creates DataView from Uint8Array
* @param arr - bytes
* @returns DataView
*/
const createView = (arr) => new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
/**
* Checks if the provided value is a plain object, not created from any class or special constructor.
* Array, Uint8Array and others are not plain objects.
* @param obj - The value to be checked.
*/
function isPlainObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]';
}
function isNum(num) {
return Number.isSafeInteger(num);
}
exports.utils = {
equalBytes,
isBytes,
isCoder,
checkBounds,
concatBytes,
createView,
isPlainObject,
};
// NOTE: we can't have terminator separate function, since it won't know about boundaries
// E.g. array of U16LE ([1,2,3]) would be [1, 0, 2, 0, 3, 0]
// But terminator will find array at index '1', which happens to be inside of an element itself
/**
* Can be:
* - Dynamic (CoderType)
* - Fixed (number)
* - Terminated (usually zero): Uint8Array with terminator
* - Field path to field with length (string)
* - Infinity (null) - decodes until end of buffer
* Used in:
* - bytes (string, prefix is implementation of bytes)
* - array
*/
const lengthCoder = (len) => {
if (len !== null && typeof len !== 'string' && !isCoder(len) && !isBytes(len) && !isNum(len)) {
throw new Error(`lengthCoder: expected null | number | Uint8Array | CoderType, got ${len} (${typeof len})`);
}
return {
encodeStream(w, value) {
if (len === null)
return;
if (isCoder(len))
return len.encodeStream(w, value);
let byteLen;
if (typeof len === 'number')
byteLen = len;
else if (typeof len === 'string')
byteLen = Path.resolve(w.stack, len);
if (typeof byteLen === 'bigint')
byteLen = Number(byteLen);
if (byteLen === undefined || byteLen !== value)
throw w.err(`Wrong length: ${byteLen} len=${len} exp=${value} (${typeof value})`);
},
decodeStream(r) {
let byteLen;
if (isCoder(len))
byteLen = Number(len.decodeStream(r));
else if (typeof len === 'number')
byteLen = len;
else if (typeof len === 'string')
byteLen = Path.resolve(r.stack, len);
if (typeof byteLen === 'bigint')
byteLen = Number(byteLen);
if (typeof byteLen !== 'number')
throw r.err(`Wrong length: ${byteLen}`);
return byteLen;
},
};
};
/**
* Small bitset structure to store position of ranges that have been read.
* Can be more efficient when internal trees are utilized at the cost of complexity.
* Needs `O(N/8)` memory for parsing.
* Purpose: if there are pointers in parsed structure,
* they can cause read of two distinct ranges:
* [0-32, 64-128], which means 'pos' is not enough to handle them
*/
const Bitset = {
BITS: 32,
FULL_MASK: -1 >>> 0, // 1<<32 will overflow
len: (len) => Math.ceil(len / 32),
create: (len) => new Uint32Array(Bitset.len(len)),
clean: (bs) => bs.fill(0),
debug: (bs) => Array.from(bs).map((i) => (i >>> 0).toString(2).padStart(32, '0')),
checkLen: (bs, len) => {
if (Bitset.len(len) === bs.length)
return;
throw new Error(`wrong length=${bs.length}. Expected: ${Bitset.len(len)}`);
},
chunkLen: (bsLen, pos, len) => {
if (pos < 0)
throw new Error(`wrong pos=${pos}`);
if (pos + len > bsLen)
throw new Error(`wrong range=${pos}/${len} of ${bsLen}`);
},
set: (bs, chunk, value, allowRewrite = true) => {
if (!allowRewrite && (bs[chunk] & value) !== 0)
return false;
bs[chunk] |= value;
return true;
},
pos: (pos, i) => ({
chunk: Math.floor((pos + i) / 32),
mask: 1 << (32 - ((pos + i) % 32) - 1),
}),
indices: (bs, len, invert = false) => {
Bitset.checkLen(bs, len);
const { FULL_MASK, BITS } = Bitset;
const left = BITS - (len % BITS);
const lastMask = left ? (FULL_MASK >>> left) << left : FULL_MASK;
const res = [];
for (let i = 0; i < bs.length; i++) {
let c = bs[i];
if (invert)
c = ~c; // allows to gen unset elements
// apply mask to last element, so we won't iterate non-existent items
if (i === bs.length - 1)
c &= lastMask;
if (c === 0)
continue; // fast-path
for (let j = 0; j < BITS; j++) {
const m = 1 << (BITS - j - 1);
if (c & m)
res.push(i * BITS + j);
}
}
return res;
},
range: (arr) => {
const res = [];
let cur;
for (const i of arr) {
if (cur === undefined || i !== cur.pos + cur.length)
res.push((cur = { pos: i, length: 1 }));
else
cur.length += 1;
}
return res;
},
rangeDebug: (bs, len, invert = false) => `[${Bitset.range(Bitset.indices(bs, len, invert))
.map((i) => `(${i.pos}/${i.length})`)
.join(', ')}]`,
setRange: (bs, bsLen, pos, len, allowRewrite = true) => {
Bitset.chunkLen(bsLen, pos, len);
const { FULL_MASK, BITS } = Bitset;
// Try to set range with maximum efficiency:
// - first chunk is always '0000[1111]' (only right ones)
// - middle chunks are set to '[1111 1111]' (all ones)
// - last chunk is always '[1111]0000' (only left ones)
// - max operations: (N/32) + 2 (first and last)
const first = pos % BITS ? Math.floor(pos / BITS) : undefined;
const lastPos = pos + len;
const last = lastPos % BITS ? Math.floor(lastPos / BITS) : undefined;
// special case, whole range inside single chunk
if (first !== undefined && first === last)
return Bitset.set(bs, first, (FULL_MASK >>> (BITS - len)) << (BITS - len - pos), allowRewrite);
if (first !== undefined) {
if (!Bitset.set(bs, first, FULL_MASK >>> pos % BITS, allowRewrite))
return false; // first chunk
}
// middle chunks
const start = first !== undefined ? first + 1 : pos / BITS;
const end = last !== undefined ? last : lastPos / BITS;
for (let i = start; i < end; i++)
if (!Bitset.set(bs, i, FULL_MASK, allowRewrite))
return false;
if (last !== undefined && first !== last)
if (!Bitset.set(bs, last, FULL_MASK << (BITS - (lastPos % BITS)), allowRewrite))
return false; // last chunk
return true;
},
};
const Path = {
/**
* Internal method for handling stack of paths (debug, errors, dynamic fields via path)
* This is looks ugly (callback), but allows us to force stack cleaning by construction (.pop always after function).
* Also, this makes impossible:
* - pushing field when stack is empty
* - pushing field inside of field (real bug)
* NOTE: we don't want to do '.pop' on error!
*/
pushObj: (stack, obj, objFn) => {
const last = { obj };
stack.push(last);
objFn((field, fieldFn) => {
last.field = field;
fieldFn();
last.field = undefined;
});
stack.pop();
},
path: (stack) => {
const res = [];
for (const i of stack)
if (i.field !== undefined)
res.push(i.field);
return res.join('/');
},
err: (name, stack, msg) => {
const err = new Error(`${name}(${Path.path(stack)}): ${typeof msg === 'string' ? msg : msg.message}`);
if (msg instanceof Error && msg.stack)
err.stack = msg.stack;
return err;
},
resolve: (stack, path) => {
const parts = path.split('/');
const objPath = stack.map((i) => i.obj);
let i = 0;
for (; i < parts.length; i++) {
if (parts[i] === '..')
objPath.pop();
else
break;
}
let cur = objPath.pop();
for (; i < parts.length; i++) {
if (!cur || cur[parts[i]] === undefined)
return undefined;
cur = cur[parts[i]];
}
return cur;
},
};
/**
* Internal structure. Reader class for reading from a byte array.
* `stack` is internal: for debugger and logging
* @class Reader
*/
class _Reader {
constructor(data, opts = {}, stack = [], parent = undefined, parentOffset = 0) {
this.pos = 0;
this.bitBuf = 0;
this.bitPos = 0;
this.data = data;
this.opts = opts;
this.stack = stack;
this.parent = parent;
this.parentOffset = parentOffset;
this.view = createView(data);
}
/** Internal method for pointers. */
_enablePointers() {
if (this.parent)
return this.parent._enablePointers();
if (this.bs)
return;
this.bs = Bitset.create(this.data.length);
Bitset.setRange(this.bs, this.data.length, 0, this.pos, this.opts.allowMultipleReads);
}
markBytesBS(pos, len) {
if (this.parent)
return this.parent.markBytesBS(this.parentOffset + pos, len);
if (!len)
return true;
if (!this.bs)
return true;
return Bitset.setRange(this.bs, this.data.length, pos, len, false);
}
markBytes(len) {
const pos = this.pos;
this.pos += len;
const res = this.markBytesBS(pos, len);
if (!this.opts.allowMultipleReads && !res)
throw this.err(`multiple read pos=${this.pos} len=${len}`);
return res;
}
pushObj(obj, objFn) {
return Path.pushObj(this.stack, obj, objFn);
}
readView(n, fn) {
if (!Number.isFinite(n))
throw this.err(`readView: wrong length=${n}`);
if (this.pos + n > this.data.length)
throw this.err('readView: Unexpected end of buffer');
const res = fn(this.view, this.pos);
this.markBytes(n);
return res;
}
// read bytes by absolute offset
absBytes(n) {
if (n > this.data.length)
throw new Error('Unexpected end of buffer');
return this.data.subarray(n);
}
finish() {
if (this.opts.allowUnreadBytes)
return;
if (this.bitPos) {
throw this.err(`${this.bitPos} bits left after unpack: ${base_1.hex.encode(this.data.slice(this.pos))}`);
}
if (this.bs && !this.parent) {
const notRead = Bitset.indices(this.bs, this.data.length, true);
if (notRead.length) {
const formatted = Bitset.range(notRead)
.map(({ pos, length }) => `(${pos}/${length})[${base_1.hex.encode(this.data.subarray(pos, pos + length))}]`)
.join(', ');
throw this.err(`unread byte ranges: ${formatted} (total=${this.data.length})`);
}
else
return; // all bytes read, everything is ok
}
// Default: no pointers enabled
if (!this.isEnd()) {
throw this.err(`${this.leftBytes} bytes ${this.bitPos} bits left after unpack: ${base_1.hex.encode(this.data.slice(this.pos))}`);
}
}
// User methods
err(msg) {
return Path.err('Reader', this.stack, msg);
}
offsetReader(n) {
if (n > this.data.length)
throw this.err('offsetReader: Unexpected end of buffer');
return new _Reader(this.absBytes(n), this.opts, this.stack, this, n);
}
bytes(n, peek = false) {
if (this.bitPos)
throw this.err('readBytes: bitPos not empty');
if (!Number.isFinite(n))
throw this.err(`readBytes: wrong length=${n}`);
if (this.pos + n > this.data.length)
throw this.err('readBytes: Unexpected end of buffer');
const slice = this.data.subarray(this.pos, this.pos + n);
if (!peek)
this.markBytes(n);
return slice;
}
byte(peek = false) {
if (this.bitPos)
throw this.err('readByte: bitPos not empty');
if (this.pos + 1 > this.data.length)
throw this.err('readBytes: Unexpected end of buffer');
const data = this.data[this.pos];
if (!peek)
this.markBytes(1);
return data;
}
get leftBytes() {
return this.data.length - this.pos;
}
get totalBytes() {
return this.data.length;
}
isEnd() {
return this.pos >= this.data.length && !this.bitPos;
}
// bits are read in BE mode (left to right): (0b1000_0000).readBits(1) == 1
bits(bits) {
if (bits > 32)
throw this.err('BitReader: cannot read more than 32 bits in single call');
let out = 0;
while (bits) {
if (!this.bitPos) {
this.bitBuf = this.byte();
this.bitPos = 8;
}
const take = Math.min(bits, this.bitPos);
this.bitPos -= take;
out = (out << take) | ((this.bitBuf >> this.bitPos) & (2 ** take - 1));
this.bitBuf &= 2 ** this.bitPos - 1;
bits -= take;
}
// Fix signed integers
return out >>> 0;
}
find(needle, pos = this.pos) {
if (!isBytes(needle))
throw this.err(`find: needle is not bytes! ${needle}`);
if (this.bitPos)
throw this.err('findByte: bitPos not empty');
if (!needle.length)
throw this.err(`find: needle is empty`);
// indexOf should be faster than full equalBytes check
for (let idx = pos; (idx = this.data.indexOf(needle[0], idx)) !== -1; idx++) {
if (idx === -1)
return;
const leftBytes = this.data.length - idx;
if (leftBytes < needle.length)
return;
if (equalBytes(needle, this.data.subarray(idx, idx + needle.length)))
return idx;
}
return;
}
}
/**
* Internal structure. Writer class for writing to a byte array.
* The `stack` argument of constructor is internal, for debugging and logs.
* @class Writer
*/
class _Writer {
constructor(stack = []) {
this.pos = 0;
// We could have a single buffer here and re-alloc it with
// x1.5-2 size each time it full, but it will be slower:
// basic/encode bench: 395ns -> 560ns
this.buffers = [];
this.ptrs = [];
this.bitBuf = 0;
this.bitPos = 0;
this.viewBuf = new Uint8Array(8);
this.finished = false;
this.stack = stack;
this.view = createView(this.viewBuf);
}
pushObj(obj, objFn) {
return Path.pushObj(this.stack, obj, objFn);
}
writeView(len, fn) {
if (this.finished)
throw this.err('buffer: finished');
if (!isNum(len) || len > 8)
throw new Error(`wrong writeView length=${len}`);
fn(this.view);
this.bytes(this.viewBuf.slice(0, len));
this.viewBuf.fill(0);
}
// User methods
err(msg) {
if (this.finished)
throw this.err('buffer: finished');
return Path.err('Reader', this.stack, msg);
}
bytes(b) {
if (this.finished)
throw this.err('buffer: finished');
if (this.bitPos)
throw this.err('writeBytes: ends with non-empty bit buffer');
this.buffers.push(b);
this.pos += b.length;
}
byte(b) {
if (this.finished)
throw this.err('buffer: finished');
if (this.bitPos)
throw this.err('writeByte: ends with non-empty bit buffer');
this.buffers.push(new Uint8Array([b]));
this.pos++;
}
finish(clean = true) {
if (this.finished)
throw this.err('buffer: finished');
if (this.bitPos)
throw this.err('buffer: ends with non-empty bit buffer');
// Can't use concatBytes, because it limits amount of arguments (65K).
const buffers = this.buffers.concat(this.ptrs.map((i) => i.buffer));
const sum = buffers.map((b) => b.length).reduce((a, b) => a + b, 0);
const buf = new Uint8Array(sum);
for (let i = 0, pad = 0; i < buffers.length; i++) {
const a = buffers[i];
buf.set(a, pad);
pad += a.length;
}
for (let pos = this.pos, i = 0; i < this.ptrs.length; i++) {
const ptr = this.ptrs[i];
buf.set(ptr.ptr.encode(pos), ptr.pos);
pos += ptr.buffer.length;
}
// Cleanup
if (clean) {
// We cannot cleanup buffers here, since it can be static user provided buffer.
// Only '.byte' and '.bits' create buffer which we can safely clean.
// for (const b of this.buffers) b.fill(0);
this.buffers = [];
for (const p of this.ptrs)
p.buffer.fill(0);
this.ptrs = [];
this.finished = true;
this.bitBuf = 0;
}
return buf;
}
bits(value, bits) {
if (bits > 32)
throw this.err('writeBits: cannot write more than 32 bits in single call');
if (value >= 2 ** bits)
throw this.err(`writeBits: value (${value}) >= 2**bits (${bits})`);
while (bits) {
const take = Math.min(bits, 8 - this.bitPos);
this.bitBuf = (this.bitBuf << take) | (value >> (bits - take));
this.bitPos += take;
bits -= take;
value &= 2 ** bits - 1;
if (this.bitPos === 8) {
this.bitPos = 0;
this.buffers.push(new Uint8Array([this.bitBuf]));
this.pos++;
}
}
}
}
// Immutable LE<->BE
const swapEndianness = (b) => Uint8Array.from(b).reverse();
/** Internal function for checking bit bounds of bigint in signed/unsinged form */
function checkBounds(value, bits, signed) {
if (signed) {
// [-(2**(32-1)), 2**(32-1)-1]
const signBit = 2n ** (bits - 1n);
if (value < -signBit || value >= signBit)
throw new Error(`value out of signed bounds. Expected ${-signBit} <= ${value} < ${signBit}`);
}
else {
// [0, 2**32-1]
if (0n > value || value >= 2n ** bits)
throw new Error(`value out of unsigned bounds. Expected 0 <= ${value} < ${2n ** bits}`);
}
}
function _wrap(inner) {
return {
// NOTE: we cannot export validate here, since it is likely mistake.
encodeStream: inner.encodeStream,
decodeStream: inner.decodeStream,
size: inner.size,
encode: (value) => {
const w = new _Writer();
inner.encodeStream(w, value);
return w.finish();
},
decode: (data, opts = {}) => {
const r = new _Reader(data, opts);
const res = inner.decodeStream(r);
r.finish();
return res;
},
};
}
/**
* Validates a value before encoding and after decoding using a provided function.
* @param inner - The inner CoderType.
* @param fn - The validation function.
* @returns CoderType which check value with validation function.
* @example
* const val = (n: number) => {
* if (n > 10) throw new Error(`${n} > 10`);
* return n;
* };
*
* const RangedInt = P.validate(P.U32LE, val); // Will check if value is <= 10 during encoding and decoding
*/
function validate(inner, fn) {
if (!isCoder(inner))
throw new Error(`validate: invalid inner value ${inner}`);
if (typeof fn !== 'function')
throw new Error('validate: fn should be function');
return _wrap({
size: inner.size,
encodeStream: (w, value) => {
let res;
try {
res = fn(value);
}
catch (e) {
throw w.err(e);
}
inner.encodeStream(w, res);
},
decodeStream: (r) => {
const res = inner.decodeStream(r);
try {
return fn(res);
}
catch (e) {
throw r.err(e);
}
},
});
}
/**
* Wraps a stream encoder into a generic encoder and optionally validation function
* @param {inner} inner BytesCoderStream & { validate?: Validate<T> }.
* @returns The wrapped CoderType.
* @example
* const U8 = P.wrap({
* encodeStream: (w: Writer, value: number) => w.byte(value),
* decodeStream: (r: Reader): number => r.byte()
* });
* const checkedU8 = P.wrap({
* encodeStream: (w: Writer, value: number) => w.byte(value),
* decodeStream: (r: Reader): number => r.byte()
* validate: (n: number) => {
* if (n > 10) throw new Error(`${n} > 10`);
* return n;
* }
* });
*/
const wrap = (inner) => {
const res = _wrap(inner);
return inner.validate ? validate(res, inner.validate) : res;
};
exports.wrap = wrap;
const isBaseCoder = (elm) => isPlainObject(elm) && typeof elm.decode === 'function' && typeof elm.encode === 'function';
/**
* Checks if the given value is a CoderType.
* @param elm - The value to check.
* @returns True if the value is a CoderType, false otherwise.
*/
function isCoder(elm) {
return (isPlainObject(elm) &&
isBaseCoder(elm) &&
typeof elm.encodeStream === 'function' &&
typeof elm.decodeStream === 'function' &&
(elm.size === undefined || isNum(elm.size)));
}
// Coders (like in @scure/base) for common operations
/**
* Base coder for working with dictionaries (records, objects, key-value map)
* Dictionary is dynamic type like: `[key: string, value: any][]`
* @returns base coder that encodes/decodes between arrays of key-value tuples and dictionaries.
* @example
* const dict: P.CoderType<Record<string, number>> = P.apply(
* P.array(P.U16BE, P.tuple([P.cstring, P.U32LE] as const)),
* P.coders.dict()
* );
*/
function dict() {
return {
encode: (from) => {
if (!Array.isArray(from))
throw new Error('array expected');
const to = {};
for (const item of from) {
if (!Array.isArray(item) || item.length !== 2)
throw new Error(`array of two elements expected`);
const name = item[0];
const value = item[1];
if (to[name] !== undefined)
throw new Error(`key(${name}) appears twice in struct`);
to[name] = value;
}
return to;
},
decode: (to) => {
if (!isPlainObject(to))
throw new Error(`expected plain object, got ${to}`);
return Object.entries(to);
},
};
}
/**
* Safely converts bigint to number.
* Sometimes pointers / tags use u64 or other big numbers which cannot be represented by number,
* but we still can use them since real value will be smaller than u32
*/
const numberBigint = {
encode: (from) => {
if (typeof from !== 'bigint')
throw new Error(`expected bigint, got ${typeof from}`);
if (from > BigInt(Number.MAX_SAFE_INTEGER))
throw new Error(`element bigger than MAX_SAFE_INTEGER=${from}`);
return Number(from);
},
decode: (to) => {
if (!isNum(to))
throw new Error('element is not a safe integer');
return BigInt(to);
},
};
/**
* Base coder for working with TypeScript enums.
* @param e - TypeScript enum.
* @returns base coder that encodes/decodes between numbers and enum keys.
* @example
* enum Color { Red, Green, Blue }
* const colorCoder = P.coders.tsEnum(Color);
* colorCoder.encode(Color.Red); // 'Red'
* colorCoder.decode('Green'); // 1
*/
function tsEnum(e) {
if (!isPlainObject(e))
throw new Error('plain object expected');
return {
encode: (from) => {
if (!isNum(from) || !(from in e))
throw new Error(`wrong value ${from}`);
return e[from];
},
decode: (to) => {
if (typeof to !== 'string')
throw new Error(`wrong value ${typeof to}`);
return e[to];
},
};
}
/**
* Base coder for working with decimal numbers.
* @param precision - Number of decimal places.
* @param round - Round fraction part if bigger than precision (throws error by default)
* @returns base coder that encodes/decodes between bigints and decimal strings.
* @example
* const decimal8 = P.coders.decimal(8);
* decimal8.encode(630880845n); // '6.30880845'
* decimal8.decode('6.30880845'); // 630880845n
*/
function decimal(precision, round = false) {
if (!isNum(precision))
throw new Error(`decimal/precision: wrong value ${precision}`);
if (typeof round !== 'boolean')
throw new Error(`decimal/round: expected boolean, got ${typeof round}`);
const decimalMask = 10n ** BigInt(precision);
return {
encode: (from) => {
if (typeof from !== 'bigint')
throw new Error(`expected bigint, got ${typeof from}`);
let s = (from < 0n ? -from : from).toString(10);
let sep = s.length - precision;
if (sep < 0) {
s = s.padStart(s.length - sep, '0');
sep = 0;
}
let i = s.length - 1;
for (; i >= sep && s[i] === '0'; i--)
;
let int = s.slice(0, sep);
let frac = s.slice(sep, i + 1);
if (!int)
int = '0';
if (from < 0n)
int = '-' + int;
if (!frac)
return int;
return `${int}.${frac}`;
},
decode: (to) => {
if (typeof to !== 'string')
throw new Error(`expected string, got ${typeof to}`);
if (to === '-0')
throw new Error(`negative zero is not allowed`);
let neg = false;
if (to.startsWith('-')) {
neg = true;
to = to.slice(1);
}
if (!/^(0|[1-9]\d*)(\.\d+)?$/.test(to))
throw new Error(`wrong string value=${to}`);
let sep = to.indexOf('.');
sep = sep === -1 ? to.length : sep;
// split by separator and strip trailing zeros from fraction. always returns [string, string] (.split doesn't).
const intS = to.slice(0, sep);
const fracS = to.slice(sep + 1).replace(/0+$/, '');
const int = BigInt(intS) * decimalMask;
if (!round && fracS.length > precision) {
throw new Error(`fractional part cannot be represented with this precision (num=${to}, prec=${precision})`);
}
const fracLen = Math.min(fracS.length, precision);
const frac = BigInt(fracS.slice(0, fracLen)) * 10n ** BigInt(precision - fracLen);
const value = int + frac;
return neg ? -value : value;
},
};
}
/**
* Combines multiple coders into a single coder, allowing conditional encoding/decoding based on input.
* Acts as a parser combinator, splitting complex conditional coders into smaller parts.
*
* `encode = [Ae, Be]; decode = [Ad, Bd]`
* ->
* `match([{encode: Ae, decode: Ad}, {encode: Be; decode: Bd}])`
*
* @param lst - Array of coders to match.
* @returns Combined coder for conditional encoding/decoding.
*/
function match(lst) {
if (!Array.isArray(lst))
throw new Error(`expected array, got ${typeof lst}`);
for (const i of lst)
if (!isBaseCoder(i))
throw new Error(`wrong base coder ${i}`);
return {
encode: (from) => {
for (const c of lst) {
const elm = c.encode(from);
if (elm !== undefined)
return elm;
}
throw new Error(`match/encode: cannot find match in ${from}`);
},
decode: (to) => {
for (const c of lst) {
const elm = c.decode(to);
if (elm !== undefined)
return elm;
}
throw new Error(`match/decode: cannot find match in ${to}`);
},
};
}
/** Reverses direction of coder */
const reverse = (coder) => {
if (!isBaseCoder(coder))
throw new Error('BaseCoder expected');
return { encode: coder.decode, decode: coder.encode };
};
exports.coders = { dict, numberBigint, tsEnum, decimal, match, reverse };
/**
* CoderType for parsing individual bits.
* NOTE: Structure should parse whole amount of bytes before it can start parsing byte-level elements.
* @param len - Number of bits to parse.
* @returns CoderType representing the parsed bits.
* @example
* const s = P.struct({ magic: P.bits(1), version: P.bits(1), tag: P.bits(4), len: P.bits(2) });
*/
const bits = (len) => {
if (!isNum(len))
throw new Error(`bits: wrong length ${len} (${typeof len})`);
return (0, exports.wrap)({
encodeStream: (w, value) => w.bits(value, len),
decodeStream: (r) => r.bits(len),
validate: (value) => {
if (!isNum(value))
throw new Error(`bits: wrong value ${value}`);
return value;
},
});
};
exports.bits = bits;
/**
* CoderType for working with bigint values.
* Unsized bigint values should be wrapped in a container (e.g., bytes or string).
*
* `0n = new Uint8Array([])`
*
* `1n = new Uint8Array([1n])`
*
* Please open issue, if you need different behavior for zero.
*
* @param size - Size of the bigint in bytes.
* @param le - Whether to use little-endian byte order.
* @param signed - Whether the bigint is signed.
* @param sized - Whether the bigint should have a fixed size.
* @returns CoderType representing the bigint value.
* @example
* const U512BE = P.bigint(64, false, true, true); // Define a CoderType for a 512-bit unsigned big-endian integer
*/
const bigint = (size, le = false, signed = false, sized = true) => {
if (!isNum(size))
throw new Error(`bigint/size: wrong value ${size}`);
if (typeof le !== 'boolean')
throw new Error(`bigint/le: expected boolean, got ${typeof le}`);
if (typeof signed !== 'boolean')
throw new Error(`bigint/signed: expected boolean, got ${typeof signed}`);
if (typeof sized !== 'boolean')
throw new Error(`bigint/sized: expected boolean, got ${typeof sized}`);
const bLen = BigInt(size);
const signBit = 2n ** (8n * bLen - 1n);
return (0, exports.wrap)({
size: sized ? size : undefined,
encodeStream: (w, value) => {
if (signed && value < 0)
value = value | signBit;
const b = [];
for (let i = 0; i < size; i++) {
b.push(Number(value & 255n));
value >>= 8n;
}
let res = new Uint8Array(b).reverse();
if (!sized) {
let pos = 0;
for (pos = 0; pos < res.length; pos++)
if (res[pos] !== 0)
break;
res = res.subarray(pos); // remove leading zeros
}
w.bytes(le ? res.reverse() : res);
},
decodeStream: (r) => {
// TODO: for le we can read until first zero?
const value = r.bytes(sized ? size : Math.min(size, r.leftBytes));
const b = le ? value : swapEndianness(value);
let res = 0n;
for (let i = 0; i < b.length; i++)
res |= BigInt(b[i]) << (8n * BigInt(i));
if (signed && res & signBit)
res = (res ^ signBit) - signBit;
return res;
},
validate: (value) => {
if (typeof value !== 'bigint')
throw new Error(`bigint: invalid value: ${value}`);
checkBounds(value, 8n * bLen, !!signed);
return value;
},
});
};
exports.bigint = bigint;
/** Unsigned 256-bit little-endian integer CoderType. */
exports.U256LE = (0, exports.bigint)(32, true);
/** Unsigned 256-bit big-endian integer CoderType. */
exports.U256BE = (0, exports.bigint)(32, false);
/** Signed 256-bit little-endian integer CoderType. */
exports.I256LE = (0, exports.bigint)(32, true, true);
/** Signed 256-bit big-endian integer CoderType. */
exports.I256BE = (0, exports.bigint)(32, false, true);
/** Unsigned 128-bit little-endian integer CoderType. */
exports.U128LE = (0, exports.bigint)(16, true);
/** Unsigned 128-bit big-endian integer CoderType. */
exports.U128BE = (0, exports.bigint)(16, false);
/** Signed 128-bit little-endian integer CoderType. */
exports.I128LE = (0, exports.bigint)(16, true, true);
/** Signed 128-bit big-endian integer CoderType. */
exports.I128BE = (0, exports.bigint)(16, false, true);
/** Unsigned 64-bit little-endian integer CoderType. */
exports.U64LE = (0, exports.bigint)(8, true);
/** Unsigned 64-bit big-endian integer CoderType. */
exports.U64BE = (0, exports.bigint)(8, false);
/** Signed 64-bit little-endian integer CoderType. */
exports.I64LE = (0, exports.bigint)(8, true, true);
/** Signed 64-bit big-endian integer CoderType. */
exports.I64BE = (0, exports.bigint)(8, false, true);
/**
* CoderType for working with numbber values (up to 6 bytes/48 bits).
* Unsized int values should be wrapped in a container (e.g., bytes or string).
*
* `0 = new Uint8Array([])`
*
* `1 = new Uint8Array([1n])`
*
* Please open issue, if you need different behavior for zero.
*
* @param size - Size of the number in bytes.
* @param le - Whether to use little-endian byte order.
* @param signed - Whether the number is signed.
* @param sized - Whether the number should have a fixed size.
* @returns CoderType representing the number value.
* @example
* const uint64BE = P.bigint(8, false, true); // Define a CoderType for a 64-bit unsigned big-endian integer
*/
const int = (size, le = false, signed = false, sized = true) => {
if (!isNum(size))
throw new Error(`int/size: wrong value ${size}`);
if (typeof le !== 'boolean')
throw new Error(`int/le: expected boolean, got ${typeof le}`);
if (typeof signed !== 'boolean')
throw new Error(`int/signed: expected boolean, got ${typeof signed}`);
if (typeof sized !== 'boolean')
throw new Error(`int/sized: expected boolean, got ${typeof sized}`);
if (size > 6)
throw new Error('int supports size up to 6 bytes (48 bits): use bigints instead');
return apply((0, exports.bigint)(size, le, signed, sized), exports.coders.numberBigint);
};
exports.int = int;
const view = (len, opts) => (0, exports.wrap)({
size: len,
encodeStream: (w, value) => w.writeView(len, (view) => opts.write(view, value)),
decodeStream: (r) => r.readView(len, opts.read),
validate: (value) => {
if (typeof value !== 'number')
throw new Error(`viewCoder: expected number, got ${typeof value}`);
if (opts.validate)
opts.validate(value);
return value;
},
});
const intView = (len, signed, opts) => {
const bits = len * 8;
const signBit = 2 ** (bits - 1);
// Inlined checkBounds for integer
const validateSigned = (value) => {
if (!isNum(value))
throw new Error(`sintView: value is not safe integer: ${value}`);
if (value < -signBit || value >= signBit) {
throw new Error(`sintView: value out of bounds. Expected ${-signBit} <= ${value} < ${signBit}`);
}
};
const maxVal = 2 ** bits;
const validateUnsigned = (value) => {
if (!isNum(value))
throw new Error(`uintView: value is not safe integer: ${value}`);
if (0 > value || value >= maxVal) {
throw new Error(`uintView: value out of bounds. Expected 0 <= ${value} < ${maxVal}`);
}
};
return view(len, {
write: opts.write,
read: opts.read,
validate: signed ? validateSigned : validateUnsigned,
});
};
/** Unsigned 32-bit little-endian integer CoderType. */
exports.U32LE = intView(4, false, {
read: (view, pos) => view.getUint32(pos, true),
write: (view, value) => view.setUint32(0, value, true),
});
/** Unsigned 32-bit big-endian integer CoderType. */
exports.U32BE = intView(4, false, {
read: (view, pos) => view.getUint32(pos, false),
write: (view, value) => view.setUint32(0, value, false),
});
/** Signed 32-bit little-endian integer CoderType. */
exports.I32LE = intView(4, true, {
read: (view, pos) => view.getInt32(pos, true),
write: (view, value) => view.setInt32(0, value, true),
});
/** Signed 32-bit big-endian integer CoderType. */
exports.I32BE = intView(4, true, {
read: (view, pos) => view.getInt32(pos, false),
write: (view, value) => view.setInt32(0, value, false),
});
/** Unsigned 16-bit little-endian integer CoderType. */
exports.U16LE = intView(2, false, {
read: (view, pos) => view.getUint16(pos, true),
write: (view, value) => view.setUint16(0, value, true),
});
/** Unsigned 16-bit big-endian integer CoderType. */
exports.U16BE = intView(2, false, {
read: (view, pos) => view.getUint16(pos, false),
write: (view, value) => view.setUint16(0, value, false),
});
/** Signed 16-bit little-endian integer CoderType. */
exports.I16LE = intView(2, true, {
read: (view, pos) => view.getInt16(pos, true),
write: (view, value) => view.setInt16(0, value, true),
});
/** Signed 16-bit big-endian integer CoderType. */
exports.I16BE = intView(2, true, {
read: (view, pos) => view.getInt16(pos, false),
write: (view, value) => view.setInt16(0, value, false),
});
/** Unsigned 8-bit integer CoderType. */
exports.U8 = intView(1, false, {
read: (view, pos) => view.getUint8(pos),
write: (view, value) => view.setUint8(0, value),
});
/** Signed 8-bit integer CoderType. */
exports.I8 = intView(1, true, {
read: (view, pos) => view.getInt8(pos),
write: (view, value) => view.setInt8(0, value),
});
// Floats
const f32 = (le) => view(4, {
read: (view, pos) => view.getFloat32(pos, le),
write: (view, value) => view.setFloat32(0, value, le),
validate: (value) => {
if (Math.fround(value) !== value && !Number.isNaN(value))
throw new Error(`f32: wrong value=${value}`);
},
});
const f64 = (le) => view(8, {
read: (view, pos) => view.getFloat64(pos, le),
write: (view, value) => view.setFloat64(0, value, le),
});
/** 32-bit big-endian floating point CoderType ("binary32", IEEE 754-2008). */
exports.F32BE = f32(false);
/** 32-bit little-endian floating point CoderType ("binary32", IEEE 754-2008). */
exports.F32LE = f32(true);
/** A 64-bit big-endian floating point type ("binary64", IEEE 754-2008). Any JS number can be encoded. */
exports.F64BE = f64(false);
/** A 64-bit little-endian floating point type ("binary64", IEEE 754-2008). Any JS number can be encoded. */
exports.F64LE = f64(true);
/** Boolean CoderType. */
exports.bool = (0, exports.wrap)({
size: 1,
encodeStream: (w, value) => w.byte(value ? 1 : 0),
decodeStream: (r) => {
const value = r.byte();
if (value !== 0 && value !== 1)
throw r.err(`bool: invalid value ${value}`);
return value === 1;
},
validate: (value) => {
if (typeof value !== 'boolean')
throw new Error(`bool: invalid value ${value}`);
return value;
},
});
/**
* Bytes CoderType with a specified length and endianness.
* The bytes can have:
* - Dynamic size (prefixed with a length CoderType like U16BE)
* - Fixed size (specified by a number)
* - Unknown size (null, will parse until end of buffer)
* - Zero-terminated (terminator can be any Uint8Array)
* @param len - CoderType, number, Uint8Array (terminator) or null
* @param le - Whether to use little-endian byte order.
* @returns CoderType representing the bytes.
* @example
* // Dynamic size bytes (prefixed with P.U16BE number of bytes length)
* const dynamicBytes = P.bytes(P.U16BE, false);
* const fixedBytes = P.bytes(32, false); // Fixed size bytes
* const unknownBytes = P.bytes(null, false); // Unknown size bytes, will parse until end of buffer
* const zeroTerminatedBytes = P.bytes(new Uint8Array([0]), false); // Zero-terminated bytes
*/
const createBytes = (len, le = false) => {
if (typeof le !== 'boolean')
throw new Error(`bytes/le: expected boolean, got ${typeof le}`);
const _length = lengthCoder(len);
const _isb = isBytes(len);
return (0, exports.wrap)({
size: typeof len === 'number' ? len : undefined,
encodeStream: (w, value) => {
if (!_isb)
_length.encodeStream(w, value.length);
w.bytes(le ? swapEndianness(value) : value);
if (_isb)
w.bytes(len);
},
decodeStream: (r) => {
let bytes;
if (_isb) {
const tPos = r.find(len);
if (!tPos)
throw r.err(`bytes: cannot find terminator`);
bytes = r.bytes(tPos - r.pos);
r.bytes(len.length);
}
else {
bytes = r.bytes(len === null ? r.leftBytes : _length.decodeStream(r));
}
return le ? swapEndianness(bytes) : bytes;
},
validate: (value) => {
if (!isBytes(value))
throw new Error(`bytes: invalid value ${value}`);
return value;
},
});
};
exports.bytes = createBytes;
/**
* Prefix-encoded value using a length prefix and an inner CoderType.
* The prefix can have:
* - Dynamic size (prefixed with a length CoderType like U16BE)
* - Fixed size (specified by a number)
* - Unknown size (null, will parse until end of buffer)
* - Zero-terminated (terminator can be any Uint8Array)
* @param len - Length CoderType (dynamic size), number (fixed size), Uint8Array (for terminator), or null (will parse until end of buffer)
* @param inner - CoderType for the actual value to be prefix-encoded.
* @returns CoderType representing the prefix-encoded value.
* @example
* const dynamicPrefix = P.prefix(P.U16BE, P.bytes(null)); // Dynamic size prefix (prefixed with P.U16BE number of bytes length)
* const fixedPrefix = P.prefix(10, P.bytes(null)); // Fixed size prefix (always 10 bytes)
*/
function prefix(len, inner) {
if (!isCoder(inner))
throw new Error(`prefix: invalid inner value ${inner}`);
return apply(createBytes(len), reverse(inner));
}
/**
* String CoderType with a specified length and endianness.
* The string can be:
* - Dynamic size (prefixed with a length CoderType like U16BE)
* - Fixed size (specified by a number)
* - Unknown size (null, will parse until end of buffer)
* - Zero-terminated (terminator can be any Uint8Array)
* @param len - Length CoderType (dynamic size), number (fixed size), Uint8Array (for terminator), or null (will parse until end of buffer)
* @param le - Whether to use little-endian byte order.
* @returns CoderType representing the string.
* @example
* const dynamicString = P.string(P.U16BE, false); // Dynamic size string (prefixed with P.U16BE number of string length)
* const fixedString = P.string(10, false); // Fixed size string
* const unknownString = P.string(null, false); // Unknown size string, will parse until end of buffer
* const nullTerminatedString = P.cstring; // NUL-terminated string
* const _cstring = P.string(new Uint8Array([0])); // Same thing
*/
const string = (len, le = false) => validate(apply(createBytes(len, le), base_1.utf8), (value) => {
// TextEncoder/TextDecoder will fail on non-string, but we create more readable errors earlier
if (typeof value !== 'string')
throw new Error(`expected string, got ${typeof value}`);
return value;
});
exports.string = string;
/** NUL-terminated string CoderType. */
exports.cstring = (0, exports.string)(exports.NULL);
/**
* Hexadecimal string CoderType with a specified length, endianness, and optional 0x prefix.
* @param len - Length CoderType (dynamic size), number (fixed size), Uint8Array (for terminator), or null (will parse until end of buffer)
* @param le - Whether to use little-endian byte order.
* @param withZero - Whether to include the 0x prefix.
* @returns CoderType representing the hexadecimal string.
* @example
* const dynamicHex = P.hex(P.U16BE, {isLE: false, with0x: true}); // Hex string with 0x prefix and U16BE length
* const fixedHex = P.hex(32, {isLE: false, with0x: false}); // Fixed-length 32-byte hex string without 0x prefix
*/
const createHex = (len, options = { isLE: false, with0x: false }) => {
let inner = apply(createBytes(len, options.isLE), base_1.hex);
const prefix = options.with0x;
if (typeof prefix !== 'boolean')
throw new Error(`hex/with0x: expected boolean, got ${typeof prefix}`);
if (prefix) {
inner = apply(inner, {
encode: (value) => `0x${value}`,
decode: (value) => {
if (!value.startsWith('0x'))
throw new Error('hex(with0x=true).encode input should start with 0x');
return value.slice(2);
},
});
}
return inner;
};
exports.hex = createHex;
/**
* Applies a base coder to a CoderType.
* @param inner - The inner CoderType.
* @param b - The base coder to apply.
* @returns CoderType representing the transformed value.
* @example
* import { hex } from '@scure/base';
* const hex = P.apply(P.bytes(32), hex); // will decode bytes into a hex string
*/
function apply(inner, base) {
if (!isCoder(inner))
throw new Error(`apply: invalid inner value ${inner}`);
if (!isB