+718
docs/plans/2026-01-05-pds-refactor.md
+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