Concepts
Server
Middleware, endpoints, form actions and server renders all get the request context: URL, params, cookies, locals and the response.
#Concept
Request context. Middleware, endpoints and actions receive
ctx = { request, url, params, locals, cookies, env, platform }. During a server render, the same values are
available through functions scoped to the current request (they work after await too):
request(), url(), params(), route() (the matched pattern), locals(), cookies(),
response() and env(name).
Platform. ctx.platform is what the host adds to a request: env (the platform's bindings,
such as Cloudflare's KV or D1; {} elsewhere), waitUntil(promise) (work that continues after the
response) and clientAddress (the client's IP as the host sees it). Each has a fallback, so the
same code runs on every host (server build). Type the
bindings by augmenting PlatformEnv. A server component that needs them gets them from middleware,
through locals.
Middleware. src/middleware.ts default-exports (ctx, next) => Promise<Response> (or an array,
run in order). Use it for sessions, auth and headers. Put request data on ctx.locals, and type it
by augmenting Locals:
declare module "lumiose" {
interface Locals {
user: { id: string; name: string } | null;
}
}Control flow. redirect(to, status?), notFound() and error(status, message?) throw; the
framework turns them into a redirect or the nearest not-found/error page. Without a status,
redirect() uses 302 for GET/HEAD and 303 for other methods.
Cookies. cookies.get/getAll/set/delete. Defaults: path=/, httpOnly, sameSite=lax, and
secure on HTTPS and in production. signedCookie(name) signs values with HMAC-SHA256 (secret:
LUMIOSE_SECRET, or rotate with an array of secrets); signing protects integrity, not
confidentiality. Secrets must be at least 32 bytes: a shorter one throws in production and warns
in development, unless you pass { allowWeakSecret: true }. Generate one with
openssl rand -base64 32.
Status and headers. response().setStatus() and setHeader() change the page response, and
a page may export headers(ctx). They are writable until the shell is sent (see
Rendering modes); a change inside a streamed <Suspense> boundary is
LUM5004.
Env. env("NAME") reads server env variables (the adapter's env, else process.env) in
server-only code. Only import.meta.env.PUBLIC_* reaches client code (@lumiose/vite sets Vite's
envPrefix to "PUBLIC_" unless the config sets its own); publicEnv() returns that subset.
Server-only code lives in *.server.ts modules and under src/server/. Importing it from code
that reaches the browser is LUM4003, also through a web worker, new URL("…", import.meta.url)
or a ?raw/?url import. lumiose dev does not serve these files over HTTP either: they are added
to Vite's server.fs.deny unless the config sets its own list (the server still loads them).
Status: the request handler, middleware, endpoints, cookies, env and error pages are available, served by
lumiose devand by the build output (lumiose preview,@lumiose/adapter-node).
#API
| API | Use |
|---|---|
RequestContext<P> (ctx) |
{ request, url, params, locals, cookies, env, platform }. |
Middleware |
(ctx, next) => Response | Promise<Response>. |
request(), url(), params<P>(), route(), locals(), cookies() |
Request APIs during a server render. |
response() |
{ status, setStatus, getHeader, setHeader } until the shell is sent. |
redirect(to, status?), notFound(message?), error(status, message?) |
Throw to stop rendering. |
isRedirect(e), isHttpError(e) |
Recognize those when you catch errors. |
signedCookie(name, options?) |
{ get(cookies), set(cookies, value), delete(cookies) }. |
env(name), publicEnv(source?) |
Server env; the PUBLIC_* subset. |
Locals |
Interface to augment with your request data. |
Platform, PlatformEnv |
ctx.platform; the interface to augment with your bindings. |
#Example
// src/middleware.ts
import { signedCookie, type Middleware } from "lumiose";
const session = signedCookie("session");
declare module "lumiose" {
interface Locals {
userId: string | null;
}
}
const middleware: Middleware = async (ctx, next) => {
ctx.locals.userId = (await session.get(ctx.cookies)) ?? null;
if (ctx.url.pathname.startsWith("/account") && ctx.locals.userId === null) {
return Response.redirect(new URL("/login", ctx.url), 303);
}
const res = await next();
res.headers.set("x-frame-options", "DENY");
return res;
};
export default middleware;// src/routes/account/page.tsx
import { locals, redirect, response } from "lumiose";
export const mode = "server";
export default function Account() {
const { userId } = locals();
if (userId === null) redirect("/login");
response().setHeader("cache-control", "private, no-store");
return <h1>Signed in as {userId}</h1>;
}#Common mistakes
- Reading request APIs in a
staticroute.cookies(),request()and friends throwLUM5001while prerendering. Setexport const mode = "server". - Setting headers or status after the shell. Inside a streamed
<Suspense>boundary it is too late (LUM5004). Do it before the boundary, in the page body outside<Suspense>, inheaders(ctx)or in middleware. - Reading secrets with
import.meta.env. OnlyPUBLIC_*values exist there. Useenv("NAME")in server-only modules. - Catching
redirect()by accident. It throws. In atry/catch, rethrow whenisRedirect(error) || isHttpError(error). - Storing request data in module-scope variables. Modules are shared by every request. Use
locals, context or component state (LUM4008). - Expecting signed cookies to hide data. They are tamper-proof, not encrypted.
#Related diagnostics
- LUM4003: server-only module in a client graph.
- LUM4008: module-scope signal written during SSR.
- LUM5001: request API used during prerender.
- LUM5004: head or status change inside a streamed boundary.
See also: Data and actions, Routing, reference: app layer.