Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/id_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Test ID Packages

# `@e2b/id` is a pure codec with no network and no API key, so unlike the SDK
# suites this one needs no secrets and no staging leg.
on:
workflow_call:

permissions:
contents: read

jobs:
test:
name: ID - Build and test (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Parse .tool-versions
uses: wistia/parse-tool-versions@v2.1.1
with:
filename: '.tool-versions'
uppercase: 'true'
prefix: 'tool_version_'

- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: '${{ env.TOOL_VERSION_PNPM }}'

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '${{ env.TOOL_VERSION_NODEJS }}'
cache: pnpm

- name: Configure pnpm
run: |
pnpm config set auto-install-peers true
pnpm config set exclude-links-from-lockfile true

- name: Install dependencies
run: pnpm install --frozen-lockfile

# The interop suite drives python3 to prove the format really is
# "b32encode, lowercase, strip padding, rotate", so an interpreter has to
# be on PATH even though the package itself has no Python component.
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '${{ env.TOOL_VERSION_PYTHON }}'

- name: Test build (JS)
working-directory: packages/js-id
run: pnpm build

- name: Run tests (JS)
working-directory: packages/js-id
run: pnpm test
14 changes: 14 additions & 0 deletions .github/workflows/sdk_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
js: ${{ steps.filter.outputs.js }}
python: ${{ steps.filter.outputs.python }}
cli: ${{ steps.filter.outputs.cli }}
id: ${{ steps.filter.outputs.id }}
steps:
- name: Filter changed paths
# On workflow_dispatch there is no PR diff and paths-filter would fall
Expand Down Expand Up @@ -51,6 +52,10 @@ jobs:
- '.github/workflows/cli_tests.yml'
- 'packages/cli/**/!(*.md)'
- 'packages/js-sdk/**/!(*.md)'
id:
- *shared
- '.github/workflows/id_tests.yml'
- 'packages/js-id/**/!(*.md)'

js-tests:
name: Production / JS SDK Tests
Expand All @@ -76,6 +81,14 @@ jobs:
secrets:
E2B_API_KEY: ${{ secrets.E2B_API_KEY }}

id-tests:
name: ID Package Tests
needs: changes
if: ${{ needs.changes.outputs.id == 'true' || github.event_name == 'workflow_dispatch' }}
# No secrets and no staging leg: the ID packages are pure codecs that never
# reach the backend, so there is nothing environment-specific to re-check.
uses: ./.github/workflows/id_tests.yml

js-tests-staging:
name: Staging / JS SDK Tests
needs: changes
Expand Down Expand Up @@ -118,6 +131,7 @@ jobs:
- js-tests
- python-tests
- cli-tests
- id-tests
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
Expand Down
11 changes: 11 additions & 0 deletions packages/js-id/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# dependencies
/node_modules

# build output
dist

# TypeScript cache
*.tsbuildinfo

# Output of 'npm pack'
*.tgz
9 changes: 9 additions & 0 deletions packages/js-id/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright (c) 2025 FOUNDRYLABS, INC.

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:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

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.
132 changes: 132 additions & 0 deletions packages/js-id/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# `@e2b/id`

Prefixed, human-legible IDs for E2B resources. No dependencies.

```sh
npm install @e2b/id
```

```ts
import { createId, decodeId, encodeId, isId, parseId } from '@e2b/id'

createId('project')
// 'prj_uk75vf2v7iagp2kgn7pfze3car'

encodeId('volume', '019fa519-bfc5-784d-9386-a5d7a93a692a')
// 'vol_uxl2sotjfiagp2kgn7yv4e3e4g'

decodeId('volume', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g')
// '019fa519-bfc5-784d-9386-a5d7a93a692a'

parseId('vol_uxl2sotjfiagp2kgn7yv4e3e4g')
// { kind: 'volume', uuid: '019fa519-bfc5-784d-9386-a5d7a93a692a' }

isId('project', 'vol_uxl2sotjfiagp2kgn7yv4e3e4g')
// false
```

The format is deliberately portable: any language can read these IDs with its
standard library. A Python port lands alongside this one.

## The format

An ID is a three-character kind prefix, an underscore, and 26 characters of
encoded UUID — 30 characters in all:

```text
prj_uk75vf2v7iagp2kgn7pfze3car
wrk_imkhttdroeagp2kgn7t53cvkiw
vol_uxl2sotjfiagp2kgn7yv4e3e4g
sbx_blo7looa3eagp2kgn75n47fkrk
usr_zm52rsumcyagp2kgoacf6i5ibz
grp_byblfq6upe6r5mcc2yzrbxfjlh
```

| kind | prefix |
| ----------- | ------ |
| `project` | `prj` |
| `workspace` | `wrk` |
| `volume` | `vol` |
| `sandbox` | `sbx` |
| `user` | `usr` |
| `group` | `grp` |

The UUID's 16 bytes are base32-encoded with the RFC 4648 section 6 alphabet
(`a-z2-7`), lowercased, unpadded — 26 characters — and then **rotated left by
16**, so what was the first character ends up 11th.

The rotation is the whole trick. A UUIDv7 leads with a 48-bit millisecond
timestamp, so unrotated encodings of IDs minted around the same time share a
long common prefix, and the leading characters barely move for months. Rotating
puts 10 characters of random bits in front, the timestamp from index 10, and the
rest of the random bits behind it. The trade is that encoded order no longer
follows time — don't build an index expecting it to.

The alphabet is exactly the one Python's `base64.b32encode` uses, so any
language can read these IDs with its standard library and no tables:

```py
import base64

def encode(b: bytes) -> str:
s = base64.b32encode(b).decode().rstrip("=").lower()
return s[16:] + s[:16]

def decode(s: str) -> bytes:
s = s[10:] + s[:10]
return base64.b32decode(s.upper() + "======")
```

### One spelling per ID

26 base32 digits carry 130 bits and a UUID has 128, so two bits of the encoding
are always zero and every UUID has four strings a permissive decoder maps to it.
`decodeId` accepts only the one `encodeId` produces and throws `InvalidIdError`
for the other three, along with uppercase, padding and anything outside the
alphabet. UUID arguments are held to the canonical 8-4-4-4-12 hex form for the
same reason.

### Types

`Id<K>` is a template literal type, so the prefix is checked at compile time and
`isId` narrows to it:

```ts
import type { Id } from '@e2b/id'

function open(volume: Id<'volume'>) {}

open(createId('volume')) // fine
open(createId('project')) // Argument of type 'prj_${string}' is not assignable

declare const input: string
if (isId('volume', input)) open(input) // narrowed to Id<'volume'>
```

## API

| | |
| --- | --- |
| `createId(kind)` | mint an ID for a new resource, from a fresh UUIDv7 |
| `encodeId(kind, uuid)` | encode a UUID you already have |
| `decodeId(kind, id)` | the UUID an ID carries; throws if the kind is wrong |
| `parseId(id)` | `{ kind, uuid }`, when the kind is what you want to find out |
| `isId(kind, value)` | `decodeId` without the throw, and a type guard; `false` for non-strings |
| `createUuid()` | mint a UUIDv7 |
| `encodeBytes(bytes)` / `decodeBytes(encoded)` | the prefix-free codec, over any 16 bytes |
| `uuidToBytes(uuid)` / `bytesToUuid(bytes)` | the canonical hex form and back |
| `ID_PREFIXES`, `ID_LENGTH`, `ENCODED_LENGTH`, `DECODED_LENGTH`, `ALPHABET` | the constants above |
| `Id`, `IdKind`, `IdPrefix`, `ParsedId`, `InvalidIdError`, `InvalidIdReason` | the types |

## Development

```sh
pnpm build
pnpm test
pnpm lint && pnpm typecheck
```

`tests/vectors.ts` holds the golden encodings, a seeded 1303-value corpus and a
`CORPUS_DIGEST` over it. Every port of this format pins the same three, so a
change to the format on one side alone cannot pass on the other. `pnpm test` also
pipes the whole corpus through `python3` to check the six-line snippet above.
67 changes: 67 additions & 0 deletions packages/js-id/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "@e2b/id",
"version": "0.1.0",
"private": true,
"description": "Prefixed, human-legible IDs for E2B resources",
"homepage": "https://e2b.dev",
"license": "MIT",
"author": {
"name": "FoundryLabs, Inc.",
"email": "hello@e2b.dev",
"url": "https://e2b.dev"
},
"bugs": "https://github.com/e2b-dev/e2b/issues",
"repository": {
"type": "git",
"url": "https://github.com/e2b-dev/e2b",
"directory": "packages/js-id"
},
"publishConfig": {
"access": "public"
},
"sideEffects": false,
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"prepublishOnly": "pnpm build",
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint --config ../../.oxlintrc.json src tests",
"format": "prettier --write src/ tests/"
},
"devDependencies": {
"@types/node": "^20.19.19",
"@typescript/native": "npm:typescript@^7.0.2",
"prettier": "^3.6.2",
"tsdown": "^0.22.3",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vitest": "^4.1.10"
},
"files": [
"dist",
"README.md",
"package.json"
],
"keywords": [
"e2b",
"id",
"uuid",
"uuidv7",
"base32",
"identifier",
"typescript"
],
"engines": {
"node": ">=20.18.1 <21 || >=22"
}
}
Loading
Loading