# 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.`. 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=` 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/`.** 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/` 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.