chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Decomposing core/src/pds.js
Status: complete. Fourteen clusters moved and three helper modules
extracted, 1,337 lines left in pds.js from 10,205. What stays in the class is
the dispatch pipeline, authentication, and the commit and blob machinery. The
static route table is empty; every route comes from a handler module.
Read CLAUDE.md first for the conventions this follows. This file is the
record: what moved, what each move needed, and the mistakes that cost time.
A second phase followed. The feature routes that handlers/account.js held
for git, drive, sites, spaces, OCI and npm moved into those packages, each
mounted through the extension seam described in
architecture.md, along with the per-feature
constructor options that wired them. The table below is the snapshot at the
end of the first phase; account.js has since lost those routes and the
adapters their feature construction.
Where it stands
| module | lines | statements | branches | functions |
|---|---|---|---|---|
handlers/account.js | 2,998 | 88.4 | 74.6 | 96.5 |
handlers/oauth.js | 1,682 | 83.7 | 73.8 | 100 |
handlers/xrpc-server.js | 1,036 | 98.0 | 94.3 | 95.0 |
handlers/xrpc-repo.js | 810 | 89.2 | 80.0 | 100 |
handlers/xrpc-identity.js | 679 | 95.3 | 82.9 | 100 |
handlers/account-backup.js | 535 | 97.5 | 90.3 | 100 |
handlers/migration.js | 356 | 96.6 | 83.9 | 84.6 |
handlers/xrpc-sync.js | 312 | 95.7 | 79.4 | 100 |
handlers/server-meta.js | 252 | 100 | 100 | 100 |
handlers/xrpc-blob.js | 189 | 100 | 100 | 100 |
handlers/xrpc-firehose.js | 155 | 100 | 86.1 | 100 |
handlers/xrpc-preferences.js | 81 | 100 | 100 | 100 |
session.js | 331 | 96.4 | 84.2 | 100 |
proxy.js | 282 | 98.8 | 96.9 | 100 |
http.js | 103 | 92.6 | 88.9 | 100 |
passkey.js | 85 | 100 | 80.0 | 100 |
Repo-wide statements are 78.1%, from 59.87% at the start. The unit suite is 1,318 tests, up from 941.
proxy.js, session.js, http.js and passkey.js are not under handlers/
because they contribute no routes. Name a module for what it knows when that
is true, and put it in core/src.
What each cluster needs before it moves
Two numbers decide the work, and they are not correlated. Coverage under the unit
suite against coverage with a PLATFORM=node e2e run says where the untested code
is. The count of instance members a cluster reaches says how much of a seam it
has. xrpc-blob was 0% covered and reached six members. The account surface
reaches forty-seven.
Measure both before starting. The commands are in the commits; the shape is:
awk 'NR>=LO && NR<HI' packages/core/src/pds.js \
| grep -oE "this\.[a-zA-Z_][a-zA-Z0-9_]*" | sort -u
A member count that is too high is a signal to extract a helper module first,
not to widen the context. xrpc-server reached 24 members and six module-level
names shared with the rest of the file. Three extractions — session.js for the
lifetimes and the registry, http.js for CORS, cookies and isSameOriginPost,
decodeJwtClaims into auth.js — brought it to a 17-member context with no
edge in either direction, and the move itself was then mechanical.
The last rounds
The account surface, 2,696 lines, 47 members
Account Pages and its JSON API sub-section moved as one project into
handlers/account.js. Most of the methods in the surface are shared machinery
rather than route handlers — requireAccountSession alone has fifteen callers —
so splitting the sub-sections would have traded a few hundred lines for fifty
lines of indirection. accountApi, the spine of 29 routes, moved once.
Of the 47 members, 27 were the surface's own methods. The email-state helpers
(sessionSeesEmail, readEmailState, emailSessionFields) went into
email.js first: the PLC endpoints and the xrpc-server session context need
them on the other side of the seam, and as module functions taking
actorStorage neither side carries them in a context. That left a 20-member
context, all ports, config values, and closures over the class.
The factory exposes six functions inward: readAccountSession,
renewAccountSession, readAccountUiState and accountAppResponse for
dispatch and the OAuth consent flow, accountApi for the backup routes, and
listConnectedApps for its unit test, which builds the factory directly.
The identity and migration residue, 580 lines, 17 members
The ten route handlers left after the account surface had the worst
payoff-per-line in the file: a fifth of that surface's lines reaching nearly
the same seam width, and sitting directly on the signing-key cache and the
commit and blob traversals. They also span three route families, so they went
three ways. The PLC operation flow and getRecommendedDidCredentials joined
xrpc-identity.js, whose renameHandle was their only sibling, with
derivePublicKey going to crypto.js. getServiceAuth joined
xrpc-server.js. The migration sequence — checkAccountStatus, importRepo,
listMissingBlobs, activateAccount, deactivateAccount — became
handlers/migration.js, taking the account-active helpers with it; the sync,
repo and account contexts read isDeactivated, checkAccountActive and
setAccountActive from its return. The traversals themselves
(collectRepoBlocks, collectReferencedBlobs, relinkBlobRecords) stay on
the class, which uses them for orphan cleanup, and reach the module as
closures. With the residue gone the static route table held nothing and is
deleted; this.routes is built from the module fragments alone.
Things that cost time here
- Check inbound references from elsewhere in the same file. Not just other
packages.
handleApiOverviewcalledthis.backupsAvailable()from 1,500 lines away and broke three tests. - A test DID must satisfy
DID_REGEX.did:plc:takes exactly 24 characters from[a-z2-7]. A short one is not rejected as a bad DID;isValidDidreturns false and the failure surfaces as an unrelated assertion. - A fake has to match arity.
listRecords(collection, cursor, limit)given a two-parameter fake receives the cursor as its limit, which reads as an empty collection rather than a wrong call. - A default parameter fires on
undefined. A test helper that models "no password configured" by passingpassword: undefinedgets the default back and tests the opposite branch. Give the helper a named flag instead. - A blanket
this.member→memberrename collides with locals. The sign-in handler holds the submitted form value in a local namedpassword; renamingthis.passwordthere producedpassword !== password, a check that passes every value. Biome'snoSelfCompareand tsc's use-before-declaration errors found all four collisions; run both before trusting the transform. - A file missing from the coverage table is not necessarily complete. Read
coverage/coverage-final.jsonwhen it matters.xrpc-preferences.jswas absent and was at 13.3%. - Glob threshold keys aggregate. A well-covered file in the same key hides a bare one. One entry per module.
- Do not type a context member off the class.
PersonalDataServer['buildFullRepoCar']broke the moment that method moved. Structural types instead. - A test that calls a handler as a method needs a new entry point.
pds.handleRefreshSession(request)stops resolving once the handler is a closure in a module.pds.fetch(request)drives the same route through the table, which is whattest/onboarding.test.jsalready does. - The e2e suite binds port 2471. A
just devstack in any checkout blocks it.
Two fixes came out of this, and how
Neither was found by reading the code. Both came from writing a test for a specific property of a handler that had just moved.
buildFullRepoCar threw Missing block from inside a try whose catch
discarded it, so only a missing root reached the caller and
com.atproto.sync.getRepo answered 200 with part of a repository. Fixed by
walking the structure it knows rather than probing field names on every block.
putRecord and applyWrites did not check the repo scope that createRecord and
deleteRecord did, so a narrowed OAuth grant reached any collection through a
different endpoint. Fixed to match packages/pds in bluesky-social/atproto.
That second fix then exposed a third thing: app-password sessions were being
refused every write, because com.atproto.appPass names no granular permission
and allowsRepo found nothing to allow. The reference gates its assertions on
auth.credentials.type === 'oauth'; skipsGranularChecks() is the equivalent
here.
Open questions
allowsSpaceandallowsSpaceManagestill refuse app-password sessions. Permissioned data has no counterpart in the reference, so there was nothing to match and the change left it strict. If an app-password client should reach spaces, that is one line.skipsGranularChecksinfers the credential type from the scope string. The reference carriescredentials.typeexplicitly. Ours holds only becausecom.atproto.appPassis set at session creation and never appears in an OAuth grant. Carrying the type onauthwould make it structural instead of implied.SCOPE_APP_PASS_PRIVILEGEDis issued and never read. Nothing distinguishes a privileged app password from an ordinary one. The reference uses that distinction to gate chat access.- No e2e test drives an app-password client through a write. The fix for it is covered by unit tests against a fake context, which is a weaker guarantee than the rest of this work. This is the first thing to verify.