Skip to content

Commit d2e569c

Browse files
Add src from ulidx
1 parent 468abe9 commit d2e569c

8 files changed

Lines changed: 391 additions & 0 deletions

File tree

src/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// These values should NEVER change. The values are precisely for
2+
// generating ULIDs.
3+
export const B32_CHARACTERS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
4+
export const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32
5+
export const ENCODING_LEN = 32; // from ENCODING.length;
6+
export const MAX_ULID = "7ZZZZZZZZZZZZZZZZZZZZZZZZZ";
7+
export const MIN_ULID = "00000000000000000000000000";
8+
export const RANDOM_LEN = 16;
9+
export const TIME_LEN = 10;
10+
export const TIME_MAX = 281474976710655; // from Math.pow(2, 48) - 1;
11+
export const ULID_REGEX = /^[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$/;
12+
export const UUID_REGEX = /^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/;

src/crockford.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Code from https://github.com/devbanana/crockford-base32/blob/develop/src/index.ts
2+
import { B32_CHARACTERS, ENCODING, ENCODING_LEN } from "./constants.js";
3+
import { ULIDError, ULIDErrorCode } from "./error.js";
4+
import { replaceCharAt } from "./utils.js";
5+
6+
export function crockfordEncode(input: Uint8Array): string {
7+
const output: number[] = [];
8+
let bitsRead = 0;
9+
let buffer = 0;
10+
const reversedInput = new Uint8Array(input.slice().reverse());
11+
for (const byte of reversedInput) {
12+
buffer |= byte << bitsRead;
13+
bitsRead += 8;
14+
15+
while (bitsRead >= 5) {
16+
output.unshift(buffer & 0x1f);
17+
buffer >>>= 5;
18+
bitsRead -= 5;
19+
}
20+
}
21+
if (bitsRead > 0) {
22+
output.unshift(buffer & 0x1f);
23+
}
24+
return output.map(byte => B32_CHARACTERS.charAt(byte)).join("");
25+
}
26+
27+
export function crockfordDecode(input: string): Uint8Array {
28+
const sanitizedInput = input.toUpperCase().split("").reverse().join("");
29+
const output: number[] = [];
30+
let bitsRead = 0;
31+
let buffer = 0;
32+
for (const character of sanitizedInput) {
33+
const byte = B32_CHARACTERS.indexOf(character);
34+
if (byte === -1) {
35+
throw new Error(`Invalid base 32 character found in string: ${character}`);
36+
}
37+
buffer |= byte << bitsRead;
38+
bitsRead += 5;
39+
while (bitsRead >= 8) {
40+
output.unshift(buffer & 0xff);
41+
buffer >>>= 8;
42+
bitsRead -= 8;
43+
}
44+
}
45+
if (bitsRead >= 5 || buffer > 0) {
46+
output.unshift(buffer & 0xff);
47+
}
48+
return new Uint8Array(output);
49+
}
50+
51+
/**
52+
* Fix a ULID's Base32 encoding -
53+
* i and l (case-insensitive) will be treated as 1 and o (case-insensitive) will be treated as 0.
54+
* hyphens are ignored during decoding.
55+
* @param id The ULID
56+
* @returns The cleaned up ULID
57+
*/
58+
export function fixULIDBase32(id: string): string {
59+
return id.replace(/i/gi, "1").replace(/l/gi, "1").replace(/o/gi, "0").replace(/-/g, "");
60+
}
61+
62+
export function incrementBase32(str: string): string {
63+
let done: string | undefined = undefined,
64+
index = str.length,
65+
char: string,
66+
charIndex: number,
67+
output = str;
68+
const maxCharIndex = ENCODING_LEN - 1;
69+
while (!done && index-- >= 0) {
70+
char = output[index];
71+
charIndex = ENCODING.indexOf(char);
72+
if (charIndex === -1) {
73+
throw new ULIDError(
74+
ULIDErrorCode.Base32IncorrectEncoding,
75+
"Incorrectly encoded string"
76+
);
77+
}
78+
if (charIndex === maxCharIndex) {
79+
output = replaceCharAt(output, index, ENCODING[0]);
80+
continue;
81+
}
82+
done = replaceCharAt(output, index, ENCODING[charIndex + 1]);
83+
}
84+
if (typeof done === "string") {
85+
return done;
86+
}
87+
throw new ULIDError(
88+
ULIDErrorCode.Base32IncorrectEncoding,
89+
"Failed incrementing string"
90+
);
91+
}

src/error.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export enum ULIDErrorCode {
2+
Base32IncorrectEncoding = "B32_ENC_INVALID",
3+
DecodeTimeInvalidCharacter = "DEC_TIME_CHAR",
4+
DecodeTimeValueMalformed = "DEC_TIME_MALFORMED",
5+
EncodeTimeNegative = "ENC_TIME_NEG",
6+
EncodeTimeSizeExceeded = "ENC_TIME_SIZE_EXCEED",
7+
EncodeTimeValueMalformed = "ENC_TIME_MALFORMED",
8+
PRNGDetectFailure = "PRNG_DETECT",
9+
ULIDInvalid = "ULID_INVALID",
10+
Unexpected = "UNEXPECTED",
11+
UUIDInvalid = "UUID_INVALID"
12+
}
13+
14+
export class ULIDError extends Error {
15+
public code: ULIDErrorCode;
16+
17+
constructor(errorCode: ULIDErrorCode, message: string) {
18+
super(`${message} (${errorCode})`);
19+
20+
this.name = "ULIDError";
21+
22+
this.code = errorCode;
23+
}
24+
}

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { isValid, monotonicFactory, ulid } from "./ulid.js";
2+
export { ulidToUUID, uuidToULID } from "./uuid.js";
3+
export { fixULIDBase32 } from "./crockford.js";
4+
export { ULIDError, ULIDErrorCode } from "./error.js";
5+
export { MAX_ULID, MIN_ULID } from "./constants.js";
6+
export * from "./types.js";

src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type PRNG = () => number;
2+
3+
export type ULID = string;
4+
5+
export type ULIDFactory = (seedTime?: number) => ULID;
6+
7+
export type UUID = string;

src/ulid.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import crypto from "node:crypto";
2+
import { incrementBase32 } from "./crockford.js";
3+
import { ENCODING, ENCODING_LEN, RANDOM_LEN, TIME_LEN, TIME_MAX } from "./constants.js";
4+
import { ULIDError, ULIDErrorCode } from "./error.js";
5+
import { PRNG, ULID, ULIDFactory } from "./types.js";
6+
import { randomChar } from "./utils.js";
7+
8+
/**
9+
* Decode time from a ULID
10+
* @param id The ULID
11+
* @returns The decoded timestamp
12+
*/
13+
export function decodeTime(id: ULID): number {
14+
if (id.length !== TIME_LEN + RANDOM_LEN) {
15+
throw new ULIDError(ULIDErrorCode.DecodeTimeValueMalformed, "Malformed ULID");
16+
}
17+
const time = id
18+
.substr(0, TIME_LEN)
19+
.toUpperCase()
20+
.split("")
21+
.reverse()
22+
.reduce((carry, char, index) => {
23+
const encodingIndex = ENCODING.indexOf(char);
24+
if (encodingIndex === -1) {
25+
throw new ULIDError(ULIDErrorCode.DecodeTimeInvalidCharacter, `Time decode error: Invalid character: ${char}`);
26+
}
27+
return (carry += encodingIndex * Math.pow(ENCODING_LEN, index));
28+
}, 0);
29+
if (time > TIME_MAX) {
30+
throw new ULIDError(ULIDErrorCode.DecodeTimeValueMalformed, `Malformed ULID: timestamp too large: ${time}`);
31+
}
32+
return time;
33+
}
34+
35+
/**
36+
* Detect the best PRNG (pseudo-random number generator)
37+
* @param root The root to check from (global/window)
38+
* @returns The PRNG function
39+
*/
40+
export function detectPRNG(root?: any): PRNG {
41+
const rootLookup = root || detectRoot();
42+
const globalCrypto =
43+
(rootLookup && (rootLookup.crypto || rootLookup.msCrypto)) ||
44+
(typeof crypto !== "undefined" ? crypto : null);
45+
if (typeof globalCrypto?.getRandomValues === "function") {
46+
return () => {
47+
const buffer = new Uint8Array(1);
48+
globalCrypto.getRandomValues(buffer);
49+
return buffer[0] / 0xff;
50+
};
51+
} else if (typeof globalCrypto?.randomBytes === "function") {
52+
return () => globalCrypto.randomBytes(1).readUInt8() / 0xff;
53+
} else if (crypto?.randomBytes) {
54+
return () => crypto.randomBytes(1).readUInt8() / 0xff;
55+
}
56+
throw new ULIDError(ULIDErrorCode.PRNGDetectFailure, "Failed to find a reliable PRNG");
57+
}
58+
59+
function detectRoot(): any {
60+
if (inWebWorker()) return self;
61+
if (typeof window !== "undefined") {
62+
return window;
63+
}
64+
if (typeof global !== "undefined") {
65+
return global;
66+
}
67+
if (typeof globalThis !== "undefined") {
68+
return globalThis;
69+
}
70+
return null;
71+
}
72+
73+
export function encodeRandom(len: number, prng: PRNG): string {
74+
let str = "";
75+
for (; len > 0; len--) {
76+
str = randomChar(prng) + str;
77+
}
78+
return str;
79+
}
80+
81+
/**
82+
* Encode the time portion of a ULID
83+
* @param now The current timestamp
84+
* @param len Length to generate
85+
* @returns The encoded time
86+
*/
87+
export function encodeTime(now: number, len: number): string {
88+
if (isNaN(now)) {
89+
throw new ULIDError(
90+
ULIDErrorCode.EncodeTimeValueMalformed,
91+
`Time must be a number: ${now}`
92+
);
93+
} else if (now > TIME_MAX) {
94+
throw new ULIDError(
95+
ULIDErrorCode.EncodeTimeSizeExceeded,
96+
`Cannot encode a time larger than ${TIME_MAX}: ${now}`
97+
);
98+
} else if (now < 0) {
99+
throw new ULIDError(
100+
ULIDErrorCode.EncodeTimeNegative,
101+
`Time must be positive: ${now}`
102+
);
103+
} else if (Number.isInteger(now) === false) {
104+
throw new ULIDError(
105+
ULIDErrorCode.EncodeTimeValueMalformed,
106+
`Time must be an integer: ${now}`
107+
);
108+
}
109+
let mod: number,
110+
str: string = "";
111+
for (let currentLen = len; currentLen > 0; currentLen--) {
112+
mod = now % ENCODING_LEN;
113+
str = ENCODING.charAt(mod) + str;
114+
now = (now - mod) / ENCODING_LEN;
115+
}
116+
return str;
117+
}
118+
119+
function inWebWorker(): boolean {
120+
// @ts-ignore
121+
return typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope;
122+
}
123+
124+
/**
125+
* Check if a ULID is valid
126+
* @param id The ULID to test
127+
* @returns True if valid, false otherwise
128+
* @example
129+
* isValid("01HNZX8JGFACFA36RBXDHEQN6E"); // true
130+
* isValid(""); // false
131+
*/
132+
export function isValid(id: string): boolean {
133+
return (
134+
typeof id === "string" &&
135+
id.length === TIME_LEN + RANDOM_LEN &&
136+
id
137+
.toUpperCase()
138+
.split("")
139+
.every(char => ENCODING.indexOf(char) !== -1)
140+
);
141+
}
142+
143+
/**
144+
* Create a ULID factory to generate monotonically-increasing
145+
* ULIDs
146+
* @param prng The PRNG to use
147+
* @returns A ulid factory
148+
* @example
149+
* const ulid = monotonicFactory();
150+
* ulid(); // "01HNZXD07M5CEN5XA66EMZSRZW"
151+
*/
152+
export function monotonicFactory(prng?: PRNG): ULIDFactory {
153+
const currentPRNG = prng || detectPRNG();
154+
let lastTime: number = 0,
155+
lastRandom: string;
156+
return function _ulid(seedTime?: number): ULID {
157+
const seed = !seedTime || isNaN(seedTime) ? Date.now() : seedTime;
158+
if (seed <= lastTime) {
159+
const incrementedRandom = (lastRandom = incrementBase32(lastRandom));
160+
return encodeTime(lastTime, TIME_LEN) + incrementedRandom;
161+
}
162+
lastTime = seed;
163+
const newRandom = (lastRandom = encodeRandom(RANDOM_LEN, currentPRNG));
164+
return encodeTime(seed, TIME_LEN) + newRandom;
165+
};
166+
}
167+
168+
/**
169+
* Generate a ULID
170+
* @param seedTime Optional time seed
171+
* @param prng Optional PRNG function
172+
* @returns A ULID string
173+
* @example
174+
* ulid(); // "01HNZXD07M5CEN5XA66EMZSRZW"
175+
*/
176+
export function ulid(seedTime?: number, prng?: PRNG): ULID {
177+
const currentPRNG = prng || detectPRNG();
178+
const seed = !seedTime || isNaN(seedTime) ? Date.now() : seedTime;
179+
return encodeTime(seed, TIME_LEN) + encodeRandom(RANDOM_LEN, currentPRNG);
180+
}

src/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ENCODING, ENCODING_LEN } from "./constants";
2+
import { PRNG } from "./types";
3+
4+
export function randomChar(prng: PRNG): string {
5+
let rand = Math.floor(prng() * ENCODING_LEN);
6+
if (rand === ENCODING_LEN) {
7+
rand = ENCODING_LEN - 1;
8+
}
9+
return ENCODING.charAt(rand);
10+
}
11+
12+
export function replaceCharAt(str: string, index: number, char: string): string {
13+
if (index > str.length - 1) {
14+
return str;
15+
}
16+
return str.substr(0, index) + char + str.substr(index + 1);
17+
}

