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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
145 lines · 8.4 KB Raw History
# 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.