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

feat: add CORS support for browser clients

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

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

Changed files
+88 -60
src
+88 -60
src/pds.js
··· 1368 1368 } 1369 1369 } 1370 1370 1371 - export default { 1372 - async fetch(request, env) { 1373 - const url = new URL(request.url) 1371 + const corsHeaders = { 1372 + 'Access-Control-Allow-Origin': '*', 1373 + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 1374 + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, atproto-accept-labelers', 1375 + } 1374 1376 1375 - // Endpoints that don't require ?did= param (for relay/federation) 1376 - if (url.pathname === '/.well-known/atproto-did' || 1377 - url.pathname === '/xrpc/com.atproto.server.describeServer') { 1378 - const did = url.searchParams.get('did') || 'default' 1379 - const id = env.PDS.idFromName(did) 1380 - const pds = env.PDS.get(id) 1381 - // Pass hostname for describeServer 1382 - const newReq = new Request(request.url, { 1383 - method: request.method, 1384 - headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } 1385 - }) 1386 - return pds.fetch(newReq) 1387 - } 1377 + function addCorsHeaders(response) { 1378 + const newHeaders = new Headers(response.headers) 1379 + for (const [key, value] of Object.entries(corsHeaders)) { 1380 + newHeaders.set(key, value) 1381 + } 1382 + return new Response(response.body, { 1383 + status: response.status, 1384 + statusText: response.statusText, 1385 + headers: newHeaders 1386 + }) 1387 + } 1388 1388 1389 - // subscribeRepos WebSocket - route to default instance for firehose 1390 - if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 1391 - const defaultId = env.PDS.idFromName('default') 1392 - const defaultPds = env.PDS.get(defaultId) 1393 - return defaultPds.fetch(request) 1389 + export default { 1390 + async fetch(request, env) { 1391 + // Handle CORS preflight 1392 + if (request.method === 'OPTIONS') { 1393 + return new Response(null, { headers: corsHeaders }) 1394 1394 } 1395 1395 1396 - // listRepos needs to aggregate from all registered DIDs 1397 - if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 1398 - const defaultId = env.PDS.idFromName('default') 1399 - const defaultPds = env.PDS.get(defaultId) 1400 - const regRes = await defaultPds.fetch(new Request('http://internal/get-registered-dids')) 1401 - const { dids } = await regRes.json() 1396 + const response = await handleRequest(request, env) 1397 + return addCorsHeaders(response) 1398 + } 1399 + } 1402 1400 1403 - const repos = [] 1404 - for (const did of dids) { 1405 - const id = env.PDS.idFromName(did) 1406 - const pds = env.PDS.get(id) 1407 - const infoRes = await pds.fetch(new Request('http://internal/repo-info')) 1408 - const info = await infoRes.json() 1409 - if (info.head) { 1410 - repos.push({ did, head: info.head, rev: info.rev, active: true }) 1411 - } 1412 - } 1413 - return Response.json({ repos, cursor: undefined }) 1414 - } 1401 + async function handleRequest(request, env) { 1402 + const url = new URL(request.url) 1415 1403 1416 - const did = url.searchParams.get('did') 1417 - if (!did) { 1418 - return new Response('missing did param', { status: 400 }) 1419 - } 1404 + // Endpoints that don't require ?did= param (for relay/federation) 1405 + if (url.pathname === '/.well-known/atproto-did' || 1406 + url.pathname === '/xrpc/com.atproto.server.describeServer') { 1407 + const did = url.searchParams.get('did') || 'default' 1408 + const id = env.PDS.idFromName(did) 1409 + const pds = env.PDS.get(id) 1410 + // Pass hostname for describeServer 1411 + const newReq = new Request(request.url, { 1412 + method: request.method, 1413 + headers: { ...Object.fromEntries(request.headers), 'x-hostname': url.hostname } 1414 + }) 1415 + return pds.fetch(newReq) 1416 + } 1420 1417 1421 - // On init, also register this DID with the default instance 1422 - if (url.pathname === '/init' && request.method === 'POST') { 1423 - const body = await request.json() 1418 + // subscribeRepos WebSocket - route to default instance for firehose 1419 + if (url.pathname === '/xrpc/com.atproto.sync.subscribeRepos') { 1420 + const defaultId = env.PDS.idFromName('default') 1421 + const defaultPds = env.PDS.get(defaultId) 1422 + return defaultPds.fetch(request) 1423 + } 1424 1424 1425 - // Register with default instance for discovery 1426 - const defaultId = env.PDS.idFromName('default') 1427 - const defaultPds = env.PDS.get(defaultId) 1428 - await defaultPds.fetch(new Request('http://internal/register-did', { 1429 - method: 'POST', 1430 - body: JSON.stringify({ did }) 1431 - })) 1425 + // listRepos needs to aggregate from all registered DIDs 1426 + if (url.pathname === '/xrpc/com.atproto.sync.listRepos') { 1427 + const defaultId = env.PDS.idFromName('default') 1428 + const defaultPds = env.PDS.get(defaultId) 1429 + const regRes = await defaultPds.fetch(new Request('http://internal/get-registered-dids')) 1430 + const { dids } = await regRes.json() 1432 1431 1433 - // Forward to the actual PDS instance 1432 + const repos = [] 1433 + for (const did of dids) { 1434 1434 const id = env.PDS.idFromName(did) 1435 1435 const pds = env.PDS.get(id) 1436 - return pds.fetch(new Request(request.url, { 1437 - method: 'POST', 1438 - headers: request.headers, 1439 - body: JSON.stringify(body) 1440 - })) 1436 + const infoRes = await pds.fetch(new Request('http://internal/repo-info')) 1437 + const info = await infoRes.json() 1438 + if (info.head) { 1439 + repos.push({ did, head: info.head, rev: info.rev, active: true }) 1440 + } 1441 1441 } 1442 + return Response.json({ repos, cursor: undefined }) 1443 + } 1442 1444 1445 + const did = url.searchParams.get('did') 1446 + if (!did) { 1447 + return new Response('missing did param', { status: 400 }) 1448 + } 1449 + 1450 + // On init, also register this DID with the default instance 1451 + if (url.pathname === '/init' && request.method === 'POST') { 1452 + const body = await request.json() 1453 + 1454 + // Register with default instance for discovery 1455 + const defaultId = env.PDS.idFromName('default') 1456 + const defaultPds = env.PDS.get(defaultId) 1457 + await defaultPds.fetch(new Request('http://internal/register-did', { 1458 + method: 'POST', 1459 + body: JSON.stringify({ did }) 1460 + })) 1461 + 1462 + // Forward to the actual PDS instance 1443 1463 const id = env.PDS.idFromName(did) 1444 1464 const pds = env.PDS.get(id) 1445 - return pds.fetch(request) 1465 + return pds.fetch(new Request(request.url, { 1466 + method: 'POST', 1467 + headers: request.headers, 1468 + body: JSON.stringify(body) 1469 + })) 1446 1470 } 1471 + 1472 + const id = env.PDS.idFromName(did) 1473 + const pds = env.PDS.get(id) 1474 + return pds.fetch(request) 1447 1475 }