Skip to content

Commit d70bfc0

Browse files
committed
encode / decode transform functions
1 parent bbaa23d commit d70bfc0

File tree

10 files changed

+257
-32
lines changed

10 files changed

+257
-32
lines changed

.vscode/settings.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,37 @@
1010
"alphanum",
1111
"ATCG",
1212
"bitauth",
13+
"charsets",
1314
"Crockford",
15+
"DÎÑG",
16+
"dîñgø",
1417
"dîngøsky",
1518
"dingoskyme",
16-
"FFTFTTFFTFTTFFTT",
19+
"Dyîdkø",
1720
"GACGGTCG",
1821
"insgkskn",
1922
"îøsîndøk",
2023
"kiyooodd",
2124
"KPGS",
2225
"libauth",
2326
"PQIB",
27+
"PRNG",
2428
"Puid",
2529
"qbhujm",
2630
"TBHY",
2731
"TTACCCAC",
28-
"TTTTTFTTFFFFFTFF",
2932
"TWQZAA",
33+
"uuidv",
3034
"ydkîsnsd"
35+
],
36+
"cSpell.ignoreRegExpList": [
37+
"\\b0x[0-9A-Fa-f]{2}\\b",
38+
"(['\"][A-Za-z0-9_-]{16,}['\"])",
39+
"\\b[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}\\b",
40+
"https?://[^\\s)]+",
41+
"\\b[A-Za-z0-9+/]{16,}={0,2}\\b",
42+
"\\b[A-Za-z0-9_-]{12,}\\b",
43+
"\\b[TF]{5,}\\b",
44+
"new\\s+Uint8Array\\s*\\(\\s*\\[(?:\\s*0x[0-9A-Fa-f]{2}\\s*,?\\s*)+\\]\\s*\\)"
3145
]
3246
}

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,43 @@ The optional `PuidConfig` object has the following fields:
219219
- `entropyBytes`: `crypto.randomBytes`
220220

221221
#### `generator` API
222-
The `puid` generator function includes:
222+
The `puid` generator function provides:
223+
- `decode` function that accepts a valid `puid` returns bytes
224+
- `encode` function that accepts `bytes` and returns the `puid`
223225
- `info` field that displays generator configuration
224226
- `risk/1` function that approximates the `risk` of a repeat given a `total` number of IDs
225227
- `total/1` function that approximates the `total` possible IDs for a given `risk`
226228

229+
- `decode`
230+
Given a valid `puid`, returns `bytes` sufficient to generate that `puid`.
231+
Since the actual **bits** for a `puid` may not fall on a byte boundary, the returned `bytes` value
232+
is zero padded to the right if necessary. The call fails if the provided string arg is not a
233+
valid puid for the generator.
234+
235+
Example:
236+
237+
```js
238+
const { Chars, puid } = require('puid-js')
239+
240+
const { generator: alphaId } = puid({bits: 64, chars: Chars.Alpha})
241+
alphaId.decode('hYrenrGOImyl')
242+
// => Uint8Array(9) [133, 138, 222, 158, 177, 142, 34, 108, 165]
243+
```
244+
245+
- `encode`
246+
Given sufficient `bytes`, generates a `puid`.
247+
The provided `bytes` must be sufficient to generate a `puid` for the generator.
248+
249+
Example:
250+
251+
```js
252+
const { Chars, puid } = require('puid-js')
253+
254+
const { generator: alphaId } = puid({bits: 64, chars: Chars.Alpha})
255+
alphaId.encode([133, 138, 222, 158, 177, 142, 34, 108, 165])
256+
// => 'hYrenrGOImyl'
257+
```
258+
227259
- `info`
228260
- `bits`: ID entropy
229261
- `bitsPerChar`: Entropy bits per ID character

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { Chars, validChars } from './lib/chars'
33
export { bitsForLen, entropyBits, entropyBitsPerChar, lenForBits } from './lib/entropy'
44
export { default as puid } from './lib/puid'
55
export { default as generate } from './generate'
6+
export { encode, decode } from './lib/encoder/transformer'

src/lib/bits.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const pow2 = (n: number): number => Math.pow(2, n)
1111
const isPow2 = (n: number): boolean => pow2(round(log2(n))) === n
1212
const isBitZero = (n: number, bit: number): boolean => (n & (1 << (bit - 1))) === 0
1313

14-
type AcceptValue = readonly [accept: boolean, shift: number]
1514
type BitShift = readonly [number, number]
1615
type BitShifts = readonly BitShift[]
1716

