Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions biblio/base45.txt
Original file line number Diff line number Diff line change
@@ -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/
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -19,6 +20,7 @@
"utf8",
"string",
"text",
"qr",
"json",
"equals",
"hex",
Expand Down
4 changes: 4 additions & 0 deletions src/.errors/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 12 additions & 0 deletions src/.helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.-:+=^!/*?&<>()[]{}@%$#'

Expand Down
91 changes: 91 additions & 0 deletions src/fromBase45String/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 20 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -65,6 +68,9 @@ export type ByteSource =
export type { BytecodecErrorCode } from './.errors/class.js'

export {
/***/
fromBase45String,
toBase45String,
/***/
fromBase58String,
toBase58String,
Expand Down Expand Up @@ -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}.
*/
Expand Down
47 changes: 47 additions & 0 deletions src/toBase45String/index.ts
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions test/e2e/shared/suite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export async function runBytecodecSuite(api, options = {}) {
Bytes,
concat,
equals,
fromBase45String,
fromBase58BtcString,
fromBase58String,
fromBase64String,
Expand All @@ -21,6 +22,7 @@ export async function runBytecodecSuite(api, options = {}) {
fromString,
fromZ85String,
toArrayBuffer,
toBase45String,
toBase58BtcString,
toBase58String,
toBase64String,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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])
Expand Down
10 changes: 10 additions & 0 deletions test/integration/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
concat,
fromBase45String,
fromBase58BtcString,
fromBase58String,
fromBase64String,
Expand All @@ -11,6 +12,7 @@ import {
fromJSON,
fromString,
fromZ85String,
toBase45String,
toBase58BtcString,
toBase58String,
toBase64String,
Expand All @@ -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)
Expand Down
Loading
Loading