From fac17533b9f1c68ec856f145fdf9ada8942a1e4e Mon Sep 17 00:00:00 2001 From: Jori Lethinen Date: Tue, 21 Apr 2026 21:09:28 +0300 Subject: [PATCH] feat: add RFC 9285 base45 encode/decode support --- README.md | 33 +++++++--- biblio/base45.txt | 3 + package.json | 4 +- src/.errors/class.ts | 4 ++ src/.helpers/index.ts | 12 ++++ src/fromBase45String/index.ts | 91 ++++++++++++++++++++++++++++ src/index.ts | 20 ++++++ src/toBase45String/index.ts | 47 ++++++++++++++ test/e2e/shared/suite.mjs | 23 +++++++ test/integration/integration.test.js | 10 +++ test/unit/base45.test.js | 87 ++++++++++++++++++++++++++ test/unit/bytes-class.test.js | 4 ++ test/unit/errors.test.js | 14 +++++ test/unit/fallbacks.test.js | 8 ++- 14 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 biblio/base45.txt create mode 100644 src/fromBase45String/index.ts create mode 100644 src/toBase45String/index.ts create mode 100644 test/unit/base45.test.js diff --git a/README.md b/README.md index 119dc6c..3dfa8a7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # bytecodec -Typed JavaScript and TypeScript byte utilities for base58, base58btc, base64, base64url, hex, Z85, UTF-8 strings, unsigned BigInt conversion, 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 base45, base58, base58btc, base64, base64url, hex, Z85, UTF-8 strings, unsigned BigInt conversion, 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 @@ -60,6 +60,19 @@ const encoded = toBase64String(bytes) // string of base64 chars const decoded = fromBase64String(encoded) // Uint8Array ``` +### Base45 + +```js +import { toBase45String, fromBase45String } from '@sovereignbase/bytecodec' + +const bytes = new Uint8Array([65, 66]) +const encoded = toBase45String(bytes) // "BB8" +const decoded = fromBase45String(encoded) // Uint8Array +``` + +Base45 is convenient for QR-friendly payloads. It encodes 2 input bytes into 3 output characters, and a trailing single byte into 2 characters. +The implementation follows RFC 9285. + ### Base58 ```js @@ -209,7 +222,7 @@ const joined = concat([new Uint8Array([1, 2]), new Uint8Array([3, 4]), [5, 6]]) ### Node -Uses pure JavaScript for base58/base58btc, `Buffer.from` for base64 helpers, `TextEncoder` and `TextDecoder` when available with `Buffer` fallback for UTF-8, and `node:zlib` for gzip. +Uses pure JavaScript for base45/base58/base58btc, `Buffer.from` for base64 helpers, `TextEncoder` and `TextDecoder` when available with `Buffer` fallback for UTF-8, and `node:zlib` for gzip. ### Bun @@ -221,7 +234,7 @@ Uses `TextEncoder`, `TextDecoder`, `btoa`, and `atob`. Gzip uses `CompressionStr ### Validation & errors -Validation failures throw `BytecodecError` instances with a `code` string, for example `BASE58_INVALID_CHARACTER`, `BASE58BTC_INVALID_PREFIX`, `BASE64URL_INVALID_LENGTH`, `BIGINT_UNSIGNED_EXPECTED`, `HEX_INVALID_CHARACTER`, `Z85_INVALID_BLOCK`, `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 `BASE45_INVALID_CHUNK`, `BASE58_INVALID_CHARACTER`, `BASE58BTC_INVALID_PREFIX`, `BASE64URL_INVALID_LENGTH`, `BIGINT_UNSIGNED_EXPECTED`, `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 @@ -231,13 +244,13 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e `npm test` covers: -- 85 unit tests -- 9 integration tests -- Node E2E: 27/27 passed in ESM and 27/27 passed in CommonJS -- Bun E2E: 27/27 passed in ESM and 27/27 passed in CommonJS -- Deno E2E: 27/27 passed in ESM -- Cloudflare Workers E2E: 27/27 passed in ESM -- Edge Runtime E2E: 27/27 passed in ESM +- 97 unit tests +- 10 integration tests +- Node E2E: 29/29 passed in ESM and 29/29 passed in CommonJS +- Bun E2E: 29/29 passed in ESM and 29/29 passed in CommonJS +- Deno E2E: 29/29 passed in ESM +- Cloudflare Workers E2E: 29/29 passed in ESM +- Edge Runtime E2E: 29/29 passed in ESM - Browser E2E: 5/5 passed in Chromium, Firefox, WebKit, mobile-chrome, and mobile-safari - Coverage gate: 100% statements, branches, functions, and lines diff --git a/biblio/base45.txt b/biblio/base45.txt new file mode 100644 index 0000000..190adbf --- /dev/null +++ b/biblio/base45.txt @@ -0,0 +1,3 @@ +- https://datatracker.ietf.org/doc/html/rfc9285 +- https://digitalproc.github.io/2021/11/11/qrCode/qr_standard.pdf +- https://ref.gs1.org/guidelines/2d-in-retail/1.0.0/ \ No newline at end of file diff --git a/package.json b/package.json index 2203d8f..a2d283d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "@sovereignbase/bytecodec", "version": "1.6.1", - "description": "JS/TS runtime-agnostic byte toolkit for UTF-8, base58, base58btc, base64, base64url, hex, Z85, unsigned BigInt conversion, JSON, normalization, compression, concatenation, and comparison.", + "description": "JS/TS runtime-agnostic byte toolkit for UTF-8, base45, base58, base58btc, base64, base64url, hex, Z85, unsigned BigInt conversion, JSON, normalization, compression, concatenation, and comparison.", "keywords": [ + "base45", "base58", "base58btc", "base64url", @@ -19,6 +20,7 @@ "utf8", "string", "text", + "qr", "json", "equals", "hex", diff --git a/src/.errors/class.ts b/src/.errors/class.ts index cba9195..16aa211 100644 --- a/src/.errors/class.ts +++ b/src/.errors/class.ts @@ -18,6 +18,10 @@ * All structured error codes thrown by the bytecodec. */ export type BytecodecErrorCode = + | 'BASE45_INPUT_EXPECTED' + | 'BASE45_INVALID_CHARACTER' + | 'BASE45_INVALID_CHUNK' + | 'BASE45_INVALID_LENGTH' | 'BASE58BTC_INPUT_EXPECTED' | 'BASE58BTC_INVALID_PREFIX' | 'BASE58_INPUT_EXPECTED' diff --git a/src/.helpers/index.ts b/src/.helpers/index.ts index 7dd38f2..8362c40 100644 --- a/src/.helpers/index.ts +++ b/src/.helpers/index.ts @@ -78,6 +78,18 @@ export const HEX_VALUES = (() => { return table })() +export const BASE45_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' + +export const BASE45_VALUES = (() => { + const table = new Int16Array(128).fill(-1) + + for (let i = 0; i < BASE45_CHARS.length; i++) { + table[BASE45_CHARS.charCodeAt(i)] = i + } + + return table +})() + export const Z85_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#' diff --git a/src/fromBase45String/index.ts b/src/fromBase45String/index.ts new file mode 100644 index 0000000..09f01c9 --- /dev/null +++ b/src/fromBase45String/index.ts @@ -0,0 +1,91 @@ +/* + * 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 { BASE45_VALUES } from '../.helpers/index.js' + +/** + * Decodes a Base45 string into a new `Uint8Array`. + * + * @param base45String The Base45 string to decode. + * @returns A new `Uint8Array` containing the decoded bytes. + */ +export function fromBase45String(base45String: string): Uint8Array { + if (typeof base45String !== 'string') + throw new BytecodecError( + 'BASE45_INPUT_EXPECTED', + 'fromBase45String expects a string input' + ) + + if (base45String.length % 3 === 1) + throw new BytecodecError( + 'BASE45_INVALID_LENGTH', + 'Base45 string length must not leave a trailing single character' + ) + + const bytes = new Uint8Array( + Math.floor(base45String.length / 3) * 2 + (base45String.length % 3 === 2 ? 1 : 0) + ) + let byteOffset = 0 + + for (let stringOffset = 0; stringOffset < base45String.length; ) { + const remaining = base45String.length - stringOffset + const digit0 = toBase45Digit(base45String, stringOffset) + const digit1 = toBase45Digit(base45String, stringOffset + 1) + + if (remaining === 2) { + const value = digit0 + digit1 * 45 + + if (value > 0xff) + throw new BytecodecError( + 'BASE45_INVALID_CHUNK', + `Invalid base45 chunk at index ${stringOffset}` + ) + + bytes[byteOffset++] = value + stringOffset += 2 + continue + } + + const digit2 = toBase45Digit(base45String, stringOffset + 2) + const value = digit0 + digit1 * 45 + digit2 * 2025 + + if (value > 0xffff) + throw new BytecodecError( + 'BASE45_INVALID_CHUNK', + `Invalid base45 chunk at index ${stringOffset}` + ) + + bytes[byteOffset++] = value >>> 8 + bytes[byteOffset++] = value & 0xff + stringOffset += 3 + } + + return bytes +} + +function toBase45Digit(base45String: string, stringOffset: number): number { + const code = base45String.charCodeAt(stringOffset) + const digit = code < 128 ? BASE45_VALUES[code] : -1 + + if (digit === -1) + throw new BytecodecError( + 'BASE45_INVALID_CHARACTER', + `Invalid base45 character at index ${stringOffset}` + ) + + return digit +} diff --git a/src/index.ts b/src/index.ts index a6b1cd3..1782ffa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +/***/ +import { fromBase45String } from './fromBase45String/index.js' +import { toBase45String } from './toBase45String/index.js' /***/ import { fromBase58String } from './fromBase58String/index.js' import { toBase58String } from './toBase58String/index.js' @@ -65,6 +68,9 @@ export type ByteSource = export type { BytecodecErrorCode } from './.errors/class.js' export { + /***/ + fromBase45String, + toBase45String, /***/ fromBase58String, toBase58String, @@ -108,6 +114,20 @@ export { * Convenience wrapper around the codec functions. */ export class Bytes { + /** + * See {@link fromBase45String}. + */ + static fromBase45String(base45String: string): Uint8Array { + return fromBase45String(base45String) + } + + /** + * See {@link toBase45String}. + */ + static toBase45String(bytes: ByteSource): string { + return toBase45String(bytes) + } + /** * See {@link fromBase58String}. */ diff --git a/src/toBase45String/index.ts b/src/toBase45String/index.ts new file mode 100644 index 0000000..e3e6d30 --- /dev/null +++ b/src/toBase45String/index.ts @@ -0,0 +1,47 @@ +/* + * 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 { BASE45_CHARS } from '../.helpers/index.js' +import type { ByteSource } from '../index.js' +import { toUint8Array } from '../index.js' + +/** + * Encodes bytes as a Base45 string. + * + * @param bytes The bytes to encode. + * @returns A Base45 string representation of `bytes`. + */ +export function toBase45String(bytes: ByteSource): string { + const view = toUint8Array(bytes) + let base45String = '' + + for (let offset = 0; offset + 1 < view.length; offset += 2) { + let value = view[offset] * 256 + view[offset + 1] + + base45String += BASE45_CHARS[value % 45] + value = Math.floor(value / 45) + base45String += BASE45_CHARS[value % 45] + base45String += BASE45_CHARS[Math.floor(value / 45)] + } + + if (view.length % 2 === 1) { + const value = view[view.length - 1] + base45String += BASE45_CHARS[value % 45] + base45String += BASE45_CHARS[Math.floor(value / 45)] + } + + return base45String +} diff --git a/test/e2e/shared/suite.mjs b/test/e2e/shared/suite.mjs index 28a21cb..8504b17 100644 --- a/test/e2e/shared/suite.mjs +++ b/test/e2e/shared/suite.mjs @@ -10,6 +10,7 @@ export async function runBytecodecSuite(api, options = {}) { Bytes, concat, equals, + fromBase45String, fromBase58BtcString, fromBase58String, fromBase64String, @@ -21,6 +22,7 @@ export async function runBytecodecSuite(api, options = {}) { fromString, fromZ85String, toArrayBuffer, + toBase45String, toBase58BtcString, toBase58String, toBase64String, @@ -121,6 +123,8 @@ export async function runBytecodecSuite(api, options = {}) { await runTest('exports shape', () => { assert(typeof Bytes === 'function', 'Bytes export missing') for (const fn of [ + fromBase45String, + toBase45String, fromBase58String, toBase58String, fromBase58BtcString, @@ -173,6 +177,21 @@ export async function runBytecodecSuite(api, options = {}) { assertThrows(() => fromBase58String('0'), /Invalid base58 character at index 0/) }) + await runTest('toBase45String', () => { + const encoded = toBase45String(base58Payload) + assertEqual(encoded, '100KB040') + + const view = new DataView(base58Payload.buffer, 1, 3) + assertEqual(toBase45String(view), 'X5030') + }) + + await runTest('fromBase45String', () => { + const decoded = fromBase45String('100KB040') + assertArrayEqual(decoded, base58Payload) + assertThrows(() => fromBase45String('A'), /Base45 string length must not leave a trailing single character/) + assertThrows(() => fromBase45String(':::'), /Invalid base45 chunk at index 0/) + }) + await runTest('toBase58BtcString', () => { const encoded = toBase58BtcString(base64Payload) assertEqual(encoded, 'zCn8eVZg') @@ -393,6 +412,10 @@ export async function runBytecodecSuite(api, options = {}) { await runTest('Bytes wrapper', async () => { const payload = Uint8Array.from([1, 2, 3, 4]) + const base45 = Bytes.toBase45String(payload) + assertEqual(base45, 'X507H0') + assertArrayEqual(Bytes.fromBase45String(base45), [1, 2, 3, 4]) + const base58 = Bytes.toBase58String(payload) assertEqual(base58, '2VfUX') assertArrayEqual(Bytes.fromBase58String(base58), [1, 2, 3, 4]) diff --git a/test/integration/integration.test.js b/test/integration/integration.test.js index 046cf1f..545433f 100644 --- a/test/integration/integration.test.js +++ b/test/integration/integration.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { concat, + fromBase45String, fromBase58BtcString, fromBase58String, fromBase64String, @@ -11,6 +12,7 @@ import { fromJSON, fromString, fromZ85String, + toBase45String, toBase58BtcString, toBase58String, toBase64String, @@ -22,6 +24,14 @@ import { toZ85String, } from '../../dist/index.js' +test('integration: utf8 -> base45 -> utf8', () => { + const text = 'pipeline check' + const bytes = fromString(text) + const encoded = toBase45String(bytes) + const decoded = fromBase45String(encoded) + assert.equal(toString(decoded), text) +}) + test('integration: utf8 -> base64 -> utf8', () => { const text = 'pipeline check' const bytes = fromString(text) diff --git a/test/unit/base45.test.js b/test/unit/base45.test.js new file mode 100644 index 0000000..7a2b362 --- /dev/null +++ b/test/unit/base45.test.js @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + fromBase45String, + fromString, + toBase45String, + toString, +} from '../../dist/index.js' + +test('base45 roundtrip uses the RFC example for "AB"', () => { + const payload = new Uint8Array([65, 66]) + const encoded = toBase45String(payload) + assert.equal(encoded, 'BB8') + const decoded = fromBase45String(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('base45 encodes a trailing single byte as two characters', () => { + const payload = new Uint8Array([65, 66, 67]) + const encoded = toBase45String(payload) + assert.equal(encoded, 'BB8M1') + const decoded = fromBase45String(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('base45 matches the RFC 9285 "Hello!!" example', () => { + const encoded = toBase45String(fromString('Hello!!')) + assert.equal(encoded, '%69 VD92EX0') + assert.equal(toString(fromBase45String(encoded)), 'Hello!!') +}) + +test('base45 matches the RFC 9285 "base-45" example', () => { + const encoded = toBase45String(fromString('base-45')) + assert.equal(encoded, 'UJCLQE7W581') + assert.equal(toString(fromBase45String(encoded)), 'base-45') +}) + +test('base45 matches the RFC 9285 decoding example', () => { + assert.equal(toString(fromBase45String('QED8WEX0')), 'ietf!') +}) + +test('base45 accepts ByteSource input', () => { + const encoded = toBase45String([1, 2, 3]) + assert.equal(encoded, 'X5030') + const decoded = fromBase45String(encoded) + assert.deepStrictEqual([...decoded], [1, 2, 3]) +}) + +test('base45 encodes and decodes empty input', () => { + assert.equal(toBase45String(new Uint8Array([])), '') + assert.deepStrictEqual([...fromBase45String('')], []) +}) + +test('base45 rejects invalid length', () => { + assert.throws( + () => fromBase45String('A'), + /Base45 string length must not leave a trailing single character/ + ) +}) + +test('base45 rejects invalid characters', () => { + assert.throws( + () => fromBase45String('bb8'), + /Invalid base45 character at index 0/ + ) + assert.throws( + () => fromBase45String('åB8'), + /Invalid base45 character at index 0/ + ) +}) + +test('base45 rejects chunks outside the byte range', () => { + assert.throws(() => fromBase45String(':::'), /Invalid base45 chunk at index 0/) + assert.throws(() => fromBase45String('::'), /Invalid base45 chunk at index 0/) + assert.throws(() => fromBase45String('GGW'), /Invalid base45 chunk at index 0/) +}) + +test('base45 accepts the largest valid 16-bit triplet', () => { + assert.deepStrictEqual([...fromBase45String('FGW')], [0xff, 0xff]) +}) + +test('base45 rejects non-string input', () => { + assert.throws( + () => fromBase45String(123), + /fromBase45String expects a string input/ + ) +}) diff --git a/test/unit/bytes-class.test.js b/test/unit/bytes-class.test.js index 0876612..3c85d40 100644 --- a/test/unit/bytes-class.test.js +++ b/test/unit/bytes-class.test.js @@ -4,6 +4,10 @@ import { Bytes } from '../../dist/index.js' test('Bytes wrapper mirrors functions', async () => { const payload = Uint8Array.from([1, 2, 3, 4]) + const base45 = Bytes.toBase45String(payload) + assert.equal(base45, 'X507H0') + assert.deepStrictEqual(Bytes.fromBase45String(base45), payload) + const base58 = Bytes.toBase58String(payload) assert.equal(base58, '2VfUX') assert.deepStrictEqual(Bytes.fromBase58String(base58), payload) diff --git a/test/unit/errors.test.js b/test/unit/errors.test.js index 05a114b..4dee6c1 100644 --- a/test/unit/errors.test.js +++ b/test/unit/errors.test.js @@ -52,6 +52,7 @@ test('public errors expose code, name, and prefixed message', async () => { test('validation errors use the same public error shape', async () => { const { + fromBase45String, fromBase58BtcString, fromBase58String, fromBase64UrlString, @@ -61,6 +62,19 @@ test('validation errors use the same public error shape', async () => { } = await importFreshBundle('validation-error') + assert.throws( + () => fromBase45String(':::'), + (error) => { + assert.equal(error.code, 'BASE45_INVALID_CHUNK') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} Invalid base45 chunk at index 0' + ) + return true + } + ) + assert.throws( () => fromBase58String('0'), (error) => { diff --git a/test/unit/fallbacks.test.js b/test/unit/fallbacks.test.js index 3cecf95..48ebab4 100644 --- a/test/unit/fallbacks.test.js +++ b/test/unit/fallbacks.test.js @@ -14,12 +14,14 @@ const bundleUrl = new URL( ) const { concat, + fromBase45String, fromBase58BtcString, fromBase58String, fromBase64UrlString, fromCompressed, fromJSON, fromString, + toBase45String, toBase58BtcString, toBase58String, toBase64UrlString, @@ -150,11 +152,15 @@ test('fromBase64UrlString throws when no base64 decoder exists', () => { restoreGlobals() }) -test('base58 helpers do not depend on Buffer or browser base64 globals', () => { +test('base45/base58 helpers do not depend on Buffer or browser base64 globals', () => { globalThis.Buffer = undefined globalThis.btoa = undefined globalThis.atob = undefined + const base45Encoded = toBase45String(new Uint8Array([65, 66, 67])) + assert.equal(base45Encoded, 'BB8M1') + assert.deepStrictEqual([...fromBase45String(base45Encoded)], [65, 66, 67]) + const encoded = toBase58String(new Uint8Array([104, 101, 108, 108, 111])) assert.equal(encoded, 'Cn8eVZg') assert.deepStrictEqual([...fromBase58String(encoded)], [104, 101, 108, 108, 111])