@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.
Files changed (3) hide show
  1. package/README.md +14 -0
  2. package/dist/src/index.js +206 -55
  3. 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
- export function base32Encode(input, variant = 'RFC4648', options = {}) {
6
- let alphabet;
7
- let defaultPadding;
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
- alphabet = RFC4648;
12
- defaultPadding = true;
13
- break;
37
+ if (rfc4648EncodePairs === undefined) {
38
+ rfc4648EncodePairs = createEncodePairs(RFC4648);
39
+ }
40
+ return rfc4648EncodePairs;
14
41
  }
15
42
  case 'RFC4648-HEX': {
16
- alphabet = RFC4648_HEX;
17
- defaultPadding = true;
18
- break;
43
+ if (rfc4648HexEncodePairs === undefined) {
44
+ rfc4648HexEncodePairs = createEncodePairs(RFC4648_HEX);
45
+ }
46
+ return rfc4648HexEncodePairs;
19
47
  }
20
48
  case 'Crockford': {
21
- alphabet = CROCKFORD;
22
- defaultPadding = false;
23
- break;
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
- const padding = options.padding ?? defaultPadding;
30
- const length = input.byteLength;
31
- const view = new Uint8Array(input);
32
- let bits = 0;
33
- let value = 0;
34
- let output = '';
35
- for (let i = 0; i < length; i++) {
36
- value = (value << 8) | view[i];
37
- bits += 8;
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
- if (bits > 0) {
44
- output += alphabet[(value << (5 - bits)) & 31];
45
- }
46
- if (padding) {
47
- while (output.length % 8 !== 0) {
48
- output += '=';
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
- function readChar(alphabet, char) {
54
- const idx = alphabet.indexOf(char);
55
- if (idx === -1) {
56
- throw new Error(`Invalid character found: ${char}`);
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
- return idx;
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
- export function base32Decode(input, variant = 'RFC4648') {
61
- let alphabet;
62
- let cleanedInput;
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
- alphabet = RFC4648;
67
- cleanedInput = input.toUpperCase().replace(/=+$/, '');
68
- break;
117
+ if (rfc4648Lookup === undefined) {
118
+ rfc4648Lookup = createDecodeLookup(RFC4648, false);
119
+ }
120
+ return rfc4648Lookup;
69
121
  }
70
122
  case 'RFC4648-HEX': {
71
- alphabet = RFC4648_HEX;
72
- cleanedInput = input.toUpperCase().replace(/=+$/, '');
73
- break;
123
+ if (rfc4648HexLookup === undefined) {
124
+ rfc4648HexLookup = createDecodeLookup(RFC4648_HEX, false);
125
+ }
126
+ return rfc4648HexLookup;
74
127
  }
75
128
  case 'Crockford': {
76
- alphabet = CROCKFORD;
77
- cleanedInput = input.toUpperCase().replace(/O/g, '0').replace(/[IL]/g, '1');
78
- break;
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
- const { length } = cleanedInput;
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 index = 0;
88
- const output = new Uint8Array(Math.trunc((length * 5) / 8));
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[index++] = (value >>> (bits - 8)) & 255;
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.1",
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": "oxfmt --check && oxlint .",
34
- "lint:fix": "oxfmt && oxlint . --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
- "oxfmt": "0.32.0",
45
- "oxlint": "1.47.0",
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"