chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Architecture
pds.js uses hexagonal architecture. @pdsjs/core holds the business logic and
the XRPC handlers, and it reaches storage only through ports. Each platform
supplies its own adapters.
┌────────────────┐ space route table ┌───────────────────────────────────┐
│ @pdsjs/spaces │ ────────────────► │ @pdsjs/core │
│ (optional) │ (the platform │ (business logic, XRPC handlers) │
└───────┬────────┘ package wires it)└────────────────┬──────────────────┘
│ │
▼ ┌───────────────────────┼─────────────────┐
┌────────────────┐ ▼ ▼ ▼
│SpaceStoragePort│ ┌────────────────┐ ┌─────────────────┐ ┌──────────┐
│ (space repos) │ │ActorStoragePort│ │SharedStoragePort│ │ BlobPort │
└──────┬─────────┘ │ (per-user data)│ │ (global data) │ │ (binary) │
│ └───────┬────────┘ └────────┬────────┘ └────┬─────┘
┌───┴──┬──────┐ ┌───┴─────┐ ┌────┴─────┐ ┌──┴────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌──────┐┌────┐┌──────┐ ┌──────┐ ┌───────┐ ┌──────┐ ┌──────┐ ┌────┐ ┌────┐
│SQLite││ DO ││Memory│ │SQLite│ │Durable│ │SQLite│ │ DO │ │ FS │ │ R2 │
│ ││ ││(test)│ │ │ │Objects│ │ │ │SQLite│ │ │ │ │
└──────┘└────┘└──────┘ └──────┘ └───────┘ └──────┘ └──────┘ └────┘ └────┘
Ports
| Port | Holds |
|---|---|
ActorStoragePort | Per-user data: the repo, preferences, OAuth tokens |
SharedStoragePort | Global data: handle resolution, DID mappings |
BlobPort | Binary storage: images, videos |
WebSocketPort | Real-time subscriptions: subscribeRepos |
SpaceStoragePort | Permissioned space data, when spaces are enabled |
The port types are in @pdsjs/core/ports. Annotate against those types rather
than redeclaring the row shapes.
Optional spaces
@pdsjs/core never imports @pdsjs/spaces. When PDS_ENABLE_SPACES is set,
the platform packages (@pdsjs/node, @pdsjs/cloudflare) build the space
storage port, which is platform-specific, pass it to core as spaceStorage,
and mount createSpacesExtension from @pdsjs/spaces/extension. Core builds
the account page's space browser over the storage; the extension serves the
space endpoints and the account page's space routes. Without the flag the
space endpoints do not exist, and a deployment never installs the package.
Extensions
A feature package mounts on the server as an extension. @pdsjs/core never
imports the package; the platform passes a factory in through the
extensions option, and core calls it with an ExtensionHost of late-binding
functions (getDid, xrpc, createCommit, accountApi) plus the storage
ports. The extension it returns contributes any of:
| Field | What core does with it |
|---|---|
collections | The record collections the extension owns. The host's actorStorage, spaceStorage, spaceBrowser and createCommit refuse a record call or commit op naming any other; ['*'] allows every collection, and an absent list allows none. Blobs, blocks, the event log and the account's own fields are not scoped, and xrpc is anonymous: credentials on a request an extension passes in are removed. |
routes | Merged into the route table. A path core already serves is refused at construction. Handlers are called unbound; an extension reaches the server through its host, never through this. |
prefixes + handle | The handler answers requests under its declared prefixes (/npm, say) before core dispatch, for surfaces outside XRPC. The extension owns the whole response. A prefix core serves under (/xrpc, /oauth, /account, /.well-known, or any route in its table) or that overlaps another extension's is refused at construction. |
handleHost | Answers requests addressed to a hostname other than the server's (a static site, say), after prefix handlers and before core dispatch. The extension owns the whole response. |
writeGuard | Consulted alongside the platform's guard for writes into the public repo. |
onCommit | Called after each commit. An error is logged and never fails the write. |
features | Merged into the account overview's features, which the account application reads to show its sections. |
stats | Merged into the overview's stats, the counts beside those sections, from a count(collection) core computes across the public repo and every live space. |
Both types are in @pdsjs/core/ports as ExtensionFactory and PdsExtension.
The npm and OCI registries, git hosting, the drive and sites are on this
seam (createNpmExtension, createOciExtension, createGitExtension,
createDriveExtension, createSitesExtension, each from its package's
/extension export), and each serves its own /account/api routes through
the host's accountApi. The ExtensionHost carries spaceStorage and
spaceBrowser when spaces are on, so an extension whose records may be held
in a space reads them there; one that manages spaces builds the admin
methods it needs over that storage from @pdsjs/spaces, the way git and
drive do. Every feature package is on the seam; spaceStorage is the one
feature-shaped option core keeps, since the storage port is the platform's.
Packages
| Package | Description |
|---|---|
@pdsjs/core | Platform-agnostic business logic and XRPC handlers |
@pdsjs/spaces | Permissioned data: space repos, credentials, routes (optional) |
@pdsjs/lexicon-resolver | Lexicon schema resolution and record validation |
@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/readonly | Read-only server that serves repositories from CAR files |
@pdsjs/account-ui | The account interface. Private, and built into a bundle |
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
// Re-export from @pdsjs/cloudflare (or point wrangler.toml directly at it)
export { default, PDSDurableObject } from '@pdsjs/cloudflare'
Handler decomposition
core/src/pds.js becomes one handler module per cluster, under
core/src/handlers/. Each module exports a createXHandlers(ctx) factory. The
factory takes an explicit context and returns a route table fragment, and the
constructor merges the fragment into this.routes.
pds-decomposition.md has the current state and the order of the remaining clusters.