Shaping the response
Status, headers, redirects, caching, files, events.
A handler returns its value and ohne serializes it: an object becomes JSON, a string becomes HTML, and an empty return becomes 204. What a return becomes has the full rules. You shape the rest of the response (status, headers, redirects, caching, streams) with composables you call inside the handler. Call them anywhere inside the request, at any depth.
Status and headers
setResponseStatus sets the status, the common case:
import { defineHandler, setResponseStatus } from 'ohnejs';
export default defineHandler(async () => {
const subscriber = await subscribe();
setResponseStatus(201);
return subscriber;
});useResponse() gives you the whole response state: assign status, and change headers in place. headers is a standard Headers, so set, append, and delete all work:
import { defineHandler, useResponse } from 'ohnejs';
export default defineHandler(() => {
useResponse().headers.set('cache-control', 'no-store');
return { ok: true };
});You still return the body. These only set the status and headers around it, and ohne reads them after the handler returns.
Redirects
sendRedirect sets the status and the Location header together. The status defaults to 302:
import { defineHandler, sendRedirect } from 'ohnejs';
export default defineHandler(() => sendRedirect('/posts', 301));It returns nothing, so the handler's body is empty, and ohne sends it with the redirect status.
Caching
The conditional-request pattern has three steps:
etagcomputes a validator you put on the response.isFreshasks whether the client's cached copy is still fresh.sendNotModifiedanswers304when it is.
import { defineHandler, isFresh, query, sendNotModified, useResponse } from 'ohnejs';
import { etag } from 'ohnejs/utils/etag';
export default defineHandler(async () => {
const posts = await query('Posts').findMany();
useResponse().headers.set('etag', etag(JSON.stringify(posts)));
if (isFresh()) return sendNotModified();
return posts;
});isFresh compares the request's If-None-Match / If-Modified-Since against the ETag / Last-Modified you set on the response, so set the validator first.
- The comparison is weak: a
W/prefix is ignored. - A request carrying
Cache-Control: no-cacheis never fresh. sendNotModifiedkeeps the validators on the response, because a304should include them.
cacheControl builds a Cache-Control value from named directives, with durations in seconds:
import { cacheControl } from 'ohnejs/utils';
useResponse().headers.set('cache-control', cacheControl({ public: true, maxAge: 3600 }));
// -> 'public, max-age=3600'Serving files
sendFile serves a file from the first of its root directories that contains it. The path is resolved against each root and must stay inside it, so .. and absolute paths cannot escape. When no root has the file, it throws a 404:
import { defineHandler, sendFile } from 'ohnejs';
export default defineHandler(({ params }) => sendFile(['public'], params.path));Call it inside a request and return its result as the body.
- The response carries a weak
ETagbuilt from the file's size and modification time. On a repeat request for an unchanged file, ohne checks only those two values and answers304without reading the file. - The default
Cache-Controlisno-cache: cached, but checked with the server on every use. Passcacheto change it. - Pass
notFoundto override the404message.
sendFile(['public'], params.path, {
cache: { public: true, maxAge: 86400 },
notFound: 'No such asset',
});A file gets the content type for its extension. For a TypeScript file (.ts / .mts), ohne strips the types while serving it and sends it as a JavaScript module. Node uses the same stripping to run .ts. That is how dashboard pages reach the browser.
Server-sent events
sendEvents opens a Server-Sent Events stream. It sets text/event-stream and returns the controls to push events and end the stream, together with the body you return from the handler. The socket stays open until you close it or the client disconnects:
import { defineHandler, sendEvents } from 'ohnejs';
export default defineHandler(() => {
const stream = sendEvents({ onClose: () => clearInterval(timer) });
const timer = setInterval(() => stream.send(new Date().toISOString()), 1000);
return stream.body;
});The browser reads it with EventSource:
const clock = new EventSource('/clock');
clock.onmessage = (event) => console.log(event.data);Each send pushes one event. A multi-line payload stays complete: each of its lines becomes one data: line. The second argument sets the event's name and id:
eventemits a typed event the browser dispatches under that name.idsets the id the browser sends back asLast-Event-IDwhen it reconnects.
Neither may contain a line break, or send throws.
close ends the stream and the socket, and a send after that does nothing. Either ending, yours or the client's disconnect, runs onClose exactly once. Use it to stop timers or remove the stream from a broadcast set.
A client that stops reading is disconnected. Once 1024 frames wait unread, the stream closes and onClose runs, so such a client cannot use up the server's memory.
A proxy may drop a connection that stays quiet too long. Set heartbeat to send a comment on an interval while the stream is open:
const stream = sendEvents({ heartbeat: '15s' });- It takes milliseconds, or a string like
'15s'. EventSourceskips comments, soonmessagenever sees one.
After the response
waitUntil keeps background work running after the response. The response is sent immediately, the promise runs after it, and a graceful shutdown waits for the work to finish. If the promise rejects, ohne logs the error, and the response that was already sent is not affected:
import { defineHandler, waitUntil } from 'ohnejs';
export default defineHandler(async () => {
const subscriber = await subscribe();
waitUntil(sendWelcomeEmail(subscriber));
return subscriber;
});It is for short side effects such as logging, analytics, or a notification. Work that must not be lost belongs in a queue, not here.
By default the work has no time limit. api.waitUntilTimeout sets one, in milliseconds or as a duration like '60s'. When the work takes longer, ohne logs an error line and stops waiting for the promise, so it no longer delays shutdown. A single route overrides the limit through its options:
import { defineHandler } from 'ohnejs';
export default defineHandler(() => track(), { waitUntilTimeout: '5s' });