[ HTTP & responses ]
@sdxc/http
Response builders, content negotiation, and HTTP caching for the Fetch API
- Installs with
- @sdxc/crypto@sdxc/duration@sdxc/location@sdxc/result
- Used by
- uptime, auth-saas, reader, blog, books
- Source
- packages/http
Response builders, content negotiation, and HTTP caching for the Fetch API.
Each concern is its own entry point — status codes, content types, response builders,
Accept negotiation, Cache-Control and validators, and HEAD middleware — so a handler
imports only the surface it uses.
Installation
npm add @sdxc/http
It installs alongside @sdxc/result (the
Result that etag and precondition answer),
@sdxc/duration (cache ages),
@sdxc/crypto,
@sdxc/location and
remix (typed headers and the router).
Usage
Answer with JSON
import { badRequest, notFound, ok } from "@sdxc/http/response/json";
export async function handler(request: Request): Promise<Response> {
let id = new URL(request.url).searchParams.get("id");
if (!id) return badRequest({ error: "Missing id" });
let user = await findUser(id);
if (!user) return notFound({ error: "User not found" });
return ok({ user });
}
Each function writes the status, the status text and Content-Type: application/json.
@sdxc/http/response/html exports the same names for HTML strings.
Use named statuses and content types
import { Json } from "@sdxc/http/content-type";
import { Accepted } from "@sdxc/http/status-code";
let headers = new Headers({ "Content-Type": Json }); // "application/json"
return new Response(JSON.stringify({ queued: true }), { ...Accepted, headers }); // 202 Accepted
A status constant is a { status, statusText } pair, the shape ResponseInit takes.
Serve the format the client asked for
import { vary } from "@sdxc/http/cache";
import { respond } from "@sdxc/http/negotiate";
import { csv, html, json } from "@sdxc/http/response";
export async function handler(request: Request): Promise<Response> {
let report = await buildReport();
let response = respond(request, {
json: () => json(report),
html: () => html(renderReport(report)),
csv: () => csv(toCsv(report)),
default: () => json(report),
});
vary(response.headers, "Accept");
return response;
}
Handlers are tried in the order the Accept header prefers; with no default, a request
matching none gets 406 Not Acceptable.
Cache a page and answer its revalidations
import { conditional, etag, Policies } from "@sdxc/http/cache";
import { html } from "@sdxc/http/response";
import { isSuccess } from "@sdxc/result";
export async function handler(request: Request): Promise<Response> {
let body = await renderPage();
let headers = new Headers({ "Cache-Control": Policies.revalidate().toString() });
let tag = await etag(body, { weak: true });
if (isSuccess(tag)) headers.set("ETag", tag.data);
return await conditional(request, html(body, { headers }));
}
The client checks back before every reuse, and conditional answers with a 304 while the
ETag still matches.
Answer HEAD like GET
import { headRequests } from "@sdxc/http/middleware/head-requests";
import { html } from "@sdxc/http/response";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [headRequests()] });
router.get("/page", () => html(renderPage()));
A HEAD to /page gets the GET's status and headers with no body, through the same
middleware chain.
API
@sdxc/http/content-type
MIME type constants, each the bare type with no charset, so one value both compares
against a parsed header and writes a new one.
Text:
Text,HTML,CSS,JavaScript,CSV,XML,MarkdownApplication:
Json,JSONLines,ApplicationXML,PDF,ZIP,GZip,FormURLEncoded,OctetStream,FormDataImage:
PNG,JPEG,GIF,WebP,SVG,ICO,AVIFAudio and video:
MP3,WAV,OGG,WebMAudio,MP4,WebMVideoFont:
WOFF,WOFF2,TTF,OTFStreaming:
EventStream,NDJson
Json is spelled so it leaves the global JSON unshadowed.
@sdxc/http/status-code
Each constant is a { status, statusText } object that spreads into a ResponseInit.
1xx:
Continue,SwitchingProtocols2xx:
Ok,Created,Accepted,NoContent,ResetContent,PartialContent3xx:
MultipleChoices,MovedPermanently,Found,SeeOther,NotModified,TemporaryRedirect,PermanentRedirect4xx:
BadRequest,Unauthorized,PaymentRequired,Forbidden,NotFound,MethodNotAllowed,NotAcceptable,ProxyAuthRequired,RequestTimeout,Conflict,Gone,LengthRequired,PreconditionFailed,PayloadTooLarge,URITooLong,UnsupportedMediaType,RangeNotSatisfiable,ExpectationFailed,ImATeapot,UnprocessableEntity,TooEarly,UpgradeRequired,PreconditionRequired,TooManyRequests,RequestHeaderFieldsTooLarge,UnavailableForLegalReasons5xx:
InternalServerError,NotImplemented,BadGateway,ServiceUnavailable,GatewayTimeout,HTTPVersionNotSupported
StatusCode is the type every constant satisfies.
@sdxc/http/response
Each builder takes a body plus an optional ResponseInit, sets the Content-Type, and
returns a plain Response.
json<T>(body: T, init?: ResponseInit): Response
Serializes any value through Response.json.
text, html, css, javascript, xml, markdown (body: string, init?: ResponseInit): Response
Write their own type with charset=utf-8.
csv(body: string | ReadableStream<Uint8Array>, init?: ResponseInit): Response
Writes text/csv; charset=utf-8; a stream body sends each row as it is written.
pdf(body: Blob | ArrayBuffer | ReadableStream, init?: ResponseInit): Response
Writes application/pdf.
file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response
A download of opaque bytes: application/octet-stream plus attachment(filename).
attachment(filename: string): string
A Content-Disposition
value that saves the response as filename. A non-ASCII name gets an ASCII fallback plus an
exact UTF-8 filename*.
attachment("Café report.csv");
// "attachment; filename=\"Caf_ report.csv\"; filename*=UTF-8''Caf%C3%A9%20report.csv"
stream(body: ReadableStream, init?: ResponseInit): Response
A Server-Sent Events response: text/event-stream, Cache-Control: no-cache and
Connection: keep-alive.
noContent(init?: Omit<ResponseInit, "status" | "statusText">): Response
A 204 No Content with no body.
redirect(target: URL | Location | string, init?: redirect.Init): Response
Writes Location with status 307 by default, and throws on a target that does not parse.
redirect.Status names SeeOther (303, turns a POST into a GET), Temporary (307) and
Permanent (308); redirect.Init is a ResponseInit whose status is one of them.
@sdxc/http/response/json
ok, created, accepted, badRequest, unauthorized, paymentRequired, forbidden,
notFound, methodNotAllowed, notAcceptable, conflict, gone, preconditionFailed,
payloadTooLarge, unsupportedMediaType, unprocessableEntity, tooManyRequests,
internalServerError, notImplemented, badGateway, serviceUnavailable,
gatewayTimeout — each (body: T, init?), writing a JSON body with its status; init
carries headers.
@sdxc/http/response/html
The same names, each taking an HTML string and writing text/html.
@sdxc/http/negotiate
accepts(request: Request): AcceptList
Parses the request's Accept header, reading an absent one as */*.
AcceptList
A parsed Accept header sorted by quality. Types are full MIME types or the shorthands
json, html, xml, text, markdown, css, javascript, csv, pdf.
new AcceptList(header: string)includes(type: string): boolean— whether the type is accepted.all(): string[]— accepted types, highest quality first.preferred(...types: string[]): string | null— the candidate the client prefers most; a wildcard picks the first candidate.toShortType(mimeType: string): string | null— the shorthand for a MIME type.
let list = new AcceptList("text/html, application/json;q=0.9");
list.preferred("application/json", "text/html"); // "text/html"
respond(request: Request, handlers: respond.Handlers): Response
Calls the handler for the most preferred type, then default, then answers
406 Not Acceptable. respond.Handlers holds an optional () => Response per shorthand
plus default.
@sdxc/http/cache
Built on the typed CacheControl header from remix/headers. Every age is a duration
("1 hour", "5 minutes"), written as whole seconds.
policy(options?: PolicyOptions): CacheControl
Builds a
Cache-Control
header emitting only the options passed: visibility ("public" | "private"), the ages
maxAge, sMaxAge, staleWhileRevalidate and staleIfError, and the flags noCache,
noStore, noTransform, mustRevalidate, proxyRevalidate and immutable.
policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
// "public, max-age=3600, s-maxage=86400"
Policies
The recurring policies, each returning a CacheControl:
Policies.noStore()—no-store, for one-time and sensitive payloads.Policies.private({ maxAge })—private, max-age=…, a per-user copy fresh formaxAge.Policies.immutable()—public, max-age=31536000, immutable, for fingerprinted URLs.Policies.revalidate()—private, no-cache, for authenticated HTML.
etag(body: BinaryLike, options?: EtagOptions): Promise<Result<string, CryptoError>>
A quoted SHA-256 base64url validator of the bytes; { weak: true } writes a W/ tag for
content that varies insignificantly between renders.
lastModified(date: Date | number): string
Formats a date as the HTTP-date a Last-Modified header carries, to whole seconds.
ifModifiedSince(headers: Headers): Date | null
The request's If-Modified-Since date, or null when absent or unparsable.
isModifiedSince(modifiedAt: Date | number, since: Date | number): boolean
Whether the resource changed after since, compared to the second.
conditional(request: Request, response: Response): Promise<Response>
Turns a 200 answer to a GET or HEAD into a 304 when the request's If-None-Match
matches the response's ETag (or, absent that header, If-Modified-Since covers its
Last-Modified), keeping only Cache-Control,
Content-Location, Date, ETag, Expires and Vary. Any other response passes through.
precondition(request: Request, options: PreconditionOptions): Result<string, PreconditionFailedError>
Checks a write's If-Match against options.etag, the resource's current tag. An absent
If-Match or * passes; any other value is compared strongly.
PreconditionFailedError
The failure precondition returns, carrying the resource's current etag.
vary(headers: Headers, names: string | string[]): Headers
Merges lowercase names into the
Vary header,
mutating and returning headers. Each name multiplies the variants a shared cache stores.
Types
CacheVisibility, PolicyOptions, PrivatePolicyOptions, EtagOptions and
PreconditionOptions.
@sdxc/http/middleware/head-requests
headRequests(): Middleware
Router middleware that dispatches a HEAD as a GET and drops the body, keeping status and
headers. Place it first in the global chain.
Pattern: A Validated API Endpoint
Pair @sdxc/validate with the status-named
JSON helpers so each outcome reads as the status it answers with.
import { badRequest, created } from "@sdxc/http/response/json";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { email, minLength } from "remix/data-schema/checks";
let Signup = s.object({
email: s.string().pipe(email()),
name: s.string().pipe(minLength(1)),
});
export async function handler(request: Request): Promise<Response> {
let result = await validate(request, Signup);
if (isFailure(result)) return badRequest({ errors: result.error.issues });
let user = await createUser(result.data);
return created({ user });
}
Pattern: A Conditional Write
The read hands out an ETag; the write requires it back, so a client cannot overwrite a
change it never saw.
import { etag, precondition } from "@sdxc/http/cache";
import { ok, preconditionFailed } from "@sdxc/http/response/json";
import { isFailure, unwrap } from "@sdxc/result";
export async function show(id: string): Promise<Response> {
let doc = await readDocument(id);
let tag = unwrap(await etag(JSON.stringify(doc)));
return ok(doc, { headers: { ETag: tag } });
}
export async function update(request: Request, id: string): Promise<Response> {
let doc = await readDocument(id);
let current = unwrap(await etag(JSON.stringify(doc)));
let checked = precondition(request, { etag: current });
if (isFailure(checked)) return preconditionFailed({ etag: checked.error.etag });
let saved = await writeDocument(id, await request.json());
return ok(saved);
}
Pattern: Revalidate by Modification Time
When a record stores its update time, Last-Modified validates it, and checking
If-Modified-Since first skips loading the body for a client whose copy is current.
import { ifModifiedSince, isModifiedSince, lastModified, Policies } from "@sdxc/http/cache";
import { json } from "@sdxc/http/response";
import { NotModified } from "@sdxc/http/status-code";
export async function handler(request: Request, id: string): Promise<Response> {
let updatedAt = await readArticleUpdatedAt(id);
let headers = new Headers({
"Cache-Control": Policies.private({ maxAge: "1 minute" }).toString(),
"Last-Modified": lastModified(updatedAt),
});
let since = ifModifiedSince(request.headers);
if (since && !isModifiedSince(updatedAt, since)) {
return new Response(null, { ...NotModified, headers });
}
return json(await readArticle(id), { headers });
}
conditional reaches the same 304 from the response's own Last-Modified when the body
is already built.
Pattern: A Cached Asset Route
Fingerprinted asset URLs change with their bytes, so the response is cached for a year with no revalidation.
import { Policies } from "@sdxc/http/cache";
import { css, javascript } from "@sdxc/http/response";
export async function handler(request: Request): Promise<Response> {
let asset = await readAsset(new URL(request.url).pathname);
let headers = { "Cache-Control": Policies.immutable().toString() };
if (asset.type === "css") return css(asset.source, { headers });
return javascript(asset.source, { headers });
}
Pattern: A Streamed CSV Download
A streamed csv sends rows as they are read, and attachment makes the browser save it.
import { Policies } from "@sdxc/http/cache";
import { attachment, csv } from "@sdxc/http/response";
export function handler(): Response {
let encoder = new TextEncoder();
let body = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode("name,city\r\n"));
for await (let row of readRows()) {
controller.enqueue(encoder.encode(`${row.name},${row.city}\r\n`));
}
controller.close();
},
});
return csv(body, {
headers: {
"Content-Disposition": attachment("Customers – 2026.csv"),
"Cache-Control": Policies.noStore().toString(),
},
});
}
Pattern: Post, Redirect, Get
A form handler answers each outcome with a builder: 303 sends the browser to the new
resource with a GET, and an invalid submission re-renders with 422.
import { redirect } from "@sdxc/http/response";
import { unprocessableEntity } from "@sdxc/http/response/html";
export async function handler(request: Request): Promise<Response> {
let form = await request.formData();
let title = form.get("title");
if (typeof title !== "string" || title === "") {
return unprocessableEntity(renderForm({ error: "Title is required" }));
}
let post = await createPost(title);
return redirect(`/posts/${post.id}`, { status: redirect.Status.SeeOther });
}
Pattern: Server-Sent Events
stream sets the event-stream headers; the body is any ReadableStream of encoded events.
import { stream } from "@sdxc/http/response";
export function handler(request: Request): Response {
let encoder = new TextEncoder();
let body = new ReadableStream<Uint8Array>({
start(controller) {
let timer = setInterval(() => {
controller.enqueue(encoder.encode(`data: ${new Date().toISOString()}\n\n`));
}, 1000);
request.signal.addEventListener("abort", () => {
clearInterval(timer);
controller.close();
});
},
});
return stream(body);
}