pds.js #
A single-account AT Protocol Personal Data Server written in JavaScript. Runs on Node.js, Deno, or Cloudflare Workers. Federates with the live network.
Work in progress. Experimental. Probably not production-ready yet, though the author's account, @chadtmiller.com, runs on it.
What makes this different from the official PDS #
The official atproto PDS is a multi-tenant server built for running thousands of accounts. pds.js is built around a different premise: one server, one identity, yours.
That constraint unlocks some things the reference implementation doesn't have:
It runs on the edge. The Cloudflare Workers build keeps repos in Durable Objects and blobs in R2. No VM, no persistent disk.
It hosts an account interface. Sign in at /account to see what each app has written to your repo, manage passkeys and app passwords, take a CAR backup, or rename your handle β without touching a command line.
It has a site platform.β Lexicon records stored in your repo can power a public website served from your PDS. No separate hosting required.
It hosts git repositories.β Push to your PDS over git-remote-atproto, clone with stock git over read-only HTTP. Repos live as dev.pdsjs.git.repo records and chunked blobs in your repo.
It includes a private file store. Driveβ is a blob-backed file manager built into the account interface. Upload files, get public /.blobs/<cid> links, stream video with seeking. Large files upload across multiple requests and land as a single blob.
It implements permissioned data.β Private per-user repos shared through spaces β a draft proposal on top of atproto. Wire formats may change. See docs/permissioned-data.md.
β Labs feature β experimental, may change.
For everything it covers and everything it doesn't, see the endpoint comparison.
Quick start #
git clone https://tangled.org/chadtmiller.com/pds.js
cd pds.js
pnpm install # must be pnpm β npm fails on workspace:* protocol
just dev
just dev requires just and Docker. It starts a local PLC directory, relay, and Caddy reverse proxy, then runs the PDS and registers a mock account with a few seed records. Open http://localhost:2471/account and sign in with the printed credentials.
Tear it all down with just dev-down.
For a manual setup without just, or for working on the account UI with live reload, see CONTRIBUTING.md.
Deploy #
The fastest path is start.pdsjs.dev β a browser wizard that deploys to your own Cloudflare account. It generates your signing key and secrets client-side; nothing sensitive leaves your tab.
For manual deployments:
| Target | Guide |
|---|---|
| Docker | docs/deploy-docker.md |
| Node.js | docs/deploy-node.md |
| Cloudflare Workers | docs/deploy-cloudflare.md |
| Deno | docs/deploy-deno.md |
All targets need TLS, a public hostname, and one run of npm run setup to register your DID with the PLC directory.
Architecture #
pds.js uses hexagonal architecture. @pdsjs/core holds all business logic and XRPC handlers. It never touches storage directly β it talks through ports. Each platform supplies its own adapters.
ββββββββββββββββββ space route table βββββββββββββββββββββββββββββββββββββ
β @pdsjs/spaces β βββββββββββββββββΊ β @pdsjs/core β
β (optional) β β (business logic, XRPC handlers) β
βββββββββ¬βββββββββ ββββββββββββββββββ¬βββββββββββββββββββ
β β
βΌ βββββββββββββββββββββββββΌββββββββββββββββββ
ββββββββββββββββββ βΌ βΌ βΌ
βSpaceStoragePortβ ββββββββββββββββββ βββββββββββββββββββ ββββββββββββ
β (space repos) β βActorStoragePortβ βSharedStoragePortβ β BlobPort β
ββββββββ¬ββββββββββ β (per-user data)β β (global data) β β (binary) β
β βββββββββ¬βββββββββ ββββββββββ¬βββββββββ ββββββ¬ββββββ
βββββ΄βββ¬βββββββ βββββ΄ββββββ ββββββ΄ββββββ ββββ΄βββββ
βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ βΌ
ββββββββββββββββββββββ ββββββββ βββββββββ ββββββββ ββββββββ ββββββ ββββββ
βSQLiteββ DO ββMemoryβ βSQLiteβ βDurableβ βSQLiteβ β DO β β FS β β R2 β
β ββ ββ(test)β β β βObjectsβ β β βSQLiteβ β β β β
ββββββββββββββββββββββ ββββββββ βββββββββ ββββββββ ββββββββ ββββββ ββββββ
Packages #
| Package | What it does |
|---|---|
@pdsjs/core |
Platform-agnostic business logic and XRPC handlers |
@pdsjs/node |
Node.js HTTP server with WebSocket support |
@pdsjs/deno |
Deno HTTP server with WebSocket support |
@pdsjs/cloudflare |
Cloudflare Workers entrypoint with Durable Objects |
@pdsjs/storage-sqlite |
SQLite storage adapter (better-sqlite3 or node:sqlite) |
@pdsjs/blobs-fs |
Filesystem blob storage for Node.js |
@pdsjs/blobs-deno |
Filesystem blob storage for Deno |
@pdsjs/blobs-s3 |
S3-compatible blob storage |
@pdsjs/spaces |
Permissioned space repos, credentials, routes (optional) |
@pdsjs/sites |
Site platform: lexicon-driven public pages served from your repo |
@pdsjs/git |
Git hosting: push via git-remote-atproto, clone over HTTP |
@pdsjs/readonly |
Read-only server that serves repositories from CAR files |
@pdsjs/lexicon-resolver |
Lexicon schema resolution and record validation |
@pdsjs/account-ui |
The account interface (bundled, not a public import) |
Library usage #
Node.js
import { createServer } from '@pdsjs/node'
const { listen } = await createServer({
dbPath: './pds.db',
blobsDir: './blobs',
jwtSecret: process.env.JWT_SECRET,
port: 3000,
})
await listen()
Deno
import { createServer } from '@pdsjs/deno'
const { listen } = await createServer({
dbPath: './pds.db',
blobsDir: './blobs',
jwtSecret: Deno.env.get('JWT_SECRET'),
port: 3000,
})
await listen()
Cloudflare Workers
// wrangler.toml points here, or re-export from your own entry
export { default, PDSDurableObject } from '@pdsjs/cloudflare'
The account interface #
Sign in at /account. The PDS serves every page itself β no external dashboard.
Your apps groups the repo by the application that wrote it. Each row shows its sessions and a write history.
Sign-in and security holds passkeys, active logins, app passwords, the recovery address, and a kill switch that pauses the account.
Driveβ is a private file store backed by blobs. Upload anything from the browser; large files chunk across multiple requests and land as a single blob. Public files get a /.blobs/<cid> link you can use anywhere an image or video URL works. Drop a video in Drive and it streams with seeking in the example video gallery app.
Sitesβ manages installable web apps served from your repo. Each app is a single .mjs file that deploys a manifest record and a set of lexicon records; anyone who visits your PDS origin sees the site.
β Labs feature β experimental, may change.
Example apps #
Four working apps live in examples/apps/. Each one discovers whose repo it serves from its own origin, so any of them runs on any pds.js account that installs it.
| App | What it does |
|---|---|
photos.mjs |
Photo galleries from social.grain.* records β justified layouts, lightbox, map, terrain headers, network favourite counts |
roasts.mjs |
Coffee roast log β live roast timer, weight-loss and development metrics against a Sweet Maria's roast card, notes and photos |
drop.mjs |
Image host β drag, paste, or pick a file and the public /.blobs/<cid> URL copies itself |
videos.mjs |
Video gallery from Driveβ β plays same-origin with seeking, read-only; uploads happen in Drive |
Deploy any of them:
PDS_URL=https://pds.example.com \
PDS_DID=did:plc:β¦ \
PDS_APP_PASSWORD=β¦ \
node photos.mjs
Then install from the account page's Sites section, or with:
pdsjs-site install at://you.example.com/dev.pdsjs.app.manifest/photos
What it implements #
pds.js covers the com.atproto.repo.*, com.atproto.sync.*, com.atproto.server.*, and com.atproto.identity.* namespaces. That includes record writes, the firehose (subscribeRepos), streaming blob upload and ranged download, handle resolution and rename, and account migration in and out.
Sessions come from passwords, app passwords, passkeys, or OAuth 2.0 with PKCE and DPoP-bound tokens.
There are no com.atproto.admin.* endpoints and no invite codes. A single-account server has nobody to administer.
The endpoint comparison lists every endpoint on both sides.
Documentation #
| Document | Contents |
|---|---|
| Configuration | Every environment variable |
| Architecture | Ports, adapters, packages, library usage |
| Building an app | Sites, manifests, installs, how to update |
| Permissioned data | Spaces proposal, the run club example |
| Endpoint comparison | Coverage against the official atproto PDS |
| Scope comparison | OAuth scopes against the reference implementation |
| Contributing | Local dev setup, tests, commit gate |