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

docs: add pds.js refactor implementation plan

Plan covers: CBOR constants, shared encodeHead, JSDoc on exports,
route table pattern, and protocol-specific "why" comments.

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

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

Changed files
+718
docs
+718
docs/plans/2026-01-05-pds-refactor.md
··· 1 + # pds.js Refactor Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Improve pds.js maintainability through consolidated CBOR encoding, JSDoc documentation, route table pattern, and clarifying comments. 6 + 7 + **Architecture:** Single-file refactor preserving current dependency order. Extract shared `encodeHead` helper for CBOR encoders. Replace `PersonalDataServer.fetch` if/else chain with declarative route table. Add JSDoc to exported functions and "why" comments to protocol-specific logic. 8 + 9 + **Tech Stack:** JavaScript (ES modules), Cloudflare Workers, JSDoc 10 + 11 + --- 12 + 13 + ## Task 1: Add CBOR Constants 14 + 15 + **Files:** 16 + - Modify: `src/pds.js:1-12` 17 + 18 + **Step 1: Write test for constants usage** 19 + 20 + No new test needed — existing CBOR tests will verify constants work correctly. 21 + 22 + **Step 2: Add constants section at top of file** 23 + 24 + Insert before the CID wrapper class: 25 + 26 + ```javascript 27 + // === CONSTANTS === 28 + // CBOR primitive markers (RFC 8949) 29 + const CBOR_FALSE = 0xf4 30 + const CBOR_TRUE = 0xf5 31 + const CBOR_NULL = 0xf6 32 + 33 + // DAG-CBOR CID link tag 34 + const CBOR_TAG_CID = 42 35 + ``` 36 + 37 + **Step 3: Update cborEncode to use constants** 38 + 39 + Replace in `cborEncode` function: 40 + - `parts.push(0xf6)` → `parts.push(CBOR_NULL)` 41 + - `parts.push(0xf5)` → `parts.push(CBOR_TRUE)` 42 + - `parts.push(0xf4)` → `parts.push(CBOR_FALSE)` 43 + 44 + **Step 4: Update cborEncodeDagCbor to use constants** 45 + 46 + Same replacements, plus: 47 + - `parts.push(0xd8, 42)` → `parts.push(0xd8, CBOR_TAG_CID)` 48 + 49 + **Step 5: Update cborEncodeMstNode to use constants** 50 + 51 + Same replacements for null/true/false and tag 42. 52 + 53 + **Step 6: Run tests to verify** 54 + 55 + Run: `npm test` 56 + Expected: All CBOR tests pass 57 + 58 + **Step 7: Commit** 59 + 60 + ```bash 61 + git add src/pds.js 62 + git commit -m "refactor: extract CBOR constants for clarity" 63 + ``` 64 + 65 + --- 66 + 67 + ## Task 2: Extract Shared encodeHead Helper 68 + 69 + **Files:** 70 + - Modify: `src/pds.js` (CBOR ENCODING section) 71 + 72 + **Step 1: Write test for large integer encoding** 73 + 74 + Already exists — `test/pds.test.js` has "encodes large integers >= 2^31 without overflow" 75 + 76 + **Step 2: Extract shared encodeHead function** 77 + 78 + Add after constants section, before `cborEncode`: 79 + 80 + ```javascript 81 + /** 82 + * Encode CBOR type header (major type + length) 83 + * @param {number[]} parts - Array to push bytes to 84 + * @param {number} majorType - CBOR major type (0-7) 85 + * @param {number} length - Value or length to encode 86 + */ 87 + function encodeHead(parts, majorType, length) { 88 + const mt = majorType << 5 89 + if (length < 24) { 90 + parts.push(mt | length) 91 + } else if (length < 256) { 92 + parts.push(mt | 24, length) 93 + } else if (length < 65536) { 94 + parts.push(mt | 25, length >> 8, length & 0xff) 95 + } else if (length < 4294967296) { 96 + // Use Math.floor instead of bitshift to avoid 32-bit signed integer overflow 97 + parts.push(mt | 26, 98 + Math.floor(length / 0x1000000) & 0xff, 99 + Math.floor(length / 0x10000) & 0xff, 100 + Math.floor(length / 0x100) & 0xff, 101 + length & 0xff) 102 + } 103 + } 104 + ``` 105 + 106 + **Step 3: Update cborEncode to use shared helper** 107 + 108 + Remove the local `encodeHead` function. Replace calls: 109 + - `encodeHead(3, bytes.length)` → `encodeHead(parts, 3, bytes.length)` 110 + - Same pattern for all other calls 111 + 112 + **Step 4: Update cborEncodeDagCbor to use shared helper** 113 + 114 + Remove the local `encodeHead` function. Update all calls to pass `parts` as first argument. 115 + 116 + **Step 5: Update cborEncodeMstNode to use shared helper** 117 + 118 + Remove the local `encodeHead` function. Update all calls to pass `parts` as first argument. 119 + 120 + **Step 6: Run tests** 121 + 122 + Run: `npm test` 123 + Expected: All tests pass 124 + 125 + **Step 7: Commit** 126 + 127 + ```bash 128 + git add src/pds.js 129 + git commit -m "refactor: consolidate CBOR encodeHead into shared helper" 130 + ``` 131 + 132 + --- 133 + 134 + ## Task 3: Add JSDoc to Exported Functions 135 + 136 + **Files:** 137 + - Modify: `src/pds.js` 138 + 139 + **Step 1: Add JSDoc to cborEncode** 140 + 141 + ```javascript 142 + /** 143 + * Encode a value as CBOR bytes (RFC 8949 deterministic encoding) 144 + * @param {*} value - Value to encode (null, boolean, number, string, Uint8Array, array, or object) 145 + * @returns {Uint8Array} CBOR-encoded bytes 146 + */ 147 + export function cborEncode(value) { 148 + ``` 149 + 150 + **Step 2: Add JSDoc to cborDecode** 151 + 152 + ```javascript 153 + /** 154 + * Decode CBOR bytes to a JavaScript value 155 + * @param {Uint8Array} bytes - CBOR-encoded bytes 156 + * @returns {*} Decoded value 157 + */ 158 + export function cborDecode(bytes) { 159 + ``` 160 + 161 + **Step 3: Add JSDoc to CID functions** 162 + 163 + ```javascript 164 + /** 165 + * Create a CIDv1 (dag-cbor + sha-256) from raw bytes 166 + * @param {Uint8Array} bytes - Content to hash 167 + * @returns {Promise<Uint8Array>} CID bytes (36 bytes: version + codec + multihash) 168 + */ 169 + export async function createCid(bytes) { 170 + 171 + /** 172 + * Convert CID bytes to base32lower string representation 173 + * @param {Uint8Array} cid - CID bytes 174 + * @returns {string} Base32lower-encoded CID with 'b' prefix 175 + */ 176 + export function cidToString(cid) { 177 + 178 + /** 179 + * Encode bytes as base32lower string 180 + * @param {Uint8Array} bytes - Bytes to encode 181 + * @returns {string} Base32lower-encoded string 182 + */ 183 + export function base32Encode(bytes) { 184 + ``` 185 + 186 + **Step 4: Add JSDoc to TID function** 187 + 188 + ```javascript 189 + /** 190 + * Generate a timestamp-based ID (TID) for record keys 191 + * Monotonic within a process, sortable by time 192 + * @returns {string} 13-character base32-sort encoded TID 193 + */ 194 + export function createTid() { 195 + ``` 196 + 197 + **Step 5: Add JSDoc to signing functions** 198 + 199 + ```javascript 200 + /** 201 + * Import a raw P-256 private key for signing 202 + * @param {Uint8Array} privateKeyBytes - 32-byte raw private key 203 + * @returns {Promise<CryptoKey>} Web Crypto key handle 204 + */ 205 + export async function importPrivateKey(privateKeyBytes) { 206 + 207 + /** 208 + * Sign data with ECDSA P-256, returning low-S normalized signature 209 + * @param {CryptoKey} privateKey - Web Crypto key from importPrivateKey 210 + * @param {Uint8Array} data - Data to sign 211 + * @returns {Promise<Uint8Array>} 64-byte signature (r || s) 212 + */ 213 + export async function sign(privateKey, data) { 214 + 215 + /** 216 + * Generate a new P-256 key pair 217 + * @returns {Promise<{privateKey: Uint8Array, publicKey: Uint8Array}>} 32-byte private key, 33-byte compressed public key 218 + */ 219 + export async function generateKeyPair() { 220 + ``` 221 + 222 + **Step 6: Add JSDoc to utility functions** 223 + 224 + ```javascript 225 + /** 226 + * Convert bytes to hexadecimal string 227 + * @param {Uint8Array} bytes - Bytes to convert 228 + * @returns {string} Hex string 229 + */ 230 + export function bytesToHex(bytes) { 231 + 232 + /** 233 + * Convert hexadecimal string to bytes 234 + * @param {string} hex - Hex string 235 + * @returns {Uint8Array} Decoded bytes 236 + */ 237 + export function hexToBytes(hex) { 238 + 239 + /** 240 + * Get MST tree depth for a key based on leading zeros in SHA-256 hash 241 + * @param {string} key - Record key (collection/rkey) 242 + * @returns {Promise<number>} Tree depth (leading zeros / 2) 243 + */ 244 + export async function getKeyDepth(key) { 245 + 246 + /** 247 + * Encode integer as unsigned varint 248 + * @param {number} n - Non-negative integer 249 + * @returns {Uint8Array} Varint-encoded bytes 250 + */ 251 + export function varint(n) { 252 + 253 + /** 254 + * Convert base32lower CID string to raw bytes 255 + * @param {string} cidStr - CID string with 'b' prefix 256 + * @returns {Uint8Array} CID bytes 257 + */ 258 + export function cidToBytes(cidStr) { 259 + 260 + /** 261 + * Decode base32lower string to bytes 262 + * @param {string} str - Base32lower-encoded string 263 + * @returns {Uint8Array} Decoded bytes 264 + */ 265 + export function base32Decode(str) { 266 + 267 + /** 268 + * Build a CAR (Content Addressable aRchive) file 269 + * @param {string} rootCid - Root CID string 270 + * @param {Array<{cid: string, data: Uint8Array}>} blocks - Blocks to include 271 + * @returns {Uint8Array} CAR file bytes 272 + */ 273 + export function buildCarFile(rootCid, blocks) { 274 + ``` 275 + 276 + **Step 7: Run tests** 277 + 278 + Run: `npm test` 279 + Expected: All tests pass (JSDoc doesn't affect runtime) 280 + 281 + **Step 8: Commit** 282 + 283 + ```bash 284 + git add src/pds.js 285 + git commit -m "docs: add JSDoc to exported functions" 286 + ``` 287 + 288 + --- 289 + 290 + ## Task 4: Add "Why" Comments to Protocol Logic 291 + 292 + **Files:** 293 + - Modify: `src/pds.js` 294 + 295 + **Step 1: Add comment to DAG-CBOR key sorting** 296 + 297 + In `cborEncodeDagCbor`, before the `keys.sort()` call: 298 + 299 + ```javascript 300 + // DAG-CBOR: sort keys by length first, then lexicographically 301 + // (differs from standard CBOR which sorts lexicographically only) 302 + const keys = Object.keys(val).filter(k => val[k] !== undefined) 303 + keys.sort((a, b) => { 304 + ``` 305 + 306 + **Step 2: Add comment to MST depth calculation** 307 + 308 + In `getKeyDepth`, before the return: 309 + 310 + ```javascript 311 + // MST depth = leading zeros in SHA-256 hash / 2 312 + // This creates a probabilistic tree where ~50% of keys are at depth 0, 313 + // ~25% at depth 1, etc., giving O(log n) lookups 314 + const depth = Math.floor(zeros / 2) 315 + ``` 316 + 317 + **Step 3: Add comment to low-S normalization** 318 + 319 + In `sign` function, before the if statement: 320 + 321 + ```javascript 322 + // Low-S normalization: Bitcoin/ATProto require S <= N/2 to prevent 323 + // signature malleability (two valid signatures for same message) 324 + if (sBigInt > P256_N_DIV_2) { 325 + ``` 326 + 327 + **Step 4: Add comment to CID tag encoding** 328 + 329 + In `cborEncodeDagCbor`, at the CID encoding: 330 + 331 + ```javascript 332 + } else if (val instanceof CID) { 333 + // CID links in DAG-CBOR use tag 42 + 0x00 multibase prefix 334 + // The 0x00 prefix indicates "identity" multibase (raw bytes) 335 + parts.push(0xd8, CBOR_TAG_CID) 336 + ``` 337 + 338 + **Step 5: Run tests** 339 + 340 + Run: `npm test` 341 + Expected: All tests pass 342 + 343 + **Step 6: Commit** 344 + 345 + ```bash 346 + git add src/pds.js 347 + git commit -m "docs: add 'why' comments to protocol-specific logic" 348 + ``` 349 + 350 + --- 351 + 352 + ## Task 5: Extract PersonalDataServer Route Table 353 + 354 + **Files:** 355 + - Modify: `src/pds.js` (PERSONAL DATA SERVER section) 356 + 357 + **Step 1: Define route table before class** 358 + 359 + Add before `export class PersonalDataServer`: 360 + 361 + ```javascript 362 + /** 363 + * Route handler function type 364 + * @callback RouteHandler 365 + * @param {PersonalDataServer} pds - PDS instance 366 + * @param {Request} request - HTTP request 367 + * @param {URL} url - Parsed URL 368 + * @returns {Promise<Response>} HTTP response 369 + */ 370 + 371 + /** 372 + * @typedef {Object} Route 373 + * @property {string} [method] - Required HTTP method (default: any) 374 + * @property {RouteHandler} handler - Handler function 375 + */ 376 + 377 + /** @type {Record<string, Route>} */ 378 + const pdsRoutes = { 379 + '/.well-known/atproto-did': { 380 + handler: (pds, req, url) => pds.handleAtprotoDid() 381 + }, 382 + '/init': { 383 + method: 'POST', 384 + handler: (pds, req, url) => pds.handleInit(req) 385 + }, 386 + '/status': { 387 + handler: (pds, req, url) => pds.handleStatus() 388 + }, 389 + '/reset-repo': { 390 + handler: (pds, req, url) => pds.handleResetRepo() 391 + }, 392 + '/forward-event': { 393 + handler: (pds, req, url) => pds.handleForwardEvent(req) 394 + }, 395 + '/register-did': { 396 + handler: (pds, req, url) => pds.handleRegisterDid(req) 397 + }, 398 + '/get-registered-dids': { 399 + handler: (pds, req, url) => pds.handleGetRegisteredDids() 400 + }, 401 + '/repo-info': { 402 + handler: (pds, req, url) => pds.handleRepoInfo() 403 + }, 404 + '/xrpc/com.atproto.server.describeServer': { 405 + handler: (pds, req, url) => pds.handleDescribeServer(req) 406 + }, 407 + '/xrpc/com.atproto.sync.listRepos': { 408 + handler: (pds, req, url) => pds.handleListRepos() 409 + }, 410 + '/xrpc/com.atproto.repo.createRecord': { 411 + method: 'POST', 412 + handler: (pds, req, url) => pds.handleCreateRecord(req) 413 + }, 414 + '/xrpc/com.atproto.repo.getRecord': { 415 + handler: (pds, req, url) => pds.handleGetRecord(url) 416 + }, 417 + '/xrpc/com.atproto.sync.getLatestCommit': { 418 + handler: (pds, req, url) => pds.handleGetLatestCommit() 419 + }, 420 + '/xrpc/com.atproto.sync.getRepoStatus': { 421 + handler: (pds, req, url) => pds.handleGetRepoStatus() 422 + }, 423 + '/xrpc/com.atproto.sync.getRepo': { 424 + handler: (pds, req, url) => pds.handleGetRepo() 425 + }, 426 + '/xrpc/com.atproto.sync.subscribeRepos': { 427 + handler: (pds, req, url) => pds.handleSubscribeRepos(req, url) 428 + } 429 + } 430 + ``` 431 + 432 + **Step 2: Extract handleAtprotoDid method** 433 + 434 + Add to PersonalDataServer class: 435 + 436 + ```javascript 437 + async handleAtprotoDid() { 438 + let did = await this.getDid() 439 + if (!did) { 440 + const registeredDids = await this.state.storage.get('registeredDids') || [] 441 + did = registeredDids[0] 442 + } 443 + if (!did) { 444 + return new Response('User not found', { status: 404 }) 445 + } 446 + return new Response(did, { headers: { 'Content-Type': 'text/plain' } }) 447 + } 448 + ``` 449 + 450 + **Step 3: Extract handleInit method** 451 + 452 + ```javascript 453 + async handleInit(request) { 454 + const body = await request.json() 455 + if (!body.did || !body.privateKey) { 456 + return Response.json({ error: 'missing did or privateKey' }, { status: 400 }) 457 + } 458 + await this.initIdentity(body.did, body.privateKey, body.handle || null) 459 + return Response.json({ ok: true, did: body.did, handle: body.handle || null }) 460 + } 461 + ``` 462 + 463 + **Step 4: Extract handleStatus method** 464 + 465 + ```javascript 466 + async handleStatus() { 467 + const did = await this.getDid() 468 + return Response.json({ initialized: !!did, did: did || null }) 469 + } 470 + ``` 471 + 472 + **Step 5: Extract handleResetRepo method** 473 + 474 + ```javascript 475 + async handleResetRepo() { 476 + this.sql.exec(`DELETE FROM blocks`) 477 + this.sql.exec(`DELETE FROM records`) 478 + this.sql.exec(`DELETE FROM commits`) 479 + this.sql.exec(`DELETE FROM seq_events`) 480 + await this.state.storage.delete('head') 481 + await this.state.storage.delete('rev') 482 + return Response.json({ ok: true, message: 'repo data cleared' }) 483 + } 484 + ``` 485 + 486 + **Step 6: Extract handleForwardEvent method** 487 + 488 + ```javascript 489 + async handleForwardEvent(request) { 490 + const evt = await request.json() 491 + const numSockets = [...this.state.getWebSockets()].length 492 + console.log(`forward-event: received event seq=${evt.seq}, ${numSockets} connected sockets`) 493 + this.broadcastEvent({ 494 + seq: evt.seq, 495 + did: evt.did, 496 + commit_cid: evt.commit_cid, 497 + evt: new Uint8Array(Object.values(evt.evt)) 498 + }) 499 + return Response.json({ ok: true, sockets: numSockets }) 500 + } 501 + ``` 502 + 503 + **Step 7: Extract handleRegisterDid method** 504 + 505 + ```javascript 506 + async handleRegisterDid(request) { 507 + const body = await request.json() 508 + const registeredDids = await this.state.storage.get('registeredDids') || [] 509 + if (!registeredDids.includes(body.did)) { 510 + registeredDids.push(body.did) 511 + await this.state.storage.put('registeredDids', registeredDids) 512 + } 513 + return Response.json({ ok: true }) 514 + } 515 + ``` 516 + 517 + **Step 8: Extract handleGetRegisteredDids method** 518 + 519 + ```javascript 520 + async handleGetRegisteredDids() { 521 + const registeredDids = await this.state.storage.get('registeredDids') || [] 522 + return Response.json({ dids: registeredDids }) 523 + } 524 + ``` 525 + 526 + **Step 9: Extract handleRepoInfo method** 527 + 528 + ```javascript 529 + async handleRepoInfo() { 530 + const head = await this.state.storage.get('head') 531 + const rev = await this.state.storage.get('rev') 532 + return Response.json({ head: head || null, rev: rev || null }) 533 + } 534 + ``` 535 + 536 + **Step 10: Extract handleDescribeServer method** 537 + 538 + ```javascript 539 + handleDescribeServer(request) { 540 + const hostname = request.headers.get('x-hostname') || 'localhost' 541 + return Response.json({ 542 + did: `did:web:${hostname}`, 543 + availableUserDomains: [`.${hostname}`], 544 + inviteCodeRequired: false, 545 + phoneVerificationRequired: false, 546 + links: {}, 547 + contact: {} 548 + }) 549 + } 550 + ``` 551 + 552 + **Step 11: Extract handleListRepos method** 553 + 554 + ```javascript 555 + async handleListRepos() { 556 + const registeredDids = await this.state.storage.get('registeredDids') || [] 557 + const did = await this.getDid() 558 + const repos = did ? [{ did, head: null, rev: null }] : 559 + registeredDids.map(d => ({ did: d, head: null, rev: null })) 560 + return Response.json({ repos }) 561 + } 562 + ``` 563 + 564 + **Step 12: Extract handleCreateRecord method** 565 + 566 + ```javascript 567 + async handleCreateRecord(request) { 568 + const body = await request.json() 569 + if (!body.collection || !body.record) { 570 + return Response.json({ error: 'missing collection or record' }, { status: 400 }) 571 + } 572 + try { 573 + const result = await this.createRecord(body.collection, body.record, body.rkey) 574 + return Response.json(result) 575 + } catch (err) { 576 + return Response.json({ error: err.message }, { status: 500 }) 577 + } 578 + } 579 + ``` 580 + 581 + **Step 13: Extract handleGetRecord method** 582 + 583 + ```javascript 584 + async handleGetRecord(url) { 585 + const collection = url.searchParams.get('collection') 586 + const rkey = url.searchParams.get('rkey') 587 + if (!collection || !rkey) { 588 + return Response.json({ error: 'missing collection or rkey' }, { status: 400 }) 589 + } 590 + const did = await this.getDid() 591 + const uri = `at://${did}/${collection}/${rkey}` 592 + const rows = this.sql.exec( 593 + `SELECT cid, value FROM records WHERE uri = ?`, uri 594 + ).toArray() 595 + if (rows.length === 0) { 596 + return Response.json({ error: 'record not found' }, { status: 404 }) 597 + } 598 + const row = rows[0] 599 + const value = cborDecode(new Uint8Array(row.value)) 600 + return Response.json({ uri, cid: row.cid, value }) 601 + } 602 + ``` 603 + 604 + **Step 14: Extract handleGetLatestCommit method** 605 + 606 + ```javascript 607 + handleGetLatestCommit() { 608 + const commits = this.sql.exec( 609 + `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 610 + ).toArray() 611 + if (commits.length === 0) { 612 + return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 613 + } 614 + return Response.json({ cid: commits[0].cid, rev: commits[0].rev }) 615 + } 616 + ``` 617 + 618 + **Step 15: Extract handleGetRepoStatus method** 619 + 620 + ```javascript 621 + async handleGetRepoStatus() { 622 + const did = await this.getDid() 623 + const commits = this.sql.exec( 624 + `SELECT cid, rev FROM commits ORDER BY seq DESC LIMIT 1` 625 + ).toArray() 626 + if (commits.length === 0 || !did) { 627 + return Response.json({ error: 'RepoNotFound', message: 'repo not found' }, { status: 404 }) 628 + } 629 + return Response.json({ did, active: true, status: 'active', rev: commits[0].rev }) 630 + } 631 + ``` 632 + 633 + **Step 16: Extract handleGetRepo method** 634 + 635 + ```javascript 636 + handleGetRepo() { 637 + const commits = this.sql.exec( 638 + `SELECT cid FROM commits ORDER BY seq DESC LIMIT 1` 639 + ).toArray() 640 + if (commits.length === 0) { 641 + return Response.json({ error: 'repo not found' }, { status: 404 }) 642 + } 643 + const blocks = this.sql.exec(`SELECT cid, data FROM blocks`).toArray() 644 + const blocksForCar = blocks.map(b => ({ 645 + cid: b.cid, 646 + data: new Uint8Array(b.data) 647 + })) 648 + const car = buildCarFile(commits[0].cid, blocksForCar) 649 + return new Response(car, { 650 + headers: { 'content-type': 'application/vnd.ipld.car' } 651 + }) 652 + } 653 + ``` 654 + 655 + **Step 17: Extract handleSubscribeRepos method** 656 + 657 + ```javascript 658 + handleSubscribeRepos(request, url) { 659 + const upgradeHeader = request.headers.get('Upgrade') 660 + if (upgradeHeader !== 'websocket') { 661 + return new Response('expected websocket', { status: 426 }) 662 + } 663 + const { 0: client, 1: server } = new WebSocketPair() 664 + this.state.acceptWebSocket(server) 665 + const cursor = url.searchParams.get('cursor') 666 + if (cursor) { 667 + const events = this.sql.exec( 668 + `SELECT * FROM seq_events WHERE seq > ? ORDER BY seq`, 669 + parseInt(cursor) 670 + ).toArray() 671 + for (const evt of events) { 672 + server.send(this.formatEvent(evt)) 673 + } 674 + } 675 + return new Response(null, { status: 101, webSocket: client }) 676 + } 677 + ``` 678 + 679 + **Step 18: Replace fetch method with router** 680 + 681 + ```javascript 682 + async fetch(request) { 683 + const url = new URL(request.url) 684 + const route = pdsRoutes[url.pathname] 685 + 686 + if (!route) { 687 + return Response.json({ error: 'not found' }, { status: 404 }) 688 + } 689 + if (route.method && request.method !== route.method) { 690 + return Response.json({ error: 'method not allowed' }, { status: 405 }) 691 + } 692 + return route.handler(this, request, url) 693 + } 694 + ``` 695 + 696 + **Step 19: Run tests** 697 + 698 + Run: `npm test` 699 + Expected: All tests pass 700 + 701 + **Step 20: Commit** 702 + 703 + ```bash 704 + git add src/pds.js 705 + git commit -m "refactor: extract PersonalDataServer route table" 706 + ``` 707 + 708 + --- 709 + 710 + ## Summary 711 + 712 + After completing all tasks, the file will have: 713 + - Named constants for CBOR markers and CID tag 714 + - Single shared `encodeHead` helper (no duplication) 715 + - JSDoc on all 15 exported functions 716 + - "Why" comments on 4 protocol-specific code sections 717 + - Declarative route table with 16 focused handler methods 718 + - Same dependency order, same single file