sdxc

[ HTTP & responses ]

@sdxc/response

Semantic helpers that build Response objects for JSON APIs and redirects

npm add @sdxc/response
pnpm add @sdxc/response
yarn add @sdxc/response
bun add @sdxc/response
Installs with
@sdxc/location

Semantic helpers that build Response objects for JSON APIs and redirects.

Each helper names its status code, so a handler says what it means — notFound({ error }) in place of Response.json({ error, ok: false }, { status: 404 }). Success helpers merge ok: true into the body and error helpers merge ok: false, giving a client one field to branch on.

Installation

npm add @sdxc/response

@sdxc/location installs alongside it; redirect accepts its Location values as targets.

Usage

Answer with JSON

import { notFound, ok } from "@sdxc/response";

export async function showArticle(slug: string) {
	let article = await findArticle(slug);
	if (!article) return notFound({ error: "Article not found" });
	return ok({ article });
}

ok({ article }) is Response.json({ article, ok: true }, { status: 200 }). The result is a plain Response: status, headers and json() read the way they do on any other.

Add headers

The second argument is a ResponseInit without status, because the helper owns the status.

import { created } from "@sdxc/response";

let response = created({ article: { id: "art_1" } }, { headers: { Location: "/articles/art_1" } });

response.status; // 201
await response.json(); // { article: { id: "art_1" }, ok: true }

Answer without a body

import { noContent } from "@sdxc/response";

await deleteArticle(id);
return noContent(); // 204, body null

Redirect

import { redirect } from "@sdxc/response";

redirect("/login"); // 307, Location: /login
redirect("/articles", { status: redirect.Status.SeeOther }); // 303

API

Every JSON helper shares one signature, and differs only in status and the ok value:

function helper<T>(input: T, init?: Omit<ResponseInit, "status" | "statusText">): Response;

input is spread into the body, then ok is set on top, so the body's ok always matches the helper. init carries headers and any other option through to the Response.

Success

These merge ok: true (typed as the literal true).

HelperStatusUse it for
ok200A read, or a write whose result the client wants back
created201A new resource; send its URL in a Location header
accepted202Work queued for later; return what the client follows up with

noContent(init?): Response

A 204 with a null body — new Response(null, { ...init, status: 204 }). It carries no ok field; the status alone is the outcome.

Client errors

These merge ok: false (typed as the literal false).

HelperStatusUse it when
badRequest400The request is unreadable — unparseable body, wrong shape
unauthorized401The caller is unknown; send a WWW-Authenticate challenge
paymentRequired402The account must pay or upgrade first
forbidden403The caller is known and denied
notFound404No such resource, or the caller may not know it exists
methodNotAllowed405The path exists for other methods; send an Allow header
notAcceptable406Nothing the endpoint produces matches the Accept header
conflict409The request contradicts current state, such as a duplicate
gone410The resource was removed for good
preconditionFailed412A conditional header such as If-Match failed
requestEntityTooLarge413The body is over the size limit
unsupportedMediaType415The Content-Type is not one the endpoint accepts
unprocessableEntity422The request parsed and failed validation or a business rule
tooManyRequests429The caller is rate limited; send Retry-After

Server errors

These also merge ok: false.

HelperStatusUse it when
internalServerError500Something broke that is not the caller's fault
notImplemented501The endpoint exists and the feature does not yet
badGateway502An upstream service answered with something unusable
serviceUnavailable503Temporarily down; send Retry-After when you can guess
gatewayTimeout504An upstream service took too long

redirect(target: URL | Location | string, init?: redirect.Init): Response

A redirect with target in the Location header and a null body, at 307 unless init.status says otherwise. It throws Invalid redirect target for a target Location.canParse rejects.

redirect.Status

MemberStatusFollow-up request
SeeOther303Always a GET
Temporary307Same method — a POST stays a POST; default
Permanent308Same method, and clients cache the redirect

Types

redirect.Init

Omit<ResponseInit, "status" | "statusText"> & { status?: redirect.Status }.

Pattern: Validating A Request Body

400 says the request could not be read; 422 says it was read and rejected. Keying field errors by name lets the client attach each message to its input.

import { badRequest, created, unprocessableEntity } from "@sdxc/response";

export async function createArticle(request: Request) {
	let body: unknown = await request.json().catch(() => null);
	if (typeof body !== "object" || body === null) {
		return badRequest({ error: "Expected a JSON object" });
	}

	let { title } = body as { title?: unknown };
	if (typeof title !== "string" || title.trim() === "") {
		return unprocessableEntity({ errors: { title: "Title is required" } });
	}

	let article = await insertArticle({ title });
	return created({ article }, { headers: { Location: `/articles/${article.id}` } });
}

Pattern: Narrowing The Body On The Client

Because ok is a literal type, a union of the bodies an endpoint returns narrows on one check, and the client keeps no list of which statuses are failures.

type CreateArticle =
	{ article: { id: string; title: string }; ok: true } | { error: string; ok: false };

let response = await fetch("/api/articles", {
	method: "POST",
	headers: { "Content-Type": "application/json" },
	body: JSON.stringify({ title: "Hello" }),
});
let result = (await response.json()) as CreateArticle;

if (result.ok) console.info(result.article.id);
else console.error(result.error);

Pattern: Post-Redirect-Get After A Form

A browser reloading after a 307 resubmits the form, since 307 keeps the method. Answer a successful submission with 303 so the follow-up is a GET, and set any cookie in the same response.

import { Location } from "@sdxc/location";
import { redirect } from "@sdxc/response";

export async function publishArticle(request: Request) {
	let form = await request.formData();
	let slug = String(form.get("slug"));
	await markPublished(slug);

	return redirect(new Location({ pathname: `/articles/${slug}`, search: "published=1" }), {
		status: redirect.Status.SeeOther,
		headers: { "Set-Cookie": "flash=published; Path=/; Max-Age=60" },
	});
}

Pattern: Guarding An Endpoint

Auth, method and rate-limit checks each answer with the status and the header a client needs to recover.

import { methodNotAllowed, ok, tooManyRequests, unauthorized } from "@sdxc/response";

export async function handle(request: Request) {
	if (request.method !== "GET") {
		return methodNotAllowed({ error: "Use GET" }, { headers: { Allow: "GET" } });
	}

	let token = request.headers.get("Authorization");
	if (!token) {
		return unauthorized(
			{ error: "invalid_token" },
			{ headers: { "WWW-Authenticate": 'Bearer realm="api"' } },
		);
	}

	if (await isRateLimited(token)) {
		return tooManyRequests({ error: "Slow down" }, { headers: { "Retry-After": "60" } });
	}

	return ok({ items: await listItems(token) });
}

Pattern: Asserting On Responses In Tests

Helpers return plain responses, so a test reads status and body directly.

import { conflict } from "@sdxc/response";
import { expect, test } from "vitest";

test("a duplicate slug answers 409", async () => {
	let response = conflict({ error: "That slug is taken" });

	expect(response.status).toBe(409);
	expect(response.headers.get("Content-Type")).toContain("application/json");
	expect(await response.json()).toEqual({ error: "That slug is taken", ok: false });
});

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