Skip to content

Commit 477071c

Browse files
Merge pull request #31 from sovereignbase/feat/issue-30-base45
RFC 9285 base45 encode/decode support.
2 parents b54da32 + fac1753 commit 477071c

14 files changed

Lines changed: 348 additions & 12 deletions

File tree

README.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# bytecodec
77

8-
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.
8+
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.
99

1010
## Compatibility
1111

@@ -60,6 +60,19 @@ const encoded = toBase64String(bytes) // string of base64 chars
6060
const decoded = fromBase64String(encoded) // Uint8Array
6161
```
6262

63+
### Base45
64+
65+
```js
66+
import { toBase45String, fromBase45String } from '@sovereignbase/bytecodec'
67+
68+
const bytes = new Uint8Array([65, 66])
69+
const encoded = toBase45String(bytes) // "BB8"
70+
const decoded = fromBase45String(encoded) // Uint8Array
71+
```
72+
73+
Base45 is convenient for QR-friendly payloads. It encodes 2 input bytes into 3 output characters, and a trailing single byte into 2 characters.
74+
The implementation follows RFC 9285.
75+
6376
### Base58
6477

6578
```js
@@ -209,7 +222,7 @@ const joined = concat([new Uint8Array([1, 2]), new Uint8Array([3, 4]), [5, 6]])
209222

210223
### Node
211224

212-
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.
225+
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.
213226

214227
### Bun
215228

