chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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,
});
}
}