/******************************************************************************* uBlock Origin - a browser extension to block requests. Copyright (C) 2024-present Raymond Hill This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see {http://www.gnu.org/licenses/}. Home: https://github.com/gorhill/uBlock */ 'use strict'; /******************************************************************************* * * Structured-Cloneable to Unicode-Only SERIALIZER * * Purpose: * * Serialize/deserialize arbitrary JS data to/from well-formed Unicode strings. * * The browser does not expose an API to serialize structured-cloneable types * into a single string. JSON.stringify() does not support complex JavaScript * objects, and does not support references to composite types. Unless the * data to serialize is only JS strings, it is difficult to easily switch * from one type of storage to another. * * Serializing to a well-formed Unicode string allows to store structured- * cloneable data to any storage. Not all storages support storing binary data, * but all storages support storing Unicode strings. * * Structured-cloneable types: * https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#supported_types * * ----------------+------------------+------------------+---------------------- * Data types | String | JSONable | structured-cloneable * ================+============================================================ * document.cookie | Yes | No | No * ----------------+------------------+------------------+---------------------- * localStorage | Yes | No | No * ----------------+------------------+------------------+---------------------- * IndexedDB | Yes | Yes | Yes * ----------------+------------------+------------------+---------------------- * browser.storage | Yes | Yes | No * ----------------+------------------+------------------+---------------------- * Cache API | Yes | No | No * ----------------+------------------+------------------+---------------------- * * The above table shows that only JS strings can be persisted natively to all * types of storage. The purpose of this library is to convert * structure-cloneable data (which is a superset of JSONable data) into a * single JS string. The resulting string is meant to be as small as possible. * As a result, it is not human-readable, though it contains only printable * ASCII characters -- and possibly Unicode characters beyond ASCII. * * The resulting JS string will not contain characters which require escaping * should it be converted to a JSON value. However it may contain characters * which require escaping should it be converted to a URI component. * * Characteristics: * * - Serializes/deserializes data to/from a single well-formed Unicode string * - Strings do not require escaping, i.e. they are stored as-is * - Supports multiple references to same object * - Supports reference cycles * - Supports synchronous and asynchronous API * - Supports usage of Worker * - Optionally supports LZ4 compression * * TODO: * * - Harden against unexpected conditions, such as corrupted string during * deserialization. * - Evaluate supporting checksum. * * */ const VERSION = 1; const SEPARATORCHAR = ' '; const SEPARATORCHARCODE = SEPARATORCHAR.charCodeAt(0); const SENTINELCHAR = '!'; const SENTINELCHARCODE = SENTINELCHAR.charCodeAt(0); const MAGICPREFIX = `UOSC_${VERSION}${SEPARATORCHAR}`; const MAGICLZ4PREFIX = `UOSC/lz4_${VERSION}${SEPARATORCHAR}`; const FAILMARK = Number.MAX_SAFE_INTEGER; // Avoid characters which require escaping when serialized to JSON: const SAFECHARS = "&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~"; const NUMSAFECHARS = SAFECHARS.length; const BITS_PER_SAFECHARS = Math.log2(NUMSAFECHARS); const { intToChar, intToCharCode, charCodeToInt } = (( ) => { const intToChar = []; const intToCharCode = []; const charCodeToInt = []; for ( let i = 0; i < NUMSAFECHARS; i++ ) { intToChar[i] = SAFECHARS.charAt(i); intToCharCode[i] = SAFECHARS.charCodeAt(i); charCodeToInt[i] = 0; } for ( let i = NUMSAFECHARS; i < 128; i++ ) { intToChar[i] = ''; intToCharCode[i] = 0; charCodeToInt[i] = 0; } for ( let i = 0; i < SAFECHARS.length; i++ ) { charCodeToInt[SAFECHARS.charCodeAt(i)] = i; } return { intToChar, intToCharCode, charCodeToInt }; })(); let iota = 1; const I_STRING_SMALL = iota++; const I_STRING_LARGE = iota++; const I_ZERO = iota++; const I_INTEGER_SMALL_POS = iota++; const I_INTEGER_SMALL_NEG = iota++; const I_INTEGER_LARGE_POS = iota++; const I_INTEGER_LARGE_NEG = iota++; const I_BOOL_FALSE = iota++; const I_BOOL_TRUE = iota++; const I_NULL = iota++; const I_UNDEFINED = iota++; const I_FLOAT = iota++; const I_REGEXP = iota++; const I_DATE = iota++; const I_REFERENCE = iota++; const I_SMALL_OBJECT = iota++; const I_LARGE_OBJECT = iota++; const I_ARRAY_SMALL = iota++; const I_ARRAY_LARGE = iota++; const I_SET_SMALL = iota++; const I_SET_LARGE = iota++; const I_MAP_SMALL = iota++; const I_MAP_LARGE = iota++; const I_ARRAYBUFFER = iota++; const I_INT8ARRAY = iota++; const I_UINT8ARRAY = iota++; const I_UINT8CLAMPEDARRAY = iota++; const I_INT16ARRAY = iota++; const I_UINT16ARRAY = iota++; const I_INT32ARRAY = iota++; const I_UINT32ARRAY = iota++; const I_FLOAT32ARRAY = iota++; const I_FLOAT64ARRAY = iota++; const I_DATAVIEW = iota++; const C_STRING_SMALL = intToChar[I_STRING_SMALL]; const C_STRING_LARGE = intToChar[I_STRING_LARGE]; const C_ZERO = intToChar[I_ZERO]; const C_INTEGER_SMALL_POS = intToChar[I_INTEGER_SMALL_POS]; const C_INTEGER_SMALL_NEG = intToChar[I_INTEGER_SMALL_NEG]; const C_INTEGER_LARGE_POS = intToChar[I_INTEGER_LARGE_POS]; const C_INTEGER_LARGE_NEG = intToChar[I_INTEGER_LARGE_NEG]; const C_BOOL_FALSE = intToChar[I_BOOL_FALSE]; const C_BOOL_TRUE = intToChar[I_BOOL_TRUE]; const C_NULL = intToChar[I_NULL]; const C_UNDEFINED = intToChar[I_UNDEFINED]; const C_FLOAT = intToChar[I_FLOAT]; const C_REGEXP = intToChar[I_REGEXP]; const C_DATE = intToChar[I_DATE]; const C_REFERENCE = intToChar[I_REFERENCE]; const C_SMALL_OBJECT = intToChar[I_SMALL_OBJECT]; const C_LARGE_OBJECT = intToChar[I_LARGE_OBJECT]; const C_ARRAY_SMALL = intToChar[I_ARRAY_SMALL]; const C_ARRAY_LARGE = intToChar[I_ARRAY_LARGE]; const C_SET_SMALL = intToChar[I_SET_SMALL]; const C_SET_LARGE = intToChar[I_SET_LARGE]; const C_MAP_SMALL = intToChar[I_MAP_SMALL]; const C_MAP_LARGE = intToChar[I_MAP_LARGE]; const C_ARRAYBUFFER = intToChar[I_ARRAYBUFFER]; const C_INT8ARRAY = intToChar[I_INT8ARRAY]; const C_UINT8ARRAY = intToChar[I_UINT8ARRAY]; const C_UINT8CLAMPEDARRAY = intToChar[I_UINT8CLAMPEDARRAY]; const C_INT16ARRAY = intToChar[I_INT16ARRAY]; const C_UINT16ARRAY = intToChar[I_UINT16ARRAY]; const C_INT32ARRAY = intToChar[I_INT32ARRAY]; const C_UINT32ARRAY = intToChar[I_UINT32ARRAY]; const C_FLOAT32ARRAY = intToChar[I_FLOAT32ARRAY]; const C_FLOAT64ARRAY = intToChar[I_FLOAT64ARRAY]; const C_DATAVIEW = intToChar[I_DATAVIEW]; // Just reuse already defined constants, we just need distinct values const I_STRING = I_STRING_SMALL; const I_NUMBER = I_FLOAT; const I_BOOL = I_BOOL_FALSE; const I_OBJECT = I_SMALL_OBJECT; const I_ARRAY = I_ARRAY_SMALL; const I_SET = I_SET_SMALL; const I_MAP = I_MAP_SMALL; const typeToSerializedInt = { 'string': I_STRING, 'number': I_NUMBER, 'boolean': I_BOOL, 'object': I_OBJECT, }; const xtypeToSerializedInt = { '[object RegExp]': I_REGEXP, '[object Date]': I_DATE, '[object Array]': I_ARRAY, '[object Set]': I_SET, '[object Map]': I_MAP, '[object ArrayBuffer]': I_ARRAYBUFFER, '[object Int8Array]': I_INT8ARRAY, '[object Uint8Array]': I_UINT8ARRAY, '[object Uint8ClampedArray]': I_UINT8CLAMPEDARRAY, '[object Int16Array]': I_INT16ARRAY, '[object Uint16Array]': I_UINT16ARRAY, '[object Int32Array]': I_INT32ARRAY, '[object Uint32Array]': I_UINT32ARRAY, '[object Float32Array]': I_FLOAT32ARRAY, '[object Float64Array]': I_FLOAT64ARRAY, '[object DataView]': I_DATAVIEW, }; const typeToSerializedChar = { '[object Int8Array]': C_INT8ARRAY, '[object Uint8Array]': C_UINT8ARRAY, '[object Uint8ClampedArray]': C_UINT8CLAMPEDARRAY, '[object Int16Array]': C_INT16ARRAY, '[object Uint16Array]': C_UINT16ARRAY, '[object Int32Array]': C_INT32ARRAY, '[object Uint32Array]': C_UINT32ARRAY, '[object Float32Array]': C_FLOAT32ARRAY, '[object Float64Array]': C_FLOAT64ARRAY, }; const toArrayBufferViewConstructor = { [`${I_INT8ARRAY}`]: Int8Array, [`${I_UINT8ARRAY}`]: Uint8Array, [`${I_UINT8CLAMPEDARRAY}`]: Uint8ClampedArray, [`${I_INT16ARRAY}`]: Int16Array, [`${I_UINT16ARRAY}`]: Uint16Array, [`${I_INT32ARRAY}`]: Int32Array, [`${I_UINT32ARRAY}`]: Uint32Array, [`${I_FLOAT32ARRAY}`]: Float32Array, [`${I_FLOAT64ARRAY}`]: Float64Array, [`${I_DATAVIEW}`]: DataView, }; /******************************************************************************/ const textDecoder = new TextDecoder(); const textEncoder = new TextEncoder(); const isInteger = Number.isInteger; const writeRefs = new Map(); const writeBuffer = []; const readRefs = new Map(); let readStr = ''; let readPtr = 0; let readEnd = 0; let refCounter = 1; let uint8Input = null; const uint8InputFromAsciiStr = s => { if ( uint8Input === null || uint8Input.length < s.length ) { uint8Input = new Uint8Array(s.length + 0x03FF & ~0x03FF); } textEncoder.encodeInto(s, uint8Input); return uint8Input; }; const isInstanceOf = (o, s) => { return typeof o === 'object' && o !== null && ( s === 'Object' || Object.prototype.toString.call(o) === `[object ${s}]` ); }; const shouldCompress = (s, options) => options.compress === true && ( options.compressThreshold === undefined || options.compressThreshold <= s.length ); /******************************************************************************* * * A large Uint is always a positive integer (can be zero), assumed to be * large, i.e. > NUMSAFECHARS -- but not necessarily. The serialized value has * always at least one digit, and is always followed by a separator. * * */ const strFromLargeUint = i => { let r = 0, s = ''; for (;;) { r = i % NUMSAFECHARS; s += intToChar[r]; i -= r; if ( i === 0 ) { break; } i /= NUMSAFECHARS; } return s + SEPARATORCHAR; }; const deserializeLargeUint = ( ) => { let c = readStr.charCodeAt(readPtr++); let n = charCodeToInt[c]; let m = 1; while ( (c = readStr.charCodeAt(readPtr++)) !== SEPARATORCHARCODE ) { m *= NUMSAFECHARS; n += m * charCodeToInt[c]; } return n; }; /******************************************************************************* * * Methods specific to ArrayBuffer objects to serialize optimally according to * the content of the buffer. * * In sparse mode, number of output bytes per input int32 (4-byte) value: * [v === zero]: 1 byte (separator) * [v !== zero]: n digits + 1 byte (separator) * * */ const sparseValueLen = v => v !== 0 ? (Math.log2(v) / BITS_PER_SAFECHARS | 0) + 2 : 1; const analyzeArrayBuffer = arrbuf => { const byteLength = arrbuf.byteLength; const uint32len = byteLength >>> 2; const uint32arr = new Uint32Array(arrbuf, 0, uint32len); let notzeroCount = 0; for ( let i = uint32len-1; i >= 0; i-- ) { if ( uint32arr[i] === 0 ) { continue; } notzeroCount = i + 1; break; } const end = notzeroCount + 1 <= uint32len ? notzeroCount << 2 : byteLength; const endUint32 = end >>> 2; const remUint8 = end & 0b11; const denseSize = endUint32 * 5 + (remUint8 ? remUint8 + 1 : 0); let sparseSize = 0; for ( let i = 0; i < endUint32; i++ ) { sparseSize += sparseValueLen(uint32arr[i]); if ( sparseSize > denseSize ) { return { end, dense: true, denseSize }; } } if ( remUint8 !== 0 ) { sparseSize += 1; // sentinel const uint8arr = new Uint8Array(arrbuf, endUint32 << 2); for ( let i = 0; i < remUint8; i++ ) { sparseSize += sparseValueLen(uint8arr[i]); } } return { end, dense: false, sparseSize }; }; const denseArrayBufferToStr = (arrbuf, details) => { const end = details.end; const m = end % 4; const n = end - m; const uin32len = n >>> 2; const uint32arr = new Uint32Array(arrbuf, 0, uin32len); const output = new Uint8Array(details.denseSize); let j = 0, v = 0; for ( let i = 0; i < uin32len; i++ ) { v = uint32arr[i]; output[j+0] = intToCharCode[v % NUMSAFECHARS]; v = v / NUMSAFECHARS | 0; output[j+1] = intToCharCode[v % NUMSAFECHARS]; v = v / NUMSAFECHARS | 0; output[j+2] = intToCharCode[v % NUMSAFECHARS]; v = v / NUMSAFECHARS | 0; output[j+3] = intToCharCode[v % NUMSAFECHARS]; v = v / NUMSAFECHARS | 0; output[j+4] = intToCharCode[v]; j += 5; } if ( m !== 0 ) { const uint8arr = new Uint8Array(arrbuf, n); v = uint8arr[0]; if ( m > 1 ) { v += uint8arr[1] << 8; if ( m > 2 ) { v += uint8arr[2] << 16; } } output[j+0] = intToCharCode[v % NUMSAFECHARS]; v = v / NUMSAFECHARS | 0; output[j+1] = intToCharCode[v % NUMSAFECHARS]; if ( m > 1 ) { v = v / NUMSAFECHARS | 0; output[j+2] = intToCharCode[v % NUMSAFECHARS]; if ( m > 2 ) { v = v / NUMSAFECHARS | 0; output[j+3] = intToCharCode[v % NUMSAFECHARS]; } } } return textDecoder.decode(output); }; const BASE88_POW1 = NUMSAFECHARS; const BASE88_POW2 = NUMSAFECHARS * BASE88_POW1; const BASE88_POW3 = NUMSAFECHARS * BASE88_POW2; const BASE88_POW4 = NUMSAFECHARS * BASE88_POW3; const denseArrayBufferFromStr = (denseStr, arrbuf) => { const input = uint8InputFromAsciiStr(denseStr); const end = denseStr.length; const m = end % 5; const n = end - m; const uin32len = n / 5 * 4 >>> 2; const uint32arr = new Uint32Array(arrbuf, 0, uin32len); let j = 0, v = 0; for ( let i = 0; i < n; i += 5 ) { v = charCodeToInt[input[i+0]]; v += charCodeToInt[input[i+1]] * BASE88_POW1; v += charCodeToInt[input[i+2]] * BASE88_POW2; v += charCodeToInt[input[i+3]] * BASE88_POW3; v += charCodeToInt[input[i+4]] * BASE88_POW4; uint32arr[j++] = v; } if ( m === 0 ) { return; } v = charCodeToInt[input[n+0]] + charCodeToInt[input[n+1]] * BASE88_POW1; if ( m > 2 ) { v += charCodeToInt[input[n+2]] * BASE88_POW2; if ( m > 3 ) { v += charCodeToInt[input[n+3]] * BASE88_POW3; } } const uint8arr = new Uint8Array(arrbuf, j << 2); uint8arr[0] = v & 255; if ( v !== 0 ) { v >>>= 8; uint8arr[1] = v & 255; if ( v !== 0 ) { v >>>= 8; uint8arr[2] = v & 255; } } }; const sparseArrayBufferToStr = (arrbuf, details) => { const end = details.end; const uint8out = new Uint8Array(details.sparseSize); const uint32len = end >>> 2; const uint32arr = new Uint32Array(arrbuf, 0, uint32len); let j = 0, n = 0, r = 0; for ( let i = 0; i < uint32len; i++ ) { n = uint32arr[i]; if ( n !== 0 ) { for (;;) { r = n % NUMSAFECHARS; uint8out[j++] = intToCharCode[r]; n -= r; if ( n === 0 ) { break; } n /= NUMSAFECHARS; } } uint8out[j++] = SEPARATORCHARCODE; } const uint8rem = end & 0b11; if ( uint8rem !== 0 ) { uint8out[j++] = SENTINELCHARCODE; const uint8arr = new Uint8Array(arrbuf, end - uint8rem, uint8rem); for ( let i = 0; i < uint8rem; i++ ) { n = uint8arr[i]; if ( n !== 0 ) { for (;;) { r = n % NUMSAFECHARS; uint8out[j++] = intToCharCode[r]; n -= r; if ( n === 0 ) { break; } n /= NUMSAFECHARS; } } uint8out[j++] = SEPARATORCHARCODE; } } return textDecoder.decode(uint8out); }; const sparseArrayBufferFromStr = (sparseStr, arrbuf) => { const sparseLen = sparseStr.length; const input = uint8InputFromAsciiStr(sparseStr); const end = arrbuf.byteLength; const uint32len = end >>> 2; const uint32arr = new Uint32Array(arrbuf, 0, uint32len); let i = 0, j = 0, c = 0, n = 0, m = 0; for ( ; j < sparseLen; i++ ) { c = input[j++]; if ( c === SEPARATORCHARCODE ) { continue; } if ( c === SENTINELCHARCODE ) { break; } n = charCodeToInt[c]; m = 1; for (;;) { c = input[j++]; if ( c === SEPARATORCHARCODE ) { break; } m *= NUMSAFECHARS; n += m * charCodeToInt[c]; } uint32arr[i] = n; } if ( c === SENTINELCHARCODE ) { i <<= 2; const uint8arr = new Uint8Array(arrbuf, i); for ( ; j < sparseLen; i++ ) { c = input[j++]; if ( c === SEPARATORCHARCODE ) { continue; } n = charCodeToInt[c]; m = 1; for (;;) { c = input[j++]; if ( c === SEPARATORCHARCODE ) { break; } m *= NUMSAFECHARS; n += m * charCodeToInt[c]; } uint8arr[i] = n; } } }; /******************************************************************************/ const _serialize = data => { // Primitive types if ( data === 0 ) { writeBuffer.push(C_ZERO); return; } if ( data === null ) { writeBuffer.push(C_NULL); return; } if ( data === undefined ) { writeBuffer.push(C_UNDEFINED); return; } // Type name switch ( typeToSerializedInt[typeof data] ) { case I_STRING: { const length = data.length; if ( length < NUMSAFECHARS ) { writeBuffer.push(C_STRING_SMALL + intToChar[length], data); } else { writeBuffer.push(C_STRING_LARGE + strFromLargeUint(length), data); } return; } case I_NUMBER: if ( isInteger(data) ) { if ( data >= NUMSAFECHARS ) { writeBuffer.push(C_INTEGER_LARGE_POS + strFromLargeUint(data)); } else if ( data > 0 ) { writeBuffer.push(C_INTEGER_SMALL_POS + intToChar[data]); } else if ( data > -NUMSAFECHARS ) { writeBuffer.push(C_INTEGER_SMALL_NEG + intToChar[-data]); } else { writeBuffer.push(C_INTEGER_LARGE_NEG + strFromLargeUint(-data)); } } else { const s = `${data}`; writeBuffer.push(C_FLOAT + strFromLargeUint(s.length) + s); } return; case I_BOOL: writeBuffer.push(data ? C_BOOL_TRUE : C_BOOL_FALSE); return; case I_OBJECT: break; default: return; } const xtypeName = Object.prototype.toString.call(data); const xtypeInt = xtypeToSerializedInt[xtypeName]; if ( xtypeInt === I_REGEXP ) { writeBuffer.push(C_REGEXP); _serialize(data.source); _serialize(data.flags); return; } if ( xtypeInt === I_DATE ) { writeBuffer.push(C_DATE + _serialize(data.getTime())); return; } // Reference to composite types const ref = writeRefs.get(data); if ( ref !== undefined ) { writeBuffer.push(C_REFERENCE + strFromLargeUint(ref)); return; } // Remember reference writeRefs.set(data, refCounter++); // Extended type name switch ( xtypeInt ) { case I_ARRAY: { const size = data.length; if ( size < NUMSAFECHARS ) { writeBuffer.push(C_ARRAY_SMALL + intToChar[size]); } else { writeBuffer.push(C_ARRAY_LARGE + strFromLargeUint(size)); } for ( const v of data ) { _serialize(v); } return; } case I_SET: { const size = data.size; if ( size < NUMSAFECHARS ) { writeBuffer.push(C_SET_SMALL + intToChar[size]); } else { writeBuffer.push(C_SET_LARGE + strFromLargeUint(size)); } for ( const v of data ) { _serialize(v); } return; } case I_MAP: { const size = data.size; if ( size < NUMSAFECHARS ) { writeBuffer.push(C_MAP_SMALL + intToChar[size]); } else { writeBuffer.push(C_MAP_LARGE + strFromLargeUint(size)); } for ( const [ k, v ] of data ) { _serialize(k); _serialize(v); } return; } case I_ARRAYBUFFER: { const byteLength = data.byteLength; writeBuffer.push(C_ARRAYBUFFER + strFromLargeUint(byteLength)); _serialize(data.maxByteLength); const arrbuffDetails = analyzeArrayBuffer(data); _serialize(arrbuffDetails.dense); const str = arrbuffDetails.dense ? denseArrayBufferToStr(data, arrbuffDetails) : sparseArrayBufferToStr(data, arrbuffDetails); _serialize(str); //console.log(`arrbuf size=${byteLength} content size=${arrbuffDetails.end} dense=${arrbuffDetails.dense} array size=${arrbuffDetails.dense ? arrbuffDetails.denseSize : arrbuffDetails.sparseSize} serialized size=${str.length}`); return; } case I_INT8ARRAY: case I_UINT8ARRAY: case I_UINT8CLAMPEDARRAY: case I_INT16ARRAY: case I_UINT16ARRAY: case I_INT32ARRAY: case I_UINT32ARRAY: case I_FLOAT32ARRAY: case I_FLOAT64ARRAY: writeBuffer.push( typeToSerializedChar[xtypeName], strFromLargeUint(data.byteOffset), strFromLargeUint(data.length) ); _serialize(data.buffer); return; case I_DATAVIEW: writeBuffer.push(C_DATAVIEW, strFromLargeUint(data.byteOffset), strFromLargeUint(data.byteLength)); _serialize(data.buffer); return; default: { const keys = Object.keys(data); const size = keys.length; if ( size < NUMSAFECHARS ) { writeBuffer.push(C_SMALL_OBJECT + intToChar[size]); } else { writeBuffer.push(C_LARGE_OBJECT + strFromLargeUint(size)); } for ( const key of keys ) { _serialize(key); _serialize(data[key]); } break; } } }; /******************************************************************************/ const _deserialize = ( ) => { if ( readPtr >= readEnd ) { return; } const type = charCodeToInt[readStr.charCodeAt(readPtr++)]; switch ( type ) { // Primitive types case I_STRING_SMALL: case I_STRING_LARGE: { const size = type === I_STRING_SMALL ? charCodeToInt[readStr.charCodeAt(readPtr++)] : deserializeLargeUint(); const beg = readPtr; readPtr += size; return readStr.slice(beg, readPtr); } case I_ZERO: return 0; case I_INTEGER_SMALL_POS: return charCodeToInt[readStr.charCodeAt(readPtr++)]; case I_INTEGER_SMALL_NEG: return -charCodeToInt[readStr.charCodeAt(readPtr++)]; case I_INTEGER_LARGE_POS: return deserializeLargeUint(); case I_INTEGER_LARGE_NEG: return -deserializeLargeUint(); case I_BOOL_FALSE: return false; case I_BOOL_TRUE: return true; case I_NULL: return null; case I_UNDEFINED: return; case I_FLOAT: { const size = deserializeLargeUint(); const beg = readPtr; readPtr += size; return parseFloat(readStr.slice(beg, readPtr)); } case I_REGEXP: { const source = _deserialize(); const flags = _deserialize(); return new RegExp(source, flags); } case I_DATE: { const time = _deserialize(); return new Date(time); } case I_REFERENCE: { const ref = deserializeLargeUint(); return readRefs.get(ref); } case I_SMALL_OBJECT: case I_LARGE_OBJECT: { const entries = []; const size = type === I_SMALL_OBJECT ? charCodeToInt[readStr.charCodeAt(readPtr++)] : deserializeLargeUint(); for ( let i = 0; i < size; i++ ) { const k = _deserialize(); const v = _deserialize(); entries.push([ k, v ]); } const out = Object.fromEntries(entries); readRefs.set(refCounter++, out); return out; } case I_ARRAY_SMALL: case I_ARRAY_LARGE: { const out = []; const size = type === I_ARRAY_SMALL ? charCodeToInt[readStr.charCodeAt(readPtr++)] : deserializeLargeUint(); for ( let i = 0; i < size; i++ ) { out.push(_deserialize()); } readRefs.set(refCounter++, out); return out; } case I_SET_SMALL: case I_SET_LARGE: { const entries = []; const size = type === I_SET_SMALL ? charCodeToInt[readStr.charCodeAt(readPtr++)] : deserializeLargeUint(); for ( let i = 0; i < size; i++ ) { entries.push(_deserialize()); } const out = new Set(entries); readRefs.set(refCounter++, out); return out; } case I_MAP_SMALL: case I_MAP_LARGE: { const entries = []; const size = type === I_MAP_SMALL ? charCodeToInt[readStr.charCodeAt(readPtr++)] : deserializeLargeUint(); for ( let i = 0; i < size; i++ ) { const k = _deserialize(); const v = _deserialize(); entries.push([ k, v ]); } const out = new Map(entries); readRefs.set(refCounter++, out); return out; } case I_ARRAYBUFFER: { const byteLength = deserializeLargeUint(); const maxByteLength = _deserialize(); let options; if ( maxByteLength !== 0 && maxByteLength !== byteLength ) { options = { maxByteLength }; } const arrbuf = new ArrayBuffer(byteLength, options); const dense = _deserialize(); const str = _deserialize(); if ( dense ) { denseArrayBufferFromStr(str, arrbuf); } else { sparseArrayBufferFromStr(str, arrbuf); } readRefs.set(refCounter++, arrbuf); return arrbuf; } case I_INT8ARRAY: case I_UINT8ARRAY: case I_UINT8CLAMPEDARRAY: case I_INT16ARRAY: case I_UINT16ARRAY: case I_INT32ARRAY: case I_UINT32ARRAY: case I_FLOAT32ARRAY: case I_FLOAT64ARRAY: case I_DATAVIEW: { const byteOffset = deserializeLargeUint(); const length = deserializeLargeUint(); const arrayBuffer = _deserialize(); const ctor = toArrayBufferViewConstructor[`${type}`]; const out = new ctor(arrayBuffer, byteOffset, length); readRefs.set(refCounter++, out); return out; } default: break; } readPtr = FAILMARK; }; /******************************************************************************* * * LZ4 block compression/decompression * * Imported from: * https://github.com/gorhill/lz4-wasm/blob/8995cdef7b/dist/lz4-block-codec-js.js * * Customized to avoid external dependencies as I entertain the idea of * spinning off the serializer as a standalone utility for all to use. * * */ class LZ4BlockJS { constructor() { this.hashTable = undefined; this.outputBuffer = undefined; } reset() { this.hashTable = undefined; this.outputBuffer = undefined; } growOutputBuffer(size) { if ( this.outputBuffer !== undefined ) { if ( this.outputBuffer.byteLength >= size ) { return; } } this.outputBuffer = new ArrayBuffer(size + 0xFFFF & 0x7FFF0000); } encodeBound(size) { return size > 0x7E000000 ? 0 : size + (size / 255 | 0) + 16; } encodeBlock(iBuf, oOffset) { const iLen = iBuf.byteLength; if ( iLen >= 0x7E000000 ) { throw new RangeError(); } // "The last match must start at least 12 bytes before end of block" const lastMatchPos = iLen - 12; // "The last 5 bytes are always literals" const lastLiteralPos = iLen - 5; if ( this.hashTable === undefined ) { this.hashTable = new Int32Array(65536); } this.hashTable.fill(-65536); if ( isInstanceOf(iBuf, 'ArrayBuffer') ) { iBuf = new Uint8Array(iBuf); } const oLen = oOffset + this.encodeBound(iLen); this.growOutputBuffer(oLen); const oBuf = new Uint8Array(this.outputBuffer, 0, oLen); let iPos = 0; let oPos = oOffset; let anchorPos = 0; // sequence-finding loop for (;;) { let refPos; let mOffset; let sequence = iBuf[iPos] << 8 | iBuf[iPos+1] << 16 | iBuf[iPos+2] << 24; // match-finding loop while ( iPos <= lastMatchPos ) { sequence = sequence >>> 8 | iBuf[iPos+3] << 24; const hash = (sequence * 0x9E37 & 0xFFFF) + (sequence * 0x79B1 >>> 16) & 0xFFFF; refPos = this.hashTable[hash]; this.hashTable[hash] = iPos; mOffset = iPos - refPos; if ( mOffset < 65536 && iBuf[refPos+0] === ((sequence ) & 0xFF) && iBuf[refPos+1] === ((sequence >>> 8) & 0xFF) && iBuf[refPos+2] === ((sequence >>> 16) & 0xFF) && iBuf[refPos+3] === ((sequence >>> 24) & 0xFF) ) { break; } iPos += 1; } // no match found if ( iPos > lastMatchPos ) { break; } // match found let lLen = iPos - anchorPos; let mLen = iPos; iPos += 4; refPos += 4; while ( iPos < lastLiteralPos && iBuf[iPos] === iBuf[refPos] ) { iPos += 1; refPos += 1; } mLen = iPos - mLen; const token = mLen < 19 ? mLen - 4 : 15; // write token, length of literals if needed if ( lLen >= 15 ) { oBuf[oPos++] = 0xF0 | token; let l = lLen - 15; while ( l >= 255 ) { oBuf[oPos++] = 255; l -= 255; } oBuf[oPos++] = l; } else { oBuf[oPos++] = (lLen << 4) | token; } // write literals while ( lLen-- ) { oBuf[oPos++] = iBuf[anchorPos++]; } if ( mLen === 0 ) { break; } // write offset of match oBuf[oPos+0] = mOffset; oBuf[oPos+1] = mOffset >>> 8; oPos += 2; // write length of match if needed if ( mLen >= 19 ) { let l = mLen - 19; while ( l >= 255 ) { oBuf[oPos++] = 255; l -= 255; } oBuf[oPos++] = l; } anchorPos = iPos; } // last sequence is literals only let lLen = iLen - anchorPos; if ( lLen >= 15 ) { oBuf[oPos++] = 0xF0; let l = lLen - 15; while ( l >= 255 ) { oBuf[oPos++] = 255; l -= 255; } oBuf[oPos++] = l; } else { oBuf[oPos++] = lLen << 4; } while ( lLen-- ) { oBuf[oPos++] = iBuf[anchorPos++]; } return new Uint8Array(oBuf.buffer, 0, oPos); } decodeBlock(iBuf, iOffset, oLen) { const iLen = iBuf.byteLength; this.growOutputBuffer(oLen); const oBuf = new Uint8Array(this.outputBuffer, 0, oLen); let iPos = iOffset, oPos = 0; while ( iPos < iLen ) { const token = iBuf[iPos++]; // literals let clen = token >>> 4; // length of literals if ( clen !== 0 ) { if ( clen === 15 ) { let l; for (;;) { l = iBuf[iPos++]; if ( l !== 255 ) { break; } clen += 255; } clen += l; } // copy literals const end = iPos + clen; while ( iPos < end ) { oBuf[oPos++] = iBuf[iPos++]; } if ( iPos === iLen ) { break; } } // match const mOffset = iBuf[iPos+0] | (iBuf[iPos+1] << 8); if ( mOffset === 0 || mOffset > oPos ) { return; } iPos += 2; // length of match clen = (token & 0x0F) + 4; if ( clen === 19 ) { let l; for (;;) { l = iBuf[iPos++]; if ( l !== 255 ) { break; } clen += 255; } clen += l; } // copy match const end = oPos + clen; let mPos = oPos - mOffset; while ( oPos < end ) { oBuf[oPos++] = oBuf[mPos++]; } } return oBuf; } encode(input, outputOffset) { if ( isInstanceOf(input, 'ArrayBuffer') ) { input = new Uint8Array(input); } else if ( isInstanceOf(input, 'Uint8Array') === false ) { throw new TypeError(); } return this.encodeBlock(input, outputOffset); } decode(input, inputOffset, outputSize) { if ( isInstanceOf(input, 'ArrayBuffer') ) { input = new Uint8Array(input); } else if ( isInstanceOf(input, 'Uint8Array') === false ) { throw new TypeError(); } return this.decodeBlock(input, inputOffset, outputSize); } } /******************************************************************************* * * Synchronous APIs * * */ export const serialize = (data, options = {}) => { refCounter = 1; _serialize(data); writeBuffer.unshift(MAGICPREFIX); const s = writeBuffer.join(''); writeRefs.clear(); writeBuffer.length = 0; if ( shouldCompress(s, options) === false ) { return s; } const lz4Util = new LZ4BlockJS(); const uint8ArrayBefore = textEncoder.encode(s); const uint8ArrayAfter = lz4Util.encode(uint8ArrayBefore, 0); const lz4 = { size: uint8ArrayBefore.length, data: new Uint8Array(uint8ArrayAfter), }; refCounter = 1; _serialize(lz4); writeBuffer.unshift(MAGICLZ4PREFIX); const t = writeBuffer.join(''); writeRefs.clear(); writeBuffer.length = 0; const ratio = t.length / s.length; return ratio <= 0.85 ? t : s; }; export const deserialize = s => { if ( s.startsWith(MAGICLZ4PREFIX) ) { refCounter = 1; readStr = s; readEnd = s.length; readPtr = MAGICLZ4PREFIX.length; const lz4 = _deserialize(); readRefs.clear(); readStr = ''; const lz4Util = new LZ4BlockJS(); const uint8ArrayAfter = lz4Util.decode(lz4.data, 0, lz4.size); s = textDecoder.decode(new Uint8Array(uint8ArrayAfter)); } if ( s.startsWith(MAGICPREFIX) === false ) { return; } refCounter = 1; readStr = s; readEnd = s.length; readPtr = MAGICPREFIX.length; const data = _deserialize(); readRefs.clear(); readStr = ''; uint8Input = null; if ( readPtr === FAILMARK ) { return; } return data; }; export const isSerialized = s => typeof s === 'string' && (s.startsWith(MAGICLZ4PREFIX) || s.startsWith(MAGICPREFIX)); export const isCompressed = s => typeof s === 'string' && s.startsWith(MAGICLZ4PREFIX); /******************************************************************************* * * Configuration * * */ const defaultConfig = { threadTTL: 5000, }; const validateConfig = { threadTTL: val => val > 0, }; const currentConfig = Object.assign({}, defaultConfig); export const getConfig = ( ) => Object.assign({}, currentConfig); export const setConfig = config => { for ( const key in Object.keys(config) ) { if ( defaultConfig.hasOwnProperty(key) === false ) { continue; } const val = config[key]; if ( typeof val !== typeof defaultConfig[key] ) { continue; } if ( (validateConfig[key])(val) === false ) { continue; } currentConfig[key] = val; } }; /******************************************************************************* * * Asynchronous APIs * * Being asynchronous allows to support workers and future features such as * checksums. * * */ const THREAD_AREYOUREADY = 1; const THREAD_IAMREADY = 2; const THREAD_SERIALIZE = 3; const THREAD_DESERIALIZE = 4; class MainThread { constructor() { this.name = 'main'; this.jobs = []; this.workload = 0; this.timer = undefined; this.busy = 2; } process() { if ( this.jobs.length === 0 ) { return; } const job = this.jobs.shift(); this.workload -= job.size; const result = job.what === THREAD_SERIALIZE ? serialize(job.data, job.options) : deserialize(job.data); job.resolve(result); this.processAsync(); if ( this.jobs.length === 0 ) { this.busy = 2; } else if ( this.busy > 2 ) { this.busy -= 1; } } processAsync() { if ( this.timer !== undefined ) { return; } if ( this.jobs.length === 0 ) { return; } this.timer = globalThis.requestIdleCallback(deadline => { this.timer = undefined; globalThis.queueMicrotask(( ) => { this.process(); }); if ( deadline.timeRemaining() === 0 ) { this.busy += 1; } }, { timeout: 5 }); } serialize(data, options) { return new Promise(resolve => { this.workload += 1; this.jobs.push({ what: THREAD_SERIALIZE, data, options, size: 1, resolve }); this.processAsync(); }); } deserialize(data, options) { return new Promise(resolve => { const size = data.length; this.workload += size; this.jobs.push({ what: THREAD_DESERIALIZE, data, options, size, resolve }); this.processAsync(); }); } get queueSize() { return this.jobs.length; } get workSize() { return this.workload * this.busy; } } class Thread { constructor(gcer) { this.name = 'worker'; this.jobs = new Map(); this.jobIdGenerator = 1; this.workload = 0; this.workerAccessTime = 0; this.workerTimer = undefined; this.gcer = gcer; this.workerPromise = new Promise(resolve => { let worker = null; try { worker = new Worker('js/scuo-serializer.js', { type: 'module' }); worker.onmessage = ev => { const msg = ev.data; if ( isInstanceOf(msg, 'Object') === false ) { return; } if ( msg.what === THREAD_IAMREADY ) { worker.onmessage = ev => { this.onmessage(ev); }; worker.onerror = null; resolve(worker); } }; worker.onerror = ( ) => { worker.onmessage = worker.onerror = null; resolve(null); }; worker.postMessage({ what: THREAD_AREYOUREADY, config: currentConfig, }); } catch(ex) { console.info(ex); worker.onmessage = worker.onerror = null; resolve(null); } }); } countdownWorker() { if ( this.workerTimer !== undefined ) { return; } this.workerTimer = setTimeout(async ( ) => { this.workerTimer = undefined; if ( this.jobs.size !== 0 ) { return; } const idleTime = Date.now() - this.workerAccessTime; if ( idleTime < currentConfig.threadTTL ) { return this.countdownWorker(); } const worker = await this.workerPromise; if ( this.jobs.size !== 0 ) { return; } this.gcer(this); if ( worker === null ) { return; } worker.onmessage = worker.onerror = null; worker.terminate(); }, currentConfig.threadTTL); } onmessage(ev) { this.ondone(ev.data); } ondone(job) { const resolve = this.jobs.get(job.id); if ( resolve === undefined ) { return; } this.jobs.delete(job.id); resolve(job.result); this.workload -= job.size; if ( this.jobs.size !== 0 ) { return; } this.countdownWorker(); } async serialize(data, options) { return new Promise(resolve => { const id = this.jobIdGenerator++; this.workload += 1; this.jobs.set(id, resolve); return this.workerPromise.then(worker => { this.workerAccessTime = Date.now(); if ( worker === null ) { this.ondone({ id, result: serialize(data, options), size: 1 }); } else { worker.postMessage({ what: THREAD_SERIALIZE, id, data, options, size: 1 }); } }); }); } async deserialize(data, options) { return new Promise(resolve => { const id = this.jobIdGenerator++; const size = data.length; this.workload += size; this.jobs.set(id, resolve); return this.workerPromise.then(worker => { this.workerAccessTime = Date.now(); if ( worker === null ) { this.ondone({ id, result: deserialize(data, options), size }); } else { worker.postMessage({ what: THREAD_DESERIALIZE, id, data, options, size }); } }); }); } get queueSize() { return this.jobs.size; } get workSize() { return this.workload; } } const threads = { pool: [ new MainThread() ], thread(maxPoolSize) { const poolSize = this.pool.length; if ( poolSize !== 0 && poolSize >= maxPoolSize ) { if ( poolSize === 1 ) { return this.pool[0]; } return this.pool.reduce((a, b) => { //console.log(`${a.name}: q=${a.queueSize} w=${a.workSize} ${b.name}: q=${b.queueSize} w=${b.workSize}`); if ( b.queueSize === 0 ) { return b; } if ( a.queueSize === 0 ) { return a; } return b.workSize < a.workSize ? b : a; }); } const thread = new Thread(thread => { const pos = this.pool.indexOf(thread); if ( pos === -1 ) { return; } this.pool.splice(pos, 1); }); this.pool.push(thread); return thread; }, }; export async function serializeAsync(data, options = {}) { const maxThreadCount = options.multithreaded || 0; if ( maxThreadCount === 0 ) { return serialize(data, options); } const thread = threads.thread(maxThreadCount); //console.log(`serializeAsync: thread=${thread.name} workload=${thread.workSize}`); const result = await thread.serialize(data, options); if ( result !== undefined ) { return result; } return serialize(data, options); } export async function deserializeAsync(data, options = {}) { if ( isSerialized(data) === false ) { return data; } const maxThreadCount = options.multithreaded || 0; if ( maxThreadCount === 0 ) { return deserialize(data, options); } const thread = threads.thread(maxThreadCount); //console.log(`deserializeAsync: thread=${thread.name} data=${data.length} workload=${thread.workSize}`); const result = await thread.deserialize(data, options); if ( result !== undefined ) { return result; } return deserialize(data, options); } /******************************************************************************* * * Worker-only code * * */ if ( isInstanceOf(globalThis, 'DedicatedWorkerGlobalScope') ) { globalThis.onmessage = ev => { const msg = ev.data; switch ( msg.what ) { case THREAD_AREYOUREADY: setConfig(msg.config); globalThis.postMessage({ what: THREAD_IAMREADY }); break; case THREAD_SERIALIZE: const result = serialize(msg.data, msg.options); globalThis.postMessage({ id: msg.id, size: msg.size, result }); break; case THREAD_DESERIALIZE: { const result = deserialize(msg.data); globalThis.postMessage({ id: msg.id, size: msg.size, result }); break; } } }; } /******************************************************************************/