src/uuid.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { UUID } from "crypto";
2+
import { ULID_REGEX, UUID_REGEX } from "./constants";
3+
import { crockfordDecode, crockfordEncode } from "./crockford";
4+
import { ULIDError, ULIDErrorCode } from "./error";
5+
import { ULID } from "./types";
6+
7+
/**
8+
* Convert a ULID to a UUID
9+
* @param ulid The ULID to convert
10+
* @returns A UUID string
11+
*/
12+
export function ulidToUUID(ulid: ULID): UUID {
13+
const isValid = ULID_REGEX.test(ulid);
14+
if (!isValid) {
15+
throw new ULIDError(ULIDErrorCode.ULIDInvalid, `Invalid ULID: ${ulid}`);
16+
}
17+
const uint8Array = crockfordDecode(ulid);
18+
let uuid = Array.from(uint8Array)
19+
.map(byte => byte.toString(16).padStart(2, "0"))
20+
.join("");
21+
uuid =
22+
uuid.substring(0, 8) +
23+
"-" +
24+
uuid.substring(8, 12) +
25+
"-" +
26+
uuid.substring(12, 16) +
27+
"-" +
28+
uuid.substring(16, 20) +
29+
"-" +
30+
uuid.substring(20);
31+
return uuid as UUID;
32+
}
33+
34+
/**
35+
* Convert a UUID to a ULID
36+
* @param uuid The UUID to convert
37+
* @returns A ULID string
38+
*/
39+
export function uuidToULID(uuid: string): ULID {
40+
const isValid = UUID_REGEX.test(uuid);
41+
if (!isValid) {
42+
throw new ULIDError(ULIDErrorCode.UUIDInvalid, `Invalid UUID: ${uuid}`);
43+
}
44+
const bytes = uuid
45+
.replace(/-/g, "")
46+
.match(/.{1,2}/g);
47+
if (!bytes) {
48+
throw new ULIDError(ULIDErrorCode.Unexpected, `Failed parsing UUID bytes: ${uuid}`);
49+
}
50+
const uint8Array = new Uint8Array(
51+
bytes.map(byte => parseInt(byte, 16))
52+
);
53+
return crockfordEncode(uint8Array);
54+
}

0 commit comments

Comments
 (0)