Skip to content

Commit 27a952b

Browse files
mishushakovclaude
andcommitted
Add @e2b/id: prefixed IDs for E2B resources
A three-character kind prefix, an underscore, and the 26-character rotated base32 encoding of a UUID: prj_uk75vf2v7iagp2kgn7pfze3car sbx_blo7looa3eagp2kgn75n47fkrk The rotation is the point. A UUIDv7 leads with a 48-bit millisecond timestamp, so unrotated encodings of IDs minted together share a long common prefix and the leading characters barely move for months. Rotating left by 16 puts 10 characters of random bits in front and the timestamp from index 10 — at the cost of encoded order no longer following time, which the tests state outright so nobody indexes on it. The alphabet is exactly the one base64.b32encode produces, so any language can read these IDs with its standard library and no tables. The interop suite drives python3 over the whole corpus to hold that claim to account rather than trusting it. No dependencies, and no runtime-specific API: a test asserts src reaches for nothing but platform globals, so the package works in Workers, Deno, Bun and browsers with no compat flags. Not published yet. `changeset publish` releases any public workspace package whose version is absent from the registry, so a public @e2b/id would ship as a side effect of the next unrelated release — and creating a new name in the @e2b scope needs rights the release token may not have, which fails after the version-bump commit (the SDK-298 shape). Marked private until that is arranged. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6733f36 commit 27a952b

20 files changed

Lines changed: 2108 additions & 0 deletions

.github/workflows/id_tests.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Test ID Packages
2+
3+
# `@e2b/id` is a pure codec with no network and no API key, so unlike the SDK
4+
# suites this one needs no secrets and no staging leg.
5+
on:
6+
workflow_call:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
name: ID - Build and test (${{ matrix.os }})
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
os: [ubuntu-22.04, windows-latest]
18+
runs-on: ${{ matrix.os }}
19+
defaults:
20+
run:
21+
shell: bash
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
26+
- name: Parse .tool-versions
27+
uses: wistia/parse-tool-versions@v2.1.1
28+
with:
29+
filename: '.tool-versions'
30+
uppercase: 'true'
31+
prefix: 'tool_version_'
32+
33+
- name: Install pnpm
34+
uses: pnpm/action-setup@v4
35+
with:
36+
version: '${{ env.TOOL_VERSION_PNPM }}'
37+
38+
- name: Setup Node
39+
uses: actions/setup-node@v6
40+
with:
41+
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
42+
cache: pnpm
43+
44+
- name: Configure pnpm
45+
run: |
46+
pnpm config set auto-install-peers true
47+
pnpm config set exclude-links-from-lockfile true
48+
49+
- name: Install dependencies
50+
run: pnpm install --frozen-lockfile
51+
52+
# The interop suite drives python3 to prove the format really is
53+
# "b32encode, lowercase, strip padding, rotate", so an interpreter has to
54+
# be on PATH even though the package itself has no Python component.
55+
- name: Setup Python
56+
uses: actions/setup-python@v5
57+
with:
58+
python-version: '${{ env.TOOL_VERSION_PYTHON }}'
59+
60+
- name: Test build (JS)
61+
working-directory: packages/js-id
62+
run: pnpm build
63+
64+
- name: Run tests (JS)
65+
working-directory: packages/js-id
66+
run: pnpm test

.github/workflows/sdk_tests.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jobs:
2020
js: ${{ steps.filter.outputs.js }}
2121
python: ${{ steps.filter.outputs.python }}
2222
cli: ${{ steps.filter.outputs.cli }}
23+
id: ${{ steps.filter.outputs.id }}
2324
steps:
2425
- name: Filter changed paths
2526
# On workflow_dispatch there is no PR diff and paths-filter would fall
@@ -51,6 +52,10 @@ jobs:
5152
- '.github/workflows/cli_tests.yml'
5253
- 'packages/cli/**/!(*.md)'
5354
- 'packages/js-sdk/**/!(*.md)'
55+
id:
56+
- *shared
57+
- '.github/workflows/id_tests.yml'
58+
- 'packages/js-id/**/!(*.md)'
5459
5560
js-tests:
5661
name: Production / JS SDK Tests
@@ -76,6 +81,14 @@ jobs:
7681
secrets:
7782
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}
7883

84+
id-tests:
85+
name: ID Package Tests
86+
needs: changes
87+
if: ${{ needs.changes.outputs.id == 'true' || github.event_name == 'workflow_dispatch' }}
88+
# No secrets and no staging leg: the ID packages are pure codecs that never
89+
# reach the backend, so there is nothing environment-specific to re-check.
90+
uses: ./.github/workflows/id_tests.yml
91+
7992
js-tests-staging:
8093
name: Staging / JS SDK Tests
8194
needs: changes
@@ -118,6 +131,7 @@ jobs:
118131
- js-tests
119132
- python-tests
120133
- cli-tests
134+
- id-tests
121135
if: ${{ always() }}
122136
runs-on: ubuntu-latest
123137
steps:

packages/js-id/.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# dependencies
2+
/node_modules
3+
4+
# build output
5+
dist
6+
7+
# TypeScript cache
8+
*.tsbuildinfo
9+
10+
# Output of 'npm pack'
11+
*.tgz

packages/js-id/LICENSE

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
MIT License
2+
3+
Copyright (c) 2025 FOUNDRYLABS, INC.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

packages/js-id/README.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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.

packages/js-id/package.json

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"name": "@e2b/id",
3+
"version": "0.1.0",
4+
"private": true,
5+
"description": "Prefixed, human-legible IDs for E2B resources",
6+
"homepage": "https://e2b.dev",
7+
"license": "MIT",
8+
"author": {
9+
"name": "FoundryLabs, Inc.",
10+
"email": "hello@e2b.dev",
11+
"url": "https://e2b.dev"
12+
},
13+
"bugs": "https://github.com/e2b-dev/e2b/issues",
14+
"repository": {
15+
"type": "git",
16+
"url": "https://github.com/e2b-dev/e2b",
17+
"directory": "packages/js-id"
18+
},
19+
"publishConfig": {
20+
"access": "public"
21+
},
22+
"sideEffects": false,
23+
"main": "dist/index.js",
24+
"module": "dist/index.mjs",
25+
"types": "dist/index.d.ts",
26+
"exports": {
27+
".": {
28+
"types": "./dist/index.d.ts",
29+
"import": "./dist/index.mjs",
30+
"require": "./dist/index.js"
31+
}
32+
},
33+
"scripts": {
34+
"prepublishOnly": "pnpm build",
35+
"build": "tsc --noEmit && tsdown",
36+
"dev": "tsdown --watch",
37+
"test": "vitest run",
38+
"typecheck": "tsc --noEmit",
39+
"lint": "oxlint --config ../../.oxlintrc.json src tests",
40+
"format": "prettier --write src/ tests/"
41+
},
42+
"devDependencies": {
43+
"@types/node": "^20.19.19",
44+
"@typescript/native": "npm:typescript@^7.0.2",
45+
"prettier": "^3.6.2",
46+
"tsdown": "^0.22.3",
47+
"typescript": "npm:@typescript/typescript6@^6.0.2",
48+
"vitest": "^4.1.10"
49+
},
50+
"files": [
51+
"dist",
52+
"README.md",
53+
"package.json"
54+
],
55+
"keywords": [
56+
"e2b",
57+
"id",
58+
"uuid",
59+
"uuidv7",
60+
"base32",
61+
"identifier",
62+
"typescript"
63+
],
64+
"engines": {
65+
"node": ">=20.18.1 <21 || >=22"
66+
}
67+
}

0 commit comments

Comments
 (0)