~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
225 lines · 10.8 KB Raw History

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:

TargetGuide
Dockerdocs/deploy-docker.md
Node.jsdocs/deploy-node.md
Cloudflare Workersdocs/deploy-cloudflare.md
Denodocs/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

PackageWhat it does
@pdsjs/corePlatform-agnostic business logic and XRPC handlers
@pdsjs/nodeNode.js HTTP server with WebSocket support
@pdsjs/denoDeno HTTP server with WebSocket support
@pdsjs/cloudflareCloudflare Workers entrypoint with Durable Objects
@pdsjs/storage-sqliteSQLite storage adapter (better-sqlite3 or node:sqlite)
@pdsjs/blobs-fsFilesystem blob storage for Node.js
@pdsjs/blobs-denoFilesystem blob storage for Deno
@pdsjs/blobs-s3S3-compatible blob storage
@pdsjs/spacesPermissioned space repos, credentials, routes (optional)
@pdsjs/sitesSite platform: lexicon-driven public pages served from your repo
@pdsjs/gitGit hosting: push via git-remote-atproto, clone over HTTP
@pdsjs/readonlyRead-only server that serves repositories from CAR files
@pdsjs/lexicon-resolverLexicon schema resolution and record validation
@pdsjs/account-uiThe 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.

AppWhat it does
photos.mjsPhoto galleries from social.grain.* records — justified layouts, lightbox, map, terrain headers, network favourite counts
roasts.mjsCoffee roast log — live roast timer, weight-loss and development metrics against a Sweet Maria's roast card, notes and photos
drop.mjsImage host — drag, paste, or pick a file and the public /.blobs/<cid> URL copies itself
videos.mjsVideo 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

DocumentContents
ConfigurationEvery environment variable
ArchitecturePorts, adapters, packages, library usage
Building an appSites, manifests, installs, how to update
Permissioned dataSpaces proposal, the run club example
Endpoint comparisonCoverage against the official atproto PDS
Scope comparisonOAuth scopes against the reference implementation
ContributingLocal dev setup, tests, commit gate

License

MIT