src/features/response.ts
Rama main · réplica de lectura del repositorio original.
/**
* Response builder utilities for route handlers
*/
import { addPageImageUrls } from "#db/image-urls.ts";
import { settings } from "#db/settings.ts";
import type {
AdminDestinationId,
AdminPathParams,
} from "#shared/admin-surface/ids.ts";
import { adminPath } from "#shared/admin-surface.ts";
import { buildFlashCookie, type FlashLevel } from "#shared/cookies.ts";
import { stashForm } from "#shared/form-stash.ts";
import { getSavedFormData } from "#shared/forms/saved-data.ts";
import { appendIframeParam, getRequestId } from "#shared/request-context.ts";
import {
databaseBusyPage,
MIGRATION_IN_PROGRESS_REFRESH_SECONDS,
migrationInProgressPage,
notFoundPage,
TEMPORARY_ERROR_REFRESH_SECONDS,
temporaryErrorPage,
} from "#templates/public/errors.tsx";
/**
* Shared TextEncoder for pre-encoding string response bodies to Uint8Array.
* Bunny Edge's runtime intermittently fails to decode JS string bodies
* ("Unknown: error decoding response body"). Pre-encoding to bytes
* bypasses the runtime's string-to-UTF8 path entirely.
*/
const encoder = new TextEncoder();
export const encodeBody = (text: string): ArrayBuffer =>
encoder.encode(text).buffer as ArrayBuffer;
/** One response builder of a kind: a body, and a status that defaults to 200. */
export type EncodedResponse = (body: string, status?: number) => Response;
/**
* A response of one kind, ready to take a body. Every kind we send is text we
* built ourselves, so the charset is always UTF-8.
*/
const encodedResponse =
(contentType: string): EncodedResponse =>
(body, status = 200) =>
new Response(encodeBody(body), {
headers: { "content-type": `${contentType}; charset=utf-8` },
status,
});
/**
* Hand on something that was loaded, unless it came back as a Response. A
* Response is already the answer — a 404, a 400 — so it goes straight back.
*/
export const orResponse = async <T>(
loaded: T | Response,
use: (value: T) => Response | Promise<Response>,
): Promise<Response> => (loaded instanceof Response ? loaded : use(loaded));
/** Create HTML response */
export const htmlResponse: EncodedResponse = encodedResponse("text/html");
/**
* Respond with a rendered public page. The page renders the site footer, so
* the footer's img#N references resolve like the page's own before the page
* renders. The lookup no-ops outside a request. A failed lookup still renders
* the page: an unresolved img#N falls back to its alt text.
*/
export const publicPageResponse = async (
renderPage: () => string,
status = 200,
): Promise<Response> => {
try {
await addPageImageUrls([settings.siteFooter]);
} catch {
// The database is unavailable. The footer shows its alt text instead.
}
return htmlResponse(renderPage(), status);
};
/**
* Create 404 not found response.
*/
export const notFoundResponse = (): Promise<Response> =>
publicPageResponse(notFoundPage, 404);
type RequestErrorResponse = (method: string) => Promise<Response>;
// A refresh loads the URL as GET. It cannot preserve a submitted form.
const requestErrorResponse =
(
page: (autoRefresh: boolean) => string,
retryAfterSeconds: number,
): RequestErrorResponse =>
async (method) =>
withHeaders(
await publicPageResponse(
() => page(method === "GET" || method === "HEAD"),
503,
),
{ "retry-after": `${retryAfterSeconds}` },
);
export const temporaryErrorResponse: RequestErrorResponse =
requestErrorResponse(temporaryErrorPage, TEMPORARY_ERROR_REFRESH_SECONDS);
export const databaseBusyResponse: RequestErrorResponse = requestErrorResponse(
databaseBusyPage,
TEMPORARY_ERROR_REFRESH_SECONDS,
);
/**
* Create "migration in progress" response, shown while another isolate runs a
* database migration and pre-migration backup. 503 with an auto-refresh, like
* temporaryErrorResponse, but with a message that explains the wait instead of
* presenting it as an error.
*/
export const migrationInProgressResponse = async (): Promise<Response> =>
withHeaders(await publicPageResponse(migrationInProgressPage, 503), {
"retry-after": `${MIGRATION_IN_PROGRESS_REFRESH_SECONDS}`,
});
/**
* Create bare 302 redirect response (no message).
* Use for external URLs, setup flow, public pages, and other cases
* where the target page doesn't render success/error banners.
* For admin redirects that should show a message, use `redirect` instead.
* Automatically appends ?iframe=true when the current request is in iframe mode.
*/
export const redirectResponse = (url: string, cookie?: string): Response => {
const headers: HeadersInit = {
"content-type": "text/html; charset=utf-8",
location: appendIframeParam(url),
};
if (cookie) {
headers["set-cookie"] = cookie;
}
return new Response(null, { headers, status: 302 });
};
/** Options for redirect */
type RedirectOpts = {
formId?: string;
cookie?: string;
form?: URLSearchParams;
result?: string;
/** Override the flash level; defaults to success/error from `succeeded`. */
level?: FlashLevel;
};
type RedirectFeedback = [
message: string,
succeeded: boolean,
opts?: RedirectOpts,
];
type UrlRedirect = (url: string, ...feedback: RedirectFeedback) => Response;
type AdminRedirect = <Id extends AdminDestinationId>(
endpoint: Id,
params: AdminPathParams<Id>,
...feedback: RedirectFeedback
) => Response;
/**
* Field-name segments whose values must never be written to the re-fill stash,
* even briefly: secrets the user should re-enter rather than have persisted in
* server memory (passwords, API/secret keys, signing tokens, the CSRF token).
* Matched as whole underscore-delimited segments, so "monkey"/"keyword" are
* safe. PII is deliberately not listed — restoring it is the point of the
* feature, and it is never reflected for sensitive field *types* anyway.
*/
const SENSITIVE_STASH_KEY = /(?:^|_)(?:password|secret|token|key)(?:_|$)/i;
/**
* On a failed PRG redirect, stash the just-submitted form values so the
* follow-up GET can re-fill the fields. Prefers an explicitly passed form,
* otherwise the per-request submission captured during CSRF parsing. Secret
* fields are dropped — they should be re-entered, never persisted. Returns the
* redemption token, or null when there is nothing (eligible) to stash.
*/
const maybeStashForm = (explicit?: URLSearchParams): string | null => {
const source = explicit ?? getSavedFormData();
if (!source) return null;
const safe = new URLSearchParams();
for (const [key, value] of source) {
if (!SENSITIVE_STASH_KEY.test(key)) safe.append(key, value);
}
const serialized = safe.toString();
return serialized ? stashForm(serialized) : null;
};
/**
* Redirect with a success or error message (PRG pattern).
* Stores the message in a flash cookie instead of the query string
* to avoid leaking potentially sensitive data in URLs, browser history,
* and referrer headers.
* When formId is provided, adds a `form` param and `#formId` anchor so the
* browser scrolls to the form that was just submitted.
*/
export const redirect: UrlRedirect = (url, message, succeeded, opts) => {
const target = opts?.form?.get("return_url") || url;
if (!URL.canParse(target, "http://localhost")) {
throw new TypeError("Invalid redirect URL");
}
const u = new URL(target, "http://localhost");
const flashId = getRequestId();
u.searchParams.set("flash", flashId);
if (opts?.formId) {
u.searchParams.set("form", opts.formId);
u.hash = opts.formId;
}
const flash = buildFlashCookie(
flashId,
message,
succeeded,
opts?.result,
opts?.level,
succeeded ? undefined : (maybeStashForm(opts?.form) ?? undefined),
);
const response = redirectResponse(u.pathname + u.search + u.hash, flash);
if (opts?.cookie) withCookie(response, opts.cookie);
return response;
};
/** Redirect to a declared admin destination with the existing flash and form rules. */
export const adminRedirect: AdminRedirect = (
endpoint,
params,
message,
succeeded,
opts,
) => redirect(adminPath(endpoint, params), message, succeeded, opts);
/**
* Redirect with an error message (PRG pattern).
* Shorthand for `redirect(url, message, false, { formId })`.
*/
export const errorRedirect = (
url: string,
message: string,
formId?: string,
): Response => redirect(url, message, false, formId ? { formId } : undefined);
/** The `onInvalid` handler that returns to one row's own detail page. */
export const redirectToDetail =
<Id extends AdminDestinationId>(endpoint: Id) =>
(args: { error: string; params: AdminPathParams<Id> }): Response =>
adminRedirect(endpoint, args.params, args.error, false);
/**
* Redirect with a neutral informational message (PRG pattern), e.g. confirming
* an opt-out. Rendered in the info style rather than success or error.
*/
export const infoRedirect = (url: string, message: string): Response =>
redirect(url, message, true, { level: "info" });
const jsonBody = encodedResponse("application/json");
/** Create JSON response */
export const jsonResponse = (data: unknown, status = 200): Response =>
jsonBody(JSON.stringify(data), status);
/** One JSON response whose body is a record re-read from the primary, so a
* lagging replica cannot 404 the row the write just made. */
export const reReadResponse =
(key: string, read: (id: number) => Promise<unknown>) =>
async (id: number, status?: number): Promise<Response> =>
jsonResponse({ [key]: await read(id) }, status);
/** Create plain text response */
export const plainResponse: EncodedResponse = encodedResponse("text/plain");
/** Create iCalendar response */
export const icsResponse: EncodedResponse = encodedResponse("text/calendar");
/** Create RSS/XML response */
export const rssResponse: EncodedResponse = encodedResponse(
"application/rss+xml",
);
/**
* Build a file-download response: the body, a fixed content-type, and a
* `Content-Disposition: attachment` header that names the download. The
* `filename` is already validated by the caller (it is operator-chosen and
* constrained — a backup leaf name, a slug-based catalog name, or a CSV
* name), so it is quoted directly without RFC 5987 encoding.
*/
export const downloadResponse = (
body: BodyInit,
filename: string,
contentType: string,
): Response =>
new Response(body, {
headers: {
"content-disposition": `attachment; filename="${filename}"`,
"content-type": contentType,
},
});
/**
* Set headers on a built response.
* Takes a response the app built: a redirect or fetched response has
* immutable headers.
* Mutates headers in-place to avoid re-reading the response body.
*/
export const withHeaders = (
response: Response,
headers: Record<string, string>,
): Response => {
for (const [name, value] of Object.entries(headers)) {
response.headers.set(name, value);
}
return response;
};
/**
* Add cookie header to response.
* Mutates headers in-place to avoid re-reading the response body.
*/
export const withCookie = (response: Response, cookie: string): Response => {
response.headers.append("set-cookie", cookie);
return response;
};