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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
164 lines · 7.2 KB Raw History

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 for the site variables and deploy-cloudflare for the routing a deployment needs. examples/apps/ holds two working apps to start from.

The three records

RecordLives inHolds
dev.pdsjs.site.deployanyone serving a sitethe files, as blob refs by path
dev.pdsjs.app.manifestthe developer's repothe app's name, icon, settings, and a CID pin on its site record
dev.pdsjs.app.installthe installer's repowhich 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:

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:

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:

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.

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.