HTTP Responses
Learn how to handle different types of HTTP responses with rest-rpc
JSON Response
import { router } from "@rest-rpc/core";
import z from "zod";
const todoSchema = z.object({
id: z.string(),
title: z.string(),
});
export const api = router({
todos: {
create: {
method: "POST",
path: "/todos",
body: z.object({
title: z.string().min(1),
}),
response: todoSchema,
},
},
});
export const routes = router(api, {
todos: {
create({ title }) {
return createTodo({ title });
},
},
});
const todo = await client.todos.create.fetch({
title: "Write docs",
});
Response With Multiple Status Codes
Use fetchResponse() when the client needs to handle multiple declared statuses.
export const api = router({
todos: {
get: {
method: "GET",
path: "/todos/:id",
responses: {
200: todoSchema,
404: z.object({
code: z.literal("TODO_NOT_FOUND"),
}),
},
},
},
});
import { ContractResponseError } from "@rest-rpc/server";
export const routes = router(api, {
todos: {
get({ id }) {
const todo = getTodo(id);
if (!todo) {
throw new ContractResponseError(api.todos.get, {
status: 404,
body: { code: "TODO_NOT_FOUND" },
});
}
return todo;
},
},
});
const response = await client.todos.get.fetchResponse({
id: "todo_1",
});
if (response.declared && response.status === 200) {
response.body.title;
}
if (response.declared && response.status === 404) {
response.body.code;
}
Response Without Body
import { noBody, router } from "@rest-rpc/core";
export const api = router({
todos: {
remove: {
method: "DELETE",
path: "/todos/:id",
responses: {
204: noBody(),
},
},
},
});
export const routes = router(api, {
todos: {
remove({ id }) {
removeTodo(id);
},
},
});
await client.todos.remove.fetch({
id: "todo_1",
});
Response With Typed Headers
Headers declared in the contract are returned from responseHeaders.
export const api = router({
todos: {
get: {
method: "GET",
path: "/todos/:id",
responses: {
200: {
body: todoSchema,
headers: {
etag: z.string(),
"x-next-cursor": z.string().optional(),
},
},
},
},
},
});
export const routes = router(api, {
todos: {
get({ id }) {
const todo = getTodo(id);
return {
status: 200,
body: todo,
responseHeaders: {
etag: todo.etag,
"x-next-cursor": todo.nextCursor,
},
};
},
},
});
const response = await client.todos.get.fetchResponse({
id: "todo_1",
});
if (response.declared && response.status === 200) {
response.responseHeaders.etag;
response.responseHeaders["x-next-cursor"];
}
Response With Undeclared Headers
Undeclared headers are raw HTTP headers returned from headers.
export const api = router({
health: {
method: "GET",
path: "/health",
response: z.object({
ok: z.literal(true),
}),
},
});
export const routes = router(api, {
health() {
return {
status: 200,
body: { ok: true },
headers: {
"cache-control": "no-store",
"x-request-id": createRequestId(),
},
};
},
});
const response = await client.health.fetchResponse();
if (response.declared) {
response.headers.get("cache-control");
response.headers.get("x-request-id");
}
Response with set-cookie Header
export const api = router({
sessions: {
create: {
method: "POST",
path: "/sessions",
body: z.object({
email: z.string().email(),
password: z.string(),
}),
response: z.object({
ok: z.literal(true),
}),
},
},
});
import { clearCookie, setCookie } from "@rest-rpc/server";
export const routes = router(api, {
sessions: {
create({ email, password }) {
const sessionId = createSession({ email, password });
return {
status: 201,
body: { ok: true },
headers: {
"set-cookie": [
clearCookie("old_session", { path: "/" }),
setCookie("session", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
}),
],
},
};
},
},
});
await client.sessions.create.fetch({
email: "ada@example.com",
password: "password123",
});
Response With Custom Content Type
import { customBody, router } from "@rest-rpc/core";
export const api = router({
reports: {
csv: {
method: "GET",
path: "/reports.csv",
responses: {
200: customBody({
contentType: "text/csv",
schema: z.string(),
}),
},
},
},
});
export const routes = router(api, {
reports: {
csv() {
return "id,title\n1,First\n";
},
},
});
const response = await client.reports.csv.fetch();
await response.text();
Response With Multiple Custom Content Types
When multiple content types are declared, return the selected contentType with the response payload.
export const api = router({
images: {
download: {
method: "GET",
path: "/images/:id",
responses: {
200: customBody({
contentType: ["image/png", "image/jpeg"],
schema: z.instanceof(Uint8Array),
}),
},
},
},
});
export const routes = router(api, {
images: {
download({ id }) {
const image = getImage(id);
return {
status: 200,
body: {
contentType: image.type,
payload: image.bytes,
},
};
},
},
});
const response = await client.images.download.fetchResponse({
id: "image_1",
});
if (response.declared && response.status === 200) {
if (response.responseHeaders["content-type"] === "image/png") {
const pngBytes = await response.arrayBuffer();
} else {
const jpegBytes = await response.arrayBuffer();
}
}
Streaming NDJSON Responses
stream(schema) sends newline-delimited JSON and the client reads parsed items.
request.context.signal is aborted when the HTTP request is cancelled.
import { router, stream } from "@rest-rpc/core";
const tokenSchema = z.object({
index: z.number(),
text: z.string(),
});
export const api = router({
ai: {
tokens: {
method: "GET",
path: "/ai/tokens",
query: z.object({
prompt: z.string().min(1),
}),
responses: {
200: stream(tokenSchema),
},
},
},
});
async function* streamTokens(prompt: string, signal: AbortSignal) {
for await (const token of llm.stream({ prompt, signal })) {
yield {
index: token.index,
text: token.text,
};
}
}
export const routes = router(api, {
ai: {
tokens(request) {
const { signal } = request.context;
return streamTokens(request.prompt, signal);
},
},
});
const controller = new AbortController();
const tokens = await client.ai.tokens.fetch(
{ prompt: "Write a changelog entry" },
{ signal: controller.signal },
);
let fullText = "";
for await (const token of tokens) {
fullText += token.text;
if (token.text.includes("</done>")) {
controller.abort();
break;
}
}
Streaming Responses With Custom Content Type
stream(customBody(...)) keeps the client response as a raw Response stream.
export const api = router({
files: {
download: {
method: "GET",
path: "/files/:id",
responses: {
200: stream(
customBody({
contentType: "application/octet-stream",
schema: z.instanceof(Uint8Array),
}),
),
},
},
},
});
export const routes = router(api, {
files: {
download(request) {
const { signal } = request.context;
return readLargeFile(request.id, signal);
},
},
});
const controller = new AbortController();
const response = await client.files.download.fetch(
{ id: "file_1" },
{ signal: controller.signal },
);
const reader = response.body?.getReader();
const file = await openWritableFile("archive.zip");
let downloaded = 0;
try {
while (reader) {
const { value, done } = await reader.read();
if (done) break;
downloaded += value.byteLength;
await file.write(value);
if (downloaded > maxDownloadBytes) {
controller.abort();
break;
}
}
} finally {
await file.close();
}