chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# Building an app
An app is a static site plus a record that describes it. Both live in a repo,
so publishing is a write and installing is a copy. Nothing is registered
anywhere, and no server holds a list of apps.
Read [configuration](configuration.md) for the site variables and
[deploy-cloudflare](deploy-cloudflare.md) for the routing a deployment needs.
`examples/apps/` holds two working apps to start from.
## The three records
| Record | Lives in | Holds |
|--------|----------|-------|
| `dev.pdsjs.site.deploy` | anyone serving a site | the files, as blob refs by path |
| `dev.pdsjs.app.manifest` | the developer's repo | the app's name, icon, settings, and a CID pin on its site record |
| `dev.pdsjs.app.install` | the installer's repo | which manifest version was installed, under what name, with which settings |
A site record alone is a website. A manifest makes it installable. An install
record is somebody's copy of it.
## Deploying a site
`pdsjs-site deploy ./dist --site blog --pds https://pds.example.com --handle
you.example.com` uploads each file as a blob and writes one record. It is
stock atproto traffic, so the server needs nothing beyond an ordinary PDS.
Files whose bytes have not changed are skipped by CID.
The record key is the subdomain: `blog` answers at `blog.<site domain>`. The
site named by `PDS_SITE_APEX_NAME` (default `home`) answers at the apex too.
Set `fallback: 'index.html'` for a single-page app and every unmatched path
serves the shell with status 200.
## Writing the app itself
Apps are ordinary web pages. Three conventions make one work for whoever
installs it.
**Discover the host from the origin.** Bake in no DID and no PDS URL. The
site's own hostname answers `/.well-known/atproto-did`, and the DID document
names the PDS:
```js
const did = (await (await fetch('/.well-known/atproto-did')).text()).trim();
const doc = await (await fetch('https://plc.directory/' + did)).json();
const pds = doc.service.find((s) => s.id === '#atproto_pds').serviceEndpoint;
```
An app that does this runs on every installer's data with no rebuild.
**Read through queries.** A `dev.pdsjs.query.def` record names a live view
over one collection; `GET /xrpc/dev.pdsjs.query.run?query=<name>` returns it
sorted, in one request, with no auth. Declare the defs in the manifest and the
installer writes them. Reading is public, so a visitor sees the app without
signing in. Fall back to `com.atproto.repo.listRecords` where a deployment
serves no queries.
**Show repo media through `/.blobs/<cid>`.** Every site hostname serves any
blob in its repo at that path, immutable and cacheable, so a client-rendered
page shows images without a manifest entry per file.
### Writing records
Reads need nothing; writes need a session. The page asks for an app password,
exchanges it at `com.atproto.server.createSession`, keeps the tokens in
`localStorage`, and refreshes at `com.atproto.server.refreshSession` on the
first 401. App passwords can write records and upload blobs.
The PDS answers cross-origin requests with `Access-Control-Allow-Origin: *`
and allows the `Authorization` header, so a site origin can write to its own
PDS origin. Sites are served on a different origin from the PDS on purpose:
that is what keeps repo-controlled HTML away from the account session cookie.
### Choosing a collection
An NSID's authority is every segment except the last, reversed. So
`com.example.coffee.roast` claims `coffee.example.com` and needs a domain you
control. Records validate open for unresolvable collections, so a lexicon is
documentation until you publish one.
## Publishing it as an app
Deploy the site, read back its CID, and write a manifest pinning it:
```js
const site = await getRecord(did, 'dev.pdsjs.site.deploy', 'photos-app');
await putRecord(did, 'dev.pdsjs.app.manifest', 'photos', {
name: 'Photos',
description: '…',
icon, // an uploaded image blob
site: { uri: site.uri, cid: site.cid }, // the pin
suggestedSite: 'photos', // default subdomain at install
queries: [{ name: 'grain-galleries', collection: 'social.grain.gallery' }],
settings: [{ key: 'map', label: 'Map view', type: 'boolean', default: true }],
external: true, // true if it calls anything but the user's PDS
createdAt: new Date().toISOString(),
});
```
- **The manifest's record key is the app's permanent address.** Installs track
`at://you/dev.pdsjs.app.manifest/<rkey>` and notice new CIDs at it. Pick a
readable one and never change it.
- **The site record's key is only an artifact address.** Name it something like
`photos-app`; installs are named by `suggestedSite`, so the two never
collide. This is what keeps a rename from breaking a published app.
- **Settings are declared data, never code**: `{key, label, type, default,
options}` with type `boolean`, `string` or `select`. The install snapshots
the schema, the account page generates a form from it, and the app reads the
chosen values from its own install record.
- **A manifest that declares `scopes` cannot be installed yet.** Grants do not
exist; an app that only reads public records needs none.
## Installing
From the account page, Sites → Install an app, paste the manifest at-uri, and
the sheet shows what it is before anything happens. Or:
```bash
PDS_APP_PASSWORD=… pdsjs-site install at://you.example.com/dev.pdsjs.app.manifest/photos \
--pds https://pds.example.com --handle me.example.com --set map=false
```
Either way the blobs are copied into the installer's repo and hashed against
the CIDs the record names, the query defs are written, and an install record
pins the manifest version. The copy means the app keeps working if the
developer's repo disappears.
## Updating an app you published
Re-run the deploy so the site record gets new bytes, then republish the
manifest so its pin matches. **Both steps, in that order** — a manifest
pinning a stale CID refuses to install, and the account page's install sheet
says so.
```bash
node examples/apps/photos.mjs # deploys the site, then repins the manifest
```
Your own instance is an install like anyone else's, so pick up the new version
the same way they do: the account page shows **Update available** on the row,
or run the install again. Updating keeps the subdomain and the settings the
user chose.
**Keep the source.** The deployed record holds one built file; the readable
source is whatever produced it. Losing it means fetching the blob and editing
the built output. The two apps in `examples/apps/` are in the repo for exactly
this reason.
## Testing before deploying
Deploying is a record write, so a broken app is live immediately. Two habits
that catch most of it:
- **Render it headlessly and look at the picture**, not just the HTML. Layout
and text bugs are invisible in source.
- **Drive the interactions**, do not only load routes. Navigating to a view by
URL skips the click handler that reaches it, which is exactly where a
navigation bug hides.
A local harness helps: fetch the deployed page, stub the discovery call and a
session, and serve it over `http://localhost` — `history.pushState` refuses to
run on `file://`, so a file-served harness fails for reasons that have nothing
to do with the app.