An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
JavaScript 99%
CSS <1%
TypeScript <1%
Just <1%
Dockerfile <1%
Shell <1%
HTML <1%

README.md

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

License #

MIT