|
| 1 | +# `@e2b/id` |
| 2 | + |
| 3 | +Prefixed, human-legible IDs for E2B resources. No dependencies. |
| 4 | + |
| 5 | +```sh |
| 6 | +npm install @e2b/id |
| 7 | +``` |
| 8 | + |
| 9 | +```ts |
| 10 | +import { createId, decodeId, encodeId, isId, parseId } from '@e2b/id' |
| 11 | + |
| 12 | +createId('project') |
| 13 | +// 'prj_uk75vf2v7iagp2kgn7pfze3car' |
| 14 | + |
| 15 | +encodeId('volume', '019fa519-bfc5-784d-9386-a5d7a93a692a') |
| 16 | +// 'vol_uxl2sotjfiagp2kgn7yv4e3e4g' |
| 17 | + |
| 18 | +decodeId('volume', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g') |
| 19 | +// '019fa519-bfc5-784d-9386-a5d7a93a692a' |
| 20 | + |
| 21 | +parseId('vol_uxl2sotjfiagp2kgn7yv4e3e4g') |
| 22 | +// { kind: 'volume', uuid: '019fa519-bfc5-784d-9386-a5d7a93a692a' } |
| 23 | + |
| 24 | +isId('project', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g') |
| 25 | +// false |
| 26 | +``` |
| 27 | + |
| 28 | +The format is deliberately portable: any language can read these IDs with its |
| 29 | +standard library. A Python port lands alongside this one. |
| 30 | + |
| 31 | +## The format |
| 32 | + |
| 33 | +An ID is a three-character kind prefix, an underscore, and 26 characters of |
| 34 | +encoded UUID — 30 characters in all: |
| 35 | + |
| 36 | +```text |
| 37 | +prj_uk75vf2v7iagp2kgn7pfze3car |
| 38 | +wrk_imkhttdroeagp2kgn7t53cvkiw |
| 39 | +vol_uxl2sotjfiagp2kgn7yv4e3e4g |
| 40 | +sbx_blo7looa3eagp2kgn75n47fkrk |
| 41 | +usr_zm52rsumcyagp2kgoacf6i5ibz |
| 42 | +grp_byblfq6upe6r5mcc2yzrbxfjlh |
| 43 | +``` |
| 44 | + |
| 45 | +| kind | prefix | |
| 46 | +| ----------- | ------ | |
| 47 | +| `project` | `prj` | |
| 48 | +| `workspace` | `wrk` | |
| 49 | +| `volume` | `vol` | |
| 50 | +| `sandbox` | `sbx` | |
| 51 | +| `user` | `usr` | |
| 52 | +| `group` | `grp` | |
| 53 | + |
| 54 | +The UUID's 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet |
| 55 | +(`a-z2-7`), lowercased, unpadded — 26 characters — and then **rotated left by |
| 56 | +16**, so what was the first character ends up 11th. |
| 57 | + |
| 58 | +The rotation is the whole trick. A UUIDv7 leads with a 48-bit millisecond |
| 59 | +timestamp, so unrotated encodings of IDs minted around the same time share a |
| 60 | +long common prefix, and the leading characters barely move for months. Rotating |
| 61 | +puts 10 characters of random bits in front, the timestamp from index 10, and the |
| 62 | +rest of the random bits behind it. The trade is that encoded order no longer |
| 63 | +follows time — don't build an index expecting it to. |
| 64 | + |
| 65 | +The alphabet is exactly the one Python's `base64.b32encode` uses, so any |
| 66 | +language can read these IDs with its standard library and no tables: |
| 67 | + |
| 68 | +```py |
| 69 | +import base64 |
| 70 | + |
| 71 | +def encode(b: bytes) -> str: |
| 72 | + s = base64.b32encode(b).decode().rstrip("=").lower() |
| 73 | + return s[16:] + s[:16] |
| 74 | + |
| 75 | +def decode(s: str) -> bytes: |
| 76 | + s = s[10:] + s[:10] |
| 77 | + return base64.b32decode(s.upper() + "======") |
| 78 | +``` |
| 79 | + |
| 80 | +### One spelling per ID |
| 81 | + |
| 82 | +26 base32 digits carry 130 bits and a UUID has 128, so two bits of the encoding |
| 83 | +are always zero and every UUID has four strings a permissive decoder maps to it. |
| 84 | +`decodeId` accepts only the one `encodeId` produces and throws `InvalidIdError` |
| 85 | +for the other three, along with uppercase, padding and anything outside the |
| 86 | +alphabet. UUID arguments are held to the canonical 8-4-4-4-12 hex form for the |
| 87 | +same reason. |
| 88 | + |
| 89 | +### Types |
| 90 | + |
| 91 | +`Id<K>` is a template literal type, so the prefix is checked at compile time and |
| 92 | +`isId` narrows to it: |
| 93 | + |
| 94 | +```ts |
| 95 | +import type { Id } from '@e2b/id' |
| 96 | + |
| 97 | +function open(volume: Id<'volume'>) {} |
| 98 | + |
| 99 | +open(createId('volume')) // fine |
| 100 | +open(createId('project')) // Argument of type 'prj_${string}' is not assignable |
| 101 | + |
| 102 | +declare const input: string |
| 103 | +if (isId('volume', input)) open(input) // narrowed to Id<'volume'> |
| 104 | +``` |
| 105 | + |
| 106 | +## API |
| 107 | + |
| 108 | +| | | |
| 109 | +| --- | --- | |
| 110 | +| `createId(kind)` | mint an ID for a new resource, from a fresh UUIDv7 | |
| 111 | +| `encodeId(kind, uuid)` | encode a UUID you already have | |
| 112 | +| `decodeId(kind, id)` | the UUID an ID carries; throws if the kind is wrong | |
| 113 | +| `parseId(id)` | `{ kind, uuid }`, when the kind is what you want to find out | |
| 114 | +| `isId(kind, value)` | `decodeId` without the throw, and a type guard; `false` for non-strings | |
| 115 | +| `createUuid()` | mint a UUIDv7 | |
| 116 | +| `encodeBytes(bytes)` / `decodeBytes(encoded)` | the prefix-free codec, over any 16 bytes | |
| 117 | +| `uuidToBytes(uuid)` / `bytesToUuid(bytes)` | the canonical hex form and back | |
| 118 | +| `ID_PREFIXES`, `ID_LENGTH`, `ENCODED_LENGTH`, `DECODED_LENGTH`, `ALPHABET` | the constants above | |
| 119 | +| `Id`, `IdKind`, `IdPrefix`, `ParsedId`, `InvalidIdError`, `InvalidIdReason` | the types | |
| 120 | + |
| 121 | +## Development |
| 122 | + |
| 123 | +```sh |
| 124 | +pnpm build |
| 125 | +pnpm test |
| 126 | +pnpm lint && pnpm typecheck |
| 127 | +``` |
| 128 | + |
| 129 | +`tests/vectors.ts` holds the golden encodings, a seeded 1303-value corpus and a |
| 130 | +`CORPUS_DIGEST` over it. Every port of this format pins the same three, so a |
| 131 | +change to the format on one side alone cannot pass on the other. `pnpm test` also |
| 132 | +pipes the whole corpus through `python3` to check the six-line snippet above. |
0 commit comments