diff --git a/source/constants.ts b/source/constants.ts index c585096..5bf6375 100644 --- a/source/constants.ts +++ b/source/constants.ts @@ -1,2 +1,6 @@ export const MAX_ULID = "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"; export const MIN_ULID = "00000000000000000000000000"; + +export const ULID_REGEX = /^[0-7][0-9a-hjkmnp-tv-zA-HJKMNP-TV-Z]{25}$/; +export const UUID_REGEX = /^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/; +export const B32_CHARACTERS = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; diff --git a/source/crockford.ts b/source/crockford.ts new file mode 100644 index 0000000..9ea6975 --- /dev/null +++ b/source/crockford.ts @@ -0,0 +1,58 @@ +// Code from https://github.com/devbanana/crockford-base32/blob/develop/src/index.ts +import { B32_CHARACTERS } from "./constants.js"; + +export function crockfordEncode(input: Uint8Array): string { + const output: number[] = []; + let bitsRead = 0; + let buffer = 0; + + const reversedInput = new Uint8Array(input.slice().reverse()); + + for (const byte of reversedInput) { + buffer |= byte << bitsRead; + bitsRead += 8; + + while (bitsRead >= 5) { + output.unshift(buffer & 0x1f); + buffer >>>= 5; + bitsRead -= 5; + } + } + + if (bitsRead > 0) { + output.unshift(buffer & 0x1f); + } + + return output.map(byte => B32_CHARACTERS.charAt(byte)).join(""); +} + +export function crockfordDecode(input: string): Uint8Array { + const sanitizedInput = input.toUpperCase().split("").reverse().join(""); + + const output: number[] = []; + let bitsRead = 0; + let buffer = 0; + + for (const character of sanitizedInput) { + const byte = B32_CHARACTERS.indexOf(character); + + if (byte === -1) { + throw new Error(`Invalid base 32 character found in string: ${character}`); + } + + buffer |= byte << bitsRead; + bitsRead += 5; + + while (bitsRead >= 8) { + output.unshift(buffer & 0xff); + buffer >>>= 8; + bitsRead -= 8; + } + } + + if (bitsRead >= 5 || buffer > 0) { + output.unshift(buffer & 0xff); + } + + return new Uint8Array(output); +} diff --git a/source/index.ts b/source/index.ts index 5d82a01..b9bc622 100644 --- a/source/index.ts +++ b/source/index.ts @@ -5,7 +5,9 @@ export { detectPRNG, isValid, monotonicFactory, - ulid + ulid, + ulidToUUID, + uuidToULID } from "./ulid.js"; export * from "./constants.js"; export * from "./types.js"; diff --git a/source/types.ts b/source/types.ts index bb500a4..2715941 100644 --- a/source/types.ts +++ b/source/types.ts @@ -3,3 +3,5 @@ export type PRNG = () => number; export type ULID = string; export type ULIDFactory = (seedTime?: number) => ULID; + +export type UUID = string; diff --git a/source/ulid.ts b/source/ulid.ts index c964f6e..1bd03e3 100644 --- a/source/ulid.ts +++ b/source/ulid.ts @@ -1,6 +1,8 @@ import crypto from "node:crypto"; import { Layerr } from "layerr"; -import { PRNG, ULID, ULIDFactory } from "./types.js"; +import { PRNG, ULID, ULIDFactory, UUID } from "./types.js"; +import { crockfordDecode, crockfordEncode } from "./crockford.js"; +import { ULID_REGEX, UUID_REGEX } from "./constants.js"; // These values should NEVER change. The values are precisely for // generating ULIDs. @@ -265,3 +267,47 @@ export function ulid(seedTime?: number, prng?: PRNG): ULID { const seed = isNaN(seedTime) ? Date.now() : seedTime; return encodeTime(seed, TIME_LEN) + encodeRandom(RANDOM_LEN, currentPRNG); } + +export function ulidToUUID(ulid: string): UUID { + const isValid = ULID_REGEX.test(ulid); + + if (!isValid) { + throw new Layerr({ info: { code: "INVALID_ULID", ...ERROR_INFO } }, "Invalid ULID"); + } + + const uint8Array = crockfordDecode(ulid); + let uuid = Array.from(uint8Array) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); + + uuid = + uuid.substring(0, 8) + + "-" + + uuid.substring(8, 12) + + "-" + + uuid.substring(12, 16) + + "-" + + uuid.substring(16, 20) + + "-" + + uuid.substring(20); + + return uuid; +} + +export function uuidToULID(uuid: string): ULID { + const isValid = UUID_REGEX.test(uuid); + + if (!isValid) { + throw new Layerr({ info: { code: "INVALID_UUID", ...ERROR_INFO } }, "Invalid UUID"); + } + + const uint8Array = new Uint8Array( + uuid + .replace(/-/g, "") + .match(/.{1,2}/g) + .map(byte => parseInt(byte, 16)) + ); + const ulid = crockfordEncode(uint8Array); + + return ulid; +} diff --git a/test/browser-cjs/index.spec.cjs b/test/browser-cjs/index.spec.cjs index 04436d3..39ba600 100644 --- a/test/browser-cjs/index.spec.cjs +++ b/test/browser-cjs/index.spec.cjs @@ -3,13 +3,17 @@ const sinon = require("sinon"); const { MAX_ULID, MIN_ULID, + ULID_REGEX, + UUID_REGEX, decodeTime, detectPRNG, encodeTime, fixULIDBase32, isValid, monotonicFactory, - ulid + ulid, + ulidToUUID, + uuidToULID } = require("../../dist/browser/index.cjs"); describe("ulid", function() { @@ -225,4 +229,28 @@ describe("ulid", function() { expect(MAX_ULID).to.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ"); }); }); + + describe("ulid to uuid", function() { + it("should return a valid uuid", function() { + expect(ulidToUUID(ulid())).to.match(UUID_REGEX); + }); + + it("should throw an error if an invalid ulid is provided", function() { + expect(() => { + ulidToUUID("whatever"); + }).to.throw(/Invalid ULID/); + }); + }); + + describe("uuid to ulid", function() { + it("should return a valid ulid", function() { + expect(uuidToULID("8299b5fe-3824-45b6-9bc9-b13ec3b3656c")).to.match(ULID_REGEX); + }); + + it("should throw an error if an invalid uuid is provided", function() { + expect(() => { + uuidToULID("whatever"); + }).to.throw(/Invalid UUID/); + }); + }); }); diff --git a/test/node-cjs/index.spec.cjs b/test/node-cjs/index.spec.cjs index 74c5a1c..2890010 100644 --- a/test/node-cjs/index.spec.cjs +++ b/test/node-cjs/index.spec.cjs @@ -1,15 +1,20 @@ const { expect } = require("chai"); const sinon = require("sinon"); +const { randomUUID } = require("node:crypto"); const { MAX_ULID, MIN_ULID, + ULID_REGEX, + UUID_REGEX, decodeTime, detectPRNG, encodeTime, fixULIDBase32, isValid, monotonicFactory, - ulid + ulid, + ulidToUUID, + uuidToULID } = require("../../dist/node/index.cjs"); describe("ulid", function() { @@ -225,4 +230,28 @@ describe("ulid", function() { expect(MAX_ULID).to.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ"); }); }); + + describe("ulid to uuid", function() { + it("should return a valid uuid", function() { + expect(ulidToUUID(ulid())).to.match(UUID_REGEX); + }); + + it("should throw an error if an invalid ulid is provided", function() { + expect(() => { + ulidToUUID("whatever"); + }).to.throw(/Invalid ULID/); + }); + }); + + describe("uuid to ulid", function() { + it("should return a valid ulid", function() { + expect(uuidToULID(randomUUID())).to.match(ULID_REGEX); + }); + + it("should throw an error if an invalid uuid is provided", function() { + expect(() => { + uuidToULID("whatever"); + }).to.throw(/Invalid UUID/); + }); + }); }); diff --git a/test/node-esm/index.spec.js b/test/node-esm/index.spec.js index eb733b4..94a6664 100644 --- a/test/node-esm/index.spec.js +++ b/test/node-esm/index.spec.js @@ -1,15 +1,20 @@ import { expect } from "chai"; import sinon from "sinon"; +import { randomUUID } from "node:crypto"; import { MAX_ULID, MIN_ULID, + ULID_REGEX, + UUID_REGEX, decodeTime, detectPRNG, encodeTime, fixULIDBase32, isValid, monotonicFactory, - ulid + ulid, + ulidToUUID, + uuidToULID } from "../../dist/node/index.js"; describe("ulid", function () { @@ -225,4 +230,28 @@ describe("ulid", function () { expect(MAX_ULID).to.equal("7ZZZZZZZZZZZZZZZZZZZZZZZZZ"); }); }); + + describe("ulid to uuid", function () { + it("should return a valid uuid", function () { + expect(ulidToUUID(ulid())).to.match(UUID_REGEX); + }); + + it("should throw an error if an invalid ulid is provided", function () { + expect(() => { + ulidToUUID("whatever"); + }).to.throw(/Invalid ULID/); + }); + }); + + describe("uuid to ulid", function () { + it("should return a valid ulid", function () { + expect(uuidToULID(randomUUID())).to.match(ULID_REGEX); + }); + + it("should throw an error if an invalid uuid is provided", function () { + expect(() => { + uuidToULID("whatever"); + }).to.throw(/Invalid UUID/); + }); + }); });