UNPKG

ulid-workers

Version:

ULID generator for Cloudflare workers

168 lines (167 loc) 5.92 kB
"use strict"; // 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, };