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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "private": false,
3
3
  "name": "uniku",
4
- "version": "0.0.11",
4
+ "version": "0.0.13",
5
5
  "description": "Minimal, tree-shakeable UUID utilities for every JavaScript runtime",
6
6
  "author": {
7
7
  "name": "Alberto Schiabel",
@@ -45,69 +45,49 @@
45
45
  "access": "public"
46
46
  },
47
47
  "engines": {
48
- "node": ">=24.13.0"
48
+ "node": ">=20.19.0"
49
49
  },
50
50
  "type": "module",
51
51
  "sideEffects": false,
52
52
  "files": [
53
+ "src/**/*.ts",
53
54
  "build",
54
55
  "README.md"
55
56
  ],
56
57
  "exports": {
57
58
  "./uuid/v4": {
58
59
  "types": "./build/uuid/v4.d.mts",
59
- "import": {
60
- "@jkomyno/source": "./src/uuid/v4.ts",
61
- "default": "./build/uuid/v4.mjs"
62
- },
60
+ "import": "./build/uuid/v4.mjs",
63
61
  "default": "./build/uuid/v4.mjs"
64
62
  },
65
63
  "./uuid/v7": {
66
64
  "types": "./build/uuid/v7.d.mts",
67
- "import": {
68
- "@jkomyno/source": "./src/uuid/v7.ts",
69
- "default": "./build/uuid/v7.mjs"
70
- },
65
+ "import": "./build/uuid/v7.mjs",
71
66
  "default": "./build/uuid/v7.mjs"
72
67
  },
73
68
  "./ulid": {
74
69
  "types": "./build/ulid/ulid.d.mts",
75
- "import": {
76
- "@jkomyno/source": "./src/ulid/ulid.ts",
77
- "default": "./build/ulid/ulid.mjs"
78
- },
70
+ "import": "./build/ulid/ulid.mjs",
79
71
  "default": "./build/ulid/ulid.mjs"
80
72
  },
81
73
  "./cuid2": {
82
74
  "types": "./build/cuid2/cuid2.d.mts",
83
- "import": {
84
- "@jkomyno/source": "./src/cuid2/cuid2.ts",
85
- "default": "./build/cuid2/cuid2.mjs"
86
- },
75
+ "import": "./build/cuid2/cuid2.mjs",
87
76
  "default": "./build/cuid2/cuid2.mjs"
88
77
  },
89
78
  "./nanoid": {
90
79
  "types": "./build/nanoid/nanoid.d.mts",
91
- "import": {
92
- "@jkomyno/source": "./src/nanoid/nanoid.ts",
93
- "default": "./build/nanoid/nanoid.mjs"
94
- },
80
+ "import": "./build/nanoid/nanoid.mjs",
95
81
  "default": "./build/nanoid/nanoid.mjs"
96
82
  },
97
83
  "./ksuid": {
98
84
  "types": "./build/ksuid/ksuid.d.mts",
99
- "import": {
100
- "@jkomyno/source": "./src/ksuid/ksuid.ts",
101
- "default": "./build/ksuid/ksuid.mjs"
102
- },
85
+ "import": "./build/ksuid/ksuid.mjs",
103
86
  "default": "./build/ksuid/ksuid.mjs"
104
87
  },
105
88
  "./errors": {
106
89
  "types": "./build/errors.d.mts",
107
- "import": {
108
- "@jkomyno/source": "./src/errors.ts",
109
- "default": "./build/errors.mjs"
110
- },
90
+ "import": "./build/errors.mjs",
111
91
  "default": "./build/errors.mjs"
112
92
  },
113
93
  "./package.json": "./package.json"
@@ -132,6 +112,9 @@
132
112
  "test:integration": "vitest --project integration run",
133
113
  "test:e2e": "pnpm --filter @uniku/e2e-cloudflare test:e2e",
134
114
  "test:e2e:cloudflare": "pnpm --filter @uniku/e2e-cloudflare test:e2e:cloudflare",
115
+ "smoke:publish": "node scripts/publish-smoke.mjs",
116
+ "smoke:runtime:bun": "bun scripts/runtime-smoke.mjs",
117
+ "smoke:runtime:deno": "deno run --node-modules-dir=manual scripts/runtime-smoke.mjs",
135
118
  "bench": "vitest bench --run --project unit",
136
119
  "bench:compat": "vitest bench ./__tests__/bench/compat.bench.ts --run --project unit",
137
120
  "bench:summary": "bun scripts/bench-summary.ts",
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Common byte manipulation utilities shared across ID generators.
3
+ */
4
+
5
+ /**
6
+ * Increment a byte array by 1 in-place, propagating carry from LSB to MSB.
7
+ * Mutates the input array directly - no allocation.
8
+ *
9
+ * Used by ULID and KSUID for monotonic ordering within the same time unit.
10
+ *
11
+ * @returns true if increment succeeded, false if all bytes overflowed to 0
12
+ */
13
+ export function incrementBytesInPlace(bytes: Uint8Array): boolean {
14
+ for (let i = bytes.length - 1; i >= 0; i -= 1) {
15
+ if (bytes[i] < 255) {
16
+ bytes[i] += 1
17
+ return true
18
+ }
19
+ bytes[i] = 0
20
+ }
21
+
22
+ // All bytes overflowed to 0 - astronomically unlikely (1 in 2^80 for ULID, 1 in 2^128 for KSUID)
23
+ return false
24
+ }
25
+
26
+ /**
27
+ * Write a 48-bit timestamp as big-endian bytes.
28
+ * Used by UUID v7 and ULID for millisecond-precision timestamps.
29
+ *
30
+ * @param buf - Target buffer
31
+ * @param offset - Starting byte offset
32
+ * @param msecs - Milliseconds since Unix epoch (must fit in 48 bits)
33
+ */
34
+ export function writeTimestamp48(buf: Uint8Array, offset: number, msecs: number): void {
35
+ buf[offset] = (msecs / 0x10000000000) & 0xff
36
+ buf[offset + 1] = (msecs / 0x100000000) & 0xff
37
+ buf[offset + 2] = (msecs / 0x1000000) & 0xff
38
+ buf[offset + 3] = (msecs / 0x10000) & 0xff
39
+ buf[offset + 4] = (msecs / 0x100) & 0xff
40
+ buf[offset + 5] = msecs & 0xff
41
+ }
42
+
43
+ /**
44
+ * Write a 32-bit timestamp as big-endian bytes.
45
+ * Used by KSUID for second-precision timestamps.
46
+ *
47
+ * `writeTimestamp32(buf, offset, timestamp)` is equivalent to `new DataView(buf.buffer, offset, 4).setUint32(offset, timestamp, false)`,
48
+ * but it's faster because it doesn't require creating a DataView object.
49
+ *
50
+ * @param buf - Target buffer
51
+ * @param offset - Starting byte offset
52
+ * @param secs - Seconds (must fit in 32 bits)
53
+ */
54
+ export function writeTimestamp32(buf: Uint8Array, offset: number, secs: number): void {
55
+ buf[offset] = (secs >>> 24) & 0xff
56
+ buf[offset + 1] = (secs >>> 16) & 0xff
57
+ buf[offset + 2] = (secs >>> 8) & 0xff
58
+ buf[offset + 3] = secs & 0xff
59
+ }
@@ -0,0 +1,66 @@
1
+ const randomFill = /*@__PURE__*/ globalThis.crypto.getRandomValues.bind(globalThis.crypto)
2
+
3
+ /**
4
+ * Simple random byte pool for ID generation.
5
+ *
6
+ * Module-level state is per JavaScript isolate, so SharedArrayBuffer and Atomics
7
+ * only add overhead here. Returned views are consumed synchronously by callers.
8
+ */
9
+
10
+ const POOL_SIZE = 256
11
+ const pool = new Uint8Array(POOL_SIZE)
12
+ let poolOffset = POOL_SIZE
13
+
14
+ function refillPool(): void {
15
+ randomFill(pool)
16
+ poolOffset = 0
17
+ }
18
+
19
+ /**
20
+ * Return a view of `count` random bytes from the pool.
21
+ */
22
+ export function randomBytes(count: number): Uint8Array {
23
+ if (count > POOL_SIZE) {
24
+ return randomFill(new Uint8Array(count))
25
+ }
26
+
27
+ if (poolOffset + count > POOL_SIZE) {
28
+ refillPool()
29
+ }
30
+
31
+ const start = poolOffset
32
+ poolOffset += count
33
+ return pool.subarray(start, poolOffset)
34
+ }
35
+
36
+ /**
37
+ * Generate a random unsigned 32-bit integer from the shared pool.
38
+ */
39
+ export function randomUint32(): number {
40
+ if (poolOffset > POOL_SIZE - 4) {
41
+ refillPool()
42
+ }
43
+
44
+ const value =
45
+ (pool[poolOffset] * 0x1000000 +
46
+ pool[poolOffset + 1] * 0x10000 +
47
+ pool[poolOffset + 2] * 0x100 +
48
+ pool[poolOffset + 3]) >>>
49
+ 0
50
+ poolOffset += 4
51
+ return value
52
+ }
53
+
54
+ /**
55
+ * Generate 16 bytes of cryptographically strong random data.
56
+ * Uses a pre-filled pool to minimize crypto API calls.
57
+ */
58
+ export function rng(): Uint8Array {
59
+ if (poolOffset > POOL_SIZE - 16) {
60
+ refillPool()
61
+ }
62
+
63
+ const start = poolOffset
64
+ poolOffset += 16
65
+ return pool.subarray(start, poolOffset)
66
+ }
@@ -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
+ }