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, route, locals, cookies, env, platform }, where route is the
matched pattern ("/users/[id]"; undefined in src/middleware.ts for a URL no route matches).
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) and runs for every request. 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;
}
}Folder middleware. A middleware.ts (or .js) in a route folder has the same default export and
runs for every page, form action and route.ts endpoint in that folder and below it. That makes it
the place for a section's auth: a layout guard (locals() plus redirect()) covers the folder's
pages, but not their actions or endpoints. Group folders count: (account)/middleware.ts covers
every route in the group without a URL segment.
- Order. The trailing-slash redirect and the cross-site check answer first. Then
src/middleware.ts, and inside itsnext()the route's locale checks (/es/adminruns the/adminmiddleware), the folder middleware from the outermost folder to the innermost, and the route. Responses unwind in reverse. URLs that match no route runsrc/middleware.tsonly. - Errors. What folder middleware throws (
redirect(),error(), any error) renders the route's error page outside the chain, as forsrc/middleware.ts. - Types.
Middleware<"/admin">takes the folder's URL scope (the patternLayoutPropstakes):ctx.paramsholds the folder's params andctx.routethe patterns below it. - SPA routes. Client navigations between
spapages do not reach the server. The middleware runs for the document request and for the actions and endpoints below the folder, which is what protects an SPA's data. - Prerendered routes. Like
src/middleware.ts, folder middleware runs whenever the app handles a route: per request for server routes, for every route inlumiose dev, and at build time for prerendered pages, whose files production serves without the app. It cannot guard those, solumiose checkand the build warn (LUM5008). src/routes/middleware.tsis an error (LUM5002): the app's middleware issrc/middleware.ts.lumiose routesprints each route's middleware chain.
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. So is response().setStreaming(false), which makes the page wait for every
<Suspense> boundary and send one fully resolved document instead of streaming them
(for crawlers).
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).
#Crawlers get one resolved document
Crawlers that do not run JavaScript index what the HTML holds: with streaming, the fallbacks, and
the content only inside <template> elements at the end. Lumiose ships no list of crawlers; test
the user agent in middleware, for example with the isbot
package, and turn streaming off for them:
// src/middleware.ts
import { isbot } from "isbot";
import { response, type Middleware } from "lumiose";
const middleware: Middleware = (ctx, next) => {
if (isbot(ctx.request.headers.get("user-agent"))) response().setStreaming(false);
return next();
};
export default middleware;Everyone else still gets the shell first. The response waits up to lumiose({ streamTimeout })
(30 seconds by default), then the boundaries still pending fail (LUM5009).
A shared cache that stores such pages should vary on the user agent, or store neither version.
#Instrumentation
src/instrumentation.server.ts (also .js or .mjs; server-only by its .server. segment) is
where error reporting and tracing start. lumiose dev, lumiose preview, the server build
(dist/server/entry.js on any host) and the deploy adapters load it; prerendering at build time
does not, since errors there fail the build. Its three exports are optional:
register()runs once per server instance (per isolate on workers), before the first request, which waits for it: start an SDK here.lumiose devruns it again after an edit of the file. What it throws is logged, and the server goes on.onRequestError(error, info)receives the unexpected errors of a request on every host,lumiose devincluded: those that render theerrorpage or answer 500, errors of streamed<Suspense>boundaries (also thosestreamTimeoutended), rejectedctx.platform.waitUntil()work, and errors an<ErrorBoundary>caught, with thedigestits fallback shows outside dev.redirect(),notFound()anderror(status)are not errors.infois{ request, route, params, phase, digest }, withphaseone of"middleware"(also the route's locale checks),"action","endpoint","render","stream"or"waitUntil". The handler'sonError(the host's log,console.errorby default) still gets each error first, except those an<ErrorBoundary>caught in dev.instrumentations, an array of observers with optional wrappers per phase:request(a request until its response is ready to send,call()'s result addsresponse.status),middleware(each middleware file,info.file),action(info.name: the key inactions, or"default"),endpoint(info.method),render(a page or error page until its shell is ready) andstream(each streamed boundary). A wrapper getscallandinfo({ request, route, params }plus those): callcall()where the phase runs and await it to time it. It never throws and resolves to{ status: "success" }or{ status: "error", error }. A wrapper only observes: the phase runs once, also when the wrapper never callscall(), its result is the phase's own, and what a wrapper throws is logged. The first observer is the outermost. Static files are served before the app and are not observed; afetchJSON()the render handles in-process gets its phases observed, not arequestof its own.
Without the file nothing loads; with it, a phase without wrappers costs one check. Its browser
counterpart, src/instrumentation.client.ts, runs on every page before hydration
(Islands).
Status: the request handler, middleware, endpoints, cookies, env, error pages and instrumentation are available, served by
lumiose devand by the build output (lumiose preview,@lumiose/adapter-node, the deploy adapters).
#API
| API | Use |
|---|---|
RequestContext<P> (ctx) |
{ request, url, params, route, locals, cookies, env, platform }. |
Middleware<P?> |
(ctx, next) => Response | Promise<Response>; P: a folder's scope. |
request(), url(), params<P>(), route(), locals(), cookies() |
Request APIs during a server render. |
response() |
{ status, setStatus, getHeader, setHeader, setStreaming } 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. |
src/instrumentation.server.ts: register, onRequestError, instrumentations |
Observes the server (Instrumentation). |
RequestErrorInfo, ServerInstrumentation |
onRequestError's info; one observer of instrumentations. |
#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;
const res = await next();
res.headers.set("x-frame-options", "DENY");
return res;
};
export default middleware;// src/routes/account/middleware.ts: every page, action and endpoint under /account
import type { Middleware } from "lumiose";
const requireUser: Middleware<"/account"> = (ctx, next) => {
if (ctx.locals.userId === null) return Response.redirect(new URL("/login", ctx.url), 303);
return next();
};
export default requireUser;// 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>;
}// src/instrumentation.server.ts
import type { RequestErrorInfo, ServerInstrumentation } from "lumiose";
export async function register() {
// Once per server instance, before the first request: start an SDK here.
}
export function onRequestError(error: unknown, info: RequestErrorInfo) {
const where = `${info.request.method} ${info.route ?? "(no route)"} [${info.phase}]`;
console.error(where, info.digest ?? "", error);
}
export const instrumentations: ServerInstrumentation[] = [
{
request: async (call, info) => {
const start = performance.now();
const result = await call();
const status = result.status === "success" ? result.response?.status : "failed";
const ms = Math.round(performance.now() - start);
console.log(`${info.request.method} ${info.route ?? "-"} ${status} ${ms} ms`);
},
},
];Tracing with OpenTelemetry: install
@opentelemetry/api and an SDK (@opentelemetry/sdk-node with an exporter), start the SDK in
register(), and open a span per phase. Spans nest as the phases do: middleware inside the request,
the action, endpoint or render inside the middleware. Instrumenting other libraries automatically
(database drivers, fetch) still needs node --import, as OpenTelemetry documents.
// src/instrumentation.server.ts
import { SpanStatusCode, trace, type Attributes } from "@opentelemetry/api";
import type { ServerInstrumentation } from "lumiose";
export async function register() {
const { NodeSDK } = await import("@opentelemetry/sdk-node");
new NodeSDK({ serviceName: "my-app" }).start(); // exporters from OTEL_* env variables
}
const tracer = trace.getTracer("my-app");
/** A span around one phase, failed when the phase failed. */
const span =
(name: string, attributes: Attributes) =>
(call: () => Promise<{ status: "success" | "error"; error?: unknown }>) =>
tracer.startActiveSpan(name, { attributes }, async (s) => {
const result = await call();
if (result.status === "error") {
s.recordException(result.error as Error);
s.setStatus({ code: SpanStatusCode.ERROR });
}
s.end();
});
export const instrumentations: ServerInstrumentation[] = [
{
request: (call, info) =>
tracer.startActiveSpan(`${info.request.method} ${info.route ?? ""}`, async (s) => {
s.setAttributes({
"http.request.method": info.request.method,
"http.route": info.route ?? "",
});
const result = await call();
if (result.status === "success" && result.response !== undefined) {
s.setAttribute("http.response.status_code", result.response.status);
if (result.response.status >= 500) s.setStatus({ code: SpanStatusCode.ERROR });
}
s.end();
}),
middleware: (call, info) => span("middleware", { "code.file.path": info.file })(call),
action: (call, info) => span(`action ${info.name}`, { "http.route": info.route ?? "" })(call),
endpoint: (call, info) =>
span(`${info.method} handler`, { "http.route": info.route ?? "" })(call),
render: (call, info) => span("render", { "http.route": info.route ?? "" })(call),
stream: (call, info) => span("stream", { "http.route": info.route ?? "" })(call),
},
];#Common mistakes
Reporting errors from middleware with
try/catcharoundnext(). The error page, the500of an endpoint and streamed boundaries never throw there. UseonRequestErrorinsrc/instrumentation.server.ts.Changing the request in an instrumentation wrapper. Wrappers observe: they cannot change arguments, results or the response. Use middleware for that.
Guarding a section in a layout. A layout's
redirect()does not cover the folder's form actions or endpoints. Put the check in the folder'smiddleware.ts.Guarding prerendered routes with folder middleware. It runs at build time only for them (
LUM5008). Setexport const mode = "server"on the routes it protects.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.
- LUM5002: invalid route file, such as
src/routes/middleware.ts. - LUM5004: head or status change inside a streamed boundary.
- LUM5008: folder middleware covering prerendered routes.
- LUM5009: boundary still pending when
streamTimeoutexpired.
See also: Data and actions, Routing, reference: app layer.