// @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} 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} */ 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} */ 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, }); } }