diff --git a/README.md b/README.md index e62371a..d16f0c8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # bytecodec -Typed JavaScript and TypeScript byte utilities for base64, base64url, UTF-8 strings, JSON, gzip, concatenation, comparison, and byte-source normalization. The package ships tree-shakeable ESM plus CommonJS entry points and keeps the same API across Node, Bun, Deno, browsers, and edge runtimes. +Typed JavaScript and TypeScript byte utilities for base64, base64url, hex, Z85, UTF-8 strings, JSON, gzip, concatenation, comparison, and byte-source normalization. The package ships tree-shakeable ESM plus CommonJS entry points and keeps the same API across Node, Bun, Deno, browsers, and edge runtimes. ## Compatibility @@ -17,7 +17,7 @@ Typed JavaScript and TypeScript byte utilities for base64, base64url, UTF-8 stri ## Goals -- Developer-friendly API for base64, base64url, UTF-8, JSON, gzip, concat, equality, and byte normalization. +- Developer-friendly API for base64, base64url, hex, Z85, UTF-8, JSON, gzip, concat, equality, and byte normalization. - No runtime dependencies or bundler shims. - Tree-shakeable ESM by default with CommonJS compatibility and no side effects. - Returns copies for safety when normalizing inputs. @@ -82,6 +82,28 @@ const encoded = toBase64UrlString(bytes) // string of base64url chars const decoded = fromBase64UrlString(encoded) // Uint8Array ``` +### Hex + +```js +import { toHex, fromHex } from '@sovereignbase/bytecodec' + +const bytes = new Uint8Array([222, 173, 190, 239]) +const encoded = toHex(bytes) // "deadbeef" +const decoded = fromHex(encoded) // Uint8Array +``` + +### Z85 + +```js +import { toZ85String, fromZ85String } from '@sovereignbase/bytecodec' + +const bytes = new Uint8Array([0x86, 0x4f, 0xd2, 0x6f, 0xb5, 0x59, 0xf7, 0x5b]) +const encoded = toZ85String(bytes) // "HelloWorld" +const decoded = fromZ85String(encoded) // Uint8Array +``` + +Z85 encodes 4 input bytes into 5 output characters, so `toZ85String()` requires a byte length divisible by 4 and `fromZ85String()` requires a string length divisible by 5. + ### UTF-8 strings ```js @@ -162,7 +184,7 @@ Uses `TextEncoder`, `TextDecoder`, `btoa`, and `atob`. Gzip uses `CompressionStr ### Validation & errors -Validation failures throw `BytecodecError` instances with a `code` string, for example `BASE64URL_INVALID_LENGTH`, `BASE64_DECODER_UNAVAILABLE`, `UTF8_DECODER_UNAVAILABLE`, and `GZIP_COMPRESSION_UNAVAILABLE`. Messages are prefixed with `{@sovereignbase/bytecodec}`. +Validation failures throw `BytecodecError` instances with a `code` string, for example `BASE64URL_INVALID_LENGTH`, `HEX_INVALID_CHARACTER`, `Z85_INVALID_BLOCK`, `BASE64_DECODER_UNAVAILABLE`, `UTF8_DECODER_UNAVAILABLE`, and `GZIP_COMPRESSION_UNAVAILABLE`. Messages are prefixed with `{@sovereignbase/bytecodec}`. ### Safety / copying semantics @@ -172,8 +194,8 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e `npm test` covers: -- 53 unit tests -- 4 integration tests +- 68 unit tests +- 6 integration tests - Node E2E: ESM and CommonJS - Bun E2E: ESM and CommonJS - Deno E2E: ESM @@ -183,26 +205,30 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e ## Benchmarks -Latest local `npm run bench` run on 2026-03-19 with Node `v22.14.0 (win32 x64)`: +Latest local `npm run bench` run on 2026-03-23 with Node `v22.14.0 (win32 x64)`: | Benchmark | Result | | ---------------- | ------------------------- | -| base64 encode | 1,717,210 ops/s (29.1 ms) | -| base64 decode | 2,326,783 ops/s (21.5 ms) | -| base64url encode | 768,469 ops/s (65.1 ms) | -| base64url decode | 1,173,307 ops/s (42.6 ms) | -| utf8 encode | 1,479,264 ops/s (33.8 ms) | -| utf8 decode | 4,109,139 ops/s (12.2 ms) | -| json encode | 353,666 ops/s (56.6 ms) | -| json decode | 513,064 ops/s (39.0 ms) | -| concat 3 buffers | 664,735 ops/s (75.2 ms) | -| toUint8Array | 4,721,669 ops/s (42.4 ms) | -| toArrayBuffer | 751,732 ops/s (266.1 ms) | -| toBufferSource | 8,952,992 ops/s (22.3 ms) | -| equals same | 3,766,379 ops/s (53.1 ms) | -| equals diff | 4,285,463 ops/s (46.7 ms) | -| gzip compress | 3,118 ops/s (128.3 ms) | -| gzip decompress | 5,070 ops/s (78.9 ms) | +| base64 encode | 1,391,126 ops/s (35.9 ms) | +| base64 decode | 2,089,279 ops/s (23.9 ms) | +| base64url encode | 697,088 ops/s (71.7 ms) | +| base64url decode | 1,095,554 ops/s (45.6 ms) | +| hex encode | 1,053,832 ops/s (47.4 ms) | +| hex decode | 1,027,413 ops/s (48.7 ms) | +| z85 encode | 244,928 ops/s (204.1 ms) | +| z85 decode | 1,596,730 ops/s (31.3 ms) | +| utf8 encode | 1,537,199 ops/s (32.5 ms) | +| utf8 decode | 3,481,143 ops/s (14.4 ms) | +| json encode | 681,747 ops/s (29.3 ms) | +| json decode | 989,746 ops/s (20.2 ms) | +| concat 3 buffers | 846,612 ops/s (59.1 ms) | +| toUint8Array | 9,396,818 ops/s (21.3 ms) | +| toArrayBuffer | 884,096 ops/s (226.2 ms) | +| toBufferSource | 9,279,881 ops/s (21.6 ms) | +| equals same | 3,932,572 ops/s (50.9 ms) | +| equals diff | 4,060,534 ops/s (49.3 ms) | +| gzip compress | 4,126 ops/s (96.9 ms) | +| gzip decompress | 5,550 ops/s (72.1 ms) | Command: `npm run bench` diff --git a/benchmark/bench.js b/benchmark/bench.js index 989cacc..84beee8 100644 --- a/benchmark/bench.js +++ b/benchmark/bench.js @@ -6,16 +6,20 @@ import { fromBase64String, fromBase64UrlString, fromCompressed, + fromHex, fromJSON, fromString, + fromZ85String, toArrayBuffer, toBase64String, toBase64UrlString, toBufferSource, toCompressed, + toHex, toJSON, toString, toUint8Array, + toZ85String, } from '../dist/index.js' function formatOps(iterations, durationMs) { @@ -51,12 +55,18 @@ const sampleJson = { ok: true, count: 42, note: '@sovereignbase/bytecodec' } const sampleJsonBytes = fromJSON(sampleJson) const base64 = toBase64String(sampleBytes) const base64Url = toBase64UrlString(sampleBytes) +const hex = toHex(sampleBytes) +const z85 = toZ85String(sampleBytes) const compressed = await toCompressed(sampleBytes) bench('base64 encode', 50000, () => toBase64String(sampleBytes)) bench('base64 decode', 50000, () => fromBase64String(base64)) bench('base64url encode', 50000, () => toBase64UrlString(sampleBytes)) bench('base64url decode', 50000, () => fromBase64UrlString(base64Url)) +bench('hex encode', 50000, () => toHex(sampleBytes)) +bench('hex decode', 50000, () => fromHex(hex)) +bench('z85 encode', 50000, () => toZ85String(sampleBytes)) +bench('z85 decode', 50000, () => fromZ85String(z85)) bench('utf8 encode', 50000, () => fromString(sampleText)) bench('utf8 decode', 50000, () => toString(sampleTextBytes)) bench('json encode', 20000, () => fromJSON(sampleJson)) diff --git a/jsr.json b/jsr.json index dc80935..4bc423b 100644 --- a/jsr.json +++ b/jsr.json @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@sovereignbase/bytecodec", - "version": "1.3.2", + "version": "1.3.3", "exports": "./src/index.ts", "publish": { "include": [ diff --git a/package.json b/package.json index dc80fcf..bbd9622 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@sovereignbase/bytecodec", "version": "1.3.3", - "description": "JS/TS Runtime-Agnostic byte toolkit for UTF-8 strings, base64, base64url, JSON, normalization, compression, concatenation, and comparison.", + "description": "JS/TS runtime-agnostic byte toolkit for UTF-8, base64, base64url, hex, Z85, JSON, normalization, compression, concatenation, and comparison.", "keywords": [ "base64url", "base64", @@ -15,9 +15,14 @@ "web", "node", "utf8", + "string", "text", "json", "equals", + "hex", + "hexadecimal", + "z85", + "zeromq", "compression", "gzip", "binary", diff --git a/src/.errors/class.ts b/src/.errors/class.ts index 5aa2d69..c4f13dd 100644 --- a/src/.errors/class.ts +++ b/src/.errors/class.ts @@ -23,11 +23,18 @@ export type BytecodecErrorCode = | 'CONCAT_NORMALIZE_FAILED' | 'GZIP_COMPRESSION_UNAVAILABLE' | 'GZIP_DECOMPRESSION_UNAVAILABLE' + | 'HEX_INPUT_EXPECTED' + | 'HEX_INVALID_CHARACTER' + | 'HEX_INVALID_LENGTH' | 'JSON_PARSE_FAILED' | 'JSON_STRINGIFY_FAILED' | 'STRING_INPUT_EXPECTED' | 'UTF8_DECODER_UNAVAILABLE' | 'UTF8_ENCODER_UNAVAILABLE' + | 'Z85_INPUT_EXPECTED' + | 'Z85_INVALID_BLOCK' + | 'Z85_INVALID_CHARACTER' + | 'Z85_INVALID_LENGTH' export class BytecodecError extends Error { readonly code: BytecodecErrorCode diff --git a/src/.helpers/index.ts b/src/.helpers/index.ts index 219fcee..00c01cd 100644 --- a/src/.helpers/index.ts +++ b/src/.helpers/index.ts @@ -54,9 +54,39 @@ export async function importNodeBuiltin( specifier: string ): Promise { // Keep neutral bundles from rewriting node: specifiers for non-Node runtimes. - const importer = new Function( - 'specifier', - 'return import(specifier)' - ) as (value: string) => Promise + const importer = new Function('specifier', 'return import(specifier)') as ( + value: string + ) => Promise return importer(specifier) } + +export const HEX_PAIRS = Array.from({ length: 256 }, (_, value) => + value.toString(16).padStart(2, '0') +) + +export const HEX_VALUES = (() => { + const table = new Int16Array(128).fill(-1) + + for (let index = 0; index < 10; index++) + table['0'.charCodeAt(0) + index] = index + + for (let index = 0; index < 6; index++) { + table['A'.charCodeAt(0) + index] = index + 10 + table['a'.charCodeAt(0) + index] = index + 10 + } + + return table +})() + +export const Z85_CHARS = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#' + +export const Z85_VALUES = (() => { + const table = new Int16Array(128).fill(-1) + + for (let i = 0; i < Z85_CHARS.length; i++) { + table[Z85_CHARS.charCodeAt(i)] = i + } + + return table +})() diff --git a/src/fromHex/index.ts b/src/fromHex/index.ts new file mode 100644 index 0000000..d14ba50 --- /dev/null +++ b/src/fromHex/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BytecodecError } from '../.errors/class.js' +import { HEX_VALUES } from '../.helpers/index.js' + +/** + * Decodes a hexadecimal string into a new `Uint8Array`. + * + * @param hex The hexadecimal string to decode. + * @returns A new `Uint8Array` containing the decoded bytes. + */ +export function fromHex(hex: string): Uint8Array { + if (typeof hex !== 'string') + throw new BytecodecError( + 'HEX_INPUT_EXPECTED', + 'fromHex expects a string input' + ) + + if (hex.length % 2 !== 0) + throw new BytecodecError( + 'HEX_INVALID_LENGTH', + 'Hex string must have an even length' + ) + + const bytes = new Uint8Array(hex.length / 2) + + for (let offset = 0; offset < hex.length; offset += 2) { + const highCode = hex.charCodeAt(offset) + const lowCode = hex.charCodeAt(offset + 1) + const highNibble = highCode < 128 ? HEX_VALUES[highCode] : -1 + const lowNibble = lowCode < 128 ? HEX_VALUES[lowCode] : -1 + + if (highNibble === -1 || lowNibble === -1) + throw new BytecodecError( + 'HEX_INVALID_CHARACTER', + `Invalid hex character at index ${ + highNibble === -1 ? offset : offset + 1 + }` + ) + + bytes[offset / 2] = (highNibble << 4) | lowNibble + } + + return bytes +} diff --git a/src/fromZ85String/index.ts b/src/fromZ85String/index.ts new file mode 100644 index 0000000..065018c --- /dev/null +++ b/src/fromZ85String/index.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BytecodecError } from '../.errors/class.js' +import { Z85_VALUES } from '../.helpers/index.js' + +/** + * Decodes a Z85 string into a new `Uint8Array`. + * + * @param z85String The Z85 string to decode. + * @returns A new `Uint8Array` containing the decoded bytes. + */ +export function fromZ85String(z85String: string): Uint8Array { + if (typeof z85String !== 'string') + throw new BytecodecError( + 'Z85_INPUT_EXPECTED', + 'fromZ85String expects a string input' + ) + + if (z85String.length % 5 !== 0) + throw new BytecodecError( + 'Z85_INVALID_LENGTH', + 'Z85 string length must be divisible by 5' + ) + + const bytes = new Uint8Array((z85String.length / 5) * 4) + let byteOffset = 0 + + for (let blockOffset = 0; blockOffset < z85String.length; blockOffset += 5) { + let value = 0 + + for (let digitOffset = 0; digitOffset < 5; digitOffset++) { + const stringOffset = blockOffset + digitOffset + const code = z85String.charCodeAt(stringOffset) + const digit = code < 128 ? Z85_VALUES[code] : -1 + + if (digit === -1) + throw new BytecodecError( + 'Z85_INVALID_CHARACTER', + `Invalid Z85 character at index ${stringOffset}` + ) + + value = value * 85 + digit + } + + if (value > 0xffffffff) + throw new BytecodecError( + 'Z85_INVALID_BLOCK', + `Invalid Z85 block at index ${blockOffset}` + ) + + bytes[byteOffset++] = value >>> 24 + bytes[byteOffset++] = (value >>> 16) & 0xff + bytes[byteOffset++] = (value >>> 8) & 0xff + bytes[byteOffset++] = value & 0xff + } + + return bytes +} diff --git a/src/index.ts b/src/index.ts index fc045cf..98cfbc1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,12 @@ import { toBase64String } from './toBase64String/index.js' import { fromBase64UrlString } from './fromBase64UrlString/index.js' import { toBase64UrlString } from './toBase64UrlString/index.js' /***/ +import { fromHex } from './fromHex/index.js' +import { toHex } from './toHex/index.js' +/***/ +import { fromZ85String } from './fromZ85String/index.js' +import { toZ85String } from './toZ85String/index.js' +/***/ import { fromString } from './fromString/index.js' import { toString } from './toString/index.js' /***/ @@ -51,6 +57,12 @@ export { fromBase64UrlString, toBase64UrlString, /***/ + fromHex, + toHex, + /***/ + fromZ85String, + toZ85String, + /***/ fromString, toString, /***/ @@ -100,6 +112,34 @@ export class Bytes { return toBase64UrlString(bytes) } + /** + * See {@link fromHex}. + */ + static fromHex(hex: string): Uint8Array { + return fromHex(hex) + } + + /** + * See {@link toHex}. + */ + static toHex(bytes: ByteSource): string { + return toHex(bytes) + } + + /** + * See {@link fromZ85String}. + */ + static fromZ85String(z85String: string): Uint8Array { + return fromZ85String(z85String) + } + + /** + * See {@link toZ85String}. + */ + static toZ85String(bytes: ByteSource): string { + return toZ85String(bytes) + } + /** * See {@link fromString}. */ diff --git a/src/toHex/index.ts b/src/toHex/index.ts new file mode 100644 index 0000000..06f4961 --- /dev/null +++ b/src/toHex/index.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HEX_PAIRS } from '../.helpers/index.js' +import type { ByteSource } from '../index.js' +import { toUint8Array } from '../index.js' + +/** + * Encodes bytes as a lowercase hexadecimal string. + * + * @param bytes The bytes to encode. + * @returns A hexadecimal string representation of `bytes`. + */ +export function toHex(bytes: ByteSource): string { + const view = toUint8Array(bytes) + let hex = '' + + for (let index = 0; index < view.length; index++) { + hex += HEX_PAIRS[view[index]] + } + + return hex +} diff --git a/src/toZ85String/index.ts b/src/toZ85String/index.ts new file mode 100644 index 0000000..bb130f4 --- /dev/null +++ b/src/toZ85String/index.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BytecodecError } from '../.errors/class.js' +import { Z85_CHARS } from '../.helpers/index.js' +import type { ByteSource } from '../index.js' +import { toUint8Array } from '../index.js' + +/** + * Encodes bytes as a Z85 string. + * + * @param bytes The bytes to encode. + * @returns A Z85 string representation of `bytes`. + */ +export function toZ85String(bytes: ByteSource): string { + const view = toUint8Array(bytes) + + if (view.length % 4 !== 0) + throw new BytecodecError( + 'Z85_INVALID_LENGTH', + 'Z85 input length must be divisible by 4' + ) + + let z85String = '' + + for (let offset = 0; offset < view.length; offset += 4) { + let value = + ((view[offset] * 256 + view[offset + 1]) * 256 + view[offset + 2]) * 256 + + view[offset + 3] + + const block = new Array(5) + + for (let index = 4; index >= 0; index--) { + block[index] = Z85_CHARS[value % 85] + value = Math.floor(value / 85) + } + + z85String += block.join('') + } + + return z85String +} diff --git a/test/e2e/shared/suite.mjs b/test/e2e/shared/suite.mjs index 96db54d..ecc2c31 100644 --- a/test/e2e/shared/suite.mjs +++ b/test/e2e/shared/suite.mjs @@ -13,16 +13,20 @@ export async function runBytecodecSuite(api, options = {}) { fromBase64String, fromBase64UrlString, fromCompressed, + fromHex, fromJSON, fromString, + fromZ85String, toArrayBuffer, toBase64String, toBase64UrlString, toBufferSource, toCompressed, + toHex, toJSON, toString, toUint8Array, + toZ85String, } = api function assert(condition, message) { @@ -114,6 +118,10 @@ export async function runBytecodecSuite(api, options = {}) { toBase64String, fromBase64UrlString, toBase64UrlString, + fromHex, + toHex, + fromZ85String, + toZ85String, fromString, toString, fromJSON, @@ -154,6 +162,39 @@ export async function runBytecodecSuite(api, options = {}) { assertThrows(() => fromBase64UrlString('a'), /Invalid base64url length/) }) + await runTest('toHex', () => { + assertEqual(toHex(base64Payload), '68656c6c6f') + + const view = new DataView(base64Payload.buffer, 1, 3) + assertEqual(toHex(view), '656c6c') + }) + + await runTest('fromHex', () => { + const decoded = fromHex('68656C6C6F') + assertArrayEqual(decoded, base64Payload) + assertThrows(() => fromHex('6g'), /Invalid hex character at index 1/) + }) + + await runTest('toZ85String', () => { + const payload = Uint8Array.from([ + 0x86, 0x4f, 0xd2, 0x6f, 0xb5, 0x59, 0xf7, 0x5b, + ]) + assertEqual(toZ85String(payload), 'HelloWorld') + assertThrows( + () => toZ85String(base64Payload), + /Z85 input length must be divisible by 4/ + ) + }) + + await runTest('fromZ85String', () => { + const decoded = fromZ85String('HelloWorld') + assertArrayEqual(decoded, [0x86, 0x4f, 0xd2, 0x6f, 0xb5, 0x59, 0xf7, 0x5b]) + assertThrows( + () => fromZ85String('Hell~'), + /Invalid Z85 character at index 4/ + ) + }) + await runTest('fromString', () => { const bytes = fromString(utf8Text) assertEqual(toString(bytes), utf8Text) @@ -307,6 +348,13 @@ export async function runBytecodecSuite(api, options = {}) { const encoded = Bytes.toBase64UrlString(payload) assertArrayEqual(Bytes.fromBase64UrlString(encoded), [1, 2, 3, 4]) + const hex = Bytes.toHex(payload) + assertEqual(hex, '01020304') + assertArrayEqual(Bytes.fromHex(hex), [1, 2, 3, 4]) + + const z85 = Bytes.toZ85String(payload) + assertArrayEqual(Bytes.fromZ85String(z85), [1, 2, 3, 4]) + const text = 'bytes wrapper' assertEqual(Bytes.toString(Bytes.fromString(text)), text) diff --git a/test/integration/integration.test.js b/test/integration/integration.test.js index 480cfdf..50395c8 100644 --- a/test/integration/integration.test.js +++ b/test/integration/integration.test.js @@ -4,12 +4,16 @@ import { concat, fromBase64String, fromBase64UrlString, + fromHex, fromJSON, fromString, + fromZ85String, toBase64String, toBase64UrlString, + toHex, toJSON, toString, + toZ85String, } from '../../dist/index.js' test('integration: utf8 -> base64 -> utf8', () => { @@ -28,6 +32,14 @@ test('integration: utf8 -> base64url -> utf8', () => { assert.equal(toString(decoded), text) }) +test('integration: utf8 -> hex -> utf8', () => { + const text = 'pipeline check' + const bytes = fromString(text) + const encoded = toHex(bytes) + const decoded = fromHex(encoded) + assert.equal(toString(decoded), text) +}) + test('integration: json -> bytes -> base64url -> json', () => { const value = { ok: true, list: [1, 2, 3] } const bytes = fromJSON(value) @@ -36,6 +48,15 @@ test('integration: json -> bytes -> base64url -> json', () => { assert.deepStrictEqual(toJSON(decoded), value) }) +test('integration: z85 -> hex -> bytes', () => { + const payload = Uint8Array.from([ + 0x86, 0x4f, 0xd2, 0x6f, 0xb5, 0x59, 0xf7, 0x5b, + ]) + const z85 = toZ85String(payload) + const hex = toHex(fromZ85String(z85)) + assert.equal(hex, '864fd26fb559f75b') +}) + test('integration: concat + base64url', () => { const left = fromString('left') const right = fromString('right') diff --git a/test/unit/bytes-class.test.js b/test/unit/bytes-class.test.js index 7468cb7..f27474b 100644 --- a/test/unit/bytes-class.test.js +++ b/test/unit/bytes-class.test.js @@ -11,6 +11,13 @@ test('Bytes wrapper mirrors functions', async () => { const encoded = Bytes.toBase64UrlString(payload) assert.deepStrictEqual(Bytes.fromBase64UrlString(encoded), payload) + const hex = Bytes.toHex(payload) + assert.equal(hex, '01020304') + assert.deepStrictEqual(Bytes.fromHex(hex), payload) + + const z85 = Bytes.toZ85String(payload) + assert.deepStrictEqual(Bytes.fromZ85String(z85), payload) + const text = 'wrapper check' assert.equal(Bytes.toString(Bytes.fromString(text)), text) diff --git a/test/unit/errors.test.js b/test/unit/errors.test.js index d4aadf2..e9b3a2e 100644 --- a/test/unit/errors.test.js +++ b/test/unit/errors.test.js @@ -20,16 +20,19 @@ test('public errors expose code, name, and prefixed message', async () => { try { const { fromString } = await importFreshBundle('utf8-error') - assert.throws(() => fromString('hello'), (error) => { - capturedError = error - assert.equal(error.code, 'UTF8_ENCODER_UNAVAILABLE') - assert.equal(error.name, 'BytecodecError') - assert.equal( - error.message, - '{@sovereignbase/bytecodec} No UTF-8 encoder available in this environment.' - ) - return true - }) + assert.throws( + () => fromString('hello'), + (error) => { + capturedError = error + assert.equal(error.code, 'UTF8_ENCODER_UNAVAILABLE') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} No UTF-8 encoder available in this environment.' + ) + return true + } + ) } finally { globalThis.TextEncoder = originalTextEncoder globalThis.Buffer = originalBuffer @@ -48,15 +51,45 @@ test('public errors expose code, name, and prefixed message', async () => { }) test('validation errors use the same public error shape', async () => { - const { fromBase64UrlString } = await importFreshBundle('validation-error') - - assert.throws(() => fromBase64UrlString('a'), (error) => { - assert.equal(error.code, 'BASE64URL_INVALID_LENGTH') - assert.equal(error.name, 'BytecodecError') - assert.equal( - error.message, - '{@sovereignbase/bytecodec} Invalid base64url length' - ) - return true - }) + const { fromBase64UrlString, fromHex, toZ85String } = + await importFreshBundle('validation-error') + + assert.throws( + () => fromBase64UrlString('a'), + (error) => { + assert.equal(error.code, 'BASE64URL_INVALID_LENGTH') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} Invalid base64url length' + ) + return true + } + ) + + assert.throws( + () => fromHex('abc'), + (error) => { + assert.equal(error.code, 'HEX_INVALID_LENGTH') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} Hex string must have an even length' + ) + return true + } + ) + + assert.throws( + () => toZ85String([1, 2, 3]), + (error) => { + assert.equal(error.code, 'Z85_INVALID_LENGTH') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} Z85 input length must be divisible by 4' + ) + return true + } + ) }) diff --git a/test/unit/hex.test.js b/test/unit/hex.test.js new file mode 100644 index 0000000..2adf25a --- /dev/null +++ b/test/unit/hex.test.js @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { fromHex, toHex } from '../../dist/index.js' + +test('hex roundtrip', () => { + const payload = new Uint8Array([0, 1, 15, 16, 171, 205, 239, 255]) + const encoded = toHex(payload) + assert.equal(encoded, '00010f10abcdefff') + const decoded = fromHex(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('hex accepts uppercase strings and ByteSource input', () => { + assert.equal(toHex([1, 2, 254, 255]), '0102feff') + const decoded = fromHex('0102FEFF') + assert.deepStrictEqual([...decoded], [1, 2, 254, 255]) +}) + +test('fromHex rejects non-string input', () => { + assert.throws(() => fromHex(123), /fromHex expects a string input/) +}) + +test('fromHex rejects odd-length strings', () => { + assert.throws(() => fromHex('abc'), /Hex string must have an even length/) +}) + +test('fromHex rejects invalid high nibble characters', () => { + assert.throws(() => fromHex('x0'), /Invalid hex character at index 0/) +}) + +test('fromHex rejects invalid low nibble characters', () => { + assert.throws(() => fromHex('0x'), /Invalid hex character at index 1/) +}) + +test('fromHex rejects non-ASCII characters', () => { + assert.throws(() => fromHex('åå'), /Invalid hex character at index 0/) +}) diff --git a/test/unit/z85.test.js b/test/unit/z85.test.js new file mode 100644 index 0000000..6e3c351 --- /dev/null +++ b/test/unit/z85.test.js @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { fromZ85String, toZ85String } from '../../dist/index.js' + +const helloWorldBytes = [0x86, 0x4f, 0xd2, 0x6f, 0xb5, 0x59, 0xf7, 0x5b] + +test('z85 roundtrip uses the ZeroMQ test vector', () => { + const payload = new Uint8Array(helloWorldBytes) + const encoded = toZ85String(payload) + assert.equal(encoded, 'HelloWorld') + const decoded = fromZ85String(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('z85 accepts number[] input', () => { + assert.equal(toZ85String(helloWorldBytes), 'HelloWorld') +}) + +test('fromZ85String rejects non-string input', () => { + assert.throws( + () => fromZ85String(123), + /fromZ85String expects a string input/ + ) +}) + +test('toZ85String rejects lengths that are not divisible by 4', () => { + assert.throws( + () => toZ85String([1, 2, 3]), + /Z85 input length must be divisible by 4/ + ) +}) + +test('fromZ85String rejects lengths that are not divisible by 5', () => { + assert.throws( + () => fromZ85String('abcd'), + /Z85 string length must be divisible by 5/ + ) +}) + +test('fromZ85String rejects invalid ASCII characters', () => { + assert.throws( + () => fromZ85String('Hell~'), + /Invalid Z85 character at index 4/ + ) +}) + +test('fromZ85String rejects invalid non-ASCII characters', () => { + assert.throws( + () => fromZ85String('Hellå'), + /Invalid Z85 character at index 4/ + ) +}) + +test('fromZ85String rejects blocks outside the uint32 range', () => { + assert.throws(() => fromZ85String('#####'), /Invalid Z85 block at index 0/) +})