chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# pds.js
A single-account [AT Protocol](https://atproto.com) 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](https://bsky.app/profile/chadtmiller.com),
> runs on it.
---
## What makes this different from the official PDS
The [official atproto PDS](https://github.com/bluesky-social/atproto/tree/main/packages/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](docs/permissioned-data.md).
> † Labs feature — experimental, may change.
For everything it covers and everything it doesn't, see the [endpoint comparison](docs/endpoint-comparison.md).
---
## Quick start
```sh
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`](https://github.com/casey/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](CONTRIBUTING.md).
---
## Deploy
The fastest path is **[start.pdsjs.dev](https://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](docs/deploy-docker.md) |
| Node.js | [docs/deploy-node.md](docs/deploy-node.md) |
| Cloudflare Workers | [docs/deploy-cloudflare.md](docs/deploy-cloudflare.md) |
| Deno | [docs/deploy-deno.md](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**
```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**
```js
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**
```js
// 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:
```sh
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:
```sh
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](docs/endpoint-comparison.md) lists every endpoint on both sides.
---
## Documentation
| Document | Contents |
|---|---|
| [Configuration](docs/configuration.md) | Every environment variable |
| [Architecture](docs/architecture.md) | Ports, adapters, packages, library usage |
| [Building an app](docs/apps.md) | Sites, manifests, installs, how to update |
| [Permissioned data](docs/permissioned-data.md) | Spaces proposal, the run club example |
| [Endpoint comparison](docs/endpoint-comparison.md) | Coverage against the official atproto PDS |
| [Scope comparison](docs/scope-comparison.md) | OAuth scopes against the reference implementation |
| [Contributing](CONTRIBUTING.md) | Local dev setup, tests, commit gate |
---
## License
[MIT](LICENSE)