-
Notifications
You must be signed in to change notification settings - Fork 30
deps(protocol): vendor TypeID generation, drop typeid-js #6103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { CROCKFORD_ALPHABET, typeid, uuidV7Bytes } from "../typeid.js"; | ||
|
|
||
| // Ported from the Arcjet Python SDK's tests/unit/test_typeid.py — both SDKs | ||
| // vendor the same inline TypeID generation, so they must satisfy the same | ||
| // invariants (https://github.com/jetify-com/typeid). | ||
|
|
||
| const CROCKFORD_RE = /^[0-9a-hj-km-np-tv-z]{26}$/; | ||
|
|
||
| /** Assert `rid` is a well-formed `lreq` TypeID and return the suffix. */ | ||
| function assertValidTypeid(rid: string): string { | ||
| const sep = rid.indexOf("_"); | ||
| const prefix = rid.slice(0, sep); | ||
| const suffix = rid.slice(sep + 1); | ||
| assert.equal(prefix, "lreq"); | ||
| assert.equal(suffix.length, 26); | ||
| assert.match(suffix, CROCKFORD_RE, `bad suffix chars: ${suffix}`); | ||
| // 128-bit value encoded in 130 bits, so the leading char never exceeds 7. | ||
| assert.ok("01234567".includes(suffix[0]), `overflow: first char '${suffix[0]}'`); | ||
| return suffix; | ||
| } | ||
|
|
||
| /** Assert `raw` is valid UUIDv7 bytes (version 7, RFC 4122 variant). */ | ||
| function assertValidUuidV7(raw: Uint8Array): void { | ||
| assert.equal(raw.length, 16); | ||
| assert.equal(raw[6] >> 4, 7, "version nibble must be 7"); | ||
| assert.equal(raw[8] >> 6, 0b10, "variant bits must be 10"); | ||
| } | ||
|
|
||
| // --- Happy path ----------------------------------------------------------- | ||
|
|
||
| test("format: lreq_ prefix and 26-char Crockford base32 suffix", () => { | ||
| assertValidTypeid(typeid("lreq")); | ||
| }); | ||
|
|
||
| test("uuidv7 version and variant", () => { | ||
| assertValidUuidV7(uuidV7Bytes()); | ||
| }); | ||
|
|
||
| test("suffix first char is always 0-7", () => { | ||
| for (let i = 0; i < 50; i++) { | ||
| assertValidTypeid(typeid("lreq")); | ||
| } | ||
| }); | ||
|
|
||
| test("ids are unique", () => { | ||
| const ids = new Set<string>(); | ||
| for (let i = 0; i < 200; i++) { | ||
| ids.add(typeid("lreq")); | ||
| } | ||
| assert.equal(ids.size, 200); | ||
| }); | ||
|
|
||
| test("ids are time-sortable", () => { | ||
| const earlier = typeid("lreq", 1_000); | ||
| const later = typeid("lreq", 2_000); | ||
| assert.ok(later > earlier); | ||
| }); | ||
|
|
||
| // --- Edge cases ----------------------------------------------------------- | ||
|
|
||
| const zero10 = new Uint8Array(10); // all 0x00 | ||
| const ff10 = new Uint8Array(10).fill(0xff); // all 0xff | ||
|
|
||
| test("zero timestamp still well-formed", () => { | ||
| assertValidTypeid(typeid("lreq", 0)); | ||
| }); | ||
|
|
||
| test("max 48-bit timestamp still well-formed", () => { | ||
| assertValidTypeid(typeid("lreq", 2 ** 48 - 1)); | ||
| }); | ||
|
|
||
| test("low-entropy (all-zero) random still valid", () => { | ||
| assertValidTypeid(typeid("lreq", Date.now(), zero10)); | ||
| }); | ||
|
|
||
| test("max-entropy (all-0xff) random still valid", () => { | ||
| assertValidTypeid(typeid("lreq", Date.now(), ff10)); | ||
| }); | ||
|
|
||
| test("all-0xff random preserves UUIDv7 version and variant", () => { | ||
| assertValidUuidV7(uuidV7Bytes(Date.now(), ff10)); | ||
| }); | ||
|
|
||
| test("Crockford alphabet excludes ambiguous chars", () => { | ||
| for (const ch of "ilou") { | ||
| assert.ok(!CROCKFORD_ALPHABET.includes(ch)); | ||
| } | ||
| assert.equal(CROCKFORD_ALPHABET.length, 32); | ||
| }); | ||
|
|
||
| // --- Fuzz (loop over random timestamp + random bytes) --------------------- | ||
|
|
||
| const MAX_TIMESTAMP_MS = 2 ** 48 - 1; | ||
|
|
||
| test("fuzz: any timestamp + random bytes yields a valid TypeID and UUIDv7", () => { | ||
| for (let i = 0; i < 500; i++) { | ||
| const ts = Math.floor(Math.random() * MAX_TIMESTAMP_MS); | ||
| const random = crypto.getRandomValues(new Uint8Array(10)); | ||
| assertValidTypeid(typeid("lreq", ts, random)); | ||
| assertValidUuidV7(uuidV7Bytes(ts, random)); | ||
| } | ||
| }); | ||
|
|
||
| test("fuzz: embedded timestamp round-trips (truncated to integer ms)", () => { | ||
| for (let i = 0; i < 500; i++) { | ||
| const ts = Math.floor(Math.random() * MAX_TIMESTAMP_MS); | ||
| const random = crypto.getRandomValues(new Uint8Array(10)); | ||
| const raw = uuidV7Bytes(ts, random); | ||
| let actualMs = 0n; | ||
| for (let b = 0; b < 6; b++) { | ||
| actualMs = (actualMs << 8n) | BigInt(raw[b]); | ||
| } | ||
| assert.equal(actualMs, BigInt(ts), `timestamp mismatch: ${actualMs} != ${ts}`); | ||
| } | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| /** | ||
| * Minimal, dependency-free TypeID generator for local request IDs. | ||
| * | ||
| * Replaces the external `typeid-js` package (and its transitive `uuid` | ||
| * dependency) with an inline implementation. We only ever mint new IDs with a | ||
| * fixed prefix, so this covers generation — not parsing or decoding. | ||
| * | ||
| * The suffix is the 26-character Crockford base32 encoding of a UUIDv7 | ||
| * (RFC 9562), matching the TypeID specification | ||
| * (https://github.com/jetify-com/typeid). Mirrors the vendored implementation | ||
| * in the Arcjet Python SDK so both SDKs produce identical IDs. | ||
| */ | ||
|
|
||
| /** Crockford base32 alphabet (lowercase, excludes `i`, `l`, `o`, `u`). */ | ||
| export const CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | ||
|
|
||
| /** Fill a 10-byte buffer from the platform CSPRNG (Web Crypto). */ | ||
| function randomBytes(): Uint8Array { | ||
| return crypto.getRandomValues(new Uint8Array(10)); | ||
| } | ||
|
|
||
| /** | ||
| * Generate the 16 raw bytes of a UUIDv7 (RFC 9562): a 48-bit big-endian | ||
| * millisecond timestamp, version `7`, the RFC 4122 variant (`10`), and random | ||
| * bits filling the remainder. | ||
| * | ||
| * `nowMs` and `random` are injectable for deterministic testing; production | ||
| * callers use the defaults. | ||
| */ | ||
| export function uuidV7Bytes( | ||
| nowMs: number = Date.now(), | ||
| random: Uint8Array = randomBytes(), | ||
| ): Uint8Array { | ||
| const timestampMs = BigInt(Math.trunc(nowMs)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please consider validating |
||
| const randA = BigInt((random[0] << 4) | (random[1] >> 4)); // 12 bits | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please consider validating There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please consider validating |
||
| let randBFull = 0n; | ||
| for (let i = 2; i < 10; i++) { | ||
| randBFull = (randBFull << 8n) | BigInt(random[i]); | ||
| } | ||
| const randB = randBFull & ((1n << 62n) - 1n); // 62 bits | ||
|
|
||
| const hi = (timestampMs << 16n) | (0x7n << 12n) | randA; // version 7 | ||
| const lo = (0b10n << 62n) | randB; // RFC 4122 variant | ||
|
|
||
| const bytes = new Uint8Array(16); | ||
| const view = new DataView(bytes.buffer); | ||
| view.setBigUint64(0, hi, false); // big-endian | ||
| view.setBigUint64(8, lo, false); | ||
| return bytes; | ||
| } | ||
|
|
||
| /** | ||
| * Encode 16 bytes as a 26-character Crockford base32 string. The bytes are read | ||
| * as a big-endian 128-bit integer, most-significant character first. 26 × 5 = | ||
| * 130 bits, so the leading character carries only the top 3 bits and is always | ||
| * `0`-`7`. | ||
| */ | ||
| function encodeCrockford(bytes: Uint8Array): string { | ||
| let n = 0n; | ||
| for (const byte of bytes) { | ||
| n = (n << 8n) | BigInt(byte); | ||
| } | ||
| const out = new Array<string>(26); | ||
| for (let i = 25; i >= 0; i--) { | ||
| out[i] = CROCKFORD_ALPHABET[Number(n & 0x1fn)]; | ||
| n >>= 5n; | ||
| } | ||
| return out.join(""); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a new TypeID string — `<prefix>_<suffix>`, where the suffix is the | ||
| * Crockford base32 encoding of a freshly generated UUIDv7. | ||
| * | ||
| * `nowMs` and `random` are injectable for deterministic testing. | ||
| */ | ||
| export function typeid( | ||
| prefix: string, | ||
| nowMs?: number, | ||
| random?: Uint8Array, | ||
| ): string { | ||
| return `${prefix}_${encodeCrockford(uuidV7Bytes(nowMs, random))}`; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please consider validating
nowMsis a finite integer in the UUIDv7 timestamp range0 <= nowMs < 2 ** 48before converting and shifting it. Because this helper is exported and accepts injectable values, negative or too-large timestamps can wrap when written viasetBigUint64, producing IDs with an unexpected embedded timestamp.