@ctrl/ts-base32 4.2.1 → 4.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/src/index.js +206 -55
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -29,6 +29,20 @@ console.log(uint8ArrayToString(base32Decode('ME======'))
|
|
|
29
29
|
// 'a'
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
### Benchmarks
|
|
33
|
+
|
|
34
|
+
64KB payload, Node.js, ops/s (higher is better):
|
|
35
|
+
[@exodus/bytes](https://github.com/ExodusOSS/bytes) uses nodejs `Buffer`, but is very fast.
|
|
36
|
+
|
|
37
|
+
| Benchmark | @ctrl/ts-base32 | @exodus/bytes | base32-encode/decode | @scure/base |
|
|
38
|
+
| ---------------- | --------------- | ------------- | -------------------- | ----------- |
|
|
39
|
+
| encode RFC4648 | 8,500 | 31,200 | 2,600 | 283 |
|
|
40
|
+
| encode Crockford | 8,500 | — | — | 332 |
|
|
41
|
+
| decode RFC4648 | 6,500 | 9,900 | 755 | 333 |
|
|
42
|
+
| decode Crockford | 6,700 | — | — | 387 |
|
|
43
|
+
|
|
44
|
+
Run `pnpm bench` to reproduce.
|
|
45
|
+
|
|
32
46
|
### See Also
|
|
33
47
|
|
|
34
48
|
base32-encode - https://github.com/LinusU/base32-encode
|
package/dist/src/index.js
CHANGED
|
@@ -2,95 +2,246 @@
|
|
|
2
2
|
const RFC4648 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
3
3
|
const RFC4648_HEX = '0123456789ABCDEFGHIJKLMNOPQRSTUV';
|
|
4
4
|
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// Pre-computed 2-char string table (32x32 = 1024 entries). Lets us emit 8 output
|
|
6
|
+
// chars per 5-byte input chunk with 4 string concats instead of 8. This is the
|
|
7
|
+
// fastest portable encode strategy — the only thing faster is Buffer.latin1Slice
|
|
8
|
+
// which is Node-specific.
|
|
9
|
+
function createEncodePairs(alphabet) {
|
|
10
|
+
const pairs = [];
|
|
11
|
+
for (let i = 0; i < 32; i++) {
|
|
12
|
+
for (let j = 0; j < 32; j++) {
|
|
13
|
+
pairs.push(alphabet[i] + alphabet[j]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return pairs;
|
|
17
|
+
}
|
|
18
|
+
// Single-char lookup for the trailing partial block (0-4 bytes) that doesn't
|
|
19
|
+
// fill a complete 5-byte chunk.
|
|
20
|
+
function createEncodeLookup(alphabet) {
|
|
21
|
+
const table = new Uint8Array(32);
|
|
22
|
+
for (let i = 0; i < 32; i++) {
|
|
23
|
+
table[i] = alphabet.charCodeAt(i);
|
|
24
|
+
}
|
|
25
|
+
return table;
|
|
26
|
+
}
|
|
27
|
+
let rfc4648EncodePairs;
|
|
28
|
+
let rfc4648HexEncodePairs;
|
|
29
|
+
let crockfordEncodePairs;
|
|
30
|
+
let rfc4648EncodeLookup;
|
|
31
|
+
let rfc4648HexEncodeLookup;
|
|
32
|
+
let crockfordEncodeLookup;
|
|
33
|
+
function getEncodePairs(variant) {
|
|
8
34
|
switch (variant) {
|
|
9
35
|
case 'RFC3548':
|
|
10
36
|
case 'RFC4648': {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
37
|
+
if (rfc4648EncodePairs === undefined) {
|
|
38
|
+
rfc4648EncodePairs = createEncodePairs(RFC4648);
|
|
39
|
+
}
|
|
40
|
+
return rfc4648EncodePairs;
|
|
14
41
|
}
|
|
15
42
|
case 'RFC4648-HEX': {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
43
|
+
if (rfc4648HexEncodePairs === undefined) {
|
|
44
|
+
rfc4648HexEncodePairs = createEncodePairs(RFC4648_HEX);
|
|
45
|
+
}
|
|
46
|
+
return rfc4648HexEncodePairs;
|
|
19
47
|
}
|
|
20
48
|
case 'Crockford': {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
49
|
+
if (crockfordEncodePairs === undefined) {
|
|
50
|
+
crockfordEncodePairs = createEncodePairs(CROCKFORD);
|
|
51
|
+
}
|
|
52
|
+
return crockfordEncodePairs;
|
|
24
53
|
}
|
|
25
54
|
default: {
|
|
26
55
|
throw new Error(`Unknown base32 variant: ${variant}`);
|
|
27
56
|
}
|
|
28
57
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
while (bits >= 5) {
|
|
39
|
-
output += alphabet[(value >>> (bits - 5)) & 31];
|
|
40
|
-
bits -= 5;
|
|
58
|
+
}
|
|
59
|
+
function getEncodeLookup(variant) {
|
|
60
|
+
switch (variant) {
|
|
61
|
+
case 'RFC3548':
|
|
62
|
+
case 'RFC4648': {
|
|
63
|
+
if (rfc4648EncodeLookup === undefined) {
|
|
64
|
+
rfc4648EncodeLookup = createEncodeLookup(RFC4648);
|
|
65
|
+
}
|
|
66
|
+
return rfc4648EncodeLookup;
|
|
41
67
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
68
|
+
case 'RFC4648-HEX': {
|
|
69
|
+
if (rfc4648HexEncodeLookup === undefined) {
|
|
70
|
+
rfc4648HexEncodeLookup = createEncodeLookup(RFC4648_HEX);
|
|
71
|
+
}
|
|
72
|
+
return rfc4648HexEncodeLookup;
|
|
73
|
+
}
|
|
74
|
+
case 'Crockford': {
|
|
75
|
+
if (crockfordEncodeLookup === undefined) {
|
|
76
|
+
crockfordEncodeLookup = createEncodeLookup(CROCKFORD);
|
|
77
|
+
}
|
|
78
|
+
return crockfordEncodeLookup;
|
|
79
|
+
}
|
|
80
|
+
default: {
|
|
81
|
+
throw new Error(`Unknown base32 variant: ${variant}`);
|
|
49
82
|
}
|
|
50
83
|
}
|
|
51
|
-
return output;
|
|
52
84
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
85
|
+
// Decode lookup: charCode → 5-bit value. Int8Array so -1 sentinel propagates
|
|
86
|
+
// through bitwise OR, letting us batch-validate 4 lookups with a single sign check.
|
|
87
|
+
// Includes lowercase mappings (a-z → same as A-Z) to avoid toUpperCase() calls.
|
|
88
|
+
function createDecodeLookup(alphabet, crockfordAliases) {
|
|
89
|
+
const table = new Int8Array(128);
|
|
90
|
+
table.fill(-1);
|
|
91
|
+
for (let i = 0; i < alphabet.length; i++) {
|
|
92
|
+
const code = alphabet.charCodeAt(i);
|
|
93
|
+
table[code] = i;
|
|
94
|
+
if (code >= 65 && code <= 90) {
|
|
95
|
+
table[code + 32] = i;
|
|
96
|
+
}
|
|
57
97
|
}
|
|
58
|
-
|
|
98
|
+
// Crockford treats O as 0 and I/L as 1 — bake these aliases into the table
|
|
99
|
+
// so we never need replace() calls on the input string.
|
|
100
|
+
if (crockfordAliases) {
|
|
101
|
+
table[79] = 0; // O
|
|
102
|
+
table[111] = 0; // o
|
|
103
|
+
table[73] = 1; // I
|
|
104
|
+
table[105] = 1; // i
|
|
105
|
+
table[76] = 1; // L
|
|
106
|
+
table[108] = 1; // l
|
|
107
|
+
}
|
|
108
|
+
return table;
|
|
59
109
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
110
|
+
let rfc4648Lookup;
|
|
111
|
+
let rfc4648HexLookup;
|
|
112
|
+
let crockfordLookup;
|
|
113
|
+
function getDecodeLookup(variant) {
|
|
63
114
|
switch (variant) {
|
|
64
115
|
case 'RFC3548':
|
|
65
116
|
case 'RFC4648': {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
117
|
+
if (rfc4648Lookup === undefined) {
|
|
118
|
+
rfc4648Lookup = createDecodeLookup(RFC4648, false);
|
|
119
|
+
}
|
|
120
|
+
return rfc4648Lookup;
|
|
69
121
|
}
|
|
70
122
|
case 'RFC4648-HEX': {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
123
|
+
if (rfc4648HexLookup === undefined) {
|
|
124
|
+
rfc4648HexLookup = createDecodeLookup(RFC4648_HEX, false);
|
|
125
|
+
}
|
|
126
|
+
return rfc4648HexLookup;
|
|
74
127
|
}
|
|
75
128
|
case 'Crockford': {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
129
|
+
if (crockfordLookup === undefined) {
|
|
130
|
+
crockfordLookup = createDecodeLookup(CROCKFORD, true);
|
|
131
|
+
}
|
|
132
|
+
return crockfordLookup;
|
|
79
133
|
}
|
|
80
134
|
default: {
|
|
81
135
|
throw new Error(`Unknown base32 variant: ${variant}`);
|
|
82
136
|
}
|
|
83
137
|
}
|
|
84
|
-
|
|
138
|
+
}
|
|
139
|
+
// Indexed by trailing byte count (0-4) after full 5-byte chunks.
|
|
140
|
+
const PADDING_CHARS = ['', '======', '====', '===', '='];
|
|
141
|
+
export function base32Encode(input, variant = 'RFC4648', options = {}) {
|
|
142
|
+
const pairs = getEncodePairs(variant);
|
|
143
|
+
const encodeLookup = getEncodeLookup(variant);
|
|
144
|
+
const defaultPadding = variant !== 'Crockford';
|
|
145
|
+
const padding = options.padding ?? defaultPadding;
|
|
146
|
+
const length = input.byteLength;
|
|
147
|
+
const fullChunks = Math.floor(length / 5);
|
|
148
|
+
const fullChunksBytes = fullChunks * 5;
|
|
149
|
+
let o = '';
|
|
150
|
+
let i = 0;
|
|
151
|
+
// Fast path: process 5 input bytes → 8 output chars using the pair table.
|
|
152
|
+
// Each 5-byte chunk yields four 10-bit indices into the 1024-entry pairs array.
|
|
153
|
+
for (; i < fullChunksBytes; i += 5) {
|
|
154
|
+
const a = input[i];
|
|
155
|
+
const b = input[i + 1];
|
|
156
|
+
const c = input[i + 2];
|
|
157
|
+
const d = input[i + 3];
|
|
158
|
+
const e = input[i + 4];
|
|
159
|
+
const x0 = (a << 2) | (b >> 6);
|
|
160
|
+
const x1 = ((b & 0x3f) << 4) | (c >> 4);
|
|
161
|
+
const x2 = ((c & 0xf) << 6) | (d >> 2);
|
|
162
|
+
const x3 = ((d & 0x3) << 8) | e;
|
|
163
|
+
o += pairs[x0];
|
|
164
|
+
o += pairs[x1];
|
|
165
|
+
o += pairs[x2];
|
|
166
|
+
o += pairs[x3];
|
|
167
|
+
}
|
|
168
|
+
// Slow path: remaining 1-4 bytes, emit one char at a time.
|
|
169
|
+
const remaining = length - fullChunksBytes;
|
|
170
|
+
if (remaining > 0) {
|
|
171
|
+
let bits = 0;
|
|
172
|
+
let value = 0;
|
|
173
|
+
for (; i < length; i++) {
|
|
174
|
+
value = (value << 8) | input[i];
|
|
175
|
+
bits += 8;
|
|
176
|
+
while (bits >= 5) {
|
|
177
|
+
o += String.fromCharCode(encodeLookup[(value >>> (bits - 5)) & 31]);
|
|
178
|
+
bits -= 5;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (bits > 0) {
|
|
182
|
+
o += String.fromCharCode(encodeLookup[(value << (5 - bits)) & 31]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (padding) {
|
|
186
|
+
o += PADDING_CHARS[remaining];
|
|
187
|
+
}
|
|
188
|
+
return o;
|
|
189
|
+
}
|
|
190
|
+
function readChar(table, charCode) {
|
|
191
|
+
const idx = charCode < 128 ? table[charCode] : -1;
|
|
192
|
+
if (idx === -1) {
|
|
193
|
+
throw new Error(`Invalid character found: ${String.fromCharCode(charCode)}`);
|
|
194
|
+
}
|
|
195
|
+
return idx;
|
|
196
|
+
}
|
|
197
|
+
export function base32Decode(input, variant = 'RFC4648') {
|
|
198
|
+
const m = getDecodeLookup(variant);
|
|
199
|
+
// Strip trailing '=' padding with a charCodeAt loop instead of replace().
|
|
200
|
+
let end = input.length;
|
|
201
|
+
if (variant !== 'Crockford') {
|
|
202
|
+
while (end > 0 && input.charCodeAt(end - 1) === 61) {
|
|
203
|
+
end--;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const tailLength = end % 8;
|
|
207
|
+
const mainLength = end - tailLength;
|
|
208
|
+
const output = new Uint8Array(Math.trunc((end * 5) / 8));
|
|
209
|
+
let at = 0;
|
|
210
|
+
// Fast path: process 8 input chars → 5 output bytes. Packs two groups of 4
|
|
211
|
+
// lookups into 20-bit integers, then extracts 5 bytes. The Int8Array table
|
|
212
|
+
// makes any -1 (invalid char) propagate through the OR chain, so a single
|
|
213
|
+
// sign check covers all 4 lookups.
|
|
214
|
+
for (let i = 0; i < mainLength; i += 8) {
|
|
215
|
+
const x0 = input.charCodeAt(i);
|
|
216
|
+
const x1 = input.charCodeAt(i + 1);
|
|
217
|
+
const x2 = input.charCodeAt(i + 2);
|
|
218
|
+
const x3 = input.charCodeAt(i + 3);
|
|
219
|
+
const x4 = input.charCodeAt(i + 4);
|
|
220
|
+
const x5 = input.charCodeAt(i + 5);
|
|
221
|
+
const x6 = input.charCodeAt(i + 6);
|
|
222
|
+
const x7 = input.charCodeAt(i + 7);
|
|
223
|
+
const a = (m[x0] << 15) | (m[x1] << 10) | (m[x2] << 5) | m[x3];
|
|
224
|
+
const b = (m[x4] << 15) | (m[x5] << 10) | (m[x6] << 5) | m[x7];
|
|
225
|
+
if (a < 0 || b < 0) {
|
|
226
|
+
for (let j = i; j < i + 8; j++) {
|
|
227
|
+
readChar(m, input.charCodeAt(j));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
output[at] = a >> 12;
|
|
231
|
+
output[at + 1] = (a >> 4) & 0xff;
|
|
232
|
+
output[at + 2] = ((a << 4) & 0xff) | (b >> 16);
|
|
233
|
+
output[at + 3] = (b >> 8) & 0xff;
|
|
234
|
+
output[at + 4] = b & 0xff;
|
|
235
|
+
at += 5;
|
|
236
|
+
}
|
|
237
|
+
// Slow path: remaining 0-7 chars.
|
|
85
238
|
let bits = 0;
|
|
86
239
|
let value = 0;
|
|
87
|
-
let
|
|
88
|
-
|
|
89
|
-
for (let i = 0; i < length; i++) {
|
|
90
|
-
value = (value << 5) | readChar(alphabet, cleanedInput[i]);
|
|
240
|
+
for (let i = mainLength; i < end; i++) {
|
|
241
|
+
value = (value << 5) | readChar(m, input.charCodeAt(i));
|
|
91
242
|
bits += 5;
|
|
92
243
|
if (bits >= 8) {
|
|
93
|
-
output[
|
|
244
|
+
output[at++] = (value >>> (bits - 8)) & 255;
|
|
94
245
|
bits -= 8;
|
|
95
246
|
}
|
|
96
247
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ctrl/ts-base32",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.2",
|
|
4
4
|
"description": "Base32 encoder/decoder with support for multiple variants",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"base32",
|
|
@@ -30,19 +30,25 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"demo:build": "pnpm run -r build",
|
|
32
32
|
"demo:watch": "pnpm run -r dev",
|
|
33
|
-
"lint": "
|
|
34
|
-
"lint:fix": "
|
|
33
|
+
"lint": "oxlint . && oxfmt --check",
|
|
34
|
+
"lint:fix": "oxlint . --fix && oxfmt",
|
|
35
35
|
"prepare": "npm run build",
|
|
36
36
|
"build": "tsc",
|
|
37
|
+
"bench": "pnpm build && node test/benchmark.mjs",
|
|
37
38
|
"test": "vitest run",
|
|
38
39
|
"test:watch": "vitest"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@ctrl/oxlint-config": "1.4.0",
|
|
43
|
+
"@exodus/bytes": "^1.14.1",
|
|
44
|
+
"@scure/base": "^2.0.0",
|
|
42
45
|
"@sindresorhus/tsconfig": "8.1.0",
|
|
43
46
|
"@types/node": "25.2.3",
|
|
44
|
-
"
|
|
45
|
-
"
|
|
47
|
+
"base32-decode": "^1.0.0",
|
|
48
|
+
"base32-encode": "^2.0.0",
|
|
49
|
+
"oxfmt": "0.33.0",
|
|
50
|
+
"oxlint": "1.48.0",
|
|
51
|
+
"tinybench": "^6.0.0",
|
|
46
52
|
"typescript": "5.9.3",
|
|
47
53
|
"uint8array-extras": "1.5.0",
|
|
48
54
|
"vitest": "4.0.18"
|