chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/core/http - Request and response mechanics every handler shares:
// CORS, cookies, HTML responses and draining a body a handler will not read.
// CORS headers for cross-origin requests
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
// Range lets a browser page read single objects out of a git bundle blob
// with com.atproto.sync.getBlob.
'Access-Control-Allow-Headers':
'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics, x-bsky-is-beta-user, Range',
// Browser clients must be able to READ these response headers. Without
// exposing DPoP-Nonce, a browser OAuth client cannot see the nonce the server
// hands back with `use_dpop_nonce` and so cannot retry — login fails on it.
// Atproto-Space-Mac carries the MAC a browser syncer checks a space read by.
'Access-Control-Expose-Headers':
'DPoP-Nonce, WWW-Authenticate, Atproto-Space-Mac',
};
/**
* The CORS headers answering one preflight. A browser names the headers it
* wants in Access-Control-Request-Headers, and the reply allows exactly those.
* Thus a client sending a header this server has never heard of still gets
* through, and the Bluesky app does not break each time it adds one. The
* allowance grants nothing on its own: the origin is `*`, so the browser sends
* no credentials, and the request that follows still has to authenticate.
* @param {Request} request
* @returns {Record<string, string>}
*/
export function preflightHeaders(request) {
const requested = request.headers.get('Access-Control-Request-Headers');
if (!requested) return corsHeaders;
return { ...corsHeaders, 'Access-Control-Allow-Headers': requested };
}
/**
* Parse a single-range `Range` header against a known length. Returns null
* when there is no range to honour, and the string 'unsatisfiable' for a
* range that falls outside the file, which is a 416 rather than an error.
* @param {string|null} header
* @param {number} size
* @returns {{start: number, end: number}|null|'unsatisfiable'}
*/
export function parseByteRange(header, size) {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const [, rawStart, rawEnd] = match;
if (rawStart === '' && rawEnd === '') return null;
if (size === 0) return 'unsatisfiable';
let start;
let end;
if (rawStart === '') {
// A suffix range asks for the last N bytes.
const length = Number(rawEnd);
if (length === 0) return 'unsatisfiable';
start = Math.max(0, size - length);
end = size - 1;
} else {
start = Number(rawStart);
end = rawEnd === '' ? size - 1 : Number(rawEnd);
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start >= size || start > end) return 'unsatisfiable';
return { start, end: Math.min(end, size - 1) };
}
/**
* Add CORS headers to response
* @param {Response} response
* @returns {Response}
*/
export function addCorsHeaders(response) {
// WebSocket upgrade responses (101) can't be reconstructed
// webSocket is a Cloudflare-specific property
if (response.status === 101 || /** @type {any} */ (response).webSocket) {
return response;
}
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
/**
* Consume a request body that a handler is about to reject unread.
* Workers runtimes raise an uncaught error when a response is sent while
* the request stream is still readable.
* @param {Request} request
* @returns {Promise<void>}
*/
export async function drainRequestBody(request) {
if (!request.body) return;
try {
await request.arrayBuffer();
} catch {
// Body already consumed or aborted
}
}
/**
* Form posts are only accepted from this server's own pages. `SameSite=Lax`
* already keeps the session cookie off cross-site posts; this rejects the
* request outright rather than treating it as signed out.
* @param {Request} request
* @param {URL} url
* @returns {boolean}
*/
export function isSameOriginPost(request, url) {
const origin = request.headers.get('origin');
return !origin || origin === `${url.protocol}//${url.host}`;
}
/**
* Read one cookie from a request's Cookie header.
* @param {Request} request
* @param {string} name
* @returns {string|null}
*/
export function readCookie(request, name) {
const header = request.headers.get('cookie');
if (!header) return null;
for (const pair of header.split(';')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
if (pair.slice(0, eq).trim() === name) {
return decodeURIComponent(pair.slice(eq + 1).trim());
}
}
return null;
}
/**
* An HTML response for the account pages. These carry per-account data and a
* session cookie, so they must never be cached.
* @param {string} html
* @param {number} [status=200]
* @param {string|null} [setCookie=null]
* @returns {Response}
*/
export function htmlResponse(html, status = 200, setCookie = null) {
const headers = new Headers({
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'same-origin',
});
if (setCookie) headers.set('Set-Cookie', setCookie);
return new Response(html, { status, headers });
}