Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260// @pdsjs/sites - the site request handler//// Built by a platform package from ports and config, passed to core as// `config.siteHandler`, and invoked for every request whose hostname is not// the PDS hostname. Returning null hands the request back to normal PDS// dispatch; any Response is final — core adds no CORS headers, no DPoP// nonce, nothing.
import { parseByteRange } from '@pdsjs/core/http';import { isSpaceRecordUri } from '@pdsjs/core/repo';import { hostnameToSite } from './hostname.js';import { loadSite } from './manifest.js';import { etagMatches, fileHeaders, notFoundResponse, ORIGIN_PLACEHOLDER, repoBlobHeaders, resolvePath,} from './serve.js';
/** * @typedef {Object} SiteHandlerContext * @property {import('@pdsjs/core/ports').ActorStoragePort} actorStorage * @property {import('@pdsjs/core/ports').BlobPort} blobs * @property {() => Promise<string|null>} getDid - the hosted account's DID * @property {string} siteDomain - apex domain sites serve under * @property {string} [apexName] - site name bound to the apex (default "home") */
/** * Build the site handler. * @param {SiteHandlerContext} ctx * @returns {(request: Request, url: URL) => Promise<Response|null>} */export function createSiteHandler(ctx) { const { actorStorage, blobs, getDid, siteDomain } = ctx; if (!actorStorage) throw new Error('createSiteHandler requires actorStorage'); if (!blobs) throw new Error('createSiteHandler requires blobs'); if (!getDid) throw new Error('createSiteHandler requires getDid'); if (!siteDomain) throw new Error('createSiteHandler requires siteDomain'); const apexName = ctx.apexName || 'home';
return async (request, url) => { const resolved = hostnameToSite(url.hostname, siteDomain, apexName); if (!resolved) return null;
try { return await serve(request, url, resolved); } catch (err) { console.error(`site request failed: ${url.hostname}${url.pathname}`, err); // A website origin: plain text, never the PDS's JSON error shape return new Response('Something went wrong serving this site.', { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } };
/** * @param {Request} request * @param {URL} url * @param {{site: string}|{notFound: true}} resolved * @returns {Promise<Response>} */ async function serve(request, url, resolved) { // The apex doubles as the account handle; every claimed hostname // answers handle verification so resolution never depends on which // subdomain a verifier picked. A browser app resolves a handle by // fetching this path from the handle's own hostname, so it is the one // path on a site origin that carries CORS headers. if (url.pathname === '/.well-known/atproto-did') { const cors = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', }; if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: cors }); } const did = await getDid(); return new Response(did || 'User not found', { status: did ? 200 : 404, headers: { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }, }); }
if (request.method !== 'GET' && request.method !== 'HEAD') { // OPTIONS included: a static site has no preflightable API, and the // PDS's permissive CORS headers must not appear on this origin. return new Response(null, { status: 405, headers: { Allow: 'GET, HEAD' }, }); }
if ('notFound' in resolved) return notFoundResponse();
const did = await getDid(); if (!did) return notFoundResponse();
// Any repo blob, addressed by CID under a reserved dot-prefix no // deployed file can claim (the CLI never uploads dotfiles). The same // bytes are public at com.atproto.sync.getBlob; this alias is // immutable and edge-cacheable, so a client-rendered site can show // repo media without a manifest entry per file. const blobMatch = url.pathname.match(/^\/\.blobs\/([a-z2-7]{16,})$/); if (blobMatch) { const cid = blobMatch[1]; if (etagMatches(request.headers.get('if-none-match'), cid)) { // Answered before the blob read, so the mime is unknown; the // client's cached Content-Type stands on a 304. const headers = repoBlobHeaders('application/octet-stream', cid); headers.delete('Content-Type'); return new Response(null, { status: 304, headers }); }
// A blob only space records reference is private data, absent here // exactly as it is absent from com.atproto.sync.getBlob. const uris = actorStorage.listBlobRecordUris ? await actorStorage.listBlobRecordUris(cid) : []; if (uris.length > 0 && uris.every(isSpaceRecordUri)) { return notFoundResponse(); }
// The metadata row carries the size, so ranges resolve and HEAD // answers before any bytes move. Without the row the whole object // goes out buffered. const meta = actorStorage.getBlob ? await actorStorage.getBlob(cid) : null; if (meta) { const headers = repoBlobHeaders(meta.mimeType, cid); headers.set('Accept-Ranges', 'bytes'); if (request.method === 'HEAD') { headers.set('Content-Length', String(meta.size)); return new Response(null, { headers }); } const range = parseByteRange(request.headers.get('range'), meta.size); if (range === 'unsatisfiable') { return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${meta.size}`, 'Accept-Ranges': 'bytes', }, }); } /** @type {BodyInit} */ let body; if (blobs.getStream) { const found = await blobs.getStream(did, cid, range ?? undefined); if (!found) return notFoundResponse(); body = found.body; } else { const blob = await blobs.get(did, cid); if (!blob) return notFoundResponse(); body = /** @type {BodyInit} */ ( range ? blob.data.subarray(range.start, range.end + 1) : blob.data ); } const length = range ? range.end - range.start + 1 : meta.size; headers.set('Content-Length', String(length)); if (range) { headers.set( 'Content-Range', `bytes ${range.start}-${range.end}/${meta.size}`, ); } return new Response(body, { status: range ? 206 : 200, headers }); }
const blob = await blobs.get(did, cid); if (!blob) return notFoundResponse(); const headers = repoBlobHeaders(blob.mimeType, cid); if (request.method === 'HEAD') { headers.set('Content-Length', String(blob.data.length)); return new Response(null, { headers }); } return new Response(/** @type {BodyInit} */ (blob.data), { headers }); }
const manifest = await loadSite(actorStorage, did, resolved.site); if (!manifest) { // The www of the site domain redirects to the apex unless a site // named www is deployed, matching what static hosts do by default. if (resolved.site === 'www') { return new Response(null, { status: 301, headers: { Location: `https://${siteDomain}${url.pathname}${url.search}`, }, }); } return notFoundResponse(); }
const target = resolvePath(url.pathname, manifest); if (target.kind === 'badRequest') { return new Response('Bad request path.', { status: 400, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } if (target.kind === 'redirect') { return new Response(null, { status: 301, headers: { Location: target.location + url.search }, }); } if (target.kind === 'notFound') return notFoundResponse();
// A file that names the origin it is served from: the deploy could not // know the origin, so it left a token the request fills in. The bytes // differ per origin, so the blob cid no longer names them and neither an // ETag nor a stored length holds. if (target.file.originTemplate) { const blob = await blobs.get(did, target.file.cid); if (!blob) return notFoundResponse(); const origin = `${url.protocol}//${url.host}`; const filled = new TextEncoder().encode( new TextDecoder() .decode(blob.data) .replaceAll(ORIGIN_PLACEHOLDER, origin), ); const templated = fileHeaders(target.path, target.file); templated.delete('ETag'); templated.set('Content-Length', String(filled.length)); if (request.method === 'HEAD') { return new Response(null, { status: target.status, headers: templated, }); } return new Response(filled, { status: target.status, headers: templated, }); }
const headers = fileHeaders(target.path, target.file);
if (etagMatches(request.headers.get('if-none-match'), target.file.cid)) { return new Response(null, { status: 304, headers }); }
if (request.method === 'HEAD') { headers.set('Content-Length', String(target.file.size)); return new Response(null, { status: target.status, headers }); }
const blob = await blobs.get(did, target.file.cid); if (!blob) return notFoundResponse(); return new Response(/** @type {BodyInit} */ (blob.data), { status: target.status, headers, }); }}