207 lines
7.3 KiB
JavaScript
207 lines
7.3 KiB
JavaScript
// Manifest parsing and binary codecs. This module has no DOM dependencies.
|
|
export const SERIALIZATION_VERSION = 1;
|
|
export const PATH_DELIMITER = '>';
|
|
const STATE_ENCODINGS = new Set(['adaptive', 'dense', 'tiered']);
|
|
|
|
export function loadManifest(source) {
|
|
let records;
|
|
let schema = 1;
|
|
|
|
if (typeof source === 'string') {
|
|
records = source.split(/\r?\n/)
|
|
.map(line => line.trim())
|
|
.filter(line => line && !line.startsWith('#'))
|
|
.map(line => line === '!deleted' ? null : line);
|
|
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
|
|
schema = source.schema;
|
|
records = source.nodes;
|
|
} else if (source && Array.isArray(source.slots)) {
|
|
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
|
|
} else {
|
|
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
|
|
}
|
|
|
|
return normalizeManifest(schema, records);
|
|
}
|
|
|
|
function normalizeManifest(schema, records) {
|
|
if (schema !== 1) {
|
|
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
|
|
}
|
|
|
|
const pathToSlot = new Map();
|
|
const slots = records.map((record, index) => {
|
|
if (record === null || record === '!deleted') {
|
|
return { path: null, deleted: true };
|
|
}
|
|
if (typeof record !== 'string' || !record || record.trim() !== record) {
|
|
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
|
|
}
|
|
if (pathToSlot.has(record)) {
|
|
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
|
|
}
|
|
pathToSlot.set(record, index);
|
|
return { path: record, deleted: false };
|
|
});
|
|
|
|
return { schema: 1, slots, pathToSlot };
|
|
}
|
|
|
|
export function setBit(bytes, bitIndex, value) {
|
|
const byteIndex = Math.floor(bitIndex / 8);
|
|
const mask = 1 << (bitIndex % 8);
|
|
if (value) bytes[byteIndex] |= mask;
|
|
else bytes[byteIndex] &= ~mask;
|
|
}
|
|
|
|
export function getBit(bytes, bitIndex) {
|
|
const byteIndex = Math.floor(bitIndex / 8);
|
|
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
|
|
}
|
|
|
|
export function bytesToBase64Url(bytes) {
|
|
let binary = '';
|
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
}
|
|
|
|
export function base64UrlToBytes(value, maximumLength = Infinity) {
|
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
|
throw new Error('Invalid Base64URL value.');
|
|
}
|
|
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
|
|
throw new Error('Encoded tree state exceeds the manifest size.');
|
|
}
|
|
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
|
|
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
|
|
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
}
|
|
|
|
function trimTrailingZeroBytes(bytes) {
|
|
let end = bytes.length;
|
|
while (end && bytes[end - 1] === 0) end--;
|
|
return bytes.slice(0, end);
|
|
}
|
|
|
|
function encodeUnsignedVarint(value) {
|
|
const bytes = [];
|
|
do {
|
|
let byte = value & 0x7f;
|
|
value = Math.floor(value / 128);
|
|
if (value) byte |= 0x80;
|
|
bytes.push(byte);
|
|
} while (value);
|
|
return bytes;
|
|
}
|
|
|
|
function decodeUnsignedVarint(bytes, offset) {
|
|
let value = 0;
|
|
let multiplier = 1;
|
|
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
|
|
const byte = bytes[index];
|
|
value += (byte & 0x7f) * multiplier;
|
|
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
|
|
multiplier *= 128;
|
|
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
|
|
}
|
|
throw new Error('Invalid adaptive tree-state integer.');
|
|
}
|
|
|
|
function encodeSparseIndexes(indexes) {
|
|
const bytes = [];
|
|
let previous = -1;
|
|
for (const index of indexes) {
|
|
bytes.push(...encodeUnsignedVarint(index - previous - 1));
|
|
previous = index;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function decodeSparseIndexes(bytes, offset, limit) {
|
|
const indexes = new Set();
|
|
let previous = -1;
|
|
while (offset < bytes.length) {
|
|
const decoded = decodeUnsignedVarint(bytes, offset);
|
|
const index = previous + decoded.value + 1;
|
|
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
|
|
indexes.add(index);
|
|
previous = index;
|
|
offset = decoded.offset;
|
|
}
|
|
return indexes;
|
|
}
|
|
|
|
export function encodeAdaptiveBitset(values, slotCount) {
|
|
const enabled = [];
|
|
const disabled = [];
|
|
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
|
|
for (const [slot, value] of values) {
|
|
(value ? enabled : disabled).push(slot);
|
|
setBit(denseBytes, slot, value);
|
|
}
|
|
enabled.sort((left, right) => left - right);
|
|
disabled.sort((left, right) => left - right);
|
|
if (enabled.length === 0) return new Uint8Array();
|
|
|
|
const candidates = [
|
|
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
|
|
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
|
|
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
|
|
];
|
|
return candidates.reduce((shortest, candidate) =>
|
|
candidate.length < shortest.length ? candidate : shortest
|
|
);
|
|
}
|
|
|
|
export function decodeAdaptiveBitset(bytes, slotCount) {
|
|
if (bytes.length === 0) return () => false;
|
|
const mode = bytes[0];
|
|
if (mode === 0) {
|
|
const dense = bytes.slice(1);
|
|
if (dense.length > Math.ceil(slotCount / 8)) {
|
|
throw new Error('Dense tree state exceeds the manifest size.');
|
|
}
|
|
return slot => getBit(dense, slot);
|
|
}
|
|
if (mode === 1) {
|
|
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
|
|
return slot => enabled.has(slot);
|
|
}
|
|
if (mode === 2) {
|
|
const coverage = decodeUnsignedVarint(bytes, 1);
|
|
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
|
|
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
|
|
return slot => slot < coverage.value && !disabled.has(slot);
|
|
}
|
|
throw new Error('Unknown adaptive tree-state mode.');
|
|
}
|
|
|
|
|
|
export function encodeDenseBitset(values, slotCount) {
|
|
const bytes = new Uint8Array(Math.ceil(slotCount / 8));
|
|
for (const [slot, value] of values) setBit(bytes, slot, value);
|
|
return trimTrailingZeroBytes(bytes);
|
|
}
|
|
|
|
export function decodeDenseBitset(bytes, slotCount) {
|
|
if (bytes.length > Math.ceil(slotCount / 8)) {
|
|
throw new Error('Dense tree state exceeds the manifest size.');
|
|
}
|
|
return slot => getBit(bytes, slot);
|
|
}
|
|
|
|
export function maximumEncodedLength(slotCount) {
|
|
return Math.max(Math.ceil(slotCount / 8) + 1, slotCount * 2 + 10);
|
|
}
|
|
|
|
export function assertStateEncoding(encoding) {
|
|
if (!STATE_ENCODINGS.has(encoding)) {
|
|
throw new Error(`Unsupported CheckboxTree state encoding: ${encoding}`);
|
|
}
|
|
}
|
|
|
|
export function asManifestSource(manifest) {
|
|
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
|
|
}
|