@@ -221,7 +234,7 @@ Uses `TextEncoder`, `TextDecoder`, `btoa`, and `atob`. Gzip uses `CompressionStr
221234

222235
### Validation & errors
223236

224-
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}`.
237+
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}`.
225238

226239
### Safety / copying semantics
227240

@@ -231,13 +244,13 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e
231244

232245
`npm test` covers:
233246

234-
- 85 unit tests
235-
- 9 integration tests
236-
- Node E2E: 27/27 passed in ESM and 27/27 passed in CommonJS
237-
- Bun E2E: 27/27 passed in ESM and 27/27 passed in CommonJS
238-
- Deno E2E: 27/27 passed in ESM
239-
- Cloudflare Workers E2E: 27/27 passed in ESM
240-
- Edge Runtime E2E: 27/27 passed in ESM
247+
- 97 unit tests
248+
- 10 integration tests
249+
- Node E2E: 29/29 passed in ESM and 29/29 passed in CommonJS
250+
- Bun E2E: 29/29 passed in ESM and 29/29 passed in CommonJS
251+
- Deno E2E: 29/29 passed in ESM
252+
- Cloudflare Workers E2E: 29/29 passed in ESM
253+
- Edge Runtime E2E: 29/29 passed in ESM
241254
- Browser E2E: 5/5 passed in Chromium, Firefox, WebKit, mobile-chrome, and mobile-safari
242255
- Coverage gate: 100% statements, branches, functions, and lines
243256

biblio/base45.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- https://datatracker.ietf.org/doc/html/rfc9285
2+
- https://digitalproc.github.io/2021/11/11/qrCode/qr_standard.pdf
3+
- https://ref.gs1.org/guidelines/2d-in-retail/1.0.0/

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
{
22
"name": "@sovereignbase/bytecodec",
33
"version": "1.6.1",
4-
"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.",
4+
"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.",
55
"keywords": [
6+
"base45",
67
"base58",
78
"base58btc",
89
"base64url",
@@ -19,6 +20,7 @@
1920
"utf8",
2021
"string",
2122
"text",
23+
"qr",
2224
"json",
2325
"equals",
2426
"hex",

src/.errors/class.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
* All structured error codes thrown by the bytecodec.
1919
*/
2020
export type BytecodecErrorCode =
21+
| 'BASE45_INPUT_EXPECTED'
22+
| 'BASE45_INVALID_CHARACTER'
23+
| 'BASE45_INVALID_CHUNK'
24+
| 'BASE45_INVALID_LENGTH'
2125
| 'BASE58BTC_INPUT_EXPECTED'
2226
| 'BASE58BTC_INVALID_PREFIX'
2327
| 'BASE58_INPUT_EXPECTED'

src/.helpers/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ export const HEX_VALUES = (() => {
7878
return table
7979
})()
8080

81+
export const BASE45_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
82+
83+
export const BASE45_VALUES = (() => {
84+
const table = new Int16Array(128).fill(-1)
85+
86+
for (let i = 0; i < BASE45_CHARS.length; i++) {
87+
table[BASE45_CHARS.charCodeAt(i)] = i
88+
}
89+
90+
return table
91+
})()
92+
8193
export const Z85_CHARS =
8294
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#'
8395

src/fromBase45String/index.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2026 Sovereignbase
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { BytecodecError } from '../.errors/class.js'
18+
import { BASE45_VALUES } from '../.helpers/index.js'
19+
20+
/**
21+
* Decodes a Base45 string into a new `Uint8Array`.
22+
*
23+
* @param base45String The Base45 string to decode.
24+
* @returns A new `Uint8Array` containing the decoded bytes.
25+
*/
26+
export function fromBase45String(base45String: string): Uint8Array {
27+
if (typeof base45String !== 'string')
28+
throw new BytecodecError(
29+
'BASE45_INPUT_EXPECTED',
30+
'fromBase45String expects a string input'
31+
)
32+
33+
if (base45String.length % 3 === 1)
34+
throw new BytecodecError(
35+
'BASE45_INVALID_LENGTH',
36+
'Base45 string length must not leave a trailing single character'
37+
)
38+
39+
const bytes = new Uint8Array(
40+
Math.floor(base45String.length / 3) * 2 + (base45String.length % 3 === 2 ? 1 : 0)
41+
)
42+
let byteOffset = 0
43+
44+
for (let stringOffset = 0; stringOffset < base45String.length; ) {
45+
const remaining = base45String.length - stringOffset
46+
const digit0 = toBase45Digit(base45String, stringOffset)
47+
const digit1 = toBase45Digit(base45String, stringOffset + 1)
48+
49+
if (remaining === 2) {
50+
const value = digit0 + digit1 * 45
51+
52+
if (value > 0xff)
53+
throw new BytecodecError(
54+
'BASE45_INVALID_CHUNK',
55+
`Invalid base45 chunk at index ${stringOffset}`
56+
)
57+
58+
bytes[byteOffset++] = value
59+
stringOffset += 2
60+
continue
61+
}
62+
63+
const digit2 = toBase45Digit(base45String, stringOffset + 2)
64+
const value = digit0 + digit1 * 45 + digit2 * 2025
65+
66+
if (value > 0xffff)
67+
throw new BytecodecError(
68+
'BASE45_INVALID_CHUNK',
69+
`Invalid base45 chunk at index ${stringOffset}`
70+
)
71+
72+
bytes[byteOffset++] = value >>> 8
73+
bytes[byteOffset++] = value & 0xff
74+
stringOffset += 3
75+
}
76+
77+
return bytes
78+
}
79+
80+
function toBase45Digit(base45String: string, stringOffset: number): number {
81+
const code = base45String.charCodeAt(stringOffset)
82+
const digit = code < 128 ? BASE45_VALUES[code] : -1
83+
84+
if (digit === -1)
85+
throw new BytecodecError(
86+
'BASE45_INVALID_CHARACTER',
87+
`Invalid base45 character at index ${stringOffset}`
88+
)
89+
90+
return digit
91+
}

src/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17+
/***/
18+
import { fromBase45String } from './fromBase45String/index.js'
19+
import { toBase45String } from './toBase45String/index.js'
1720
/***/
1821
import { fromBase58String } from './fromBase58String/index.js'
1922
import { toBase58String } from './toBase58String/index.js'
@@ -65,6 +68,9 @@ export type ByteSource =
6568
export type { BytecodecErrorCode } from './.errors/class.js'
6669

6770
export {
71+
/***/
72+
fromBase45String,
73+
toBase45String,
6874
/***/
6975
fromBase58String,
7076
toBase58String,
@@ -108,6 +114,20 @@ export {
108114
* Convenience wrapper around the codec functions.
109115
*/
110116
export class Bytes {
117+
/**
118+
* See {@link fromBase45String}.
119+
*/
120+
static fromBase45String(base45String: string): Uint8Array {
121+
return fromBase45String(base45String)
122+
}
123+
124+
/**
125+
* See {@link toBase45String}.
126+
*/
127+
static toBase45String(bytes: ByteSource): string {
128+
return toBase45String(bytes)
129+
}
130+
111131
/**
112132
* See {@link fromBase58String}.
113133
*/

src/toBase45String/index.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2026 Sovereignbase
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { BASE45_CHARS } from '../.helpers/index.js'
18+
import type { ByteSource } from '../index.js'
19+
import { toUint8Array } from '../index.js'
20+
21+
/**
22+
* Encodes bytes as a Base45 string.
23+
*
24+
* @param bytes The bytes to encode.
25+
* @returns A Base45 string representation of `bytes`.
26+
*/
27+
export function toBase45String(bytes: ByteSource): string {
28+
const view = toUint8Array(bytes)
29+
let base45String = ''
30+
31+
for (let offset = 0; offset + 1 < view.length; offset += 2) {
32+
let value = view[offset] * 256 + view[offset + 1]
33+
34+
base45String += BASE45_CHARS[value % 45]
35+
value = Math.floor(value / 45)
36+
base45String += BASE45_CHARS[value % 45]
37+
base45String += BASE45_CHARS[Math.floor(value / 45)]
38+
}
39+
40+
if (view.length % 2 === 1) {
41+
const value = view[view.length - 1]
42+
base45String += BASE45_CHARS[value % 45]
43+
base45String += BASE45_CHARS[Math.floor(value / 45)]
44+
}
45+
46+
return base45String
47+
}

test/e2e/shared/suite.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export async function runBytecodecSuite(api, options = {}) {
1010
Bytes,
1111
concat,
1212
equals,
13+
fromBase45String,
1314
fromBase58BtcString,
1415
fromBase58String,
1516
fromBase64String,
@@ -21,6 +22,7 @@ export async function runBytecodecSuite(api, options = {}) {
2122
fromString,
2223
fromZ85String,
2324
toArrayBuffer,
25+
toBase45String,
2426
toBase58BtcString,
2527
toBase58String,
2628
toBase64String,
@@ -121,6 +123,8 @@ export async function runBytecodecSuite(api, options = {}) {
121123
await runTest('exports shape', () => {
122124
assert(typeof Bytes === 'function', 'Bytes export missing')
123125
for (const fn of [
126+
fromBase45String,
127+
toBase45String,
124128
fromBase58String,
125129
toBase58String,
126130
fromBase58BtcString,
@@ -173,6 +177,21 @@ export async function runBytecodecSuite(api, options = {}) {
173177
assertThrows(() => fromBase58String('0'), /Invalid base58 character at index 0/)
174178
})
175179

180+
await runTest('toBase45String', () => {
181+
const encoded = toBase45String(base58Payload)
182+
assertEqual(encoded, '100KB040')
183+
184+
const view = new DataView(base58Payload.buffer, 1, 3)
185+
assertEqual(toBase45String(view), 'X5030')
186+
})
187+
188+
await runTest('fromBase45String', () => {
189+
const decoded = fromBase45String('100KB040')
190+
assertArrayEqual(decoded, base58Payload)
191+
assertThrows(() => fromBase45String('A'), /Base45 string length must not leave a trailing single character/)
192+
assertThrows(() => fromBase45String(':::'), /Invalid base45 chunk at index 0/)
193+
})
194+
176195
await runTest('toBase58BtcString', () => {
177196
const encoded = toBase58BtcString(base64Payload)
178197
assertEqual(encoded, 'zCn8eVZg')
@@ -393,6 +412,10 @@ export async function runBytecodecSuite(api, options = {}) {
393412
await runTest('Bytes wrapper', async () => {
394413
const payload = Uint8Array.from([1, 2, 3, 4])
395414

415+
const base45 = Bytes.toBase45String(payload)
416+
assertEqual(base45, 'X507H0')
417+
assertArrayEqual(Bytes.fromBase45String(base45), [1, 2, 3, 4])
418+
396419
const base58 = Bytes.toBase58String(payload)
397420
assertEqual(base58, '2VfUX')
398421
assertArrayEqual(Bytes.fromBase58String(base58), [1, 2, 3, 4])

test/integration/integration.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
22
import test from 'node:test'
33
import {
44
concat,
5+
fromBase45String,
56
fromBase58BtcString,
67
fromBase58String,
78
fromBase64String,
@@ -11,6 +12,7 @@ import {
1112
fromJSON,
1213
fromString,
1314
fromZ85String,
15+
toBase45String,
1416
toBase58BtcString,
1517
toBase58String,
1618
toBase64String,
@@ -22,6 +24,14 @@ import {
2224
toZ85String,
2325
} from '../../dist/index.js'
2426

27+
test('integration: utf8 -> base45 -> utf8', () => {
28+
const text = 'pipeline check'
29+
const bytes = fromString(text)
30+
const encoded = toBase45String(bytes)
31+
const decoded = fromBase45String(encoded)
32+
assert.equal(toString(decoded), text)
33+
})
34+
2535
test('integration: utf8 -> base64 -> utf8', () => {
2636
const text = 'pipeline check'
2737
const bytes = fromString(text)

0 commit comments

Comments
 (0)