@@ -61,6 +60,18 @@ const bitShifts = (chars: string): BitShifts => {
6160
return shifts
6261
}
6362

63+
const acceptValueFor = (chars: string) => {
64+
const nBitsPerChar = bitsPerChar(chars)
65+
const nChars = chars.length
66+
const shifts = bitShifts(chars)
67+
return (value: number): readonly [boolean, number] => {
68+
if (value < nChars) return [true, nBitsPerChar]
69+
const bitShift = shifts.find((bs) => value <= bs[0])
70+
const shift = (bitShift && bitShift[1]) || nBitsPerChar
71+
return [false, shift]
72+
}
73+
}
74+
6475
const entropyByBytes = (skipBytes: number, entropyBuffer: ArrayBuffer, sourceBytes: EntropyByBytes) => {
6576
const entropyBytes = new Uint8Array(entropyBuffer)
6677
const bytesLen = entropyBytes.length
@@ -109,11 +120,9 @@ const fillEntropy = (entropyOffset: number, entropyBuffer: ArrayBuffer, entropyF
109120
entropyBytes.set(unusedBytes)
110121

111122
// Fill right bytes with new random values
112-
if (byValues) {
113-
entropyByValues(nUnusedBytes, entropyBuffer, source)
114-
} else {
115-
entropyByBytes(nUnusedBytes, entropyBuffer, source)
116-
}
123+
byValues
124+
? entropyByValues(nUnusedBytes, entropyBuffer, source)
125+
: entropyByBytes(nUnusedBytes, entropyBuffer, source)
117126
}
118127

119128
return entropyOffset % 8
@@ -166,21 +175,8 @@ export default (puidLen: number, puidChars: string, entropyFunction: EntropyFunc
166175
}
167176

168177
// When chars count not a power of 2, sliced bits may yield an invalid value
169-
170178
const nEntropyBits = 8 * entropyBytes.length
171-
const puidShifts = bitShifts(puidChars)
172-
173-
const acceptValue = (value: number): AcceptValue => {
174-
// Value is valid if it is less than the number of characters
175-
if (value < nChars) {
176-
return [true, nBitsPerChar]
177-
}
178-
179-
// For invalid value, shift the minimal bits necessary to determine validity
180-
const bitShift = puidShifts.find((bs) => value <= bs[0])
181-
const shift = bitShift && bitShift[1]
182-
return [false, shift || nBitsPerChar]
183-
}
179+
const acceptValue = acceptValueFor(puidChars)
184180

185181
// Slice value from entropy bytes
186182
const sliceValue = () => {
@@ -205,4 +201,4 @@ export default (puidLen: number, puidChars: string, entropyFunction: EntropyFunc
205201
return () => String.fromCharCode(...mapper.map(() => sliceValue()))
206202
}
207203

208-
export { bitShifts }
204+
export { bitShifts, valueAt, bitsPerChar, isPow2, acceptValueFor }
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import test from 'ava'
2+
3+
import { Chars } from '../chars'
4+
5+
import { decode, encode } from './transformer'
6+
7+
test('encode/decode AlphaNum', (t) => {
8+
const bytes = new Uint8Array([141, 138, 2, 168, 7, 11, 13, 0])
9+
const puid = encode(Chars.AlphaLower, bytes)
10+
t.is(puid, 'rwfafkahbmgq')
11+
t.deepEqual(decode(Chars.AlphaLower, puid), bytes)
12+
})
13+
14+
test('encode/decode Base32', (t) => {
15+
const bytes = new Uint8Array([0xd2, 0xe3, 0xe9, 0xda, 0x19, 0x00, 0x22])
16+
const puid = encode(Chars.Base32, bytes)
17+
t.true(puid.startsWith('2LR6TWQZAA'))
18+
t.deepEqual(decode(Chars.Base32, puid), bytes)
19+
})
20+
21+
test('encode/decode HexUpper', (t) => {
22+
const bytes = new Uint8Array([0xc7, 0xc9, 0x00, 0x2a, 0x16, 0x32])
23+
const puid = encode(Chars.HexUpper, bytes)
24+
t.is(puid, 'C7C9002A1632')
25+
t.deepEqual(decode(Chars.HexUpper, puid), bytes)
26+
})
27+
28+
test('encode/decode Safe32', (t) => {
29+
const bytes = new Uint8Array([0xd2, 0xe3, 0xe9, 0xda, 0x19, 0x03, 0xb7, 0x30])
30+
const puid = 'MhrRBGqL2nNB'
31+
t.is(encode(Chars.Safe32, bytes), puid)
32+
t.deepEqual(decode(Chars.Safe32, puid), bytes)
33+
})
34+
35+
test('encode/decode Safe64', (t) => {
36+
const bytes = new Uint8Array([
37+
0x09, 0x25, 0x84, 0x3c, 0xbd, 0xc0, 0x89, 0xeb, 0x61, 0x75, 0x81, 0x65, 0x09, 0xb4, 0x9a, 0x54, 0x20
38+
])
39+
const puid = 'CSWEPL3AiethdYFlCbSaVC'
40+
t.is(encode(Chars.Safe64, bytes), puid)
41+
t.deepEqual(decode(Chars.Safe64, puid), bytes)
42+
})

src/lib/encoder/transformer.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { acceptValueFor, bitsPerChar, valueAt } from '../bits'
2+
import puidEncoder from '../puidEncoder'
3+
4+
// Named export so we can add decode later
5+
/**
6+
* Encode bytes into a string using the provided character set.
7+
*
8+
* ### Example (es module)
9+
* ```js
10+
* import { Chars, encode } from 'puid-js'
11+
*
12+
* const bytes = new Uint8Array([
13+
* 0x09, 0x25, 0x84, 0x3c, 0xbd, 0xc0, 0x89, 0xeb, 0x61, 0x75, 0x81, 0x65,
14+
* 0x09, 0xb4, 0x9a, 0x54, 0x20
15+
* ])
16+
* encode(Chars.Safe64, bytes)
17+
* // => 'CSWEPL3AiethdYFlCbSaVC'
18+
* ```
19+
*
20+
* ### Example (commonjs)
21+
* ```js
22+
* const { Chars, encode } = require('puid-js')
23+
*
24+
* const bytes = new Uint8Array([
25+
* 0x09, 0x25, 0x84, 0x3c, 0xbd, 0xc0, 0x89, 0xeb, 0x61, 0x75, 0x81, 0x65,
26+
* 0x09, 0xb4, 0x9a, 0x54, 0x20
27+
* ])
28+
* encode(Chars.Safe64, bytes)
29+
* // => 'CSWEPL3AiethdYFlCbSaVC'
30+
* ```
31+
*/
32+
const encode = (chars: string, bytes: Uint8Array): string => {
33+
const nBitsPerChar = bitsPerChar(chars)
34+
const nEntropyBits = 8 * bytes.length
35+
if (nEntropyBits === 0) return ''
36+
37+
const encoder = puidEncoder(chars)
38+
// Use rejection sampling path for all charsets
39+
const acceptValue = acceptValueFor(chars)
40+
41+
let offset = 0
42+
const codes: number[] = []
43+
while (offset + nBitsPerChar <= nEntropyBits) {
44+
const v = valueAt(offset, nBitsPerChar, bytes)
45+
const [accept, shift] = acceptValue(v)
46+
offset += shift
47+
if (accept) codes.push(encoder(v))
48+
}
49+
return String.fromCharCode(...codes)
50+
}
51+
52+
/**
53+
* Decode a string of characters back into bytes using the provided character set.
54+
* Pads the final partial byte with zeros if the bit-length is not a multiple of 8.
55+
*
56+
* ### Example (es module)
57+
* ```js
58+
* import { Chars, decode } from 'puid-js'
59+
*
60+
* const text = 'CSWEPL3AiethdYFlCbSaVC'
61+
* const bytes = decode(Chars.Safe64, text)
62+
* // => new Uint8Array([
63+
* 0x09, 0x25, 0x84, 0x3c, 0xbd, 0xc0, 0x89, 0xeb, 0x61, 0x75, 0x81, 0x65,
64+
* 0x09, 0xb4, 0x9a, 0x54, 0x20
65+
* ])
66+
* ```
67+
*
68+
* ### Example (commonjs)
69+
* ```js
70+
* const { Chars, decode } = require('puid-js')
71+
*
72+
* const text = 'CSWEPL3AiethdYFlCbSaVC'
73+
* const bytes = decode(Chars.Safe64, text)
74+
* // => new Uint8Array([
75+
* 0x09, 0x25, 0x84, 0x3c, 0xbd, 0xc0, 0x89, 0xeb, 0x61, 0x75, 0x81, 0x65,
76+
* 0x09, 0xb4, 0x9a, 0x54, 0x20
77+
* ])
78+
* ```
79+
*/
80+
const decode = (chars: string, text: string): Uint8Array => {
81+
const nBitsPerChar = bitsPerChar(chars)
82+
if (text.length === 0) return new Uint8Array(0)
83+
84+
// Map charCode -> value index
85+
const map = new Map<number, number>()
86+
for (let i = 0; i < chars.length; i++) {
87+
map.set(chars.charCodeAt(i), i)
88+
}
89+
90+
let acc = 0
91+
let accBits = 0
92+
const out: number[] = []
93+
94+
for (let i = 0; i < text.length; i++) {
95+
const code = text.charCodeAt(i)
96+
const val = map.get(code)
97+
if (val === undefined) throw new Error('Invalid character for charset')
98+
99+
acc = (acc << nBitsPerChar) | val
100+
accBits += nBitsPerChar
101+
102+
while (accBits >= 8) {
103+
const shift = accBits - 8
104+
const byte = (acc >> shift) & 0xff
105+
out.push(byte)
106+
accBits -= 8
107+
acc = acc & ((1 << accBits) - 1)
108+
}
109+
}
110+
111+
if (accBits > 0) {
112+
// Pad remaining bits with zeros on the right
113+
out.push((acc << (8 - accBits)) & 0xff)
114+
}
115+
116+
return new Uint8Array(out)
117+
}
118+
119+
export { encode, decode }

src/lib/puid.spec.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,13 @@ const puidGenerator = (config?: PuidConfig): Puid => {
5656
ere: -1,
5757
length: -1
5858
}
59-
cxError.risk = () => -1
60-
cxError.total = () => -1
59+
/* eslint-disable @typescript-eslint/no-unused-vars */
60+
cxError.risk = (_total: number) => -1
61+
cxError.total = (_risk: number) => -1
62+
cxError.encode = (_bytes: Uint8Array) => 'CxError'
63+
cxError.decode = (_puid: string) => new Uint8Array()
6164
return cxError
65+
/* eslint-enable @typescript-eslint/no-unused-vars */
6266
}
6367

6468
test('puid default', (t) => {
@@ -77,6 +81,13 @@ test('puid default', (t) => {
7781
t.is(length, 22)
7882
t.is(randId().length, length)
7983

84+
const bytes = new Uint8Array([
85+
0xfd, 0xd8, 0xa7, 0x82, 0x8f, 0xac, 0x93, 0x2f, 0xff, 0x0c, 0x83, 0x46, 0x3b, 0xe4, 0x8a, 0x63, 0xf0
86+
])
87+
const puid = '_dingo-sky__DINGO-SKY_'
88+
t.is(randId.encode(bytes), puid)
89+
t.deepEqual(randId.decode(puid), bytes)
90+
8091
t.is(Math.round(randId.risk(1e15)), 10889035741)
8192
t.is(Math.round(randId.total(1e15)), 3299853896989)
8293
})

src/lib/puid.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EntropyFunction, Puid, PuidConfig, PuidResult } from '../types/puid'
55
import muncher from './bits'
66
import { byteLength } from './byteLength'
77
import { Chars, charsName, validChars } from './chars'
8+
import { decode, encode } from './encoder/transformer'
89
import { entropyBits, entropyBitsPerChar, entropyRisk, entropyTotal } from './entropy'
910

1011
const round2 = (f: number): number => round(f * 100) / 100
@@ -101,9 +102,8 @@ export default (puidConfig: PuidConfig = {}): PuidResult => {
101102

102103
const puid: Puid = (): string => bitsMuncher()
103104

104-
const effectiveBits = puidBitsPerChar * puidLen
105-
puid.risk = (total: number): number => entropyRisk(effectiveBits, total)
106-
puid.total = (risk: number): number => entropyTotal(effectiveBits, risk)
105+
puid.decode = (text: string): Uint8Array => decode(puidChars, text)
106+
puid.encode = (bytes: Uint8Array): string => encode(puidChars, bytes)
107107

108108
puid.info = Object.freeze({
109109
bits: round2(puidBitsPerChar * puidLen),
@@ -114,5 +114,9 @@ export default (puidConfig: PuidConfig = {}): PuidResult => {
114114
length: puidLen
115115
})
116116

117+
const effectiveBits = puidBitsPerChar * puidLen
118+
puid.risk = (total: number): number => entropyRisk(effectiveBits, total)
119+
puid.total = (risk: number): number => entropyTotal(effectiveBits, risk)
120+
117121
return { generator: puid }
118122
}

0 commit comments

Comments
 (0)