uniku 0.0.12 → 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.
@@ -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
+ }