chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# 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 <your-private-key-hex> \
-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