# Deploying Read-Only PDS to Cloudflare Workers Deploy a read-only AT Protocol PDS to Cloudflare Workers with sample records. ## Prerequisites - Node.js 20+, pnpm - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) - Cloudflare account with Workers and R2 enabled ## Step 1: Build the CAR File ```bash cd pds.js # Generate a signing key node packages/readonly/src/build-car.js --generate-key # Save the privateKey output # Create sample records cat > records.json << 'EOF' [ { "collection": "app.bsky.actor.profile", "rkey": "self", "record": { "$type": "app.bsky.actor.profile", "displayName": "Read-Only Archive", "description": "A read-only archive served from Cloudflare Workers." } }, { "collection": "app.bsky.feed.post", "rkey": "3abc123def456", "record": { "$type": "app.bsky.feed.post", "text": "Hello from my read-only PDS!", "createdAt": "2024-01-15T12:00:00.000Z" } }, { "collection": "app.bsky.feed.post", "rkey": "3abc123def789", "record": { "$type": "app.bsky.feed.post", "text": "The AT Protocol makes data portable!", "createdAt": "2024-01-16T14:30:00.000Z" } } ] EOF # Build CAR (replace did and key with your values) node packages/readonly/src/build-car.js \ -o repo.car \ -d did:plc:your-did-here \ -k \ -F records.json ``` ## Step 2: Create Worker Project ```bash mkdir cloudflare-pds && cd cloudflare-pds mv ../repo.car . ``` Create `wrangler.toml`: ```toml name = "readonly-pds" main = "worker.js" compatibility_date = "2024-01-01" [[r2_buckets]] binding = "REPO_BUCKET" bucket_name = "pds-repos" ``` Create `worker.js`: ```javascript // Read-Only PDS Worker - serves AT Protocol repo from R2-stored CAR file // CAR/CBOR parsing adapted from @pdsjs/core (see packages/core/src/car.js, repo.js) export default { async fetch(request, env) { const url = new URL(request.url); const cors = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS' }; if (request.method === 'OPTIONS') return new Response(null, { headers: cors }); if (request.method !== 'GET') return json({ error: 'MethodNotAllowed' }, 405, cors); try { const repo = await loadRepo(env); if (!repo) return json({ error: 'RepoNotFound' }, 404, cors); const p = url.pathname; const q = (k) => url.searchParams.get(k); if (p === '/xrpc/com.atproto.sync.listRepos') { return json({ repos: [{ did: repo.did, head: repo.commitCid, rev: repo.rev, active: true }] }, 200, cors); } if (p === '/xrpc/com.atproto.sync.getRepo' && q('did') === repo.did) { return new Response(repo.carBytes, { headers: { ...cors, 'Content-Type': 'application/vnd.ipld.car' } }); } if (p === '/xrpc/com.atproto.sync.getLatestCommit' && q('did') === repo.did) { return json({ cid: repo.commitCid, rev: repo.rev }, 200, cors); } if (p === '/xrpc/com.atproto.repo.describeRepo' && q('repo') === repo.did) { const collections = [...new Set([...repo.records.keys()].map(u => u.split('/')[3]))]; return json({ did: repo.did, handle: 'handle.invalid', collections, didDoc: {} }, 200, cors); } if (p === '/xrpc/com.atproto.repo.getRecord') { const rec = repo.records.get(`at://${q('repo')}/${q('collection')}/${q('rkey')}`); return rec ? json({ uri: rec.uri, cid: rec.cid, value: rec.value }, 200, cors) : json({ error: 'RecordNotFound' }, 404, cors); } if (p === '/xrpc/com.atproto.repo.listRecords') { const prefix = `at://${q('repo')}/${q('collection')}/`; const recs = [...repo.records.values()].filter(r => r.uri.startsWith(prefix)).sort((a, b) => b.uri.localeCompare(a.uri)); return json({ records: recs.slice(0, 50) }, 200, cors); } if (p.startsWith('/xrpc/')) return json({ error: 'AuthenticationRequired', message: 'Read-only PDS' }, 401, cors); if (p === '/') return new Response('Read-Only PDS', { headers: cors }); return json({ error: 'NotFound' }, 404, cors); } catch (e) { return json({ error: 'InternalServerError', message: e.message }, 500, cors); } } }; const json = (data, status, cors) => new Response(JSON.stringify(data), { status, headers: { ...cors, 'Content-Type': 'application/json' } }); let cached; async function loadRepo(env) { if (cached) return cached; const obj = await env.REPO_BUCKET.get('repo.car'); if (!obj) return null; const carBytes = new Uint8Array(await obj.arrayBuffer()); const { roots, blocks } = parseCar(carBytes); const commit = cbor(blocks.get(roots[0])); const records = new Map(); if (commit.data) walkMst(blocks, cidStr(commit.data), (k, v) => { const val = blocks.get(v); if (val) { const [c, r] = k.split('/'); records.set(`at://${commit.did}/${c}/${r}`, { uri: `at://${commit.did}/${c}/${r}`, cid: v, value: cbor(val) }); } }); return cached = { did: commit.did, commitCid: roots[0], rev: commit.rev, records, carBytes }; } // Minimal CAR parser (see @pdsjs/core/src/car.js for full implementation) function parseCar(bytes) { let o = 0; const varint = () => { let r = 0, s = 0; while (bytes[o] & 0x80) r |= (bytes[o++] & 0x7f) << s, s += 7; return r | (bytes[o++] << s); }; const hLen = varint(), h = cbor(bytes.slice(o, o += hLen)); const roots = h.roots.map(r => cidStr(r)), blocks = new Map(); while (o < bytes.length) { const bLen = varint(), end = o + bLen; if (bytes[o] === 1) { o++; const codec = varint(), hf = varint(), hl = varint(), hash = bytes.slice(o, o += hl); blocks.set('b' + b32([1, ...enc(codec), ...enc(hf), ...enc(hl), ...hash]), bytes.slice(o, end)); } o = end; } return { roots, blocks }; } // Minimal CBOR decoder (see @pdsjs/core/src/repo.js for full implementation) function cbor(b) { let o = 0; const read = () => { const i = b[o++], maj = i >> 5, add = i & 0x1f; let v = add < 24 ? add : add === 24 ? b[o++] : add === 25 ? (b[o++] << 8) | b[o++] : add === 26 ? (b[o++] << 24 | b[o++] << 16 | b[o++] << 8 | b[o++]) >>> 0 : 0; if (maj === 0) return v; if (maj === 1) return -1 - v; if (maj === 2) { const r = b.slice(o, o + v); o += v; return r; } if (maj === 3) { const r = new TextDecoder().decode(b.slice(o, o + v)); o += v; return r; } if (maj === 4) { const a = []; for (let i = 0; i < v; i++) a.push(read()); return a; } if (maj === 5) { const m = {}; for (let i = 0; i < v; i++) m[read()] = read(); return m; } if (maj === 6) { const t = read(); return v === 42 ? { bytes: t.slice(1) } : t; } if (maj === 7) return add === 20 ? false : add === 21 ? true : add === 22 ? null : undefined; }; return read(); } function walkMst(blocks, cid, cb) { const n = cbor(blocks.get(cid)); if (!n?.e) return; let key = ''; for (const e of n.e) { key = key.slice(0, e.p || 0) + (e.k ? new TextDecoder().decode(e.k) : ''); if (e.l) walkMst(blocks, cidStr(e.l), cb); if (e.v) cb(key, cidStr(e.v)); } if (n.e.length && n.e[n.e.length - 1].t) walkMst(blocks, cidStr(n.e[n.e.length - 1].t), cb); } const cidStr = (c) => c?.bytes ? 'b' + b32(c.bytes) : String(c); const b32 = (b) => { let bits = 0, val = 0, out = ''; const A = 'abcdefghijklmnopqrstuvwxyz234567'; for (const byte of b) { val = (val << 8) | byte; bits += 8; while (bits >= 5) out += A[(val >>> (bits -= 5)) & 31]; } return bits > 0 ? out + A[(val << (5 - bits)) & 31] : out; }; const enc = (v) => { const b = []; while (v > 0x7f) b.push((v & 0x7f) | 0x80), v >>>= 7; b.push(v); return b; }; ``` ## Step 3: Deploy ```bash # Create R2 bucket and upload CAR wrangler r2 bucket create pds-repos wrangler r2 object put pds-repos/repo.car --file repo.car # Deploy worker wrangler deploy ``` ## Step 4: Test ```bash BASE_URL="https://readonly-pds.your-account.workers.dev" # List repos curl "$BASE_URL/xrpc/com.atproto.sync.listRepos" # Get record curl "$BASE_URL/xrpc/com.atproto.repo.getRecord?repo=did:plc:your-did&collection=app.bsky.feed.post&rkey=3abc123def456" # List posts curl "$BASE_URL/xrpc/com.atproto.repo.listRecords?repo=did:plc:your-did&collection=app.bsky.feed.post" # Write endpoint (returns 401) curl -X POST "$BASE_URL/xrpc/com.atproto.repo.createRecord" ``` ## Custom Domain (Optional) Add to `wrangler.toml`: ```toml routes = [{ pattern = "pds.yourdomain.com", custom_domain = true }] ``` ## Limitations - **No blobs** - Use R2 directly for media - **Single repo** - Modify R2 path logic for multiple repos - **No WebSocket** - `subscribeRepos` requires Durable Objects - **Memory limit** - Keep CAR files under 10MB