API routes
A file with HTTP-method exports and no default component, or whose default export is apiRoute({ ...
A file with HTTP-method exports and no default component, or whose default export is apiRoute({ ... }), answers
requests before the page router sees the path. The exports are GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, and ALL (every other method, see
The ALL export); each receives the
request event and returns a Response, or any value (a string becomes
text/plain, an object becomes JSON, null and undefined become 204). HEAD is answered by GET without a
body (or by ALL), OPTIONS answers 204 with Allow unless you export it or ALL, a method the route does not export
is 405 with Allow, and a token Dusk does not know (TRACE, PURGE) is 501 (see Allow lists). Params are in
event.params. API routes are CSRF-checked like everything that changes state; export csrf = false for a
webhook that authenticates with a signature, and middleware = [...] to wrap every method of the route.
import { apiRoute, HttpError, json, readJson } from "@shadow-js/dusk/server";
import { listPosts } from "../../../server/posts";
export const { GET, POST } = apiRoute({
GET: async (event) => {
const post = (await listPosts()).find((entry) => entry.id === event.params["id"]);
if (post === undefined) throw new HttpError(404, "No such post");
return json(post);
},
POST: async (event) => json({ received: await readJson(event.request) }, { status: 201 }),
});
A route can also export its whole module as the default, which reads well when the file holds several methods and
csrf or middleware:
import { apiRoute, json } from "@shadow-js/dusk/server";
export default apiRoute({
GET: () => json({ pong: true }),
POST: async (event) => json({ at: event.url.pathname }, { status: 201 }),
});
apiRoute is optional. It checks at startup that every key is an HTTP method (or csrf / middleware) and every
handler is a function, so a misspelled Get throws instead of answering 405 in production, and it types event.
Both spellings are API routes: export the methods by name, as in the first block, or export the whole module as the
default, as in the second.
The scanner reads the syntax tree, not the running module, so it recognizes the default form when the default export
is a call of apiRoute imported from @shadow-js/dusk/server (also under another local name, through a namespace
import, or through a const that is exported as default) and the argument is an object literal with plain method
names, as above. It then knows the file's methods for the page conflict check. A default apiRoute(handlers)
whose argument it cannot read, or one that names no method, fails the scan with ROUTE_API_NOT_READABLE naming the
file; a file that mixes the default form with method exports, or with a component, fails with ROUTE_MIXED_EXPORTS.
apiHandler({ body, query }, handler) builds a handler that validates the JSON body and
the query with a Standard Schema before your code runs; see
server functions for schemas.
The ALL export (G-B1)#
Export ALL to answer every method the module does not export by name. It covers HEAD and OPTIONS as well, unless
you export those by name. An explicit method export always wins: with GET and ALL both exported, a GET runs GET
and a POST runs ALL. ALL answers the seven methods Dusk knows; an unknown token such as TRACE is never answered
by it (see Allow lists). A file that exports GET and ALL is one API route.
The scanner counts ALL as a method: it answers GET and HEAD too, so a page at the same path is a
ROUTE_PAGE_API_CONFLICT.
import { json, type RequestEvent } from "@shadow-js/dusk/server";
export const GET = () => json({ read: true });
export const ALL = (event: RequestEvent) => json({ method: event.method, path: event.url.pathname });
Trailing slashes on API routes (G-B3)#
The trailingSlash option of createHandler (default "never") applies to API routes as it does to pages. With
"never", /items/ is a 308 to /items; with "always", /items is a 308 to /items/; with "preserve", both forms
answer and nothing is redirected (the value is preserve; there is no ignore). The 308
keeps the query string and the method, so a POST to /items/ under "never" is sent as a POST to /items. The root
/ is never redirected under any policy, and a rewrite target keeps the address the client asked for. CSRF is checked
before the redirect, so a cross-site state change still gets its 403.
Params are decoded (G-B2)#
A param is decoded once, as event.params gives it to you. %20 becomes a space, and %2F becomes / inside one
segment: on files/[name].ts, /files/a%2Fb.txt gives name === "a/b.txt". The router does not split a segment on an
encoded slash, so the route still matches one segment, but the handler gets a value that contains a slash. A param that
names a file or a key must not trust it. Refuse a /, a \, or .. in it, or check that the joined path stays under
its root:
import { apiRoute, HttpError, json } from "@shadow-js/dusk/server";
export const { GET } = apiRoute({
GET: (event) => {
const name = event.params["name"] ?? "";
if (name === "" || name.includes("/") || name.includes("\\") || name === "..") {
throw new HttpError(400, "Bad file name");
}
return json({ name });
},
});
An escape that is not valid (%zz) is not decoded: a page or API route answers 404 for it, and a static file 400.
Streams, html() and error negotiation (G-B9)#
An API route can return a Response whose body is a ReadableStream (the status and headers are the Response's), or a
bare ReadableStream, which is sent chunk by chunk and not encoded as JSON. html(body) from @shadow-js/dusk/server
answers text/html; charset=utf-8 with the given body; a content-type the handler sets is kept.
When a page request fails, the error is answered in the format the Accept header asks for. It is JSON when the header
names application/json and names it before text/html, or does not name text/html at all. Otherwise it is an HTML
error page, including a request with no Accept header. An API route always answers its errors as JSON. In production
the message of an unexpected error is not sent to either format: the body carries the public text only.