uniku 0.0.12 → 0.1.0
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 +63 -13
- package/build/{bytes-BBjKLOBR.mjs → bytes-DzcOKIx1.mjs} +1 -1
- package/build/{bytes-BBjKLOBR.mjs.map → bytes-DzcOKIx1.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 +1 -1
- package/build/nanoid/nanoid.mjs.map +1 -1
- package/build/random-CGMm8owG.mjs +2 -0
- package/build/random-CGMm8owG.mjs.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/typeid/typeid.d.mts +38 -0
- package/build/typeid/typeid.d.mts.map +1 -0
- package/build/typeid/typeid.mjs +2 -0
- package/build/typeid/typeid.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/build/{uuid-QysI8pWE.mjs → uuid-Ub6ladEr.mjs} +1 -1
- package/build/{uuid-QysI8pWE.mjs.map → uuid-Ub6ladEr.mjs.map} +1 -1
- package/package.json +21 -31
- 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 +257 -0
- package/src/typeid/typeid.ts +283 -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,211 @@
|
|
|
1
|
+
import { sha3_512 } from '@noble/hashes/sha3.js'
|
|
2
|
+
import { randomUint32 } from '../common/random'
|
|
3
|
+
import { InvalidInputError } from '../errors'
|
|
4
|
+
|
|
5
|
+
export type Cuid2Options = {
|
|
6
|
+
/**
|
|
7
|
+
* Length of the generated ID (2-32 characters).
|
|
8
|
+
* Default: 24
|
|
9
|
+
*/
|
|
10
|
+
length?: number
|
|
11
|
+
/**
|
|
12
|
+
* Custom random bytes for deterministic testing.
|
|
13
|
+
* Must be at least 1 byte. For adequate entropy, use at least 16 bytes.
|
|
14
|
+
* Note: The fingerprint always uses cryptographically secure random bytes,
|
|
15
|
+
* regardless of this option.
|
|
16
|
+
*/
|
|
17
|
+
random?: Uint8Array
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Cuid2 = {
|
|
21
|
+
(options?: Cuid2Options): string
|
|
22
|
+
isValid(id: unknown): id is string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_LENGTH = 24
|
|
26
|
+
const MAX_LENGTH = 32
|
|
27
|
+
const MIN_LENGTH = 2
|
|
28
|
+
|
|
29
|
+
// Maximum initial counter value from cuid2 spec - provides ~29 bits of
|
|
30
|
+
// initial entropy to prevent cross-process collisions at startup
|
|
31
|
+
const INITIAL_COUNT_MAX = 476782367
|
|
32
|
+
|
|
33
|
+
// Validation regex: first char must be a-z, rest can be a-z or 0-9
|
|
34
|
+
const CUID2_REGEX = /^[a-z][0-9a-z]+$/
|
|
35
|
+
|
|
36
|
+
// Base36 alphabet
|
|
37
|
+
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
|
|
38
|
+
const LETTER_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
|
|
39
|
+
|
|
40
|
+
// Reusable TextEncoder instance (stateless, safe to share)
|
|
41
|
+
const textEncoder = new TextEncoder()
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Module-level state for counter and fingerprint.
|
|
45
|
+
* Counter is initialized lazily on first call to prevent unnecessary crypto operations.
|
|
46
|
+
* Fingerprint is also generated lazily on first call.
|
|
47
|
+
*/
|
|
48
|
+
const state: { counter: number | undefined; fingerprint: string | undefined } = {
|
|
49
|
+
counter: undefined,
|
|
50
|
+
fingerprint: undefined,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Initialize counter using crypto for consistency with other entropy sources.
|
|
55
|
+
*/
|
|
56
|
+
function initializeCounter(): number {
|
|
57
|
+
return randomUint32() % (INITIAL_COUNT_MAX + 1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Base36 utilities ---
|
|
61
|
+
|
|
62
|
+
function bufToBigInt(buf: Uint8Array): bigint {
|
|
63
|
+
let value = 0n
|
|
64
|
+
for (const byte of buf) {
|
|
65
|
+
value = value * 256n + BigInt(byte)
|
|
66
|
+
}
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function bigIntToBase36(value: bigint): string {
|
|
71
|
+
if (value === 0n) return '0'
|
|
72
|
+
const chars: string[] = []
|
|
73
|
+
while (value > 0n) {
|
|
74
|
+
chars.push(ALPHABET[Number(value % 36n)])
|
|
75
|
+
value = value / 36n
|
|
76
|
+
}
|
|
77
|
+
return chars.reverse().join('')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function randomLetter(random: () => number): string {
|
|
81
|
+
return LETTER_ALPHABET[Math.floor(random() * 26)]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createEntropy(length: number, random: () => number): string {
|
|
85
|
+
const chars = new Array<string>(length)
|
|
86
|
+
for (let i = 0; i < length; i++) {
|
|
87
|
+
chars[i] = ALPHABET[Math.floor(random() * 36)]
|
|
88
|
+
}
|
|
89
|
+
return chars.join('')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- SHA3 hash wrapper ---
|
|
93
|
+
|
|
94
|
+
function hash(input: string): Uint8Array {
|
|
95
|
+
return sha3_512(textEncoder.encode(input))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Fingerprint generation ---
|
|
99
|
+
|
|
100
|
+
const BIG_LENGTH = 32
|
|
101
|
+
|
|
102
|
+
function createFingerprint(): string {
|
|
103
|
+
// Always use CSPRNG for fingerprint to ensure security regardless of custom random option
|
|
104
|
+
const random = getCryptoRandom
|
|
105
|
+
const globals = Object.keys(globalThis).toString()
|
|
106
|
+
const sourceString = globals + createEntropy(BIG_LENGTH, random)
|
|
107
|
+
const hashed = hash(sourceString)
|
|
108
|
+
return bigIntToBase36(bufToBigInt(hashed)).slice(1, BIG_LENGTH + 1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Random function factory ---
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get a random number in [0, 1) using CUID2's own random pool.
|
|
115
|
+
*/
|
|
116
|
+
function getCryptoRandom(): number {
|
|
117
|
+
return randomUint32() / 0x100000000
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getRandomFn(random?: Uint8Array): () => number {
|
|
121
|
+
if (random) {
|
|
122
|
+
if (random.length === 0) {
|
|
123
|
+
throw new InvalidInputError('CUID2_RANDOM_BYTES_EMPTY', 'Random byte array cannot be empty')
|
|
124
|
+
}
|
|
125
|
+
let index = 0
|
|
126
|
+
return () => {
|
|
127
|
+
const value = random[index % random.length] / 256
|
|
128
|
+
index += 1
|
|
129
|
+
return value
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return getCryptoRandom
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Main generator ---
|
|
136
|
+
|
|
137
|
+
function cuid2Fn(options?: Cuid2Options): string {
|
|
138
|
+
const length = options?.length ?? DEFAULT_LENGTH
|
|
139
|
+
|
|
140
|
+
if (length < MIN_LENGTH || length > MAX_LENGTH) {
|
|
141
|
+
throw new InvalidInputError(
|
|
142
|
+
'CUID2_LENGTH_OUT_OF_RANGE',
|
|
143
|
+
`CUID2 length must be between ${MIN_LENGTH} and ${MAX_LENGTH}. Received: ${length}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const random = getRandomFn(options?.random)
|
|
148
|
+
|
|
149
|
+
// Initialize counter lazily on first call
|
|
150
|
+
if (state.counter === undefined) {
|
|
151
|
+
state.counter = initializeCounter()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Initialize fingerprint lazily on first call (always uses CSPRNG)
|
|
155
|
+
if (state.fingerprint === undefined) {
|
|
156
|
+
state.fingerprint = createFingerprint()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const firstLetter = randomLetter(random)
|
|
160
|
+
const time = Date.now().toString(36)
|
|
161
|
+
state.counter += 1
|
|
162
|
+
const count = state.counter.toString(36)
|
|
163
|
+
const salt = createEntropy(length, random)
|
|
164
|
+
|
|
165
|
+
const hashInput = time + salt + count + state.fingerprint
|
|
166
|
+
const hashed = hash(hashInput)
|
|
167
|
+
const base36Hash = bigIntToBase36(bufToBigInt(hashed))
|
|
168
|
+
|
|
169
|
+
// Drop first char of hash to avoid histogram bias, prepend random letter
|
|
170
|
+
return firstLetter + base36Hash.slice(1, length)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- Validation (type guard) ---
|
|
174
|
+
|
|
175
|
+
function isValid(id: unknown): id is string {
|
|
176
|
+
return typeof id === 'string' && id.length >= MIN_LENGTH && id.length <= MAX_LENGTH && CUID2_REGEX.test(id)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate a CUID2 string.
|
|
181
|
+
*
|
|
182
|
+
* CUID2 is a secure, collision-resistant identifier that hashes multiple
|
|
183
|
+
* entropy sources using SHA3-512. Unlike time-ordered IDs (ULID, UUID v7),
|
|
184
|
+
* CUID2 prevents enumeration attacks by making IDs non-predictable.
|
|
185
|
+
*
|
|
186
|
+
* Note: CUID2 does not provide toBytes/fromBytes because it is a string-native
|
|
187
|
+
* format with no canonical binary representation (unlike UUID's 16-byte format).
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* import { cuid2 } from 'uniku/cuid2'
|
|
192
|
+
*
|
|
193
|
+
* const id = cuid2()
|
|
194
|
+
* // => "pfh0haxfpzowht3oi213cqos"
|
|
195
|
+
*
|
|
196
|
+
* // Custom length
|
|
197
|
+
* const shortId = cuid2({ length: 10 })
|
|
198
|
+
* // => "tz4a98xxat"
|
|
199
|
+
*
|
|
200
|
+
* // Validation (type guard)
|
|
201
|
+
* const maybeId: unknown = getUserInput()
|
|
202
|
+
* if (cuid2.isValid(maybeId)) {
|
|
203
|
+
* console.log(maybeId.length) // TypeScript knows maybeId is string
|
|
204
|
+
* }
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
export const cuid2: Cuid2 = Object.assign(cuid2Fn, {
|
|
208
|
+
isValid,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
export { InvalidInputError, UniqueIdError } from '../errors'
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base error for all uniku errors.
|
|
3
|
+
* Provides `_tag` for discriminated matching (compatible with Effect's `catchTag`)
|
|
4
|
+
* and `code` for machine-readable error identification.
|
|
5
|
+
*/
|
|
6
|
+
export abstract class UniqueIdError extends Error {
|
|
7
|
+
abstract readonly _tag: string
|
|
8
|
+
abstract readonly code: string
|
|
9
|
+
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = this.constructor.name
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Thrown when generator arguments are invalid (bad size, alphabet, length, timestamp, version).
|
|
18
|
+
*/
|
|
19
|
+
export class InvalidInputError extends UniqueIdError {
|
|
20
|
+
readonly _tag = 'InvalidInputError' as const
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
readonly code: string,
|
|
24
|
+
message: string,
|
|
25
|
+
) {
|
|
26
|
+
super(message)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Thrown when parsing/decoding an ID string that has invalid format or characters.
|
|
32
|
+
*/
|
|
33
|
+
export class ParseError extends UniqueIdError {
|
|
34
|
+
readonly _tag = 'ParseError' as const
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
readonly code: string,
|
|
38
|
+
message: string,
|
|
39
|
+
) {
|
|
40
|
+
super(message)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Thrown when a byte array or buffer is too short or an offset is out of bounds.
|
|
46
|
+
*/
|
|
47
|
+
export class BufferError extends UniqueIdError {
|
|
48
|
+
readonly _tag = 'BufferError' as const
|
|
49
|
+
|
|
50
|
+
constructor(
|
|
51
|
+
readonly code: string,
|
|
52
|
+
message: string,
|
|
53
|
+
) {
|
|
54
|
+
super(message)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { BufferError, ParseError } from '../errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base62 encoding/decoding for KSUID.
|
|
5
|
+
* Alphabet: 0-9A-Za-z (standard Base62 ordering)
|
|
6
|
+
*
|
|
7
|
+
* KSUID binary format: 20 bytes (160 bits)
|
|
8
|
+
* - 4 bytes: timestamp (seconds since KSUID epoch)
|
|
9
|
+
* - 16 bytes: payload (cryptographically random)
|
|
10
|
+
*
|
|
11
|
+
* String format: 27 characters of Base62
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Base62 alphabet: digits (0-9), uppercase (A-Z), lowercase (a-z)
|
|
15
|
+
const BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
16
|
+
const BASE = 62n
|
|
17
|
+
const MAX_KSUID_VALUE = (1n << 160n) - 1n
|
|
18
|
+
const KSUID_BYTES = 20
|
|
19
|
+
const KSUID_STRING_LEN = 27
|
|
20
|
+
|
|
21
|
+
// Pre-computed decode table covering every UTF-16 code unit charCodeAt can
|
|
22
|
+
// return, so lookups never go out of bounds and a single `=== 255` check
|
|
23
|
+
// rejects invalid input (including non-ASCII) without a per-character range
|
|
24
|
+
// check. Costs 64 KiB once at module load; valid inputs only touch the first
|
|
25
|
+
// 128 bytes, so cache behavior is unaffected.
|
|
26
|
+
// Note: Base62 is case-sensitive - 'A' (value 10) and 'a' (value 36) are different
|
|
27
|
+
const DECODING = new Uint8Array(65536)
|
|
28
|
+
DECODING.fill(255) // 255 = invalid marker
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < BASE62_ALPHABET.length; i += 1) {
|
|
31
|
+
DECODING[BASE62_ALPHABET.charCodeAt(i)] = i
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Encode a 20-byte KSUID to a 27-character Base62 string.
|
|
36
|
+
*
|
|
37
|
+
* Algorithm: Convert the 160-bit number to base 62 using BigInt.
|
|
38
|
+
* V8 has highly optimized BigInt operations for this size.
|
|
39
|
+
*/
|
|
40
|
+
export function encodeBase62(bytes: Uint8Array): string {
|
|
41
|
+
if (bytes.length < KSUID_BYTES) {
|
|
42
|
+
throw new BufferError(
|
|
43
|
+
'KSUID_BYTES_TOO_SHORT',
|
|
44
|
+
`KSUID bytes must be at least ${KSUID_BYTES} bytes, got ${bytes.length}`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Convert bytes to BigInt (big-endian)
|
|
49
|
+
let num = 0n
|
|
50
|
+
for (let i = 0; i < KSUID_BYTES; i += 1) {
|
|
51
|
+
num = (num << 8n) | BigInt(bytes[i])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Convert to Base62 string (build from right to left)
|
|
55
|
+
// Direct string concatenation is faster than array + join in V8
|
|
56
|
+
let encoded = ''
|
|
57
|
+
while (num > 0n) {
|
|
58
|
+
const remainder = num % BASE
|
|
59
|
+
num = num / BASE
|
|
60
|
+
encoded = BASE62_ALPHABET[Number(remainder)] + encoded
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Pad the result with the zero-character ('0') to ensure a fixed length
|
|
64
|
+
return encoded.padStart(KSUID_STRING_LEN, '0')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decode a 27-character Base62 string to a 20-byte KSUID.
|
|
69
|
+
*
|
|
70
|
+
* Algorithm: Convert Base62 string to BigInt, then to bytes.
|
|
71
|
+
* V8 has highly optimized BigInt operations.
|
|
72
|
+
*/
|
|
73
|
+
export function decodeBase62(str: string): Uint8Array {
|
|
74
|
+
if (str.length !== KSUID_STRING_LEN) {
|
|
75
|
+
throw new ParseError(
|
|
76
|
+
'KSUID_INVALID_LENGTH',
|
|
77
|
+
`KSUID string must be ${KSUID_STRING_LEN} characters, got ${str.length}`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convert Base62 string to BigInt
|
|
82
|
+
let num = 0n
|
|
83
|
+
for (let i = 0; i < KSUID_STRING_LEN; i += 1) {
|
|
84
|
+
const value = DECODING[str.charCodeAt(i)]
|
|
85
|
+
if (value === 255) {
|
|
86
|
+
throw new ParseError('KSUID_INVALID_CHAR', `Invalid KSUID character: ${str[i]}`)
|
|
87
|
+
}
|
|
88
|
+
num = num * BASE + BigInt(value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (num > MAX_KSUID_VALUE) {
|
|
92
|
+
throw new ParseError('KSUID_OVERFLOW', 'KSUID string exceeds 160-bit range')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Convert BigInt to bytes (big-endian)
|
|
96
|
+
const bytes = new Uint8Array(KSUID_BYTES)
|
|
97
|
+
for (let i = KSUID_BYTES - 1; i >= 0; i -= 1) {
|
|
98
|
+
bytes[i] = Number(num & 0xffn)
|
|
99
|
+
num = num >> 8n
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return bytes
|
|
103
|
+
}
|
|
@@ -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'
|