uniku 0.0.11 → 0.0.13
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 +17 -12
- package/build/{bytes-BBjKLOBR.mjs → bytes-NRtBOHK5.mjs} +1 -1
- package/build/{bytes-BBjKLOBR.mjs.map → bytes-NRtBOHK5.mjs.map} +1 -1
- package/build/cuid2/cuid2.d.mts.map +1 -1
- package/build/cuid2/cuid2.mjs +1 -1
- package/build/cuid2/cuid2.mjs.map +1 -1
- package/build/ksuid/ksuid.d.mts.map +1 -1
- package/build/ksuid/ksuid.mjs +1 -1
- package/build/ksuid/ksuid.mjs.map +1 -1
- package/build/nanoid/nanoid.d.mts.map +1 -1
- package/build/nanoid/nanoid.mjs.map +1 -1
- package/build/random-CWCVvNLb.mjs +2 -0
- package/build/random-CWCVvNLb.mjs.map +1 -0
- package/build/ulid/ulid.d.mts.map +1 -1
- package/build/ulid/ulid.mjs +1 -1
- package/build/ulid/ulid.mjs.map +1 -1
- package/build/uuid/v4.mjs +1 -1
- package/build/uuid/v7.d.mts.map +1 -1
- package/build/uuid/v7.mjs +1 -1
- package/build/uuid/v7.mjs.map +1 -1
- package/package.json +13 -30
- package/src/common/bytes.ts +59 -0
- package/src/common/random.ts +66 -0
- package/src/cuid2/cuid2.ts +211 -0
- package/src/errors.ts +56 -0
- package/src/ksuid/base62.ts +103 -0
- package/src/ksuid/ksuid.ts +195 -0
- package/src/nanoid/nanoid.ts +248 -0
- package/src/ulid/crockford.ts +249 -0
- package/src/ulid/ulid.ts +184 -0
- package/src/uuid/common/hex.ts +24 -0
- package/src/uuid/common/uuid.ts +157 -0
- package/src/uuid/v4.ts +119 -0
- package/src/uuid/v7.ts +182 -0
- package/build/random-DcKA4vF7.mjs +0 -2
- package/build/random-DcKA4vF7.mjs.map +0 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { writeTimestamp32 } from '../common/bytes'
|
|
2
|
+
import { rng } from '../common/random'
|
|
3
|
+
import { BufferError, InvalidInputError } from '../errors'
|
|
4
|
+
import { decodeBase62, encodeBase62 } from './base62'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* KSUID (K-Sortable Unique Identifier)
|
|
8
|
+
*
|
|
9
|
+
* A 160-bit identifier consisting of:
|
|
10
|
+
* - 4 bytes: timestamp (seconds since KSUID epoch: May 13, 2014)
|
|
11
|
+
* - 16 bytes: cryptographically random payload
|
|
12
|
+
*
|
|
13
|
+
* Encoded as a 27-character Base62 string.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// KSUID epoch: May 13, 2014 00:00:00 UTC (Unix timestamp in seconds)
|
|
17
|
+
const KSUID_EPOCH = 1400000000
|
|
18
|
+
|
|
19
|
+
const KSUID_BYTES = 20
|
|
20
|
+
const KSUID_STRING_LEN = 27
|
|
21
|
+
const TIMESTAMP_BYTES = 4
|
|
22
|
+
const PAYLOAD_BYTES = 16
|
|
23
|
+
const KSUID_MAX_SECS = KSUID_EPOCH + 0xffffffff
|
|
24
|
+
// 2^160 - 1 in Base62 ('aWgEPTl1tmebfsQzFP4bxwgy80V'), derived from the
|
|
25
|
+
// encoder so it cannot drift from the decoder's 160-bit overflow bound.
|
|
26
|
+
const KSUID_MAX_STRING = encodeBase62(new Uint8Array(KSUID_BYTES).fill(0xff))
|
|
27
|
+
|
|
28
|
+
// Validation regex: 27 alphanumeric characters
|
|
29
|
+
// Note: Both cases are valid Base62 characters, but they decode to different values
|
|
30
|
+
// (e.g., 'A' = 10, 'a' = 36). The regex validates format, not semantic equivalence.
|
|
31
|
+
const KSUID_REGEX = /^[0-9A-Za-z]{27}$/
|
|
32
|
+
|
|
33
|
+
export type KsuidOptions = {
|
|
34
|
+
/**
|
|
35
|
+
* 16 bytes of random data to use for KSUID payload.
|
|
36
|
+
*/
|
|
37
|
+
random?: Uint8Array
|
|
38
|
+
/**
|
|
39
|
+
* Timestamp in seconds since Unix epoch.
|
|
40
|
+
* Defaults to Math.floor(Date.now() / 1000).
|
|
41
|
+
* KSUID natively uses second precision.
|
|
42
|
+
*/
|
|
43
|
+
secs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Ksuid = {
|
|
47
|
+
(): string
|
|
48
|
+
<TBuf extends Uint8Array = Uint8Array>(options: KsuidOptions | undefined, buf: TBuf, offset?: number): TBuf
|
|
49
|
+
(options?: KsuidOptions, buf?: undefined, offset?: number): string
|
|
50
|
+
toBytes(id: string): Uint8Array
|
|
51
|
+
fromBytes(bytes: Uint8Array): string
|
|
52
|
+
timestamp(id: string): number
|
|
53
|
+
isValid(id: unknown): id is string
|
|
54
|
+
/** The nil KSUID (all zeros) */
|
|
55
|
+
NIL: string
|
|
56
|
+
/** The max KSUID (maximum valid value) */
|
|
57
|
+
MAX: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Write KSUID bytes to a buffer.
|
|
62
|
+
*/
|
|
63
|
+
function ksuidBytes(timestamp: number, payload: Uint8Array, buf?: Uint8Array, offset = 0): Uint8Array {
|
|
64
|
+
if (!buf) {
|
|
65
|
+
buf = new Uint8Array(KSUID_BYTES)
|
|
66
|
+
offset = 0
|
|
67
|
+
} else if (offset < 0 || offset + KSUID_BYTES > buf.length) {
|
|
68
|
+
throw new BufferError(
|
|
69
|
+
'KSUID_BUFFER_OUT_OF_BOUNDS',
|
|
70
|
+
`KSUID byte range ${offset}:${offset + KSUID_BYTES - 1} is out of buffer bounds`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Timestamp (32-bit big-endian seconds since KSUID epoch) -> bytes 0-3
|
|
75
|
+
writeTimestamp32(buf, offset, timestamp)
|
|
76
|
+
|
|
77
|
+
// Payload (128 bits) -> bytes 4-19
|
|
78
|
+
for (let i = 0; i < PAYLOAD_BYTES; i += 1) {
|
|
79
|
+
buf[offset + TIMESTAMP_BYTES + i] = payload[i]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return buf
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* Overload: no buffer => return a KSUID string.
|
|
87
|
+
*/
|
|
88
|
+
function ksuidFn(options?: KsuidOptions, buf?: undefined, offset?: number): string
|
|
89
|
+
/*
|
|
90
|
+
* Overload: caller provides a buffer slice to fill with KSUID bytes.
|
|
91
|
+
*/
|
|
92
|
+
function ksuidFn<TBuf extends Uint8Array = Uint8Array>(
|
|
93
|
+
options: KsuidOptions | undefined,
|
|
94
|
+
buf: TBuf,
|
|
95
|
+
offset?: number,
|
|
96
|
+
): TBuf
|
|
97
|
+
function ksuidFn<TBuf extends Uint8Array = Uint8Array>(options?: KsuidOptions, buf?: TBuf, offset = 0): string | TBuf {
|
|
98
|
+
const random = options?.random
|
|
99
|
+
if (random && random.length < PAYLOAD_BYTES) {
|
|
100
|
+
throw new InvalidInputError('KSUID_RANDOM_BYTES_TOO_SHORT', 'Random bytes length must be >= 16 for KSUID')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let timestamp: number
|
|
104
|
+
const secs = options?.secs
|
|
105
|
+
if (secs !== undefined) {
|
|
106
|
+
if (secs < KSUID_EPOCH) {
|
|
107
|
+
throw new InvalidInputError('KSUID_TIMESTAMP_TOO_LOW', 'Timestamp must be >= KSUID epoch')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (secs > KSUID_MAX_SECS) {
|
|
111
|
+
throw new InvalidInputError('KSUID_TIMESTAMP_TOO_HIGH', 'Timestamp must be <= maximum KSUID timestamp')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
timestamp = secs - KSUID_EPOCH
|
|
115
|
+
} else {
|
|
116
|
+
/**
|
|
117
|
+
* Note: by default, Cloudflare Workers "freezes" time during request handling to prevent
|
|
118
|
+
* side-channel attacks. This means that Date.now() will return the same value for the entire
|
|
119
|
+
* duration of a request.
|
|
120
|
+
* Implications:
|
|
121
|
+
* - all KSUIDs generated within a single request will have the same timestamp.
|
|
122
|
+
*/
|
|
123
|
+
timestamp = Math.floor(Date.now() / 1000) - KSUID_EPOCH
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const payload = random ?? rng()
|
|
127
|
+
|
|
128
|
+
if (buf) {
|
|
129
|
+
ksuidBytes(timestamp, payload, buf, offset)
|
|
130
|
+
return buf
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const bytes = ksuidBytes(timestamp, payload)
|
|
134
|
+
|
|
135
|
+
// String mode: create bytes then encode to Base62
|
|
136
|
+
return encodeBase62(bytes)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert a KSUID string to 20 bytes.
|
|
141
|
+
*
|
|
142
|
+
* Note: Base62 is case-sensitive. 'A' (value 10) and 'a' (value 36) decode
|
|
143
|
+
* to different byte values. This function accepts both cases as valid input.
|
|
144
|
+
*/
|
|
145
|
+
function toBytes(id: string): Uint8Array {
|
|
146
|
+
return decodeBase62(id)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Convert 20 bytes to a KSUID string.
|
|
151
|
+
*/
|
|
152
|
+
function fromBytes(bytes: Uint8Array): string {
|
|
153
|
+
return encodeBase62(bytes)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract the timestamp from a KSUID string.
|
|
158
|
+
* Returns Unix timestamp in milliseconds for API consistency with ulid/uuidv7.
|
|
159
|
+
* Note: KSUID only has second precision, so the returned value will always end in 000.
|
|
160
|
+
*/
|
|
161
|
+
function timestamp(id: string): number {
|
|
162
|
+
const bytes = decodeBase62(id)
|
|
163
|
+
// First 4 bytes are big-endian timestamp (seconds since KSUID epoch)
|
|
164
|
+
const secs = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0
|
|
165
|
+
// Add KSUID epoch and convert to milliseconds
|
|
166
|
+
return (secs + KSUID_EPOCH) * 1000
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Validate a KSUID string format and 160-bit numeric range.
|
|
171
|
+
*/
|
|
172
|
+
function isValid(id: unknown): id is string {
|
|
173
|
+
return (
|
|
174
|
+
typeof id === 'string' &&
|
|
175
|
+
id.length === KSUID_STRING_LEN &&
|
|
176
|
+
KSUID_REGEX.test(id) &&
|
|
177
|
+
// Fixed-length Base62 strings preserve numeric order because the alphabet is ASCII-sorted.
|
|
178
|
+
id <= KSUID_MAX_STRING
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Generate a KSUID string or write the bytes into a buffer.
|
|
184
|
+
* Also includes helpers to convert to and from byte arrays.
|
|
185
|
+
*/
|
|
186
|
+
export const ksuid: Ksuid = Object.assign(ksuidFn, {
|
|
187
|
+
toBytes,
|
|
188
|
+
fromBytes,
|
|
189
|
+
timestamp,
|
|
190
|
+
isValid,
|
|
191
|
+
NIL: '000000000000000000000000000',
|
|
192
|
+
MAX: KSUID_MAX_STRING,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
export { BufferError, InvalidInputError, ParseError, UniqueIdError } from '../errors'
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { InvalidInputError } from '../errors'
|
|
2
|
+
|
|
3
|
+
/** Default URL-safe alphabet (64 characters): A-Z, a-z, 0-9, underscore, hyphen */
|
|
4
|
+
export const URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SIZE = 21
|
|
7
|
+
const MAX_SIZE = 2048
|
|
8
|
+
const NANOID_REGEX = /^[A-Za-z0-9_-]+$/
|
|
9
|
+
|
|
10
|
+
// Keep Nanoid's private pool for its default hot path. It consumes `size`
|
|
11
|
+
// bytes directly without the shared helper's UUID/CUID2-oriented branches.
|
|
12
|
+
const POOL_SIZE_MULTIPLIER = 128
|
|
13
|
+
let pool: Uint8Array | undefined
|
|
14
|
+
let poolOffset = 0
|
|
15
|
+
|
|
16
|
+
function fillPool(bytes: number): void {
|
|
17
|
+
const size = bytes * POOL_SIZE_MULTIPLIER
|
|
18
|
+
if (!pool || pool.length < size) {
|
|
19
|
+
pool = new Uint8Array(size)
|
|
20
|
+
crypto.getRandomValues(pool)
|
|
21
|
+
poolOffset = 0
|
|
22
|
+
} else if (poolOffset + bytes > pool.length) {
|
|
23
|
+
crypto.getRandomValues(pool)
|
|
24
|
+
poolOffset = 0
|
|
25
|
+
}
|
|
26
|
+
poolOffset += bytes
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type NanoidOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* Random bytes for deterministic output (testing).
|
|
32
|
+
* For power-of-2 alphabets (2, 4, 8, 16, 32, 64, 128, 256): exactly `size` bytes needed.
|
|
33
|
+
* For other alphabets: ~size * 2 bytes needed (rejection sampling).
|
|
34
|
+
*/
|
|
35
|
+
random?: Uint8Array
|
|
36
|
+
/**
|
|
37
|
+
* Custom alphabet to use. Default: URL-safe A-Za-z0-9_-
|
|
38
|
+
* Must be 2-256 printable ASCII characters (32-126) with no duplicates.
|
|
39
|
+
*/
|
|
40
|
+
alphabet?: string
|
|
41
|
+
/**
|
|
42
|
+
* Length of generated ID. Default: 21. Maximum: 2048.
|
|
43
|
+
*/
|
|
44
|
+
size?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type Nanoid = {
|
|
48
|
+
/** Generate nanoid with default settings */
|
|
49
|
+
(): string
|
|
50
|
+
/** Generate nanoid with custom size */
|
|
51
|
+
(size: number): string
|
|
52
|
+
/** Generate nanoid with options */
|
|
53
|
+
(options: NanoidOptions): string
|
|
54
|
+
/**
|
|
55
|
+
* Validate a nanoid string against the default URL-safe alphabet.
|
|
56
|
+
* Note: Does not validate IDs generated with custom alphabets.
|
|
57
|
+
*/
|
|
58
|
+
isValid(id: unknown): id is string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate alphabet: 2-256 printable ASCII chars, no duplicates
|
|
63
|
+
*/
|
|
64
|
+
function validateAlphabet(alphabet: string): void {
|
|
65
|
+
if (alphabet.length < 2) {
|
|
66
|
+
throw new InvalidInputError('NANOID_ALPHABET_TOO_SHORT', 'Alphabet must contain at least 2 characters')
|
|
67
|
+
}
|
|
68
|
+
if (alphabet.length > 256) {
|
|
69
|
+
throw new InvalidInputError('NANOID_ALPHABET_TOO_LONG', 'Alphabet must not exceed 256 characters')
|
|
70
|
+
}
|
|
71
|
+
const seen = new Set<string>()
|
|
72
|
+
for (const char of alphabet) {
|
|
73
|
+
const code = char.charCodeAt(0)
|
|
74
|
+
if (code < 32 || code > 126) {
|
|
75
|
+
throw new InvalidInputError(
|
|
76
|
+
'NANOID_ALPHABET_INVALID_CHAR',
|
|
77
|
+
'Alphabet must contain only printable ASCII characters (32-126)',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
if (seen.has(char)) {
|
|
81
|
+
throw new InvalidInputError('NANOID_ALPHABET_DUPLICATE', `Duplicate character in alphabet: "${char}"`)
|
|
82
|
+
}
|
|
83
|
+
seen.add(char)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate size parameter
|
|
89
|
+
*/
|
|
90
|
+
function validateSize(size: number): void {
|
|
91
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
92
|
+
throw new InvalidInputError('NANOID_SIZE_INVALID', 'Size must be a non-negative integer')
|
|
93
|
+
}
|
|
94
|
+
if (size > MAX_SIZE) {
|
|
95
|
+
throw new InvalidInputError('NANOID_SIZE_TOO_LARGE', `Size must not exceed ${MAX_SIZE}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Overloads
|
|
100
|
+
function nanoidFn(): string
|
|
101
|
+
function nanoidFn(size: number): string
|
|
102
|
+
function nanoidFn(options: NanoidOptions): string
|
|
103
|
+
function nanoidFn(sizeOrOptions?: number | NanoidOptions): string {
|
|
104
|
+
// ULTRA-FAST PATH: No arguments = default nanoid
|
|
105
|
+
// Uses simple pooled random bytes (npm nanoid style) for best performance
|
|
106
|
+
if (sizeOrOptions === undefined) {
|
|
107
|
+
fillPool(DEFAULT_SIZE)
|
|
108
|
+
let id = ''
|
|
109
|
+
for (let i = poolOffset - DEFAULT_SIZE; i < poolOffset; i++) {
|
|
110
|
+
id += URL_ALPHABET[pool![i] & 63]
|
|
111
|
+
}
|
|
112
|
+
return id
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let size = DEFAULT_SIZE
|
|
116
|
+
let alphabet = URL_ALPHABET
|
|
117
|
+
let randomBytes: Uint8Array | undefined
|
|
118
|
+
|
|
119
|
+
if (typeof sizeOrOptions === 'number') {
|
|
120
|
+
size = sizeOrOptions
|
|
121
|
+
} else {
|
|
122
|
+
size = sizeOrOptions.size ?? DEFAULT_SIZE
|
|
123
|
+
alphabet = sizeOrOptions.alphabet ?? URL_ALPHABET
|
|
124
|
+
randomBytes = sizeOrOptions.random
|
|
125
|
+
if (sizeOrOptions.alphabet !== undefined) {
|
|
126
|
+
validateAlphabet(alphabet)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
validateSize(size)
|
|
131
|
+
|
|
132
|
+
if (size === 0) return ''
|
|
133
|
+
|
|
134
|
+
const alphabetLen = alphabet.length
|
|
135
|
+
|
|
136
|
+
// FAST PATH: Power-of-2 alphabet (includes default 64-char)
|
|
137
|
+
// No rejection needed - each byte maps directly to a character
|
|
138
|
+
if ((alphabetLen & (alphabetLen - 1)) === 0) {
|
|
139
|
+
const mask = alphabetLen - 1
|
|
140
|
+
if (randomBytes && randomBytes.length < size) {
|
|
141
|
+
throw new InvalidInputError(
|
|
142
|
+
'NANOID_RANDOM_BYTES_INSUFFICIENT',
|
|
143
|
+
`Insufficient random bytes: need ${size}, have ${randomBytes.length}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
const bytes = randomBytes?.subarray(0, size) ?? globalThis.crypto.getRandomValues(new Uint8Array(size))
|
|
147
|
+
let id = ''
|
|
148
|
+
for (let i = 0; i < size; i++) {
|
|
149
|
+
id += alphabet[bytes[i] & mask]
|
|
150
|
+
}
|
|
151
|
+
return id
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// SLOW PATH: Rejection sampling for non-power-of-2 alphabets
|
|
155
|
+
// Calculate mask: smallest power-of-2 minus 1 that covers alphabet size
|
|
156
|
+
const mask = (2 << (31 - Math.clz32((alphabetLen - 1) | 1))) - 1
|
|
157
|
+
// Calculate step: random bytes per batch (1.6x accounts for rejection)
|
|
158
|
+
const step = Math.ceil((1.6 * mask * size) / alphabetLen)
|
|
159
|
+
|
|
160
|
+
let id = ''
|
|
161
|
+
let randomOffset = 0
|
|
162
|
+
|
|
163
|
+
while (id.length < size) {
|
|
164
|
+
let bytes: Uint8Array
|
|
165
|
+
if (randomBytes) {
|
|
166
|
+
if (randomBytes.length - randomOffset < step) {
|
|
167
|
+
throw new InvalidInputError(
|
|
168
|
+
'NANOID_RANDOM_BYTES_INSUFFICIENT',
|
|
169
|
+
`Insufficient random bytes: need at least ${step} more, have ${randomBytes.length - randomOffset}`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
bytes = randomBytes.subarray(randomOffset, randomOffset + step)
|
|
173
|
+
randomOffset += step
|
|
174
|
+
} else {
|
|
175
|
+
bytes = globalThis.crypto.getRandomValues(new Uint8Array(step))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < bytes.length && id.length < size; i++) {
|
|
179
|
+
const index = bytes[i] & mask
|
|
180
|
+
if (index < alphabetLen) {
|
|
181
|
+
id += alphabet[index]
|
|
182
|
+
}
|
|
183
|
+
// Otherwise reject and continue (no modulo bias)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return id
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Validate a nanoid string against the default URL-safe alphabet.
|
|
192
|
+
* Note: Does not validate IDs generated with custom alphabets.
|
|
193
|
+
*/
|
|
194
|
+
function isValid(id: unknown): id is string {
|
|
195
|
+
return typeof id === 'string' && id.length > 0 && NANOID_REGEX.test(id)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Generate a URL-friendly unique string ID.
|
|
200
|
+
*
|
|
201
|
+
* Nanoid is a tiny, secure, URL-friendly unique string ID generator.
|
|
202
|
+
* It uses a URL-safe alphabet (A-Za-z0-9_-) and generates 21-character
|
|
203
|
+
* IDs by default with 126 bits of entropy.
|
|
204
|
+
*
|
|
205
|
+
* Unlike UUID v7 or ULID, nanoid is NOT time-ordered. Use it for:
|
|
206
|
+
* - URL shorteners
|
|
207
|
+
* - Session tokens
|
|
208
|
+
* - Invite codes
|
|
209
|
+
* - Any case where you need short, random IDs
|
|
210
|
+
*
|
|
211
|
+
* @example Basic usage
|
|
212
|
+
* ```ts
|
|
213
|
+
* import { nanoid } from 'uniku/nanoid'
|
|
214
|
+
*
|
|
215
|
+
* const id = nanoid()
|
|
216
|
+
* // => "V1StGXR8_Z5jdHi6B-myT"
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* @example Custom size
|
|
220
|
+
* ```ts
|
|
221
|
+
* const shortId = nanoid(10)
|
|
222
|
+
* // => "IRFa-VaY2b"
|
|
223
|
+
* ```
|
|
224
|
+
*
|
|
225
|
+
* @example Custom alphabet (hex)
|
|
226
|
+
* ```ts
|
|
227
|
+
* const hexId = nanoid({ alphabet: '0123456789abcdef', size: 12 })
|
|
228
|
+
* // => "4f90d13a42bc"
|
|
229
|
+
* ```
|
|
230
|
+
*
|
|
231
|
+
* @example Validation
|
|
232
|
+
* ```ts
|
|
233
|
+
* const maybeId: unknown = getUserInput()
|
|
234
|
+
* if (nanoid.isValid(maybeId)) {
|
|
235
|
+
* // TypeScript knows maybeId is string
|
|
236
|
+
* console.log(maybeId.length)
|
|
237
|
+
* }
|
|
238
|
+
* ```
|
|
239
|
+
*
|
|
240
|
+
* @throws {InvalidInputError} Size must be between 0 and 2048
|
|
241
|
+
* @throws {InvalidInputError} Alphabet must contain 2-256 unique printable ASCII characters
|
|
242
|
+
* @throws {InvalidInputError} Insufficient random bytes for requested size
|
|
243
|
+
*/
|
|
244
|
+
export const nanoid: Nanoid = Object.assign(nanoidFn, {
|
|
245
|
+
isValid,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
export { InvalidInputError, UniqueIdError } from '../errors'
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crockford's Base32 encoding/decoding for ULID.
|
|
3
|
+
* Alphabet excludes I, L, O, U to avoid confusion with similar-looking characters.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BufferError, ParseError } from '../errors'
|
|
7
|
+
|
|
8
|
+
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
9
|
+
|
|
10
|
+
// Pre-computed decoding table covering every UTF-16 code unit charCodeAt can
|
|
11
|
+
// return, so lookups never go out of bounds and a single `=== 255` check
|
|
12
|
+
// rejects invalid input (including non-ASCII) without a per-character range
|
|
13
|
+
// check. Costs 64 KiB once at module load; valid inputs only touch the first
|
|
14
|
+
// 128 bytes, so cache behavior is unaffected.
|
|
15
|
+
const DECODING = new Uint8Array(65536)
|
|
16
|
+
DECODING.fill(255) // 255 = invalid marker
|
|
17
|
+
for (let i = 0; i < ENCODING.length; i += 1) {
|
|
18
|
+
const upper = ENCODING.charCodeAt(i)
|
|
19
|
+
const lower = ENCODING[i].toLowerCase().charCodeAt(0)
|
|
20
|
+
DECODING[upper] = i
|
|
21
|
+
DECODING[lower] = i
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TIME_LEN = 10
|
|
25
|
+
const ULID_LEN = 26
|
|
26
|
+
|
|
27
|
+
function invalidCharError(str: string, index: number): ParseError {
|
|
28
|
+
return new ParseError('ULID_INVALID_CHAR', `Invalid ULID character: ${str[index]}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function timestampOverflowError(): ParseError {
|
|
32
|
+
return new ParseError('ULID_TIMESTAMP_OVERFLOW', 'ULID timestamp exceeds 48 bits')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeTimeChars(str: string): number {
|
|
36
|
+
const firstValue = DECODING[str.charCodeAt(0)]
|
|
37
|
+
if (firstValue === 255) {
|
|
38
|
+
throw invalidCharError(str, 0)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (firstValue > 7) {
|
|
42
|
+
throw timestampOverflowError()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let time = firstValue
|
|
46
|
+
for (let i = 1; i < TIME_LEN; i += 1) {
|
|
47
|
+
const value = DECODING[str.charCodeAt(i)]
|
|
48
|
+
if (value === 255) {
|
|
49
|
+
throw invalidCharError(str, i)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
time = time * 32 + value
|
|
53
|
+
}
|
|
54
|
+
return time
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Encode a 48-bit timestamp to a 10-character Crockford Base32 string.
|
|
59
|
+
* Uses unrolled division for performance.
|
|
60
|
+
*/
|
|
61
|
+
export function encodeTime(time: number): string {
|
|
62
|
+
// Unrolled encoding - each step divides by 32 and extracts 5 bits
|
|
63
|
+
// Powers of 32: 32^9=0x200000000000, 32^8=0x10000000000, etc.
|
|
64
|
+
return (
|
|
65
|
+
ENCODING[Math.floor(time / 0x200000000000) & 0x1f] +
|
|
66
|
+
ENCODING[Math.floor(time / 0x10000000000) & 0x1f] +
|
|
67
|
+
ENCODING[Math.floor(time / 0x800000000) & 0x1f] +
|
|
68
|
+
ENCODING[Math.floor(time / 0x40000000) & 0x1f] +
|
|
69
|
+
ENCODING[Math.floor(time / 0x2000000) & 0x1f] +
|
|
70
|
+
ENCODING[Math.floor(time / 0x100000) & 0x1f] +
|
|
71
|
+
ENCODING[Math.floor(time / 0x8000) & 0x1f] +
|
|
72
|
+
ENCODING[Math.floor(time / 0x400) & 0x1f] +
|
|
73
|
+
ENCODING[Math.floor(time / 0x20) & 0x1f] +
|
|
74
|
+
ENCODING[time & 0x1f]
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Encode 10 bytes (80 bits) of random data to a 16-character Crockford Base32 string.
|
|
80
|
+
*/
|
|
81
|
+
export function encodeRandom(bytes: Uint8Array): string {
|
|
82
|
+
// Each character encodes 5 bits. 80 bits = 16 characters.
|
|
83
|
+
// Single concatenation expression for optimal performance.
|
|
84
|
+
return (
|
|
85
|
+
ENCODING[(bytes[0] >> 3) & 0x1f] +
|
|
86
|
+
ENCODING[((bytes[0] << 2) | (bytes[1] >> 6)) & 0x1f] +
|
|
87
|
+
ENCODING[(bytes[1] >> 1) & 0x1f] +
|
|
88
|
+
ENCODING[((bytes[1] << 4) | (bytes[2] >> 4)) & 0x1f] +
|
|
89
|
+
ENCODING[((bytes[2] << 1) | (bytes[3] >> 7)) & 0x1f] +
|
|
90
|
+
ENCODING[(bytes[3] >> 2) & 0x1f] +
|
|
91
|
+
ENCODING[((bytes[3] << 3) | (bytes[4] >> 5)) & 0x1f] +
|
|
92
|
+
ENCODING[bytes[4] & 0x1f] +
|
|
93
|
+
ENCODING[(bytes[5] >> 3) & 0x1f] +
|
|
94
|
+
ENCODING[((bytes[5] << 2) | (bytes[6] >> 6)) & 0x1f] +
|
|
95
|
+
ENCODING[(bytes[6] >> 1) & 0x1f] +
|
|
96
|
+
ENCODING[((bytes[6] << 4) | (bytes[7] >> 4)) & 0x1f] +
|
|
97
|
+
ENCODING[((bytes[7] << 1) | (bytes[8] >> 7)) & 0x1f] +
|
|
98
|
+
ENCODING[(bytes[8] >> 2) & 0x1f] +
|
|
99
|
+
ENCODING[((bytes[8] << 3) | (bytes[9] >> 5)) & 0x1f] +
|
|
100
|
+
ENCODING[bytes[9] & 0x1f]
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decode a 10-character ULID timestamp string to Unix epoch milliseconds.
|
|
106
|
+
*/
|
|
107
|
+
export function decodeTime(str: string): number {
|
|
108
|
+
if (str.length !== TIME_LEN) {
|
|
109
|
+
throw new ParseError('ULID_INVALID_LENGTH', `ULID timestamp must be ${TIME_LEN} characters`)
|
|
110
|
+
}
|
|
111
|
+
return decodeTimeChars(str)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decode the timestamp from a full 26-character ULID string.
|
|
116
|
+
*/
|
|
117
|
+
export function decodeUlidTime(str: string): number {
|
|
118
|
+
if (str.length !== ULID_LEN) {
|
|
119
|
+
throw new ParseError('ULID_INVALID_LENGTH', `ULID string must be ${ULID_LEN} characters`)
|
|
120
|
+
}
|
|
121
|
+
return decodeTimeChars(str)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Decode a 26-character ULID string to 16 bytes.
|
|
126
|
+
* Inlines all lookups to avoid intermediate array allocation.
|
|
127
|
+
*/
|
|
128
|
+
export function decodeToBytes(str: string): Uint8Array {
|
|
129
|
+
if (str.length !== ULID_LEN) {
|
|
130
|
+
throw new ParseError('ULID_INVALID_LENGTH', `ULID string must be ${ULID_LEN} characters`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const bytes = new Uint8Array(16)
|
|
134
|
+
|
|
135
|
+
// Inline all 26 character lookups
|
|
136
|
+
const v0 = DECODING[str.charCodeAt(0)]
|
|
137
|
+
const v1 = DECODING[str.charCodeAt(1)]
|
|
138
|
+
const v2 = DECODING[str.charCodeAt(2)]
|
|
139
|
+
const v3 = DECODING[str.charCodeAt(3)]
|
|
140
|
+
const v4 = DECODING[str.charCodeAt(4)]
|
|
141
|
+
const v5 = DECODING[str.charCodeAt(5)]
|
|
142
|
+
const v6 = DECODING[str.charCodeAt(6)]
|
|
143
|
+
const v7 = DECODING[str.charCodeAt(7)]
|
|
144
|
+
const v8 = DECODING[str.charCodeAt(8)]
|
|
145
|
+
const v9 = DECODING[str.charCodeAt(9)]
|
|
146
|
+
const v10 = DECODING[str.charCodeAt(10)]
|
|
147
|
+
const v11 = DECODING[str.charCodeAt(11)]
|
|
148
|
+
const v12 = DECODING[str.charCodeAt(12)]
|
|
149
|
+
const v13 = DECODING[str.charCodeAt(13)]
|
|
150
|
+
const v14 = DECODING[str.charCodeAt(14)]
|
|
151
|
+
const v15 = DECODING[str.charCodeAt(15)]
|
|
152
|
+
const v16 = DECODING[str.charCodeAt(16)]
|
|
153
|
+
const v17 = DECODING[str.charCodeAt(17)]
|
|
154
|
+
const v18 = DECODING[str.charCodeAt(18)]
|
|
155
|
+
const v19 = DECODING[str.charCodeAt(19)]
|
|
156
|
+
const v20 = DECODING[str.charCodeAt(20)]
|
|
157
|
+
const v21 = DECODING[str.charCodeAt(21)]
|
|
158
|
+
const v22 = DECODING[str.charCodeAt(22)]
|
|
159
|
+
const v23 = DECODING[str.charCodeAt(23)]
|
|
160
|
+
const v24 = DECODING[str.charCodeAt(24)]
|
|
161
|
+
const v25 = DECODING[str.charCodeAt(25)]
|
|
162
|
+
|
|
163
|
+
// Validate all characters at once (255 = invalid marker, so any invalid
|
|
164
|
+
// character sets the 0x80 bit in the OR of all values)
|
|
165
|
+
if (
|
|
166
|
+
((v0 |
|
|
167
|
+
v1 |
|
|
168
|
+
v2 |
|
|
169
|
+
v3 |
|
|
170
|
+
v4 |
|
|
171
|
+
v5 |
|
|
172
|
+
v6 |
|
|
173
|
+
v7 |
|
|
174
|
+
v8 |
|
|
175
|
+
v9 |
|
|
176
|
+
v10 |
|
|
177
|
+
v11 |
|
|
178
|
+
v12 |
|
|
179
|
+
v13 |
|
|
180
|
+
v14 |
|
|
181
|
+
v15 |
|
|
182
|
+
v16 |
|
|
183
|
+
v17 |
|
|
184
|
+
v18 |
|
|
185
|
+
v19 |
|
|
186
|
+
v20 |
|
|
187
|
+
v21 |
|
|
188
|
+
v22 |
|
|
189
|
+
v23 |
|
|
190
|
+
v24 |
|
|
191
|
+
v25) &
|
|
192
|
+
0x80) !==
|
|
193
|
+
0
|
|
194
|
+
) {
|
|
195
|
+
// Find the invalid character for error message
|
|
196
|
+
for (let i = 0; i < ULID_LEN; i += 1) {
|
|
197
|
+
if (DECODING[str.charCodeAt(i)] === 255) {
|
|
198
|
+
throw invalidCharError(str, i)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (v0 > 7) {
|
|
204
|
+
throw timestampOverflowError()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Timestamp: first 10 characters -> bytes 0-5
|
|
208
|
+
bytes[0] = (v0 << 5) | v1
|
|
209
|
+
bytes[1] = (v2 << 3) | (v3 >> 2)
|
|
210
|
+
bytes[2] = (v3 << 6) | (v4 << 1) | (v5 >> 4)
|
|
211
|
+
bytes[3] = (v5 << 4) | (v6 >> 1)
|
|
212
|
+
bytes[4] = (v6 << 7) | (v7 << 2) | (v8 >> 3)
|
|
213
|
+
bytes[5] = (v8 << 5) | v9
|
|
214
|
+
|
|
215
|
+
// Random: last 16 characters -> bytes 6-15
|
|
216
|
+
bytes[6] = (v10 << 3) | (v11 >> 2)
|
|
217
|
+
bytes[7] = (v11 << 6) | (v12 << 1) | (v13 >> 4)
|
|
218
|
+
bytes[8] = (v13 << 4) | (v14 >> 1)
|
|
219
|
+
bytes[9] = (v14 << 7) | (v15 << 2) | (v16 >> 3)
|
|
220
|
+
bytes[10] = (v16 << 5) | v17
|
|
221
|
+
bytes[11] = (v18 << 3) | (v19 >> 2)
|
|
222
|
+
bytes[12] = (v19 << 6) | (v20 << 1) | (v21 >> 4)
|
|
223
|
+
bytes[13] = (v21 << 4) | (v22 >> 1)
|
|
224
|
+
bytes[14] = (v22 << 7) | (v23 << 2) | (v24 >> 3)
|
|
225
|
+
bytes[15] = (v24 << 5) | v25
|
|
226
|
+
|
|
227
|
+
return bytes
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Encode 16 bytes to a 26-character ULID string.
|
|
232
|
+
*/
|
|
233
|
+
export function bytesToUlid(bytes: Uint8Array): string {
|
|
234
|
+
if (bytes.length < 16) {
|
|
235
|
+
throw new BufferError('ULID_BYTES_TOO_SHORT', 'Byte array must be at least 16 bytes')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Timestamp: bytes 0-5 -> 10 characters
|
|
239
|
+
let time = 0
|
|
240
|
+
for (let i = 0; i < 6; i += 1) {
|
|
241
|
+
time = time * 256 + bytes[i]
|
|
242
|
+
}
|
|
243
|
+
const timeStr = encodeTime(time)
|
|
244
|
+
|
|
245
|
+
// Random: bytes 6-15 -> 16 characters
|
|
246
|
+
const randomStr = encodeRandom(bytes.subarray(6, 16))
|
|
247
|
+
|
|
248
|
+
return timeStr + randomStr
|
|
249
|
+
}
|