chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
App platform: repo-hosted static sites (the Serve primitive)
Date: 2026-08-06
Goal: serve static sites and apps out of the account's atproto repo — files as
blobs, a manifest as a record — on the deployment's own hostnames (apex +
one-label subdomains), in a new @pdsjs/sites package behind a single
hostname-dispatch seam in core. First of five declarative app-platform
primitives (Serve, Grant, Query, React, Schedule); this spec designs Serve
fully and sketches the rest so nothing in Serve forecloses them.
0. Why this shape
- The repo is the deploy artifact. A site is ordinary repo data: blobs
uploaded with
com.atproto.repo.uploadBlob, onedev.pdsjs.site.deployrecord written withputRecord. Deploys ride the firehose, survive migration, are covered by backup and CAR export, and need zero new write endpoints or auth surface. The server side of Serve is read-only. - Origin isolation is the security model. Site content serves only on
hostnames that are not the PDS hostname. The PDS origin keeps its invariant:
com.atproto.sync.getBlobforcesContent-Disposition: attachmentand a sandbox CSP precisely so repo content cannot execute next to the account session cookie and OAuth endpoints. Sites do not weaken that; they move execution to origins that hold no credentials. - The server side is declarative. An app is a configuration of fixed primitives, never third-party code executing in the PDS process. That line is what keeps the eventual "install an app" story reviewable.
1. Packaging and module boundaries
New workspace package @pdsjs/sites, workspace-only dependency on
@pdsjs/core (for repo utilities and port typedefs), no external
dependencies.
packages/sites/src/
index.js re-exports
hostname.js hostnameToSite(hostname, siteDomain, apexName) → string|null
lexicons.js dev.pdsjs.site.deploy / dev.pdsjs.app.manifest / dev.pdsjs.app.install
manifest.js loadSite(): record read → decode → base merge → path map (LRU)
serve.js path normalization, lookup order, header assembly, MIME table
handler.js createSiteHandler(ctx) → (request, url) => Response|null
browser.js createSiteBrowser(ctx) → SiteBrowserPort (account UI listing)
deploy.js pure walk/diff/record-build helpers for the CLI
cli.js pdsjs-site deploy CLI (the only file importing node:fs)
Dependency direction
@pdsjs/core must not depend on @pdsjs/sites. Core exposes a hook; sites
plugs into it:
- The constructor gains
config.siteHandlerandconfig.siteBrowser. fetch()gains one hostname check at its top (§4).- The platform packages construct the handler from ports + env and pass it in.
A deployment that does not set PDS_SITE_DOMAIN never installs or loads the
package, and core's behavior is byte-identical to today.
Handler convention
createSiteHandler(ctx) receives ports and config, never the
PersonalDataServer instance:
{ actorStorage, blobs, getDid, siteDomain, apexName }
Handlers are closures over that ctx — unit-testable without standing up a PDS, and structurally unable to reach PDS internals. This is the same convention space routes established.
2. Lexicons
Defined as plain JSON in packages/sites/src/lexicons.js. Record validation
fails open for unresolvable collections, so the schemas gate nothing at write
time; they exist for the CLI, tooling, and eventual publication.
dev.pdsjs.site.deploy — one record per site, rkey = site name
The rkey is a lowercase DNS label; it binds the site to
<rkey>.<siteDomain>, and the configured apex name (default home) binds to
the apex.
files(required): array of{path, blob, contentType?, cacheControl?}. An array of objects rather than a map because Lexicon has no open-keyed map type — and each entry embeds a real{$type: "blob", ref, mimeType, size}value, which is exactly whatfindBlobRefswalks, so every deployed asset is protected from orphan GC by the record that serves it. Files dropped by a redeploy become orphans and the hourly reaper collects them after 24h.fallback: path served with status 200 for any unmatched request path (SPA client-side routing).base:com.atproto.repo.strongRefto anotherdev.pdsjs.site.deployrecord in the same repo whose files merge beneath this record's (this record wins on conflicts). One hop only; a base may not itself have a base. This is the headroom valve for the 1 MB record write cap: one record holds roughly 5–7k files (~130–180 bytes of DAG-CBOR per entry), a base doubles it.createdAt.
contentType overrides the stored blob mime at serve time. Upload-time mime
sniffing only recognizes image formats, so text types carry whatever
Content-Type the deploy client sent; the manifest field is the deliberate
signal and the recovery path for blobs uploaded with generic tools.
cacheControl is a single string, not an open header map — an open map is a
header-injection surface, and every other header is either dedicated
(contentType) or the server's job.
dev.pdsjs.app.manifest — published in the developer's repo
name, description, icon (image blob), site (strongRef to the
dev.pdsjs.site.deploy record holding the app's frontend), scopes (strings in
the host's OAuth scope grammar), primitives (enum array:
serve/grant/query/react/schedule), external (boolean: the app talks to
services beyond the user's own PDS — surfaced prominently at install),
createdAt.
dev.pdsjs.app.install — written to the user's repo at install (rkey tid)
manifest (strongRef — the CID pin freezes the reviewed version; a changed
manifest requires re-consent), grantedScopes, site (local site name
holding this install's copied frontend), config (unknown), createdAt.
3. Serving semantics
Per request, in order:
- Hostname → site name (
hostname.js). ExactsiteDomainmatch →apexName.<label>.<siteDomain>where label matches/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/(case-folded) → that label as rkey. Deeper subdomains ofsiteDomain→ the built-in 404 page (they are claimed hostnames with nothing behind them, and Universal SSL only covers one wildcard level anyway). Anything else →null: not ours, fall through to normal PDS dispatch so health checks, tunnels, and bare-IP requests behave exactly as today. /.well-known/atproto-didon any claimed hostname → the account DID astext/plain. Three deliberate lines re-implementing the core route, which the site handler shadows; this is what lets the apex domain double as the account handle.- Methods: GET and HEAD only. Everything else, OPTIONS included, → 405
with
Allow: GET, HEAD. A static site has no preflightable API, and answering OPTIONS with the PDS's permissive CORS headers would advertise PDS-ness on a site origin. - Load the site (
manifest.js):getRecordonat://<did>/dev.pdsjs.site.deploy/<name>; miss → built-in 404 page. Decode the DAG-CBOR, stringify blob CIDs, merge abaserecord if present (refusing a second hop), and buildMap<path, {cid, mimeType, size, contentType?, cacheControl?}>. The record is re-read every request — it is DO-local SQLite and it is the only correct redeploy signal — but the decoded, merged path map is cached in a module-level LRU (≤32 entries) keyed by record CID. Redeploy → new CID → miss: correct by construction, no TTLs, no invalidation protocol. The DO is single-threaded, so a plain Map suffices. - Path resolution (
serve.js):decodeURIComponentin try/catch (→ 400); reject any..segment, backslash, or NUL (→ 400); strip the leading slash; empty path or trailing slash appendsindex.html. Lookup: exact match → else ifpath + "/index.html"exists, 301 topath + "/"(the correct relative-URL base beats saving a round trip) → elsefallbackwith status 200 → else the built-in minimal 404 (text/html,no-store). - Conditional requests before any blob read: strong
ETag= the blob CID string.If-None-Match(list parse,W/tolerated,*honored) → 304 with ETag + Cache-Control and no blob fetch — the CID is already in the manifest. - HEAD answers entirely from manifest metadata (
Content-Lengthfrom the blob ref'ssize), no blob fetch. - GET:
blobs.get(did, cid)— fully buffered, bounded by the 5 MB upload cap; null → 404 (manifest/blob skew). - Headers:
Content-Type= manifestcontentType‖ stored blob mime ‖ extension map ‖application/octet-stream; alwaysX-Content-Type-Options: nosniff;Cache-Control= per-file override ‖public, max-age=60, must-revalidate. Notimmutableby default — paths are mutable across deploys, and the ETag turns revalidation into a header-only 304; builds emitting hashed filenames opt in viacacheControl. No sandbox CSP, noContent-Disposition: executing is the point, and this origin is not the PDS.
Errors inside the handler are caught and returned as a plain-text 500; site origins never emit the PDS's JSON error shape.
4. The core seam
Two constructor options, stored and otherwise untouched by core:
/** @property {(request: Request, url: URL) => Promise<Response|null>} [siteHandler] */
/** @property {SiteBrowserPort} [siteBrowser] */
this.sitesEnabled = Boolean(siteHandler). SiteBrowserPort joins
SpaceBrowserPort in ports.js:
/**
* @typedef {Object} SiteBrowserPort
* @property {string} domain
* @property {string} apexName
* @property {() => Promise<Array<{name: string, url: string,
* fileCount: number, totalSize: number, deployedAt: string|null,
* cid: string}>>} listSites
*/
The dispatch hook sits at the very top of fetch():
if (this.siteHandler) {
const url = new URL(request.url);
if (url.hostname !== this.hostname) {
const siteResponse = await this.siteHandler(request, url);
if (siteResponse) return siteResponse;
}
}
Every position in that placement is load-bearing, and each is pinned by a seam test:
- Before the OPTIONS short-circuit, or every site hostname answers preflights with the PDS's permissive CORS headers.
- Outside
withDpopNonce, or site responses growDPoP-Nonceheaders; sites are not OAuth resources. - No
addCorsHeaders— the handler owns its headers completely. - Before the account-app prefix intercept, or
https://blog.example.com/accountserves the account UI andreadAccountSessionreads cookies on a site origin. - A
nullreturn falls through to normal dispatch, so hostnames that are neither the PDS hostname nor undersiteDomainbehave exactly as today — including.well-known/atproto-didanswering host-agnostically. dispatch()is untouched; its error handler never sees site traffic.
Edge documented rather than engineered around: a deployment whose PDS
hostname is the site apex serves the PDS there (exact match wins) and the
apex site is unreachable. The README recommends a pds. subdomain; the Sites
account page warns when it detects the collision.
5. Feature flag
The flag is the presence of PDS_SITE_DOMAIN (with optional
PDS_SITE_APEX_NAME, default home) — a boolean without a domain is
meaningless. It gates exactly once, at platform registration: the platform
package either builds siteHandler/siteBrowser and passes them, or passes
nothing and core has no site code path at all. There are no flag checks
inside handlers.
The flag-off path is load-bearing: with PDS_SITE_DOMAIN unset, a request
carrying a foreign Host header must behave byte-identically to today (404
through normal dispatch, OPTIONS answered with CORS). That is an explicit
test, not an assumption.
6. Platform wiring
- Cloudflare: static imports (the Worker bundles the graph regardless);
gate in the DO constructor next to the spaces gate; ctx built from the DO's
actorStorage/blobsadapters withgetDidas a late-bound closure over the constructed PDS. No Worker-entry change: the Worker already forwards every request, hostname intact, to the single DO. Zone routing (example.com/*+*.example.com/*— wildcards require zone routes, not Custom Domains — plus proxied apex/wildcard DNS records) is deployment configuration, documented inexamples/cloudflare/wrangler.toml. - Node/Deno: dynamic import behind the env var, optional peer dependency, mirroring spaces. Multi-hostname TLS is the reverse proxy's job (wildcard cert via DNS-01); README note.
- readonly: once the core seam exists, the read-only server can compose the same handler over its own ports — sites keep serving from a mirror during an outage. Not wired in v1.
Deferred: Worker edge cache
A designed-but-deferred fast path for Cloudflare: the Worker checks
caches.default for non-PDS hostnames, calls the DO on miss, and
waitUntil-caches GET 200s for 60 s. Bounded staleness after redeploy
instead of a purge protocol; the DO still owns correctness via ETag. Ship v1
without it — it is the pressure valve if the single-DO hot path (site traffic
sharing one Durable Object with the firehose and writes) ever shows strain.
7. Deploy CLI
pdsjs-site deploy <dir> --site <name> --pds <url> --handle <handle>, app
password via PDS_APP_PASSWORD (never argv). Walk the directory (skip
dotfiles); compute each file's CID locally with createBlobCid; fetch the
existing deploy record and upload only changed files, with Content-Type
from the extension map (this is what makes stored blob mimes correct);
unchanged files reuse their existing blob refs verbatim and stay
GC-referenced through the new record. Abort before any upload if a file
exceeds the blob cap (list offenders, point at PDS_BLOB_UPLOAD_LIMIT), or
if the assembled record exceeds ~900 KB of DAG-CBOR (point at base). Then
one putRecord and a changed/unchanged/deleted summary. Deletes need no
calls: dropped blobs orphan and the existing GC reaps them.
8. Account UI
GET /account/api/sites (via the accountApi wrapper, backed entirely by
siteBrowser so site knowledge stays out of core) and
POST /account/api/sites/delete. features.sites = Boolean(siteBrowser)
gates the SPA page. The page is a read-and-delete view: card per site with
live link, file count, total size (summed from blob-ref sizes, no blob
reads), deploy time, record CID; empty state teaches the CLI invocation.
Deploys stay records-only — no upload UI.
9. The other primitives (sketches)
Nothing in Serve forecloses these; each attaches at its own seam.
- Grant. A repo-hosted app already has a natural OAuth
client_id: the URL of a client-metadata document the app's own site serves as a deployed file. Grant is therefore pre-consent plus a ceiling, not a new credential: the install sheet renders the manifest's requested scopes; the authorize path, for a client_id whose origin is an installed site, skips the consent screen when requested ⊆ granted and rejects when not. Known gap: grants live only as scope strings on token rows, so revoking an install means revoking all matching-client tokens; per-app toggles eventually want a grants table keyed by client_id. - Query.
dev.pdsjs.query.defrecords (collection, filter, sort) materialized into DO SQLite by replay plus incremental updates from a new optionalonCommit(evt)core callback; served atGET /xrpc/dev.pdsjs.query.runvia aqueryRoutesroute-table option (these are path-routed, so the spaceRoutes shape fits). - React.
dev.pdsjs.react.rulerecords: collection match → signed webhook POST or template-derived record write. Consumes the sameonCommittap; retries ride platform alarms. Strictly URL + static headers + payload — never code. - Schedule.
dev.pdsjs.schedule.taskrecords (cron, fetch URL, target collection); the platform clock (DO alarm / node interval, the hourly-GC precedent) calls an exportedrunDueTasks(ctx). Core untouched.
10. Testing
Unit tests gate everything (e2e is excluded from CI): hostname resolution
bounds; manifest decode/merge/LRU; the full serving matrix (traversal,
encodings, 301/fallback/404, ETag/304 and HEAD asserted to make zero blob
reads via mock call counts, MIME precedence, cacheControl); and the seam
itself driven through pds.fetch with foreign Host headers — flag-off
identical to today, flag-on serving with no CORS and no DPoP-Nonce, PDS
hostname unaffected, /account on a site host a site 404, OPTIONS a 405.
CLI diffing logic is pure and tested (CID-stable skip, oversize and
record-size aborts, MIME assignment).
11. What shipped past phase 1 (2026-08-11)
Built against a real deployment: photos.chadtmiller.com, a one-file SPA that renders 89 Grain galleries from live queries. That site drove each addition below and is the reference composition for the platform.
- Query, v1.
dev.pdsjs.query.defanddev.pdsjs.query.runshipped in@pdsjs/sites/query, wired byPDS_ENABLE_QUERIES. One deviation from the section 9 sketch: materialization is an in-memory decoded copy of each queried collection, replayed from the records table on first use, not rows in DO SQLite. At single-account scale replay is milliseconds, so persistence bought nothing; the sketch's architecture (replay plus incremental maintenance from the commit tap) is otherwise as designed. A CAR import bypasses the tap and is picked up on the restart that follows. - The commit tap. Core takes
config.onCommitand calls it after each successful commit with{did, seq, rev, ops}. Errors are logged, never failing the write. Query consumes it today; React's rules are the next consumer. - Repo blobs on the site origin.
/.blobs/<cid>under a reserved dot-prefix serves any repo blob, immutable with an ETag of the CID, safe mimes only, CSP-sandboxed otherwise. This is what lets a query-rendered site show images without a manifest entry per photo, and it composes with Cloudflare image transformations for resized variants. - Serving conveniences. Per-file Cache-Control from the deploy
(
--cache-control "[glob:]value"), an edge cache for site hostnames in the Worker entry, HTTPS-only with HSTS, and a www-to-apex redirect when no www site exists. - The live-versus-snapshot trade, resolved in practice. A snapshot site
(data baked at deploy) is fastest and pins content by CID; a query-rendered
site is always current and needs no rebuild on new records. The photos site
chose live: queries for data,
/.blobsfor images, one HTML file in the manifest. The snapshot path stays right for sites that want pinning, and a React rule firing a rebuild webhook is its freshness story.
12. Install (2026-08-11)
The app-store seam shipped as pdsjs-site install <at-uri>: no new server
surface, because an install is stock atproto traffic exactly as a deploy is.
The CLI resolves the developer's DID to their PDS, reads the manifest, and
refuses a site record that drifted from the manifest's CID pin. Files copy
blob by blob, each upload checked against the CID the record named, which is
the integrity property content addressing gives for free. The install writes
the local site record, the query defs the manifest declares, and a
dev.pdsjs.app.install record pinning the manifest version.
Apps are generic by construction: the reference app (Photos) discovers its owner from its own origin's /.well-known/atproto-did, resolves the PDS from the DID document, and reads its settings from the host's public install record. Settings are declared data on the manifest ({key, label, type, default, options}), snapshotted into the install record with chosen values, edited on the account page's Sites section through a form generated from the snapshot, and validated server-side against it. Grant stayed future: an app that only reads public records installs with no consent step at all.