A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

refactor: extract PersonalDataServer route table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+235 -189
src
+235 -189
src/pds.js
··· 814 814 return car 815 815 } 816 816 817 + /** 818 + * Route handler function type 819 + * @callback RouteHandler 820 + * @param {PersonalDataServer} pds - PDS instance 821 + * @param {Request} request - HTTP request 822 + * @param {URL} url - Parsed URL 823 + * @returns {Promise<Response>} HTTP response 824 + */ 825 + 826 + /** 827 + * @typedef {Object} Route 828 + * @property {string} [method] - Required HTTP method (default: any) 829 + * @property {RouteHandler} handler - Handler function 830 + */ 831 + 832 + /** @type {Record<string, Route>} */ 833 + const pdsRoutes = { 834 + '/.well-known/atproto-did': { 835 + handler: (pds, req, url) => pds.handleAtprotoDid() 836 + }, 837 + '/init': { 838 + method: 'POST', 839 + handler: (pds, req, url) => pds.handleInit(req) 840 + }, 841 + '/status': { 842 + handler: (pds, req, url) => pds.handleStatus() 843 + }, 844 + '/reset-repo': { 845 + handler: (pds, req, url) => pds.handleResetRepo() 846 + }, 847 + '/forward-event': { 848 + handler: (pds, req, url) => pds.handleForwardEvent(req) 849 + }, 850 + '/register-did': { 851 + handler: (pds, req, url) => pds.handleRegisterDid(req) 852 + }, 853 + '/get-registered-dids': { 854 + handler: (pds, req, url) => pds.handleGetRegisteredDids() 855 + }, 856 + '/repo-info': { 857 + handler: (pds, req, url) => pds.handleRepoInfo() 858 + }, 859 + '/xrpc/com.atproto.server.describeServer': { 860 + handler: (pds, req, url) => pds.handleDescribeServer(req) 861 + }, 862 + '/xrpc/com.atproto.sync.listRepos': { 863 + handler: (pds, req, url) => pds.handleListRepos() 864 + }, 865 + '/xrpc/com.atproto.repo.createRecord': { 866 + method: 'POST', 867 + handler: (pds, req, url) => pds.handleCreateRecord(req) 868 + }, 869 + '/xrpc/com.atproto.repo.getRecord': { 870 + handler: (pds, req, url) => pds.handleGetRecord(url) 871 + }, 872 + '/xrpc/com.atproto.sync.getLatestCommit': { 873 + handler: (pds, req, url) => pds.handleGetLatestCommit() 874 + }, 875 + '/xrpc/com.atproto.sync.getRepoStatus': { 876 + handler: (pds, req, url) => pds.handleGetRepoStatus() 877 + }, 878 + '/xrpc/com.atproto.sync.getRepo': { 879 + handler: (pds, req, url) => pds.handleGetRepo() 880 + }, 881 + '/xrpc/com.atproto.sync.subscribeRepos': { 882 + handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) 883 + } 884 + } 885 + 817 886 export class PersonalDataServer { 818 887 constructor(state, env) { 819 888 this.state = state ··· 1107 1176 } 1108 1177 } 1109 1178 1110 - async fetch(request) { 1111 - const url = new URL(request.url) 1112 - 1113 - // Handle resolution - doesn't require ?did= param 1114 - if (url.pathname === '/.well-known/atproto-did') { 1115 - let did = await this.getDid() 1116 - // If no DID on this instance, check registered DIDs (default instance) 1117 - if (!did) { 1118 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1119 - did = registeredDids[0] 1120 - } 1121 - if (!did) { 1122 - return new Response('User not found', { status: 404 }) 1123 - } 1124 - return new Response(did, { 1125 - headers: { 'Content-Type': 'text/plain' } 1126 - }) 1127 - } 1128 - 1129 - if (url.pathname === '/init') { 1130 - const body = await request.json() 1131 - if (!body.did || !body.privateKey) { 1132 - return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) 1133 - } 1134 - await this.initIdentity(body.did, body.privateKey, body.handle || null) 1135 - return Response.json({ ok: true, did: body.did, handle: body.handle || null }) 1136 - } 1137 - if (url.pathname === '/status') { 1138 - const did = await this.getDid() 1139 - return Response.json({ 1140 - initialized: !!did, 1141 - did: did || null 1142 - }) 1143 - } 1144 - // Reset endpoint - clears all repo data but keeps identity 1145 - if (url.pathname === '/reset-repo') { 1146 - this.sql.exec(`DELETE FROM blocks`) 1147 - this.sql.exec(`DELETE FROM records`) 1148 - this.sql.exec(`DELETE FROM commits`) 1149 - this.sql.exec(`DELETE FROM seq_events`) 1150 - await this.state.storage.delete('head') 1151 - await this.state.storage.delete('rev') 1152 - return Response.json({ ok: true, message: 'repo data cleared' }) 1153 - } 1154 - // Internal endpoint to forward events for relay broadcasting 1155 - if (url.pathname === '/forward-event') { 1156 - const evt = await request.json() 1157 - // Convert evt back to proper format and broadcast 1158 - const numSockets = [...this.state.getWebSockets()].length 1159 - console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) 1160 - this.broadcastEvent({ 1161 - seq: evt.seq, 1162 - did: evt.did, 1163 - commit_cid: evt.commit_cid, 1164 - evt: new Uint8Array(Object.values(evt.evt)) 1165 - }) 1166 - return Response.json({ ok: true, sockets: numSockets }) 1167 - } 1168 - // Internal endpoint to register DIDs for discovery 1169 - if (url.pathname === '/register-did') { 1170 - const body = await request.json() 1179 + async handleAtprotoDid() { 1180 + let did = await this.getDid() 1181 + if (!did) { 1171 1182 const registeredDids = await this.state.storage.get('registeredDids') || [] 1172 - if (!registeredDids.includes(body.did)) { 1173 - registeredDids.push(body.did) 1174 - await this.state.storage.put('registeredDids', registeredDids) 1175 - } 1176 - return Response.json({ ok: true }) 1177 - } 1178 - // Internal endpoint to get registered DIDs 1179 - if (url.pathname === '/get-registered-dids') { 1180 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1181 - return Response.json({ dids: registeredDids }) 1182 - } 1183 - // Internal endpoint to get repo info (head/rev) 1184 - if (url.pathname === '/repo-info') { 1185 - const head = await this.state.storage.get('head') 1186 - const rev = await this.state.storage.get('rev') 1187 - return Response.json({ head: head || null, rev: rev || null }) 1183 + did = registeredDids[0] 1188 1184 } 1189 - if (url.pathname === '/xrpc/com.atproto.server.describeServer') { 1190 - // Server DID should be did:web based on hostname, passed via header 1191 - const hostname = request.headers.get('x-hostname') || 'localhost' 1192 - return Response.json({ 1193 - did: `did:web:${hostname}`, 1194 - availableUserDomains: [`.${hostname}`], 1195 - inviteCodeRequired: false, 1196 - phoneVerificationRequired: false, 1197 - links: {}, 1198 - contact: {} 1199 - }) 1185 + if (!did) { 1186 + return new Response('User not found', { status: 404 }) 1200 1187 } 1201 - if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 1202 - const registeredDids = await this.state.storage.get('registeredDids') || [] 1203 - // If this is the default instance, return registered DIDs 1204 - // If this is a user instance, return its own DID 1205 - const did = await this.getDid() 1206 - const repos = did ? [{ did, head: null, rev: null }] : 1207 - registeredDids.map(d => ({ did: d, head: null, rev: null })) 1208 - return Response.json({ repos }) 1188 + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 1189 + } 1190 + 1191 + async handleInit(request) { 1192 + const body = await request.json() 1193 + if (!body.did || !body.privateKey) { 1194 + return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) 1209 1195 } 1210 - if (url.pathname === '/xrpc/com.atproto.repo.createRecord') { 1211 - if (request.method !== 'POST') { 1212 - return Response.json({ error: 'method not allowed' }, { status: 405 }) 1213 - } 1196 + await this.initIdentity(body.did, body.privateKey, body.handle || null) 1197 + return Response.json({ ok: true, did: body.did, handle: body.handle || null }) 1198 + } 1199 + 1200 + async handleStatus() { 1201 + const did = await this.getDid() 1202 + return Response.json({ initialized: !!did, did: did || null }) 1203 + } 1214 1204 1215 - const body = await request.json() 1216 - if (!body.collection || !body.record) { 1217 - return Response.json({ error: 'missing collection or record' }, { status: 400 }) 1218 - } 1205 + async handleResetRepo() { 1206 + this.sql.exec(`DELETE FROM blocks`) 1207 + this.sql.exec(`DELETE FROM records`) 1208 + this.sql.exec(`DELETE FROM commits`) 1209 + this.sql.exec(`DELETE FROM seq_events`) 1210 + await this.state.storage.delete('head') 1211 + await this.state.storage.delete('rev') 1212 + return Response.json({ ok: true, message: 'repo data cleared' }) 1213 + } 1214 + 1215 + async handleForwardEvent(request) { 1216 + const evt = await request.json() 1217 + const numSockets = [...this.state.getWebSockets()].length 1218 + console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) 1219 + this.broadcastEvent({ 1220 + seq: evt.seq, 1221 + did: evt.did, 1222 + commit_cid: evt.commit_cid, 1223 + evt: new Uint8Array(Object.values(evt.evt)) 1224 + }) 1225 + return Response.json({ ok: true, sockets: numSockets }) 1226 + } 1219 1227 1220 - try { 1221 - const result = await this.createRecord(body.collection, body.record, body.rkey) 1222 - return Response.json(result) 1223 - } catch (err) { 1224 - return Response.json({ error: err.message }, { status: 500 }) 1225 - } 1228 + async handleRegisterDid(request) { 1229 + const body = await request.json() 1230 + const registeredDids = await this.state.storage.get('registeredDids') || [] 1231 + if (!registeredDids.includes(body.did)) { 1232 + registeredDids.push(body.did) 1233 + await this.state.storage.put('registeredDids', registeredDids) 1226 1234 } 1227 - if (url.pathname === '/xrpc/com.atproto.repo.getRecord') { 1228 - const collection = url.searchParams.get('collection') 1229 - const rkey = url.searchParams.get('rkey') 1235 + return Response.json({ ok: true }) 1236 + } 1230 1237 1231 - if (!collection || !rkey) { 1232 - return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) 1233 - } 1238 + async handleGetRegisteredDids() { 1239 + const registeredDids = await this.state.storage.get('registeredDids') || [] 1240 + return Response.json({ dids: registeredDids }) 1241 + } 1234 1242 1235 - const did = await this.getDid() 1236 - const uri = `at://${did}/${collection}/${rkey}` 1243 + async handleRepoInfo() { 1244 + const head = await this.state.storage.get('head') 1245 + const rev = await this.state.storage.get('rev') 1246 + return Response.json({ head: head || null, rev: rev || null }) 1247 + } 1237 1248 1238 - const rows = this.sql.exec( 1239 - `SELECT cid, value FROM records WHERE uri = ?`, uri 1240 - ).toArray() 1249 + handleDescribeServer(request) { 1250 + const hostname = request.headers.get('x-hostname') || 'localhost' 1251 + return Response.json({ 1252 + did: `did:web:${hostname}`, 1253 + availableUserDomains: [`.${hostname}`], 1254 + inviteCodeRequired: false, 1255 + phoneVerificationRequired: false, 1256 + links: {}, 1257 + contact: {} 1258 + }) 1259 + } 1241 1260 1242 - if (rows.length === 0) { 1243 - return Response.json({ error: 'record not found' }, { status: 404 }) 1244 - } 1261 + async handleListRepos() { 1262 + const registeredDids = await this.state.storage.get('registeredDids') || [] 1263 + const did = await this.getDid() 1264 + const repos = did ? [{ did, head: null, rev: null }] : 1265 + registeredDids.map(d => ({ did: d, head: null, rev: null })) 1266 + return Response.json({ repos }) 1267 + } 1245 1268 1246 - const row = rows[0] 1247 - // Decode CBOR for response (convert ArrayBuffer to Uint8Array) 1248 - const value = cborDecode(new Uint8Array(row.value)) 1269 + async handleCreateRecord(request) { 1270 + const body = await request.json() 1271 + if (!body.collection || !body.record) { 1272 + return Response.json({ error: 'missing collection or record' }, { status: 400 }) 1273 + } 1274 + try { 1275 + const result = await this.createRecord(body.collection, body.record, body.rkey) 1276 + return Response.json(result) 1277 + } catch (err) { 1278 + return Response.json({ error: err.message }, { status: 500 }) 1279 + } 1280 + } 1249 1281 1250 - return Response.json({ uri, cid: row.cid, value }) 1282 + async handleGetRecord(url) { 1283 + const collection = url.searchParams.get('collection') 1284 + const rkey = url.searchParams.get('rkey') 1285 + if (!collection || !rkey) { 1286 + return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) 1287 + } 1288 + const did = await this.getDid() 1289 + const uri = `at://${did}/${collection}/${rkey}` 1290 + const rows = this.sql.exec( 1291 + `SELECT cid, value FROM records WHERE uri = ?`, uri 1292 + ).toArray() 1293 + if (rows.length === 0) { 1294 + return Response.json({ error: 'record not found' }, { status: 404 }) 1251 1295 } 1252 - if (url.pathname === '/xrpc/com.atproto.sync.getLatestCommit') { 1253 - const commits = this.sql.exec( 1254 - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1255 - ).toArray() 1296 + const row = rows[0] 1297 + const value = cborDecode(new Uint8Array(row.value)) 1298 + return Response.json({ uri, cid: row.cid, value }) 1299 + } 1256 1300 1257 - if (commits.length === 0) { 1258 - return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1259 - } 1301 + handleGetLatestCommit() { 1302 + const commits = this.sql.exec( 1303 + `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1304 + ).toArray() 1305 + if (commits.length === 0) { 1306 + return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1307 + } 1308 + return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 1309 + } 1260 1310 1261 - return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 1311 + async handleGetRepoStatus() { 1312 + const did = await this.getDid() 1313 + const commits = this.sql.exec( 1314 + `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1315 + ).toArray() 1316 + if (commits.length === 0 || !did) { 1317 + return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1262 1318 } 1263 - if (url.pathname === '/xrpc/com.atproto.sync.getRepoStatus') { 1264 - const did = await this.getDid() 1265 - const commits = this.sql.exec( 1266 - `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 1267 - ).toArray() 1319 + return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) 1320 + } 1268 1321 1269 - if (commits.length === 0 || !did) { 1270 - return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 1271 - } 1322 + handleGetRepo() { 1323 + const commits = this.sql.exec( 1324 + `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1325 + ).toArray() 1326 + if (commits.length === 0) { 1327 + return Response.json({ error: 'repo not found' }, { status: 404 }) 1328 + } 1329 + const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() 1330 + const blocksForCar = blocks.map(b => ({ 1331 + cid: b.cid, 1332 + data: new Uint8Array(b.data) 1333 + })) 1334 + const car = buildCarFile(commits[0].cid, blocksForCar) 1335 + return new Response(car, { 1336 + headers: { 'content-type': 'application/vnd.ipld.car' } 1337 + }) 1338 + } 1272 1339 1273 - return Response.json({ 1274 - did, 1275 - active: true, 1276 - status: 'active', 1277 - rev: commits[0].rev 1278 - }) 1340 + handleSubscribeRepos(request, url) { 1341 + const upgradeHeader = request.headers.get('Upgrade') 1342 + if (upgradeHeader !== 'websocket') { 1343 + return new Response('expected websocket', { status: 426 }) 1279 1344 } 1280 - if (url.pathname === '/xrpc/com.atproto.sync.getRepo') { 1281 - const commits = this.sql.exec( 1282 - `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 1345 + const { 0: client, 1: server } = new WebSocketPair() 1346 + this.state.acceptWebSocket(server) 1347 + const cursor = url.searchParams.get('cursor') 1348 + if (cursor) { 1349 + const events = this.sql.exec( 1350 + `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 1351 + parseInt(cursor) 1283 1352 ).toArray() 1284 - 1285 - if (commits.length === 0) { 1286 - return Response.json({ error: 'repo not found' }, { status: 404 }) 1353 + for (const evt of events) { 1354 + server.send(this.formatEvent(evt)) 1287 1355 } 1288 - 1289 - const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() 1290 - const blocksForCar = blocks.map(b => ({ 1291 - cid: b.cid, 1292 - data: new Uint8Array(b.data) 1293 - })) 1294 - const car = buildCarFile(commits[0].cid, blocksForCar) 1295 - 1296 - return new Response(car, { 1297 - headers: { 'content-type': 'application/vnd.ipld.car' } 1298 - }) 1299 1356 } 1300 - if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 1301 - const upgradeHeader = request.headers.get('Upgrade') 1302 - if (upgradeHeader !== 'websocket') { 1303 - return new Response('expected websocket', { status: 426 }) 1304 - } 1305 - 1306 - const { 0: client, 1: server } = new WebSocketPair() 1307 - this.state.acceptWebSocket(server) 1308 - 1309 - // Send backlog if cursor provided 1310 - const cursor = url.searchParams.get('cursor') 1311 - if (cursor) { 1312 - const events = this.sql.exec( 1313 - `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 1314 - parseInt(cursor) 1315 - ).toArray() 1357 + return new Response(null, { status: 101, webSocket: client }) 1358 + } 1316 1359 1317 - for (const evt of events) { 1318 - server.send(this.formatEvent(evt)) 1319 - } 1320 - } 1360 + async fetch(request) { 1361 + const url = new URL(request.url) 1362 + const route = pdsRoutes[url.pathname] 1321 1363 1322 - return new Response(null, { status: 101, webSocket: client }) 1364 + if (!route) { 1365 + return Response.json({ error: 'not found' }, { status: 404 }) 1366 + } 1367 + if (route.method && request.method !== route.method) { 1368 + return Response.json({ error: 'method not allowed' }, { status: 405 }) 1323 1369 } 1324 - return Response.json({ error: 'not found' }, { status: 404 }) 1370 + return route.handler(this, request, url) 1325 1371 } 1326 1372 } 1327 1373