Skip to content

Commit a444c2b

Browse files
Merge pull request #18 from sovereignbase/feature/hex-b85-b91
feat: add hex and Z85 support
2 parents 48c1438 + 1300c1a commit a444c2b

17 files changed

Lines changed: 591 additions & 49 deletions

File tree

README.md

Lines changed: 48 additions & 22 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 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.
8+
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.
99

1010
## Compatibility
1111

@@ -17,7 +17,7 @@ Typed JavaScript and TypeScript byte utilities for base64, base64url, UTF-8 stri
1717

1818
## Goals
1919

20-
- Developer-friendly API for base64, base64url, UTF-8, JSON, gzip, concat, equality, and byte normalization.
20+
- Developer-friendly API for base64, base64url, hex, Z85, UTF-8, JSON, gzip, concat, equality, and byte normalization.
2121
- No runtime dependencies or bundler shims.
2222
- Tree-shakeable ESM by default with CommonJS compatibility and no side effects.
2323
- Returns copies for safety when normalizing inputs.
@@ -82,6 +82,28 @@ const encoded = toBase64UrlString(bytes) // string of base64url chars
8282
const decoded = fromBase64UrlString(encoded) // Uint8Array
8383
```
8484

85+
### Hex
86+
87+
```js
88+
import { toHex, fromHex } from '@sovereignbase/bytecodec'
89+
90+
const bytes = new Uint8Array([222, 173, 190, 239])
91+
const encoded = toHex(bytes) // "deadbeef"
92+
const decoded = fromHex(encoded) // Uint8Array
93+
```
94+
95+
### Z85
96+
97+
```js
98+
import { toZ85String, fromZ85String } from '@sovereignbase/bytecodec'
99+
100+
const bytes = new Uint8Array([0x86, 0x4f, 0xd2, 0x6f, 0xb5, 0x59, 0xf7, 0x5b])
101+
const encoded = toZ85String(bytes) // "HelloWorld"
102+
const decoded = fromZ85String(encoded) // Uint8Array
103+
```
104+
105+
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.
106+
85107
### UTF-8 strings
86108

87109
```js
@@ -162,7 +184,7 @@ Uses `TextEncoder`, `TextDecoder`, `btoa`, and `atob`. Gzip uses `CompressionStr
162184

163185
### Validation & errors
164186

165-
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}`.
187+
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}`.
166188

167189
### Safety / copying semantics
168190

@@ -172,8 +194,8 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e
172194

173195
`npm test` covers:
174196

175-
- 53 unit tests
176-
- 4 integration tests
197+
- 68 unit tests
198+
- 6 integration tests
177199
- Node E2E: ESM and CommonJS
178200
- Bun E2E: ESM and CommonJS
179201
- Deno E2E: ESM
@@ -183,26 +205,30 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e
183205

184206
## Benchmarks
185207

186-
Latest local `npm run bench` run on 2026-03-19 with Node `v22.14.0 (win32 x64)`:
208+
Latest local `npm run bench` run on 2026-03-23 with Node `v22.14.0 (win32 x64)`:
187209

188210
| Benchmark | Result |
189211
| ---------------- | ------------------------- |
190-
| base64 encode | 1,717,210 ops/s (29.1 ms) |
191-
| base64 decode | 2,326,783 ops/s (21.5 ms) |
192-
| base64url encode | 768,469 ops/s (65.1 ms) |
193-
| base64url decode | 1,173,307 ops/s (42.6 ms) |
194-
| utf8 encode | 1,479,264 ops/s (33.8 ms) |
195-
| utf8 decode | 4,109,139 ops/s (12.2 ms) |
196-
| json encode | 353,666 ops/s (56.6 ms) |
197-
| json decode | 513,064 ops/s (39.0 ms) |
198-
| concat 3 buffers | 664,735 ops/s (75.2 ms) |
199-
| toUint8Array | 4,721,669 ops/s (42.4 ms) |
200-
| toArrayBuffer | 751,732 ops/s (266.1 ms) |
201-
| toBufferSource | 8,952,992 ops/s (22.3 ms) |
202-
| equals same | 3,766,379 ops/s (53.1 ms) |
203-
| equals diff | 4,285,463 ops/s (46.7 ms) |
204-
| gzip compress | 3,118 ops/s (128.3 ms) |
205-
| gzip decompress | 5,070 ops/s (78.9 ms) |
212+
| base64 encode | 1,391,126 ops/s (35.9 ms) |
213+
| base64 decode | 2,089,279 ops/s (23.9 ms) |
214+
| base64url encode | 697,088 ops/s (71.7 ms) |
215+
| base64url decode | 1,095,554 ops/s (45.6 ms) |
216+
| hex encode | 1,053,832 ops/s (47.4 ms) |
217+
| hex decode | 1,027,413 ops/s (48.7 ms) |
218+
| z85 encode | 244,928 ops/s (204.1 ms) |
219+
| z85 decode | 1,596,730 ops/s (31.3 ms) |
220+
| utf8 encode | 1,537,199 ops/s (32.5 ms) |
221+
| utf8 decode | 3,481,143 ops/s (14.4 ms) |
222+
| json encode | 681,747 ops/s (29.3 ms) |
223+
| json decode | 989,746 ops/s (20.2 ms) |
224+
| concat 3 buffers | 846,612 ops/s (59.1 ms) |
225+
| toUint8Array | 9,396,818 ops/s (21.3 ms) |
226+
| toArrayBuffer | 884,096 ops/s (226.2 ms) |
227+
| toBufferSource | 9,279,881 ops/s (21.6 ms) |
228+
| equals same | 3,932,572 ops/s (50.9 ms) |
229+
| equals diff | 4,060,534 ops/s (49.3 ms) |
230+
| gzip compress | 4,126 ops/s (96.9 ms) |
231+
| gzip decompress | 5,550 ops/s (72.1 ms) |
206232

207233
Command: `npm run bench`
208234

benchmark/bench.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import {
66
fromBase64String,
77
fromBase64UrlString,
88
fromCompressed,
9+
fromHex,
910
fromJSON,
1011
fromString,
12+
fromZ85String,
1113
toArrayBuffer,
1214
toBase64String,
1315
toBase64UrlString,
1416
toBufferSource,
1517
toCompressed,
18+
toHex,
1619
toJSON,
1720
toString,
1821
toUint8Array,
22+
toZ85String,
1923
} from '../dist/index.js'
2024

2125
function formatOps(iterations, durationMs) {
@@ -51,12 +55,18 @@ const sampleJson = { ok: true, count: 42, note: '@sovereignbase/bytecodec' }
5155
const sampleJsonBytes = fromJSON(sampleJson)
5256
const base64 = toBase64String(sampleBytes)
5357
const base64Url = toBase64UrlString(sampleBytes)
58+
const hex = toHex(sampleBytes)
59+
const z85 = toZ85String(sampleBytes)
5460
const compressed = await toCompressed(sampleBytes)
5561

5662
bench('base64 encode', 50000, () => toBase64String(sampleBytes))
5763
bench('base64 decode', 50000, () => fromBase64String(base64))
5864
bench('base64url encode', 50000, () => toBase64UrlString(sampleBytes))
5965
bench('base64url decode', 50000, () => fromBase64UrlString(base64Url))
66+
bench('hex encode', 50000, () => toHex(sampleBytes))
67+
bench('hex decode', 50000, () => fromHex(hex))
68+
bench('z85 encode', 50000, () => toZ85String(sampleBytes))
69+
bench('z85 decode', 50000, () => fromZ85String(z85))
6070
bench('utf8 encode', 50000, () => fromString(sampleText))
6171
bench('utf8 decode', 50000, () => toString(sampleTextBytes))
6272
bench('json encode', 20000, () => fromJSON(sampleJson))

jsr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://jsr.io/schema/config-file.v1.json",
33
"name": "@sovereignbase/bytecodec",
4-
"version": "1.3.2",
4+
"version": "1.3.3",
55
"exports": "./src/index.ts",
66
"publish": {
77
"include": [

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@sovereignbase/bytecodec",
33
"version": "1.3.3",
4-
"description": "JS/TS Runtime-Agnostic byte toolkit for UTF-8 strings, base64, base64url, JSON, normalization, compression, concatenation, and comparison.",
4+
"description": "JS/TS runtime-agnostic byte toolkit for UTF-8, base64, base64url, hex, Z85, JSON, normalization, compression, concatenation, and comparison.",
55
"keywords": [
66
"base64url",
77
"base64",
@@ -15,9 +15,14 @@
1515
"web",
1616
"node",
1717
"utf8",
18+
"string",
1819
"text",
1920
"json",
2021
"equals",
22+
"hex",
23+
"hexadecimal",
24+
"z85",
25+
"zeromq",
2126
"compression",
2227
"gzip",
2328
"binary",

src/.errors/class.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,18 @@ export type BytecodecErrorCode =
2323
| 'CONCAT_NORMALIZE_FAILED'
2424
| 'GZIP_COMPRESSION_UNAVAILABLE'
2525
| 'GZIP_DECOMPRESSION_UNAVAILABLE'
26+
| 'HEX_INPUT_EXPECTED'
27+
| 'HEX_INVALID_CHARACTER'
28+
| 'HEX_INVALID_LENGTH'
2629
| 'JSON_PARSE_FAILED'
2730
| 'JSON_STRINGIFY_FAILED'
2831
| 'STRING_INPUT_EXPECTED'
2932
| 'UTF8_DECODER_UNAVAILABLE'
3033
| 'UTF8_ENCODER_UNAVAILABLE'
34+
| 'Z85_INPUT_EXPECTED'
35+
| 'Z85_INVALID_BLOCK'
36+
| 'Z85_INVALID_CHARACTER'
37+
| 'Z85_INVALID_LENGTH'
3138

3239
export class BytecodecError extends Error {
3340
readonly code: BytecodecErrorCode

src/.helpers/index.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,39 @@ export async function importNodeBuiltin<T = unknown>(
5454
specifier: string
5555
): Promise<T> {
5656
// Keep neutral bundles from rewriting node: specifiers for non-Node runtimes.
57-
const importer = new Function(
58-
'specifier',
59-
'return import(specifier)'
60-
) as (value: string) => Promise<T>
57+
const importer = new Function('specifier', 'return import(specifier)') as (
58+
value: string
59+
) => Promise<T>
6160
return importer(specifier)
6261
}
62+
63+
export const HEX_PAIRS = Array.from({ length: 256 }, (_, value) =>
64+
value.toString(16).padStart(2, '0')
65+
)
66+
67+
export const HEX_VALUES = (() => {
68+
const table = new Int16Array(128).fill(-1)
69+
70+
for (let index = 0; index < 10; index++)
71+
table['0'.charCodeAt(0) + index] = index
72+
73+
for (let index = 0; index < 6; index++) {
74+
table['A'.charCodeAt(0) + index] = index + 10
75+
table['a'.charCodeAt(0) + index] = index + 10
76+
}
77+
78+
return table
79+
})()
80+
81+
export const Z85_CHARS =
82+
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-:+=^!/*?&<>()[]{}@%$#'
83+
84+
export const Z85_VALUES = (() => {
85+
const table = new Int16Array(128).fill(-1)
86+
87+
for (let i = 0; i < Z85_CHARS.length; i++) {
88+
table[Z85_CHARS.charCodeAt(i)] = i
89+
}
90+
91+
return table
92+
})()

src/fromHex/index.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 { HEX_VALUES } from '../.helpers/index.js'
19+
20+
/**
21+
* Decodes a hexadecimal string into a new `Uint8Array`.
22+
*
23+
* @param hex The hexadecimal string to decode.
24+
* @returns A new `Uint8Array` containing the decoded bytes.
25+
*/
26+
export function fromHex(hex: string): Uint8Array {
27+
if (typeof hex !== 'string')
28+
throw new BytecodecError(
29+
'HEX_INPUT_EXPECTED',
30+
'fromHex expects a string input'
31+
)
32+
33+
if (hex.length % 2 !== 0)
34+
throw new BytecodecError(
35+
'HEX_INVALID_LENGTH',
36+
'Hex string must have an even length'
37+
)
38+
39+
const bytes = new Uint8Array(hex.length / 2)
40+
41+
for (let offset = 0; offset < hex.length; offset += 2) {
42+
const highCode = hex.charCodeAt(offset)
43+
const lowCode = hex.charCodeAt(offset + 1)
44+
const highNibble = highCode < 128 ? HEX_VALUES[highCode] : -1
45+
const lowNibble = lowCode < 128 ? HEX_VALUES[lowCode] : -1
46+
47+
if (highNibble === -1 || lowNibble === -1)
48+
throw new BytecodecError(
49+
'HEX_INVALID_CHARACTER',
50+
`Invalid hex character at index ${
51+
highNibble === -1 ? offset : offset + 1
52+
}`
53+
)
54+
55+
bytes[offset / 2] = (highNibble << 4) | lowNibble
56+
}
57+
58+
return bytes
59+
}

src/fromZ85String/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 { Z85_VALUES } from '../.helpers/index.js'
19+
20+
/**
21+
* Decodes a Z85 string into a new `Uint8Array`.
22+
*
23+
* @param z85String The Z85 string to decode.
24+
* @returns A new `Uint8Array` containing the decoded bytes.
25+
*/
26+
export function fromZ85String(z85String: string): Uint8Array {
27+
if (typeof z85String !== 'string')
28+
throw new BytecodecError(
29+
'Z85_INPUT_EXPECTED',
30+
'fromZ85String expects a string input'
31+
)
32+
33+
if (z85String.length % 5 !== 0)
34+
throw new BytecodecError(
35+
'Z85_INVALID_LENGTH',
36+
'Z85 string length must be divisible by 5'
37+
)
38+
39+
const bytes = new Uint8Array((z85String.length / 5) * 4)
40+
let byteOffset = 0
41+
42+
for (let blockOffset = 0; blockOffset < z85String.length; blockOffset += 5) {
43+
let value = 0
44+
45+
for (let digitOffset = 0; digitOffset < 5; digitOffset++) {
46+
const stringOffset = blockOffset + digitOffset
47+
const code = z85String.charCodeAt(stringOffset)
48+
const digit = code < 128 ? Z85_VALUES[code] : -1
49+
50+
if (digit === -1)
51+
throw new BytecodecError(
52+
'Z85_INVALID_CHARACTER',
53+
`Invalid Z85 character at index ${stringOffset}`
54+
)
55+
56+
value = value * 85 + digit
57+
}
58+
59+
if (value > 0xffffffff)
60+
throw new BytecodecError(
61+
'Z85_INVALID_BLOCK',
62+
`Invalid Z85 block at index ${blockOffset}`
63+
)
64+
65+
bytes[byteOffset++] = value >>> 24
66+
bytes[byteOffset++] = (value >>> 16) & 0xff
67+
bytes[byteOffset++] = (value >>> 8) & 0xff
68+
bytes[byteOffset++] = value & 0xff
69+
}
70+
71+
return bytes
72+
}

0 commit comments

Comments
 (0)