ulid-workers
Version:
ULID generator for Cloudflare workers
168 lines (167 loc) • 5.92 kB
JavaScript
;
// Adapted from https://github.com/perry-mitchell/ulidx for use with Cloudflare
// Workers and Durable Objects
Object.defineProperty(exports, "__esModule", { value: true });
exports.exportedForTesting = exports.ulidFactory = exports.decodeTime = exports.encodeTime = void 0;
// These values should NEVER change. The values are precisely for
// generating ULIDs.
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32
const ENCODING_LEN = ENCODING.length;
const TIME_MAX = Math.pow(2, 48) - 1;
const TIME_LEN = 10;
const RANDOM_LEN = 16;
// The Cloudflare Workers Runtime implements the Web Crypto API
// `crypto.getRandomValues` function to retrieve fast and secure
// randomness. This function is not available in the Node.js
// See : https://developers.cloudflare.com/workers/runtime-apis/web-crypto#methods
function webCryptoPRNG() {
const buffer = new Uint8Array(1);
crypto.getRandomValues(buffer);
return buffer[0] / 0xff; // divide by 0xff to get a number between 0 and 1
}
function encodeRandom(len) {
let str = "";
for (; len > 0; len--) {
str = randomChar() + str;
}
return str;
}
function validateTimestamp(timestamp) {
if (isNaN(timestamp)) {
throw new Error(`timestamp must be a number: ${timestamp}`);
}
else if (timestamp > TIME_MAX) {
throw new Error(`cannot encode a timestamp larger than 2^48 - 1 (${TIME_MAX}) : ${timestamp}`);
}
else if (timestamp < 0) {
throw new Error(`timestamp must be positive: ${timestamp}`);
}
else if (Number.isInteger(timestamp) === false) {
throw new Error(`timestamp must be an integer: ${timestamp}`);
}
}
function encodeTime(timestamp) {
validateTimestamp(timestamp);
let mod;
let str = "";
for (let tLen = TIME_LEN; tLen > 0; tLen--) {
mod = timestamp % ENCODING_LEN;
str = ENCODING.charAt(mod) + str;
timestamp = (timestamp - mod) / ENCODING_LEN;
}
return str;
}
exports.encodeTime = encodeTime;
function incrementBase32(str) {
let done = undefined, index = str.length, char, charIndex, output = str;
const maxCharIndex = ENCODING_LEN - 1;
if (str.length > RANDOM_LEN) {
throw new Error(`Base32 value to increment cannot be longer than ${RANDOM_LEN} characters`);
}
if (str === "Z".repeat(RANDOM_LEN)) {
throw new Error(`Cannot increment Base32 maximum value ${"Z".repeat(RANDOM_LEN)}`);
}
while (!done && index-- >= 0) {
char = output[index];
charIndex = ENCODING.indexOf(char);
if (charIndex === -1) {
throw new Error("Incorrectly encoded string");
}
if (charIndex === maxCharIndex) {
output = replaceCharAt(output, index, ENCODING[0]);
continue;
}
done = replaceCharAt(output, index, ENCODING[charIndex + 1]);
}
if (typeof done === "string") {
return done;
}
throw new Error("Failed incrementing string");
}
function randomChar() {
let rand = Math.floor(webCryptoPRNG() * ENCODING_LEN);
if (rand === ENCODING_LEN) {
rand = ENCODING_LEN - 1;
}
return ENCODING.charAt(rand);
}
function replaceCharAt(str, index, char) {
if (index > str.length - 1) {
return str;
}
return str.substring(0, index) + char + str.substring(index + 1);
}
/**
* Decode the time component of a ULID to a number representing the UNIX epoch timestamp in milliseconds.
* @param {string} id - A ULID string.
* @returns {number} The UNIX epoch timestamp in milliseconds.
*/
function decodeTime(id) {
if (id.length !== TIME_LEN + RANDOM_LEN) {
throw new Error("Malformed ULID");
}
const time = id
.substring(0, TIME_LEN)
.split("")
.reverse()
.reduce((carry, char, index) => {
const encodingIndex = ENCODING.indexOf(char);
if (encodingIndex === -1) {
throw new Error(`Time decode error: Invalid character: ${char}`);
}
return (carry += encodingIndex * Math.pow(ENCODING_LEN, index));
}, 0);
if (time > TIME_MAX) {
throw new Error(`Malformed ULID: timestamp too large: ${time}`);
}
return time;
}
exports.decodeTime = decodeTime;
/**
* @param {ULIDFactoryArgs} args - An Object representing valid arguments for the ULID factory.
* @returns {ULIDFactory} A function that generates ULIDs.
*/
const ulidFactory = (args) => {
const monotonic = args?.monotonic ?? true;
if (monotonic) {
return (function () {
let lastTime = 0;
let lastRandom;
return function (timestamp) {
let timestampOrNow = timestamp || Date.now();
validateTimestamp(timestampOrNow);
if (timestampOrNow > lastTime) {
lastTime = timestampOrNow;
const random = encodeRandom(RANDOM_LEN);
lastRandom = random;
return encodeTime(timestampOrNow) + random;
}
else {
// <= lastTime : increment lastRandom
const random = incrementBase32(lastRandom);
lastRandom = random;
return encodeTime(lastTime) + random;
}
};
})();
}
else {
return (function () {
return function (timestamp) {
let timestampOrNow = timestamp || Date.now();
validateTimestamp(timestampOrNow);
return encodeTime(timestampOrNow) + encodeRandom(RANDOM_LEN);
};
})();
}
};
exports.ulidFactory = ulidFactory;
// Don't publicly export private functions, but allow them to be tested.
exports.exportedForTesting = {
encodeRandom,
incrementBase32,
randomChar,
replaceCharAt,
validateTimestamp,
webCryptoPRNG,
};