chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# 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**
```javascript
import { createServer } from '@pdsjs/node'
const { listen } = await createServer({
dbPath: './pds.db',
blobsDir: './blobs',
jwtSecret: process.env.JWT_SECRET,
port: 3000,
})
await listen()
```
**Deno**
```typescript
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**
```javascript
// 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](pds-decomposition.md) has the current state and the order
of the remaining clusters.