sdxc

[ HTTP & responses ]

@sdxc/http

Response builders, content negotiation, and HTTP caching for the Fetch API

npm add @sdxc/http remix
pnpm add @sdxc/http remix
yarn add @sdxc/http remix
bun add @sdxc/http remix
Used by
uptime, auth-saas, reader, blog, books

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, Markdown

  • Application: Json, JSONLines, ApplicationXML, PDF, ZIP, GZip, FormURLEncoded, OctetStream, FormData

  • Image: PNG, JPEG, GIF, WebP, SVG, ICO, AVIF

  • Audio and video: MP3, WAV, OGG, WebMAudio, MP4, WebMVideo

  • Font: WOFF, WOFF2, TTF, OTF

  • Streaming: 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, SwitchingProtocols

  • 2xx: Ok, Created, Accepted, NoContent, ResetContent, PartialContent

  • 3xx: MultipleChoices, MovedPermanently, Found, SeeOther, NotModified, TemporaryRedirect, PermanentRedirect

  • 4xx: 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, UnavailableForLegalReasons

  • 5xx: 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 for maxAge.

  • 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);
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.