[]previous and next page

API routes

A file with HTTP-method exports and no default component, or whose default export is apiRoute({ ...

For agentsUpdated

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.

src/routes/api/posts/[id].tsts
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:

src/routes/api/ping.tsts
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.

src/routes/api/proxy/[...rest].tsts
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:

src/routes/api/files/[name].tsts
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.