AppView development
The AppView lives in colibri-social/api. It’s a Node 24 and TypeScript pnpm workspace, built on @atproto/xrpc-server and Drizzle. See the architecture page for how the pieces fit together, and the specification for what the endpoints have to do.
Running it
Section titled “Running it”-
Clone it and install:
Terminal window git clone https://github.com/colibri-social/api.gitcd appviewpnpm install -
Create your configuration and fill it in:
Terminal window cp .env.example .envThe AppView refuses to boot without six variables.
.env.examplecarries theopenssllines that generate the two secrets.Variable What it is APPVIEW_DIDThe did:webthis AppView answers as. It must be a hostname, not an IP address: see below.PUBLIC_URLWhere this AppView is reachable from the public internet. A community’s PDS calls back here on every credential mint. SIGNING_KEYA secp256k1 private key, published in the DID document. CREDENTIAL_ENCRYPTION_KEYEncrypts community passwords at rest. PDS_URLThe PDS this AppView talks to. It must run the spaces alpha, ghcr.io/bluesky-social/atproto:pds-spaces-alpha. See Running a PDS.COMMUNITY_HANDLE_DOMAINThe domain communities get handles under, such as my-community.communities.example.com.Everything else in
.env.examplehas a default.DATABASE_URLdefaults to a local libSQL file, so no database service is required to start. -
Run it:
Terminal window PORT=3000 pnpm devThis runs the TypeScript directly under tsx, watching for changes. There is no build step in the dev loop, and no separate compiler to keep running alongside it.
Editing a workspace package restarts the AppView too, not just editing
apps/appview. That works because every package declares a colibri-source
export condition pointing at its own src, and the dev script asks for it:
"exports": { ".": { "types": "./dist/index.d.ts", "colibri-source": "./src/index.ts", "default": "./dist/index.js" }}The condition is not called development, which several bundlers set on their
own. Three of these packages are published, and a consumer whose
bundler picked development would resolve to a src directory that is not in
the published tarball.
Two quick checks that it came up correctly:
curl http://127.0.0.1:3000/healthcurl http://127.0.0.1:3000/.well-known/did.jsonThe first reports {"status":"ok","did":"..."}. The second is the DID
document derived from APPVIEW_DID and SIGNING_KEY.
Browser origins
Section titled “Browser origins”The AppView answers /xrpc with CORS headers for the origins named in
CORS_ORIGINS, a comma-separated list. * allows any origin. Every request
carries its own service-auth token in an Authorization header and nothing is
authenticated by a cookie, so a wildcard grants no ambient access.
With CORS_ORIGINS unset, development allows http://localhost:4321 and
http://127.0.0.1:4321, which is where the client’s dev server runs, and
production allows nothing. A deployment whose client lives on another origin
has to name it:
CORS_ORIGINS=https://app.example.com,https://staging.example.comThe headers go on error responses too. Without them a 401 reaches the browser as an opaque CORS failure instead of the error the AppView sent.
Requests may carry authorization, atproto-proxy, atproto-accept-labelers,
content-type and range. Responses expose atproto-repo-rev,
accept-ranges and content-range. A preflight is answered with a 204 and
cached for a day.
The WebSocket endpoints are unaffected: a browser does not apply CORS to a
WebSocket handshake, and the AppView does not check Origin on one.
Storage
Section titled “Storage”libSQL is the default, and needs nothing else running: DATABASE_URL
defaults to file:./data/colibri.db. A postgres:// URL switches the driver
to Postgres instead. Both dialects share one hand-written schema, generated
from the libSQL version at build time, and a test asserts the two agree on
column names, nullability, primary keys and indexes.
A PDS to sync from
Section titled “A PDS to sync from”Reading and writing anything beyond the AppView’s own tables needs a real
spaces-alpha PDS at PDS_URL, because this AppView pulls member repos
directly rather than reading a firehose. Running a PDS
covers running one locally in Docker, including why the PLC directory next to
it is built from source.
Source layout
Section titled “Source layout”Directorypackages
- lexicons every Colibri schema, the space type declarations, and the types generated from them
- space client for
com.atproto.spaceandcom.atproto.simplespace - space-sync keeps a local copy of a space current
- db Drizzle schema and migrations for both libSQL and Postgres
- projections turns synced records into the typed read models the API serves
- identity service auth, DID documents, handle resolution
- community provisioning, credential custody, roles and permissions, moderation
- notifications notification indexing, Web Push and FCM
- blobs CID-verified blob proxy for permissioned blobs
- embeds SSRF-guarded link previews and the GIF picker
- voice mediasoup voice SFU
Directoryapps
Directoryappview the server
Directorysrc
- config.ts environment parsing and validation
- context.ts wires the packages above into one
AppContext Directoryroutes/ one module per lexicon group
- …
Directoryviews/ read models assembled for the API responses
- …
Directoryws/ the events and voice WebSocket surfaces
- …
- jetstream.ts consumes identity and account events
- migrate one-shot migration of repo-backed communities onto spaces
A new endpoint is usually a handler in apps/appview/src/routes/, registered
in apps/appview/src/server.ts, plus an errors entry in its lexicon and a
matching entry in the AppView specification.
Running it with Docker
Section titled “Running it with Docker”No image is published, so compose builds one from the Dockerfile in the
repository. The first build takes a while and later ones are cached.
cp .env.example .env # required, see Running it abovedocker compose up --buildThat runs the AppView alone on libSQL against the appview-data volume,
pointed at whatever PDS_URL names. Three overlays compose on top of it:
| Overlay | What it adds |
|---|---|
docker-compose.postgres.yml |
Postgres, and rewrites DATABASE_URL to use it |
docker-compose.pds.yml |
a local spaces-alpha PDS and a private PLC directory, for developing without a PDS of your own |
docker-compose.integration.yml |
the same PDS and PLC, ephemeral, for pnpm test:integration |
docker compose -f docker-compose.yml -f docker-compose.pds.yml upThe spaces-alpha PDS is published for amd64 only, so its platform is pinned and it runs under
emulation on an arm64 host. The PLC directory is built from docker/plc
rather than pulled, because no current one is published anywhere: every PLC
image on any registry dates from 2023 and predates the operation format the
PDS emits. See Running a PDS.
Commands
Section titled “Commands”pnpm build # generate lexicon types and the pg schema, then compilepnpm test # unit testspnpm test:integration # against a real spaces-alpha PDS, not run in normal CIpnpm typecheck # sources and testspnpm lint # BiomeTesting covers what each of those runs.
Conventions
Section titled “Conventions”The code carries no comments. Anything that needs explaining is named to explain itself, written down in the README of the package it belongs to, or written up in these docs when it is protocol behaviour rather than an implementation detail.
Releasing
Section titled “Releasing”The same flow as the client repository. Nothing is released by pushing a tag by hand.
Add a changeset to your pull request describing what changed:
pnpm changesetThree packages are published from this repository:
@colibri-social/lexicons, @colibri-social/space and
@colibri-social/space-sync. A change to the lexicons is a protocol change, so
treat it as a minor bump at least.
Once your pull request lands, a workflow opens or updates a pull request titled
[ci] release holding the accumulated version bumps and changelog entries.
Merging that one publishes the packages to npm and tags the repository. The
release pull request is the review step, so nothing ships until someone merges
it.
The AppView itself is not published. There is no container image on any
registry, and the version in apps/appview/package.json exists so
describeServer
has something to report. Running it means building from the repository,
which is what docker compose up --build does.
Private packages are versioned but not tagged, set by privatePackages in
.changeset/config.json. Without it, changesets treats a private package as
ignored and refuses any changeset naming both it and a published one, which is
what a change touching the server and the lexicons together looks like.
See also
Section titled “See also”- AppView Architecture: services and how they fit together.
- AppView Specification: what every endpoint and event must do.
- Self-Hosting: running an instance for real users.