# Lumiose > Lumiose is a web framework for everything from static sites and SPAs to full-stack apps: TSX with scoped CSS built in, signals, and 0 kB of JavaScript until a page needs it. These docs match lumiose 0.8.1. Installed projects have them in `node_modules/lumiose/docs`; `lumiose docs ` prints one and `lumiose explain ` explains a diagnostic. # Lumiose docs > Lumiose is a web framework for everything from static sites and SPAs to full-stack apps: TSX with scoped CSS built in, signals, and 0 kB of JavaScript until a page needs it. **Feels like React. Runs like vanilla JS.** Components live in `.stsx` files (TSX plus raw-CSS ` ``` - The function body runs **once**. `count()` reads the signal; JSX that reads it updates on its own. - The ` ``` Context from middleware, where React Router uses `context.set()` in a `middleware` export: ```ts check // 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; return next(); }; export default middleware; ``` A route's `middleware` for its subtree is a `middleware.ts` in its folder. It runs after `src/middleware.ts`, for every page, action and endpoint below the folder: ```ts check file=routes/projects/middleware.ts // src/routes/projects/middleware.ts import type { Middleware } from "lumiose"; const requireUser: Middleware<"/projects"> = (ctx, next) => ctx.locals.userId === null ? Response.redirect(new URL("/login", ctx.url), 303) : next(); export default requireUser; ``` A custom server, where React Router uses `createRequestHandler()`: ```ts check // server.ts: node server.ts after lumiose build import { createServer } from "node:http"; import { createHandler } from "@lumiose/adapter-node"; const app = await createHandler(); // the build in dist/: static files, then the app createServer((req, res) => { if (req.url === "/healthz") { res.end("ok"); return; } app(req, res); }).listen(Number(process.env.PORT ?? 3000)); ``` ## Common mistakes - **Exporting a `loader`.** Nothing calls it. Await the data in the page component. - **Expecting client-side navigation everywhere.** `static` and `server` routes load the next document. Make app-like areas `spa` (in their layout), and keep the rest as pages. - **Expecting an MPA page to revalidate after `submit()`.** React Router reloads loaders after an action; on a `static` or `server` page `submit()` does not refresh the page. Redirect from the action (as above), update the island from the result, or call `invalidate()`. On `spa` routes a successful `submit()` refreshes the page's data. - **Server code in an island's module.** An island's module ships to the browser, so importing a `*.server.ts` there is `LUM4003`. Keep islands in their own files and import action types with `import type`. - **Throwing for validation errors.** A thrown error renders the `error` page; return `fail(400, data)` as you would return `data(…, { status: 400 })`. - **Reading `params["*"]`.** A rest segment has a name: `[...path]` is `params.path`. - **Reading the request in `spa` routes.** Their code also runs in the browser, so `cookies()` and `locals()` are not available there; call an endpoint ([LUM4003](../errors/LUM4003.md)). - **Hard-coding `/projects/${id}`.** `href("/projects/[id]", { id })` fails type-checking when the route moves. ## Related - [Routing](../concepts/routing.md): file routes, layouts, params, typed `href()` and the SPA router. - [Data and actions](../concepts/data-and-actions.md): endpoints, form actions, `submit()`, `invalidate()` and `optimistic()`. - [Async components](../concepts/async.md): awaiting data, the request cache, `` and streaming. - [Server](../concepts/server.md): middleware, `locals`, cookies, redirects and env. - [Rendering modes](../concepts/rendering-modes.md): `static`, `server` and `spa` routes, prerendering and the build. - [`lumiose/router`](../reference/router.md): `Link`, `navigate`, `params`, `searchParams` and `isNavigating`. - [`@lumiose/adapter-node`](../reference/adapter-node.md): `createServer` and `createHandler`. - [Coming from React](react.md): hooks, components and JSX. # Coming from Solid > Solid is the closest model, components set up once with fine-grained signals, so this guide is about the differences: signal and store APIs, props, control flow as plain JSX, async components, `.stsx` styles, islands, and SolidStart and Solid Router mapped to file routes, endpoints and form actions. This page maps Solid 1.x, Solid Router and SolidStart to Lumiose's current API. The linked pages have the full rules. ## Mental model **The same core idea.** As in Solid, a component body runs once, JSX expressions that read signals compile to bindings that update their part of the DOM, and there is no virtual DOM. Owners, `onCleanup`, context and `untrack` work the way you expect ([Signals](../concepts/signals.md)). Lumiose's names follow Solid and React where the behaviour matches, without Solid's `create` prefix: `signal`, `computed`, `effect`, `store`. **The differences are in the details:** - **A signal is one function**, not a `[get, set]` tuple: `count()` reads, `count.set(1)` and `count.update((n) => n + 1)` write. - **Props destructure safely.** The compiler rewrites destructured component parameters, with defaults and `...rest`, into reads of `props.x`, so `({ title, size = "md" })` stays reactive. No `splitProps` or `mergeProps`. - **Control flow is plain JSX.** There is no ``, `` or ``: the compiler turns `.map()` into a keyed list, ternaries and `&&` into memoised branches, and `item.id === selected()` inside `.map()` into a selector ([JSX](../concepts/jsx.md)). - **Async components replace resources.** A component may be `async` and `await` its data on the server and in the browser; `` and `` work as in Solid ([Async components](../concepts/async.md)). - **Styles live with the component.** `.stsx` files are TSX plus raw-CSS ` ``` A SolidStart route that reads with `query()` and `createAsync()` and writes with an `action()` becomes a page with an async component and a form action. Without JavaScript the form posts and the page re-renders with the action's data: ```stsx check file=routes/members/[id]/page.stsx // src/routes/members/[id]/page.stsx import { Title, actionData, fail, href, notFound, query, redirect, type ActionContext, type PageProps, } from "lumiose"; import { Todos } from "../../../components/Todos.stsx"; export const mode = "server"; const members = new Map([["1", { name: "Ada", bio: "" }]]); // a database in a real app export const actions = { async save(ctx: ActionContext<"/members/[id]">) { const bio = String((await ctx.formData()).get("bio") ?? ""); if (bio.length > 200) return fail(400, { error: "Keep it under 200 characters." }); const member = members.get(ctx.params.id) ?? notFound(); member.bio = bio; redirect(href("/members/[id]", { id: ctx.params.id })); }, }; export default async function Member({ params }: PageProps<"/members/[id]">) { // One query per request and key, however many components await it. const member = (await query(["member", params.id], async () => members.get(params.id))) ?? notFound(); return (
{member.name}

{member.name}

{actionData()?.error}

); } ``` ## Common mistakes - **Destructuring a signal tuple.** `const [count, setCount] = signal(0)` is a type error: a signal is one function with `.set()` and `.update()`. - **``, `` or ``.** They do not exist. Write ternaries and `.map()`; the compiler makes them fine-grained. - **Calling the index: `i()`.** In `.map((item, i) => …)`, `i` is used as a number; the compiler keeps it reactive. Passing it out of the callback is [LUM2006](../errors/LUM2006.md). - **`setStore` paths.** A store is written directly: `todo.done = true`, `state.items.push(item)`. - **`splitProps` to keep props reactive.** Destructure instead; the compiler keeps the reads reactive. Copying a signal's value into a variable is what freezes it ([LUM2001](../errors/LUM2001.md)). - **An early return on a signal.** `if (!user()) return ` runs once, as in Solid; branch in the JSX ([LUM2003](../errors/LUM2003.md)). - **`query(fn, "key")`.** Lumiose's `query` takes the key first, as an array: `query(["user", id], fn)`. - **An interactive component on a page without `client:*`.** It renders on the server, and its handlers never run. Add a directive at the usage site, or make the route `spa`. - **Expecting page context inside an island.** Islands start a new root, so context provided by the page does not reach them ([LUM4010](../errors/LUM4010.md)): pass a prop, or use `defineState`. - **A server function imported into an island.** Importing a `*.server.ts` module from browser code is [LUM4003](../errors/LUM4003.md). Post to a form action or call an endpoint. ## Related - [Signals and reactivity](../concepts/signals.md): `signal`, `computed`, `effect`, `store`, owners and scheduling. - [JSX](../concepts/jsx.md): bindings, the compiled `.map()`, ternaries and selectors, `class`, `style`, `attach` and `ref`. - [Components](../concepts/components.md): setup once, props destructuring, children and refs. - [Styles](../concepts/styles.md): ` ``` In Lumiose, the search box is a Lumiose component, which could render without a directive on another page. The theme toggle is a typed browser script that only the pages rendering the header load. Its data goes through a typed attribute: ```ts check file=components/theme-toggle.ts // src/components/theme-toggle.ts: runs in the browser only customElements.define( "theme-toggle", class extends HTMLElement { connectedCallback(): void { const key = this.getAttribute("storage-key") ?? "theme"; this.addEventListener("click", () => { const dark = document.documentElement.classList.toggle("dark"); localStorage.setItem(key, dark ? "dark" : "light"); }); } }, ); ``` ```stsx check file=components/Search.stsx // src/components/Search.stsx import { signal } from "lumiose"; export function Search({ placeholder }: { placeholder: string }) { const query = signal(""); return (
{query() &&

Press Enter to search for "{query()}".

}
); } ``` ```stsx check file=components/Header.stsx // src/components/Header.stsx import { Search } from "./Search.stsx"; import defineThemeToggle from "./theme-toggle.ts?client"; declare module "lumiose/jsx-runtime" { namespace JSX { interface IntrinsicElements { // The server side is type-checked; the script reads the attribute with getAttribute(). "theme-toggle": JSX.HTMLProps & { "storage-key"?: string }; } } } export function Header({ title }: { title: string }) { defineThemeToggle(); return (
{title}
); } ``` A blog post page built from a collection, in Astro: ```astro --- // src/pages/blog/[slug].astro import { getCollection, render } from "astro:content"; import Layout from "../../layouts/Layout.astro"; export async function getStaticPaths() { const posts = await getCollection("blog"); return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); } const { post } = Astro.props; const { Content } = await render(post); ---
``` In Lumiose, the Markdown files are imported as components. `staticPaths()` lists the slugs, and the page looks its post up from `params`. `src/routes/layout.stsx` wraps it without an import: ```stsx // src/routes/blog/[slug]/page.stsx import type { Frontmatter } from "*.md"; import { Title, notFound, type PageProps } from "lumiose"; type Post = { default: () => JSX.Element; frontmatter: Frontmatter }; const modules = import.meta.glob("../../../posts/*.md", { eager: true }); const posts = new Map( Object.entries(modules).map(([path, post]) => [path.split("/").pop()!.replace(/\.md$/, ""), post]), ); export function staticPaths() { return [...posts.keys()].map((slug) => ({ params: { slug } })); } export default function BlogPost({ params }: PageProps<"/blog/[slug]">) { const { default: Content, frontmatter } = posts.get(params.slug) ?? notFound(); return (
{frontmatter.title}
); } ``` ## Common mistakes - **Writing frontmatter.** There is no `---` fence: imports go at the top of the module, and setup code goes in the component's body. - **`[...slug]` for an optional catch-all.** It matches one or more segments in Lumiose. Astro's `[...slug]` is `[[...slug]]`. - **Importing a layout into every page.** A `layout.stsx` in the folder wraps its pages already, so importing it as well renders it twice. - **An interactive component without `client:*`.** It renders on the server, and its handlers never run. Add a directive at the usage site, or make the route `spa`. - **A `` after its stylesheet tags (54 bytes, in dev too). The browser parses the body only once the stylesheets before that script have loaded, so the body's first layout is styled. Without it, Chrome could lay out the unstyled body when a scrolled page is reloaded, restore the scroll position from that layout, and paint the first frames at the wrong place until `load`. The first paint waits for the stylesheets either way. With `csp: "strict"` the script gets the nonce or its hash (see [Security](security.md)). Each tag lists the stylesheets it holds in `data-l-css`, so an island or an SPA route that imports one does not load it again. Pages link the CSS files the build output contains: when another Vite plugin rewrites a stylesheet under a new name after Lumiose recorded it, the build follows it (by the chunks that import it, the name it was emitted with, or the one new CSS file), and warns about a linked stylesheet the output no longer has. ### Design tokens Colors, spacing, radii, fonts and the other values of a design are CSS custom properties (decision-87). Declare them once on `:root`, usually in the root layout's ` ``` **Typed names.** To catch a mistyped name, and to use the same names from JS, keep them in an `as const` module and read them with `var($vars.name)`. The module holds the names only; the values stay in CSS: ```ts check file=vars.ts // src/vars.ts export const vars = { accent: "--accent", surface: "--surface", radius: "--radius", space: "--space", } as const; ``` ```stsx check file=components/Chip.stsx // src/components/Chip.stsx import { vars } from "../vars.ts"; export function Chip(props: { label: string; color: string }) { return ( {props.label} ); } ``` `var($vars.accent)` ships as `var(--accent)`, and `var($vars.radius, 4px)` keeps its fallback. A mistyped member (`$vars.acent`) fails the build with [LUM3008](../errors/LUM3008.md), and TypeScript checks the JS side (`style={{ [vars.accent]: … }}`, `getPropertyValue(vars.accent)`). The declarations on `:root` still spell the names out, because `$` is not read in a property name. Plain `var(--accent)` needs no module: the names module is worth it when a project wants those checks. **When to use `$name` instead.** A custom property is resolved by the browser, which is what design tokens need. A `$` constant is fixed at build time: keep it for what custom properties cannot do, and for values that JS code also needs: - **Conditions** of `@media`, `@container` and `@supports`, where `var()` does not work: a breakpoint (`@media (width < $narrow)`). - **Values shared with JS:** the breakpoint that `matchMedia` also checks, the grid columns that a virtualizer also computes with, a duration that a timer also waits for, a color that a canvas draws with or that JS color math derives. ### JS values in styles `$name` and `$obj.key` put a JS value into CSS, for the cases above. The compiler evaluates the reference at build time and inlines the result, so the output is plain CSS: no runtime lookup and no JS inside CSS. TypeScript checks each reference like any other expression. Imports used only by `$` references are removed from the JS output, and editing them hot-reloads the CSS only. **What a reference can name:** a `const` whose value the compiler can work out without running the app. | Declared as | Readable from | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | A top-level `const` of the file | Every block of the file. A top-level block must come after it. | | A `const` of the enclosing component | Blocks inside that component, ` ``` Values that the styles and the JS both use live in a module of their own, imported by the components and the code that need them. Here the meter's `ticks` decide both the labels it renders and the columns and marks of its CSS, and `narrow` is the breakpoint of its `@media` and of the app's `matchMedia` check. Its design values are custom properties: ```ts check file=scale.ts // src/scale.ts: read by the styles and by the JS. export const ticks = 4; export const narrow = "40rem"; export const isNarrow = () => matchMedia(`(width < ${narrow})`).matches; ``` ```stsx check file=components/Meter.stsx // src/components/Meter.stsx import * as scale from "../scale.ts"; const step = 100 / scale.ticks; export function Meter(props: { label: string; value: number }) { return (
{props.label}
    {Array.from({ length: scale.ticks }, (_, i) => (
  1. {(i + 1) * step}%
  2. ))}
); } ``` The compiler's CSS output for that file (`data-lum-lw1tegds` is the component's scope token). Every `$` reference and every constant `calc()` is gone; the `var()` reads, which the browser resolves, and the `%`-relative stop are left as written: ```css .meter[data-lum-lw1tegds] { gap: var(--space); padding: calc(var(--space) * 2); border-radius: var(--radius); background: var(--surface); display: grid; } .track[data-lum-lw1tegds] { background: repeating-linear-gradient(to right, #0000 0 calc(25% - 1px), currentColor 0 25%); border-radius: 4px; block-size: 8px; box-shadow: inset 0 1px 2px #0003; } .fill[data-lum-lw1tegds] { inline-size: var(--value); background: var(--accent); block-size: 100%; } .scale[data-lum-lw1tegds] { text-align: end; grid-template-columns: repeat(4, 1fr); margin: 0; padding: 0; list-style: none; display: grid; } @media (width < 40rem) { .scale[data-lum-lw1tegds] { display: none; } } ``` Lightning CSS also minifies values (`rgb(0 0 0 / 0.2)` becomes `#0003`) and may reorder or merge declarations, so the output is equivalent to the source, not identical. #### Build-time math Constant math never reaches the browser. It is computed in two steps: 1. **JS math, by the Lumiose compiler.** Arithmetic in constants and template literals is evaluated before the value is inserted: `const step = 100 / scale.ticks` inlines `25`. 2. **CSS math, by Lightning CSS.** After substitution, Lightning CSS reduces `calc()`, `min()`, `max()`, `clamp()`, `round()`, `abs()`, the trigonometric functions and the other CSS math functions whenever the operands are concrete values in compatible units. `calc($gap * 2px)` becomes `calc(8 * 2px)`, which ships as `16px`. | Source (`$gap` is `8`) | Output | Why | | ----------------------------------------- | ------------------------------------- | ---------------------------------------------- | | `padding: calc($gap * 2px)` | `padding: 16px` | `calc(8 * 2px)` reduces. | | `inline-size: min(10px, 20px)` | `inline-size: 10px` | | | `block-size: clamp(4px, 8px, 12px)` | `block-size: 8px` | | | `transform: rotate(calc(45deg * 2))` | `transform: rotate(90deg)` | Math inside other functions reduces too. | | `inset: calc(2px + 3px) calc(1rem + 4px)` | `inset: 5px calc(1rem + 4px)` | Each value on its own: `rem` plus `px` stays. | | `width: calc(100% - $gap * 2px)` | `width: calc(100% - 16px)` | The constant part reduces; `%` stays. | | `font-size: calc(2 * 1rem)` | `font-size: 2rem` | One unit: nothing to convert. | | `font-size: clamp(1rem, 2vw, 3rem)` | unchanged | `rem` and `vw` are only known in the browser. | | `max-width: calc(var(--w) + $gap * 2px)` | `max-width: calc(var(--w) + 8 * 2px)` | A declaration with `var()` is kept as written. | | `--space: calc($gap * 2px)` | `--space: 16px` | A custom property, folded by Lumiose (below). | | `padding-left: calc($gap * 1px - 12px)` | `padding-left: 0` | Negative in a non-negative property (below). | | `margin-left: calc($gap * 1px - 12px)` | `margin-left: -4px` | Margins can be negative. | **What cannot fold.** A math function is replaced only by a value that means the same thing wherever it is used. Absolute units convert into each other (`calc(1in + 4px)` is `100px`) and a unit combines with itself (`calc(2 * 1rem)` is `2rem`), but context-dependent units mixed with others need the browser: `%` resolves against the containing block (or the font size, depending on the property), `vw` and `vh` against the viewport, `em` against the element's font size and `rem` against the root's. A `var()` is only known when the browser computes styles, so Lightning CSS keeps any declaration containing one exactly as written, constant parts included. To precompute those, do the arithmetic in JS: with `` const extra = `${gap * 2}px` ``, `calc(var(--w) + $extra)` ships as `calc(var(--w) + 16px)`. **Custom properties.** Lightning CSS keeps custom property values as written, because it cannot know where they will be used. Lumiose folds each math function in them that reduces to a single value in compatible units: `--space: calc($gap * 2px)` becomes `16px`, `--turn: rotate(calc(45deg * 2))` becomes `rotate(90deg)`, and `var(--x, calc($gap * 3px))` becomes `var(--x, 24px)`. Anything with `var()`, `env()` or context-dependent units mixed with other units stays whole, without the partial reduction regular properties get (`--inset: calc(100% - $gap * 2px)` stays `calc(100% - 8 * 2px)`). Negative results and fractional numbers stay inside `calc()` (`calc(-3px)`, `calc(.333333)`), because a bare value can behave differently where the property is read: `calc()` clamps to the property's range and rounds where an integer is expected. **Negative results.** Some properties cannot be negative: padding, sizes (`width`, `min-height`, …), border widths, `gap`, `font-size`, `line-height` and others. In the browser, a `calc()` that comes out negative there is clamped to 0, while a negative value written out is invalid and the whole declaration is dropped. So that folding does not change the meaning, a negative result in such a property ships as `0`: `padding-left: calc($gap * 1px - 12px)` gives `padding-left: 0`, and `font-size: calc(1rem - 2rem)` gives `font-size: 0`. This mirrors the upstream Lightning CSS fix ([lightningcss#1329](https://github.com/parcel-bundler/lightningcss/pull/1329)), which Lumiose applies until a release includes it. Properties that accept negative values, such as margins and `inset`, keep them. Folding regular properties, minification and browser targets come from [Lightning CSS](https://lightningcss.dev), the CSS parser, transformer and minifier from the Parcel project, which Lumiose's CSS compiler is built on. Lumiose adds `$` constants, scoping and the custom-property folding. Production builds minify CSS for the [supported browsers](../stability.md#browsers), which all have `:dir()`, nesting, `light-dark()` and the other modern syntax, so it ships as written. With an older `build.cssTarget` (or `build.target`) in your Vite config, Lightning CSS lowers what those browsers lack: `:dir(rtl)` becomes a list of `:lang()` selectors, which miss a `dir` attribute, and `light-dark()` becomes variables that follow the system's colour scheme only, so a `color-scheme` set on an element, inline or from a script no longer switches the colours. ### Stylesheets, CSS Modules and Tailwind Style blocks hold a component's own styles. Three other ways to style work next to them through Vite, in `lumiose dev` and in builds: imported `.css` files, CSS modules and Tailwind v4. A smoke test (`examples/ssr-styles-e2e`) checks each one in Chromium: server-rendered, without JavaScript, and in a hydrated island. StyleX is not supported yet (below); Sass is not covered. **Imported `.css` files are global.** Nothing in them is scoped, and `$` references are not read. A page links a stylesheet when its import graph reaches it: the page, its layouts and the components they import. Import a stylesheet from the root layout to link it on every page, or from one route's page or layout to link it on that route only. The server-rendered HTML links it, so the page is styled before any script runs; builds merge it with the page's other stylesheets (see "In builds" above). ```stsx check // src/routes/layout.stsx // Global CSS first: the file's style block comes after it in the cascade. import "../styles/app.css"; export default function Root(props: { children?: JSX.Element }) { return
{props.children}
; } ``` **Cascade order.** A page's stylesheets come in import order: a file's imports (stylesheets and the components it renders) before its own style blocks, and a layout before its page. Rules of the same specificity resolve in that order. A scoped selector carries an extra attribute (+0,1,0), so a block's `.card` beats a stylesheet's `.card` wherever the two come. Keep base styles (resets, typography, tokens) in a global stylesheet and refine them in blocks. **CSS Modules** suit stylesheets shared by several files or brought from another project. Vite processes `*.module.css` files: it hashes each class name (`.pill` becomes `_pill_1x2y3_1`), and the default export maps the names to the hashed classes, typed as a read-only class map by `lumiose/env`. The server render and the browser get the same names in `lumiose dev` and in builds, so islands hydrate without mismatches. Pass the classes to `class` like any string, alone or in an array with other classes: ```css /* src/components/Badge.module.css */ .pill { border-radius: 999px; padding: 0 0.5rem; } .warn { background: gold; } ``` ```stsx check file=components/Badge.stsx // src/components/Badge.stsx import styles from "./Badge.module.css"; export function Badge(props: { warn?: boolean; class?: string; children?: JSX.Element }) { return ( {props.children} ); } ``` A CSS module is global CSS with unique names: placement scoping, `$name` constants and the `LUM3xxx` diagnostics apply to style blocks only. Prefer a style block for a component's own styles; class completion in the editor covers style blocks and global stylesheets, not CSS modules. Other preprocessors' module files (`.module.scss`) work through Vite, typed by `vite/client`. **Tailwind v4.** Install `tailwindcss` and `@tailwindcss/vite`, add Tailwind's Vite plugin next to Lumiose's, and import Tailwind from a global stylesheet that the root layout imports (the `src/styles/app.css` of the layout above): ```ts // vite.config.ts import lumiose from "@lumiose/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [tailwindcss(), lumiose()] }); ``` ```css /* src/styles/app.css */ @import "tailwindcss"; ``` Tailwind's scanner reads every source file Git does not ignore, `.stsx` files included, and generates the utilities it finds. Write them in `class` as a string, an array or a record. As Tailwind requires, each class appears whole in the source, also the ones a signal turns on later: ```stsx check file=components/Toggle.stsx // src/components/Toggle.stsx import { signal } from "lumiose"; export function Toggle(props: { label: string }) { const on = signal(false); return ( ); } ``` - **Utilities lose to style blocks.** Tailwind v4 puts utilities in `@layer utilities`. Style blocks and the unlayered rules of your stylesheets are in no layer, so they win at any specificity: `

` keeps a block's `.note { padding: 2px; }`. Set a property in one place, or make the utility important (`p-8!`). - **In style blocks, read Tailwind's theme variables:** `color: var(--color-emerald-500)`, `padding: calc(var(--spacing) * 4)`. Tailwind emits the variables it finds in the sources. - **`@apply`, `theme()` and `--spacing()`** work in a block that starts with `@reference "../styles/app.css";` (the stylesheet that imports Tailwind), as in any CSS that Tailwind processes on its own. Without it, the build fails with "Cannot apply unknown utility class". The block's selectors stay scoped: `.card { @apply p-6; }` styles only this file's `.card`. ```stsx ``` The compiler keeps Tailwind's at-rules as written for Tailwind's Vite plugin and does not report them: `@apply`, `@reference`, `@theme`, `@utility`, `@variant`, `@custom-variant` (with `@slot`), `@source`, `@plugin` and `@config`. Other unknown at-rules are still [LUM3015](../errors/LUM3015.md) warnings, and so is `@APPLY`: Tailwind matches the names case-sensitively. - **In the editor,** Lumiose's class completion lists the classes of style blocks and global stylesheets, not Tailwind's utilities. Tailwind CSS IntelliSense for VS Code gives those once it treats `.stsx` like JSX, in the workspace settings: `"tailwindCSS.includeLanguages": { "stsx": "javascriptreact" }`. **StyleX is not supported yet** in server-rendered apps (spiked with StyleX 0.19 and its Vite plugin, `@stylexjs/unplugin`): - **`.stsx` files are not compiled.** The plugin only transforms `.js`, `.ts`, `.jsx` and `.tsx` files, so `stylex.create()` in an `.stsx` file throws when the page renders. In `.ts` and `.tsx` modules it compiles. - **Production builds lose rules.** The plugin appends all rules to one stylesheet (renamed under a new hash, which pages follow), while Lumiose links each route's own stylesheets: only the pages that link that one file get the rules. Pointing its `cssInjectionTarget` at a stylesheet the root layout imports stops the build in Vite (`vite:css-post`), since the plugin deletes a file Vite still reads. - **In `lumiose dev`** the rules are served at `/virtual:stylex.css` but not linked; a document must link them itself: `{import.meta.env.DEV && }`. - **Apply styles with `stylex.attrs()`,** which returns `class` and a `style` string, the attributes Lumiose's JSX takes: `

`. `stylex.props()` returns React's `className` and a camelCase `style` object, which Lumiose does not read. ## API | Syntax | Meaning | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `` at module top level | File-scoped CSS, shared by every component of the file. By convention one block, at the end of the file. | | `` in component JSX | CSS scoped to its enclosing element's subtree (the whole component when placed under the root element). | | `` | Unscoped, app-wide CSS. | | `var(--name)` | A custom property: design tokens (declared on `:root`) and runtime values. | | `var($vars.name)` | A custom property whose name comes from an `as const` module, so a typo fails the build. | | `$name`, `$obj.key` | A JS constant, inlined at build time, in property values and `@media`/`@container`/`@supports` preludes. | | `calc()`, `min()`, `max()`, `clamp()`, … | Folded at build time when every operand is a concrete value in compatible units. | | `:global(sel)`, `:deep(sel)` | Escape or extend the scope. | | `style={{ "--x": value }}` + `var(--x)` | Runtime values. | | `import "./app.css"` | A global stylesheet, linked on the pages whose import graph reaches it (every page from the root layout). | | `import styles from "./x.module.css"` | A CSS module: `styles.name` is the hashed class of `.name`. | ## Example ```stsx check // Read by the app's JS too: `matchMedia(`(width < ${BREAKPOINT})`)`. const BREAKPOINT = "40rem"; export function Card(props: { title: string; progress: number; class?: string }) { return (

{props.title}

); } export function Dashboard(props: { stats: { title: string; progress: number }[] }) { const columns = 3; return (
{props.stats.map((stat, i) => ( ))}

{Math.ceil(props.stats.length / columns)} rows

); } ``` `Card` and `Dashboard` share the file-level block, which reads the design tokens declared on `:root` (see "Design tokens"). `.featured` is set by `Dashboard` and styles the `
` inside `Card`: both are in the file's scope (from another file, the class would get there through class passthrough). The nested block in `Dashboard` is there because it reads `columns`, a constant the component's JS also uses. `BREAKPOINT` is a `$` constant because `var()` does not work in `@media`, and the app's JS checks the same width. ## Common mistakes - **A design system in `$` constants.** Colors, spacing and radii folded at build time cannot change with a theme, a media query or the cascade, and devtools do not show them. Declare them as custom properties on `:root` and read them with `var()`; keep `$` for breakpoints and values JS also uses (see "Design tokens"). - **`"var(--brand)"` in a names module.** Store the bare name (`brand: "--brand"`) and write `var($vars.brand)`: the same string then works in JS (`style={{ [vars.brand]: … }}`), and fallbacks work (`var($vars.brand, #000)`). - **A block inside `{…}`, a conditional, a `.map` callback, or a component element** such as `` or ``. Move it into an intrinsic element of the component (`LUM3010`). - **Returning a block, or using it as an expression** (`return ``` ```stsx check file=routes/posts/[id]/page.stsx // src/routes/posts/[id]/page.stsx: a server component; only LikeButton ships JS import { params } from "lumiose"; import { LikeButton } from "../../../components/LikeButton.stsx"; export default function Post() { const { id } = params<"/posts/[id]">(); return (

Post {id}

Like
); } ``` ## Common mistakes - **A non-exported island.** `function Counter` used with `client:load` cannot be imported by the browser. Export it (`LUM4001`). - **Passing functions or class instances as island props.** `onSave={() => …}` cannot be serialized. Pass data, and do the work inside the island, or post a form (`LUM4002`). - **Importing server code into an island.** `import { db } from "../server/db.server.ts"` in an island's graph is `LUM4003`. Call an endpoint with `fetchJSON` instead. - **Touching `window` or `document` at module scope** in a module that also runs on the server. Move it into `onMount` or an effect (`LUM4004`). - **Reading page context inside an island.** An island hydrates as its own root, so it does not see values the page provided outside it with a `` provider, and it renders the same way on the server: `useContext()` returns the context's default unless the island (or a component inside it) provides the value. Pass the value as a prop, or share scoped state (`defineState`, [Sharing state](state.md)); dev warns with `LUM4010` when the page did provide it. - **Different output on server and client**, for example from `Math.random()` or `Date.now()` in render. Hydration reports `LUM4006` in dev and client-renders the range in production. - **Expecting `submit()` to refresh the page.** On MPA pages a successful `submit()` does not reload: the island owns its state and updates itself from the result. Call `invalidate()` when the page needs fresh server HTML ([Data and actions](data-and-actions.md)). - **Starting an SDK inside an island.** It runs only where that island renders, and after other islands may have hydrated. Start it in `src/instrumentation.client.ts`, which every page runs first. - **Making a whole page an island to get interactivity.** Mark only the interactive parts, or use an `spa` route ([Choosing a mode](rendering-modes.md#choosing-a-mode)). - **Rendering query-dependent HTML on the server and changing the query shallowly.** `searchParams.set(next, { shallow: true })` does not re-render the page, so a table the server rendered from `url().searchParams` keeps the old rows. Drop `shallow` to load the page, or render that table inside the island, from props and an endpoint. ## Related diagnostics - [LUM4001](../errors/LUM4001.md): island component is not exported. - [LUM4002](../errors/LUM4002.md): non-serializable island prop. - [LUM4003](../errors/LUM4003.md): server-only module in a client graph. - [LUM4004](../errors/LUM4004.md): browser API at module scope. - [LUM4006](../errors/LUM4006.md): hydration mismatch. - [LUM4007](../errors/LUM4007.md): non-serializable awaited value. - [LUM4010](../errors/LUM4010.md): island reads context provided by the page. - [LUM4011](../errors/LUM4011.md): non-serializable scoped state value. See also: [Rendering modes](rendering-modes.md), [Async components](async.md), [Sharing state](state.md) (state shared by islands). # Routing > Routes are folders under `src/routes`, named like the Next.js App Router: pages, layouts, endpoints and error pages, with typed params. ## Concept ``` src/ routes/ layout.stsx root layout; a nested layout.stsx per folder page.stsx / about/page.stsx /about blog/page.stsx /blog blog/[slug]/page.stsx /blog/:slug blog/[slug]/Comments.stsx colocated module, not a route blog/_lib/posts.ts private folder: nothing below it routes docs/[[...path]]/page.stsx /docs and /docs/* (optional rest) files/[...path]/page.stsx /files/* (rest: one or more segments) [[lang]]/contact/page.stsx /contact and /:lang/contact (optional param) (marketing)/pricing/page.stsx /pricing (a group folder adds no URL segment) api/posts/route.ts endpoint: export GET / POST / … = (ctx) => Response admin/middleware.ts runs for the pages, actions and endpoints below /admin sitemap.xml/route.ts endpoint at /sitemap.xml not-found.stsx error.stsx error pages, nearest one wins document.stsx optional: owns , and middleware.ts optional: export default (ctx, next) => Promise ``` - **A folder is a URL segment; special files give it behavior:** `page.stsx` or `page.tsx` (the page), `route.ts` (an endpoint), `middleware.ts` (folder middleware), and `layout`, `not-found` and `error` (`.stsx` or `.tsx`). There is no `index` and no flat page file: `about/page.stsx` is the page at `/about`, and `about.stsx` is not a route. A folder has a page or a `route.ts`, not both (`LUM5003`). - **Colocation:** any other file in `src/routes` (components, helpers, styles, tests) is ignored by the router, so it can live next to the route that uses it. A folder whose name starts with `_` is private: nothing below it routes, not even a `page.stsx`. - **Segments:** `[param]` matches one segment, `[[param]]` zero or one, `[...rest]` one or more and `[[...rest]]` zero or more (joined with `/`); `(group)` folders add no segment. A rest segment is the last one of the URL. An optional param that matched nothing is absent: `params.lang` is `undefined` at `/contact`. - **Matching priority:** segment by segment, static beats `[param]`, which beats `[...rest]`. A route with optional segments competes with each URL shape it serves: `/[[lang]]/contact` serves `/contact` as a static route. Two files that serve the same URL are `LUM5003`, including a `[[...rest]]` folder next to a page for the URL without it. - **Next.js files Lumiose does not have** are `LUM5002`, so they never silently do nothing: `loading`, `template`, `default` and `global-error` files, `@slot` folders (parallel routes) and `(.)` folders (intercepting routes). - **Layouts** wrap every page below their folder and receive `{ params, children }`. `children` is the page (the outlet). - **Folder middleware:** a `middleware.ts` runs for every page, form action and endpoint below its folder (group folders included), after `src/middleware.ts` and outermost folder first. It is the place for a section's auth, since a layout does not see the folder's actions and endpoints. See [Server](server.md#concept); `src/routes/middleware.ts` is `LUM5002` (use `src/middleware.ts`). - **Pages** export a `default` component (it may be `async`), which receives `{ params }`. Other page exports: `mode`, `ssr`, `prerender`, `staticPaths`, `action`/`actions`, `headers` and `limits` (see [Rendering modes](rendering-modes.md), [Data and actions](data-and-actions.md) and [Security](security.md)). Endpoints may export `limits` too. - **Error pages:** `not-found` renders unmatched URLs and `notFound()`; `error` renders thrown errors and `error(status)`. Both receive `{ status, error }`. Outside dev, unexpected errors are masked as 500. An error page renders inside the layouts of its folder, like a page there: the root `not-found` gets the root `layout` (header, navigation), so it only renders its own content. If a layout itself fails, the error page renders without layouts. To give errors a different frame, move the regular pages into a group with their own layout (`(app)/layout.stsx`) and keep the error page outside it. **Locales.** With `lumiose({ i18n })`, the router also serves each page under the prefixes of the locales it declares (`/es/about` from `about/page.stsx`), and `href()`, `` and the `hreflang` links follow. See [Internationalization](i18n.md). **Typed routes.** `lumiose typegen` (run by `lumiose check` and the dev server) writes `.lumiose/types/routes.d.ts`, which augments `RouteMap` (pages and endpoints) and `LayoutMap` (the folders with a layout or a middleware file). Every route-bound type then takes the route pattern as a string literal, checked against that table: an unknown or stale pattern is a type error, and editors complete it. - `href("/blog/[slug]", { slug })` builds a URL and type-checks the pattern and params; the URL includes Vite's `base` and follows `trailingSlash` ([reference](../reference/app.md#href)); - `PageProps<"/blog/[slug]">` types a page's props, `LayoutProps<"/blog">` a layout's (`{ params, children }`, with the params of the layout's URL scope; route groups such as `(app)` add nothing to it), and `params<"/blog/[slug]">()` the params anywhere on the server; - `RequestContext

`, `ActionContext

` and `Endpoint

` type `ctx.params` of middleware helpers, actions and endpoints (see [Data and actions](data-and-actions.md)), and `Middleware<"/admin">` types a folder's middleware: `ctx.params` with the folder's params and `ctx.route` with the patterns below it; - optional params are optional properties (`RouteParams<"/[[lang]]/contact">` is `{ lang?: string }`), and `href()` leaves out an optional segment whose param is missing or empty: `href("/docs/[[...path]]")` is `/docs`. **Navigation.** Routes are MPA by default: links load the next document. Two `lumiose()` options in `vite.config.ts` make these navigations smooth ([reference](../reference/vite.md#lumiose)); both are progressive, so browsers without support navigate normally: - **Prefetch** (`prefetch`, default `"hover"`). Pages get ` ``` The Markdown's elements belong to the post's component, so the route styles them with `:deep()` under its own `.post` element. The web component's shadow styles stay in its template; its light-DOM rule is scoped to the file. ## Common mistakes - **Forgetting the option.** `.md` and `.html` imports are off by default; without `lumiose({ md: true })` Vite tries to read the file as JavaScript. - **Trusting frontmatter types without a check.** `frontmatter` has `unknown` values. Declare fields in `Frontmatter` (as above) only when the hook validates the data. - **Styling Markdown from a scoped block without `:deep()`.** The rendered elements belong to the Markdown component. Use `:deep()`, a class on a wrapper plus `:deep()`, or a ``. - A `

); } ``` ```sh $ lumiose check --format json { "command": "check", "ok": true, "diagnostics": [], "summary": { "errors": 0, "warnings": 0, "hints": 0, "files": 0 } } $ lumiose explain LUM3010 ``` ## Common mistakes - **Running bare `tsc` without shadow files.** `import "./X.stsx"` only resolves after shadows exist. Run `lumiose check` (or `lumiose typegen`), or keep the dev server or editor running. - **A `tsconfig.json` that neither extends `.lumiose/tsconfig.generated.json` nor sets `rootDirs` and `.lumiose/shadow`.** `lumiose check` reports the missing options (`LUM6001`); extend the generated file ([tsconfig](../reference/jsx.md#tsconfigjson)). - **Importing `.stsx` across `src/` or from a workspace package with a standalone tsconfig.** Its `rootDirs` only pair `src`. Extend `.lumiose/tsconfig.generated.json` instead ([tsconfig](../reference/jsx.md#imports-outside-src-and-stsx-from-other-packages)). - **Ignoring a code because the message is short.** Run `lumiose explain ` for the cause and the fix. - **Expecting TypeScript plugins.** Lumiose uses stock `tsc` and a language server; there is no TS plugin to configure. ## Related diagnostics - [LUM1001](../errors/LUM1001.md): syntax error. - [LUM1002](../errors/LUM1002.md): unsupported by this compiler version. - [LUM1003](../errors/LUM1003.md): scanner ambiguity. - [All diagnostics](../errors/README.md). See also: [Styles](styles.md), [reference: CLI](../reference/cli.md). # Dev tools > The in-page inspector of `lumiose dev`: the component tree with props and source locations, signals with their dependents and last write, islands with their hydration state, why something updated, and Alt+click to open any element's source. ## Concept `lumiose dev` adds an **inspector** to every page, in place of a browser extension such as React DevTools. Press **Alt+Shift+L**, or click the small orb in the bottom-left corner, to open it at the bottom of the window. It renders in a closed shadow root, so your styles do not reach it and your tests' locators do not find it, and it works under `lumiose({ csp: "strict" })`. It has four panels: | Panel | Shows | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Tree** | The components the browser runs (islands, `spa` pages, `render()` roots), nested as they render. Select one for its props (getters are read safely, `children` is not), its source location, what it owns and, for an island's root, the island. Hovering a row outlines its DOM. | | **Signals** | The signals, computeds and store properties a component created, or those created outside components (module scope, event handlers): the current value, what depends on them, and the last write (old → new value, when, and where). Below, its effects and bindings with their run counts. Lumiose's own signals (a resource's state, a `` count, the router's URL) are hidden until you ask for them. | | **Islands** | Every island on the page: its `client:*` strategy, its state (`waiting`, `loading`, `hydrating`, `hydrated`, `failed`), when the loader armed it, how long its module took to load and how long hydration took, and the error of an island that failed to load or hydrate. | | **Why did this update** | For the element you select (⌖ **Select**, then click it on the page), the bindings that update it and, for each, the writes that last re-ran it, through the computeds in between: `doubled ← count: 1 → 2 in src/Counter.stsx (onClick)`. Below, the latest updates on the page. | **Alt+click** any element to open its source at the line in your editor, through Vite's open-in-editor endpoint. It works on server-rendered markup too, not only on islands: in dev, the compiler gives every element that renders a box a `data-lumiose-source="src/routes/page.stsx:12:5"` attribute, on the server and in the browser alike (not `` elements such as ``). While you hold Alt, the element it would open is outlined. **What the inspector sees.** Server-only components (pages and layouts of `static` and `server` routes) run on the server, so they have no client tree: the Tree lists islands and `spa` pages, and Alt+click covers everything. Signal names come from the variables they are assigned to (`const count = signal(0)`), which the compiler passes in dev; a signal assigned elsewhere shows as `signal #12`. **It costs nothing in builds.** The reactive core, the DOM runtime and the island loader report to the inspector's registry only behind a dev-only flag that `lumiose dev` sets, so `lumiose build` output contains none of it, byte for byte. `lumiose({ inspector: false })` turns it off in dev too: no corner button, no shortcut, no source attributes, no recording. ## API | Name | What it does | | -------------------------------- | -------------------------------------------------------------------------------------------------------- | | Alt+Shift+L | Opens and closes the inspector. It remembers whether it was open, per tab. | | The corner button | Opens the inspector. | | ⌖ Select | Picks an element on the page: Tree selects its component, Why did this update explains it. Esc cancels. | | Alt+click | Opens the element's source in your editor. | | `lumiose({ inspector: false })` | Turns the inspector off ([`@lumiose/vite`](../reference/vite.md#lumiose)). Default: on in `lumiose dev`. | | `data-lumiose-source` (dev only) | The element's source location, `<file>:<line>:<column>`, root-relative. | Vite opens files with [`launch-editor`](https://github.com/yyx990803/launch-editor): the editor named by the `LAUNCH_EDITOR` environment variable, else the one it finds running, else `VISUAL` or `EDITOR`. ## Example ```tsx // src/components/Counter.stsx, used as <Counter client:visible start={1} /> import { computed, signal } from "lumiose"; export function Counter(props: { start: number }) { const count = signal(props.start); const doubled = computed(() => count() * 2); return ( <button data-doubled={doubled()} onClick={() => count.update((n) => n + 1)}> {count()} </button> ); } ``` After two clicks, the inspector shows: - **Tree:** `Counter` with the island badge `island · visible`, `Counter.stsx:4`, props `start: 1`, and "1 signal, 1 computed, 2 bindings". - **Signals:** `count` = `3`, read by `doubled` and a binding of the button, last written `2 → 3` in `src/components/Counter.stsx` (its click handler); `doubled` = `6`. - **Islands:** `Counter`, `visible`, `hydrated`, armed at 412 ms, load 3.1 ms, hydrate 0.4 ms. - **Why did this update**, with the button selected: its two bindings, one last re-run by `doubled ← count: 2 → 3` (its value `6`, the `data-doubled` attribute), the other by `count: 2 → 3` (its text, `3`). Alt+click the button: your editor opens `src/components/Counter.stsx` at line 8. ## Common mistakes | Mistake | What happens | Instead | | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Looking for a page's server component in Tree | It is not there: it ran on the server. | Alt+click its markup to open it; Tree lists what runs in the browser. | | Reading a computed marked `(stale)` as wrong | Nothing read it since its inputs changed, so it has not re-evaluated (computeds are lazy). | It updates on its next read. The inspector never evaluates it, so it never changes what your app runs. | | Expecting the line where a signal was written | The last write names the file and the function, not the line: the stack has compiled positions. | Open the file from the source link, or Alt+click the element. | | Alt+click opening the wrong editor, or none | `launch-editor` picked another editor, or found none. | Set `LAUNCH_EDITOR` (for example `code`, `cursor`, `webstorm`) in the environment of `lumiose dev`. | | Matching a dev page's raw HTML exactly in end-to-end tests | In `lumiose dev`, elements that render a box carry `data-lumiose-source`, so `<h1 id=user>` no longer matches. Builds and Vitest have none. | Allow extra attributes (`/<h1 id=user[^>]*>/`), select elements by role and text, or test the built app. | ## Related diagnostics None: the inspector reports nothing itself. The diagnostics it helps explain, such as [LUM2001](../errors/LUM2001.md) (a value computed once that never updates), appear in the terminal, the editor and Vite's overlay. <!-- source: concepts/testing.md --> # Testing > Test components with Vitest and Testing Library (queries and user-event against the real DOM that `render()` builds), server rendering as HTML strings, and whole apps end to end with Playwright. ## Concept **Three kinds of tests**, with stock tools and no Lumiose adapter package: | Test | Tools | What it runs | | ---------------- | -------------------------------------------------------------------------- | -------------------------------------------------- | | Component | Vitest, `happy-dom`, `@testing-library/dom`, `@testing-library/user-event` | `render()` from `lumiose/dom` into `document.body` | | Server rendering | Vitest (node environment) | `renderToString(() => <Page />)` | | End to end | Playwright (`@playwright/test`) | The built app, served by `lumiose preview` | Vitest uses your Vite config, so `lumiose()` compiles `.stsx` and `.tsx` test subjects the same way as the app. Two things matter for component tests: - **A DOM environment.** Use `happy-dom` (or `jsdom`), per file with `// @vitest-environment happy-dom` or for all tests with `test.environment`. - **The browser build of `lumiose`.** `lumiose` has a browser entry (signals, the DOM renderer) and a `node` entry (server rendering, where signals are plain values and effects never run). Vitest resolves Node conditions, but `lumiose()` gives tests in a DOM environment the browser entry of `lumiose`, so rendered components update. Tests without a DOM environment keep the `node` entry. **Testing Library.** `render()` builds real DOM nodes, so `@testing-library/dom` works as it is: query by role, label or text (`screen.getByRole("button", { name: "Add" })`), and drive the page with `@testing-library/user-event`, which types and clicks like a user. Render into a container appended to `document.body` (delegated handlers such as `onClick` listen at `document`), and call the disposer `render()` returns after each test. `await user.click(…)` returns after the handler's updates are applied: a write inside an event handler (`onX` or `on:x`) is flushed when the handler ends, and so is one in a listener of `el.dispatchEvent(new CustomEvent(…))`. For matchers such as `toBeDisabled()`, add `@testing-library/jest-dom`. Updates outside handlers are batched: after a write in the test itself (`count.set(3)`), call `flush()` before asserting, or use a `findBy…` query, which retries until the element appears. **Server rendering** is tested with `renderToString(() => <Page />)` under the `node` entry: a test file without a DOM environment, in the same project. **End to end.** Playwright drives a browser against the production build: its `webServer` option runs `lumiose build` and `lumiose preview`, and tests visit pages, click and assert. Islands replay clicks and input that happen before they hydrate, so tests need no wait for hydration. Keep these tests to the flows that need a real browser and a server: navigation, forms that post to actions, and the islands on them. **Scoped state is fresh in each test.** `lumiose()` adds a Vitest setup file that resets `defineState` instances before each test, so one test's cart never leaks into the next. In server tests, a state's accessor called outside a render (to seed or inspect it) uses one instance per test, which `renderToString` then shares. ## API | API | Use | | ------------------------------------------------------- | ----------------------------------------------------------------------- | | `render(() => <App />, container)` from `lumiose/dom` | Render into a DOM node; returns a disposer. | | `screen`, `within(element)` from `@testing-library/dom` | Queries: `getBy…` (now), `findBy…` (waits), `queryBy…` (may be absent). | | `userEvent.setup()` from `@testing-library/user-event` | `await user.click(el)`, `user.type(el, text)`, `user.keyboard(…)`. | | `flush()` | Run pending bindings and effects now. | | `renderToString(() => <App />)` | Server-render to a string (node entry). | | `playwright.config.ts`: `webServer`, `use.baseURL` | Build and serve the app for end-to-end tests. | ## Example ```ts // vitest.config.ts import lumiose from "@lumiose/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [lumiose()], test: { environment: "happy-dom", include: ["src/**/*.test.{ts,tsx}"] }, }); ``` A form component and its test: ```stsx check file=components/TodoForm.stsx // src/components/TodoForm.stsx import { signal } from "lumiose"; export function TodoForm(props: { onAdd?: (title: string) => void }) { const title = signal(""); const todos = signal<string[]>([]); return ( <form onSubmit={(event) => { event.preventDefault(); todos.update((list) => [...list, title()]); props.onAdd?.(title()); title.set(""); }} > <label> Title <input bind:value={title} /> </label> <button disabled={title() === ""}>Add</button> <ul> {todos().map((todo) => ( <li>{todo}</li> ))} </ul> </form> ); } ``` ```tsx check file=components/TodoForm.test.tsx // src/components/TodoForm.test.tsx import { screen, within } from "@testing-library/dom"; import userEvent from "@testing-library/user-event"; import { render } from "lumiose/dom"; import { afterEach, expect, it, vi } from "vitest"; import { TodoForm } from "./TodoForm.stsx"; let dispose: (() => void) | undefined; // Attached to document.body, where delegated handlers listen; disposed after each test. function mount(ui: () => JSX.Element) { const container = document.body.appendChild(document.createElement("div")); dispose = render(ui, container); } afterEach(() => { dispose?.(); document.body.replaceChildren(); }); it("adds a todo", async () => { const user = userEvent.setup(); const onAdd = vi.fn(); mount(() => <TodoForm onAdd={onAdd} />); const add = screen.getByRole<HTMLButtonElement>("button", { name: "Add" }); expect(add.disabled).toBe(true); await user.type(screen.getByLabelText("Title"), "Write tests"); expect(add.disabled).toBe(false); await user.click(add); expect(within(screen.getByRole("list")).getByText("Write tests")).toBeTruthy(); expect(onAdd).toHaveBeenCalledWith("Write tests"); expect(screen.getByLabelText<HTMLInputElement>("Title").value).toBe(""); }); ``` A test that writes a signal itself flushes before asserting: ```tsx check file=Counter.test.tsx // src/Counter.test.tsx import { flush } from "lumiose"; import { render } from "lumiose/dom"; import { expect, it } from "vitest"; import { Counter } from "./Counter.stsx"; it("counts clicks", () => { const container = document.body.appendChild(document.createElement("div")); const dispose = render(() => <Counter start={2} />, container); const button = container.querySelector("button")!; button.click(); flush(); expect(button.textContent).toContain("Clicked 3 times"); dispose(); container.remove(); }); ``` End to end with Playwright (`pnpm add -D @playwright/test`, then `npx playwright install chromium`): ```ts check // playwright.config.ts import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "e2e", use: { baseURL: "http://localhost:4173" }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], // Builds the app and serves the build, as production would. webServer: { command: "lumiose build && lumiose preview --port 4173", url: "http://localhost:4173", reuseExistingServer: !process.env.CI, }, }); ``` ```ts check // e2e/guestbook.spec.ts import { expect, test } from "@playwright/test"; test("signs the guestbook", async ({ page }) => { await page.goto("/guestbook"); await page.getByLabel("Name").fill("Ada"); await page.getByLabel("Message").fill("Hello"); await page.getByRole("button", { name: "Sign" }).click(); // The form posts to the page's action, which redirects back to the list. await expect(page.getByText("Ada: Hello")).toBeVisible(); }); ``` Run it with `npx playwright test`. Keep `e2e/` out of Vitest's `include`, as above. ## Common mistakes - **No DOM environment.** Without `happy-dom` (or `jsdom`) there is no `document`, and `lumiose` resolves to its `node` entry, which has no reactive graph: set the environment for component tests. - **Asserting right after a write in the test.** Effects and bindings run in a microtask; call `flush()` first, or query with `findBy…`. - **Rendering into a detached element.** Delegated handlers listen at `document`; append the container to `document.body`, or user-event clicks reach no handler. - **Not disposing.** Call the disposer returned by `render` so effects and listeners from one test do not leak into the next, and clear `document.body` so `screen` queries see one test's DOM. - **Testing server components in a DOM environment.** Async server components that use `request()`, `cookies()` or `*.server.ts` modules belong in server-side tests with `renderToString`, or in end-to-end tests. - **Running end-to-end tests against `lumiose dev`.** The dev server compiles on demand and sends no CSP; test the build with `lumiose preview`. - **Waiting for hydration in Playwright.** Islands replay early clicks and input; assert on what the page shows instead of waiting for a timeout. ## Related diagnostics - [LUM4003](../errors/LUM4003.md): server-only module in a client graph (also `renderToString` called with the browser entry). - [LUM4004](../errors/LUM4004.md): browser API at module scope. See also: [Getting started](../getting-started.md), [reference: `lumiose/dom`](../reference/dom.md), [reference: server](../reference/server.md). <!-- source: concepts/security.md --> # Security > A strict Content Security Policy, with nonces and hashes, from one option, and a cross-site check on every form action. ## Concept **Strict Content Security Policy.** A strict CSP lets the browser run only the scripts and apply only the styles the page meant to include, so injected markup cannot run code. **New projects start with it:** every `create-lumiose` template sets `csp: "strict"` in `vite.config.ts`. The framework default stays off, so a project set up by hand opts in. With `lumiose({ csp: "strict" })`: - **Pages rendered per request** (`server` routes, `spa` routes that reach the server) get a new random nonce for each response. Every inline `<script>` and `<style>` Lumiose writes carries it: the page's stylesheets that are inlined and the script after the linked ones (see [Styles](styles.md)), the island loader, the streamed `<Suspense>` swaps, the speculation rules, the View Transitions opt-in and module preloads. The response gets this header: ``` Content-Security-Policy: script-src 'nonce-…' 'strict-dynamic'; style-src 'self' 'nonce-…'; object-src 'none'; base-uri 'self'; frame-ancestors 'self' ``` `'strict-dynamic'` lets the scripts the page loads import their own modules (islands, SPA routes). Stylesheets from your own origin are allowed; inline styles need the nonce. `frame-ancestors 'self'` lets only your own pages frame them, so another site cannot overlay your forms (clickjacking). These responses also get `Cache-Control: private, no-store` unless the page sets its own: a nonce is only secret while no shared cache hands the same HTML to someone else. - **Prerendered pages** (`static` routes, the shells of `spa` pages) are files that every visitor gets as they are, so a nonce would be the same for everyone. Each one gets the policy as `<meta http-equiv="Content-Security-Policy">`, first in `<head>`, with the SHA-256 hash of each inline script and style that Lumiose or your JSX wrote (the same ones that get a nonce per request) and `'self'` for scripts and stylesheets of your origin. It works on any static host. Browsers ignore `frame-ancestors` in a `<meta>` policy, so it is left out there: to keep other sites from framing prerendered pages, send `X-Frame-Options: SAMEORIGIN` or a `Content-Security-Policy: frame-ancestors 'self'` header from your host or `lumiose({ headers })`. - **`style` props** render as rules of a nonce'd (or hashed) `<style>` that target a `data-l-v` attribute, instead of `style="…"` attributes, which a strict policy refuses. In the browser, styles are set through the CSSOM, which the policy allows. How the server writes them: - **One rule per distinct style.** Elements with the same declarations share a rule within a response, so a long list whose items repeat a few style values writes a few rules, not one per item. - **Precedence as an inline style.** A rule reads `[data-l-v="3"]:not(#a#b){…}`. `:not(#a#b)` matches every element (none has two ids) and weighs two ids, so the rule wins over your components' scoped rules, `:where()` defaults and most page rules, as an inline style does. Like an inline style, it gives way to `!important` rules, CSS animations, and the styles an island sets once it hydrates. Only a selector with two ids or more can win over it where it would not win over an inline style. - **Hydration.** An island sets every style of its elements through the CSSOM as it hydrates, and they drop their `data-l-v`: a style property the island removes later is unset, as without CSP, instead of falling back to the server's rule. - **Isolation.** A value cannot close its rule or the `<style>` element, and a comment, string, `url(` or bracket it leaves open is closed at its end, so it cannot reach other elements' rules. A value with a `url(` right after an identifier character (`éurl(`, `#url(`), which browsers may read differently, gets a `<style>` of its own. - **Your own inline scripts and styles** written in JSX get the nonce automatically: a `<script innerHTML={…}>` in the document, a JSON-LD block, an SVG `<style>`. An explicit `nonce` prop wins. Strings rendered as text are escaped and never become elements, and raw HTML strings (`innerHTML` of another element) never get a nonce, nor a hash on prerendered pages: a script injected through such a string is blocked either way. - **Static `style="…"` strings** (in JSX, and in `.md`/`.html` imports) are applied like `style` props: a nonce'd rule on the server, the CSSOM in the browser. - **`cspNonce()`** returns the response's nonce during a server render (the document included) and in middleware, and `undefined` without strict CSP or while prerendering. Use it for markup you build yourself: a third-party snippet as a string, a custom `Content-Security-Policy` header, a script you create at runtime. In the browser it reads `<meta property="csp-nonce">`. - **Your own policy wins.** A `Content-Security-Policy` header from `lumiose({ headers })` for the path, or one set with `response().setHeader()` or by middleware, replaces Lumiose's; with `headers`, the path's prerendered pages get no `<meta>` policy either. `lumiose dev` puts nonces on the page but sends no policy, because Vite injects styles of its own for hot updates. Elements that the client creates later (a `<style>` inside an `<svg>` rendered after a client navigation) take the nonce from `<meta property="csp-nonce">`. A prerendered page has no nonce, so such an element is refused there unless it was in the prerendered HTML: put its rules in the file's `<style>` block. **Client navigation.** `navigate()` and the redirects of enhanced form actions only follow `http:` and `https:` URLs (relative ones included); a `javascript:` or `data:` location throws a `TypeError` instead of running in the page. Spread props drop lowercase `on*` keys (`{...{ onclick: "…" }}`): an inline handler attribute is a script sink, so handlers are only functions in `onClick`. They drop `innerHTML`, `textContent` and an iframe's `srcdoc` (in any case) too, on the server and the client: an object from JSON (a notification passed to a toast as it came) cannot inject markup, and only a written `innerHTML` or `srcdoc` prop sets HTML, next to a spread too (`<div {...rest} innerHTML={html} />`). > **Warning:** `<Dynamic component="div">` takes its props as written ones: it applies > `innerHTML`, `textContent` and `srcdoc` from them. Never spread an object from JSON, or any > other untrusted object, into `Dynamic`. **Unsafe URLs.** A dynamic value for a URL attribute (`href`, `src`, `action`, `formaction`, `xlink:href`, `poster`, `data`, …) whose scheme is `javascript:`, `vbscript:` or `data:text/html` renders as `about:blank#blocked`, on the server and in the browser, with a [LUM4009](../errors/LUM4009.md) warning in development. Case, leading spaces and control characters, and tabs or newlines inside the scheme do not get past it. String literals in your JSX are left as written. **Raw HTML.** `innerHTML={…}` and an iframe's `srcdoc={…}` render a string as HTML, as it is: a script in it runs, in a `srcdoc` frame with the page's origin and access to the page. Pass them only HTML you trust (your own, or sanitized). With `csp: "strict"` the browser refuses a script injected either way: neither gets a nonce, and a `srcdoc` frame inherits the page's policy (checked in Chromium, Firefox and WebKit). For HTML you do not control in a frame, add `sandbox` (without `allow-same-origin`), which gives the frame an origin of its own. **JSON in a `<script>`** (JSON-LD, an import map): write it as a text child. In a JSON-typed script (`application/ld+json` and other `…json` types, `importmap`, `speculationrules`), Lumiose writes every `<` as `<`. So a value such as `"</script><script>…"` can't close the element, and the JSON reads back the same: ```tsx export function ArticleData(props: { title: string; author: string }) { const data = { "@context": "https://schema.org", "@type": "Article", headline: props.title, author: { "@type": "Person", name: props.author }, }; return <script type="application/ld+json">{JSON.stringify(data)}</script>; } ``` The text of any `<script>` or `<style>` is raw text: it is written as it is, not HTML-escaped, except for the character after a `<` that could open a tag, an end tag or a comment (a letter, `!`, `/` or `?`), which Lumiose escapes: `<img` becomes `<\u0069mg` in a script and `<\69 mg` in a style, `</script` becomes `<\/script` and `<!--` becomes `<\!--`. So a value can't end the element, nor open a tag where the element ends up inside `<svg>` or `<math>` (a component whose root is a `<style>`), whose `<style>` and `<script>` text the browser parses as markup. The escapes read back as written in JavaScript strings, template literals, regular expressions and identifiers, and in CSS strings, identifiers and URLs. They don't in script code with `<` right before a keyword or `!` (`i<this.n`, `a<!b`: write `i < this.n`), in raw template strings (`String.raw`), for `<!` in a regular expression with the `u` or `v` flag, and for a CSS `<!--`. A `<style>` or `<script>` written inside `<svg>` or `<math>`, or under a root such as `<g>`, is an SVG or MathML element: its text is escaped as usual, and the parser decodes it. See [Head and metadata](head.md) for JSON-LD per page. **Error details.** In production server rendering, `<ErrorBoundary>` fallbacks receive a generic error with a short `digest`, and the original error is logged with the same digest ([ErrorBoundary](../reference/components.md#errorboundary)). **Redirects.** `redirect()` accepts paths and `http(s)` URLs only; a `javascript:` or `data:` location throws. For a user-supplied target (`?next=`), accept only same-origin paths, or the user lands wherever a link sends them: ```ts // A `next` parameter as a path of this site, else the fallback. function safeNext(next: string | null, fallback = "/"): string { if (next === null || !next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) { return fallback; } return next; } redirect(safeNext(url.searchParams.get("next"))); ``` Trailing-slash redirects (`lumiose({ trailingSlash })`) and the redirects of i18n (a missing translation with `missing: "redirect"`, locale detection) always stay on your site, also for a request path such as `//evil.com/` or `/es//evil.com`. `href()` refuses `.` and `..` as param values, which a browser would resolve into another route. **Files from elsewhere.** Icon sets, Markdown and HTML often come from outside the project. The compiler guards against what such files could carry: - **SVG imports** are untrusted input. They keep an allowlist of SVG elements and attributes: no scripts, handlers, `<foreignObject>` HTML, `javascript:` URLs or animations of links, and `<style>` text cannot open a tag. They load nothing from outside the file: external `href`s on `<use>`, `<image>` and `<feImage>`, `url()`s to other files and `@import` are dropped, so an icon set cannot track the people who view your pages ([SVG imports](svg.md)). - **Markdown and HTML imports** are trusted author files, compiled as written: raw HTML, inline handlers and, in `.html`, scripts are kept, and nothing sanitizes them at runtime. Review them like code, and never put end-user content in them ([Markdown and HTML imports](content.md)). - **`$` constants** in style blocks cannot end their declaration or the `<style>` element ([LUM3021](../errors/LUM3021.md)), so data in a constant cannot add CSS outside its scope. - **Frontmatter** keys such as `__proto__` stay plain data. - **Depth limits** turn absurdly nested files into errors instead of crashes or stalls: CSS blocks past 64 levels and parentheses past 16 ([LUM3014](../errors/LUM3014.md)), SVG elements past 128 (in an SVG's own CSS, blocks past 64 levels lose their content instead), Markdown and HTML past 512 ([LUM1009](../errors/LUM1009.md)). **Cross-site form posts.** Form actions and form-encoded posts to endpoints are checked against the `Origin` header (on by default, `lumiose({ csrf })`); see [Data and actions](data-and-actions.md). **Request URLs.** `lumiose dev`, `lumiose preview` and `@lumiose/adapter-node` build each request's URL from the server's origin (`ORIGIN`, else the socket's protocol and the `Host` header) plus the request target's path, appended as is: a request for `//attacker.test/reset` stays on your origin with that path, so `ctx.url`, `url()`, the cross-site check and `fetchJSON()`'s same-origin test never see another host. Targets that are not a path (`http://other/x`, or `*` other than with `OPTIONS`) and invalid `Host` headers get `400 Bad Request`. Behind a proxy that rewrites the host, set `ORIGIN` (or the adapter's `origin`) so a client cannot choose the origin through `Host`. **Request body limits.** Every request body is bounded (decision-29): URL-encoded forms, JSON and other bodies at 1 MB, `multipart/form-data` (uploads) at 10 MB. A larger request gets `413 Payload Too Large`, refused before reading when its `Content-Length` says so, else as soon as the bytes read pass the limit; nothing buffers an unbounded body. The limits hold for form actions (`ctx.formData()`), endpoints (`request.formData()`, `json()`, `text()`, `arrayBuffer()` read the bounded body) and middleware alike, in every deployment. The client's `Content-Type` picks the limit, except that `json()` and `text()` keep the 1 MB one: declaring `multipart/form-data` does not raise a JSON endpoint's limit. `formData()` and raw reads (`arrayBuffer()`, `request.body`, for a multipart parser of your own) of a multipart body get the upload limit. A `request.clone()` keeps these limits; a request re-wrapped with `new Request(request)` keeps the Content-Type's limit only, so its `json()` and `text()` read a body declared multipart up to the upload limit: call them on the request itself or a clone. Raise or lower the limits for the app with `lumiose({ limits: { body: "2mb", upload: "50mb" } })`, and for one route with `export const limits = { upload: "200mb" }` in its page or endpoint file. **Default response headers.** `X-Content-Type-Options: nosniff` (no MIME sniffing of uploads and JSON) and `Referrer-Policy: strict-origin-when-cross-origin` (no full URLs to other sites) go on every response; override them in `lumiose({ headers })`, or remove one with `""`. **Server-only code** (`*.server.*` files, `src/server/`) never reaches the browser: imports from client code fail `lumiose dev`/`lumiose build` (`LUM4003`), `lumiose dev` refuses to serve the files (`server.fs.deny`), and only `import.meta.env.PUBLIC_*` variables reach client code (`envPrefix`). See [Server](server.md). **What the page sends.** Island props, values awaited inside islands and the scoped state islands use (`defineState`) are serialized into the page as plain data (JSON in `<script type="application/json">`, escaped so it cannot end the element): anyone who loads the page can read them. Never pass a secret there; keep it in server-only modules. **Secrets.** `signedCookie()` needs a secret of at least 32 bytes in production (`LUMIOSE_SECRET`); a shorter one throws when the cookie is used (in development it warns). `{ allowWeakSecret: true }` opts out. **Compression.** `@lumiose/adapter-node` compresses responses; for pages that render a secret next to reflected input, see BREACH in the [adapter reference](../reference/adapter-node.md). ## API | Option / export | Meaning | | --------------------------------- | --------------------------------------------------------------------------- | | `lumiose({ csp: "strict" })` | Nonces per response, the policy header, hash policies for prerendered pages | | `cspNonce(): string \| undefined` | The response's nonce (from `lumiose`), for markup the app builds itself | | `<meta property="csp-nonce">` | Written by Lumiose with the nonce; the client's preload helper reads it | ## Example ```ts // vite.config.ts import lumiose from "@lumiose/vite"; export default { plugins: [lumiose({ csp: "strict" })] }; ``` ```tsx // src/document.tsx: an inline script that runs before the first paint import type { DocumentProps } from "lumiose"; const THEME = `document.documentElement.dataset.theme = localStorage.getItem("theme") ?? "auto"`; export default function Document(props: DocumentProps) { return ( <html lang="en"> <head> <meta charset="utf-8" /> <script innerHTML={THEME} /> {props.head} </head> <body>{props.body}</body> </html> ); } ``` On a server-rendered page Lumiose gives the script the response's nonce; on a prerendered page the page's policy lists the script's hash. ## Common mistakes - **An inline script built as a string** (`<div innerHTML={'<script>…</script>'}>`, a snippet added at runtime). Lumiose only nonces elements written in JSX: the browser refuses it and logs a CSP violation. Write it as a JSX `<script>`, add `nonce={cspNonce()}` to the markup you build, or load the code as a module. - **A `<style>` inside an `<svg>` that only the client renders, on a prerendered page.** No nonce exists there. Put the rules in the file's `<style>` block. - **Expecting the policy in `lumiose dev`.** Dev sends none; check with `lumiose build` and `lumiose preview` or your server. - **Adding `'unsafe-inline'` to make an error go away.** It disables the protection for every inline script. Find the element in the console message and give it the nonce instead. ## Related diagnostics A CSP violation shows in the browser console, with the refused element. For files: [LUM1009](../errors/LUM1009.md) (content nested too deeply), [LUM3014](../errors/LUM3014.md) (CSS nested too deeply), [LUM3021](../errors/LUM3021.md) (a `$` constant that would break out of its value). See also: [Head and document](head.md), [Styles](styles.md), [Rendering modes](rendering-modes.md), [reference: `@lumiose/vite`](../reference/vite.md), [reference: app APIs](../reference/app.md). <!-- source: guides/auth.md --> # Authentication with Better Auth > Accounts with Better Auth and Prisma: sign-up, sign-in with a password or an email code, sign-out, the session in middleware, protected pages and onboarding. Lumiose has no auth module of its own: [Better Auth](https://www.better-auth.com) runs on any server that hands it a web `Request`, and that is what endpoints, middleware and actions have. This guide stores accounts with [Prisma](https://www.prisma.io) on a SQLite file. Every piece of the password flow is in the [ssr-auth sample](https://github.com/itsjavi/lumiose/tree/main/examples/ssr-auth), with an e2e test; [sign-in by email code](#sign-in-by-email-code) builds on it. | File | Role | | --------------------------------------- | ------------------------------------------------------------------- | | `prisma/schema.prisma` | Better Auth's tables. | | `src/server/db.server.ts` | The Prisma client. Server-only. | | `src/server/auth.server.ts` | The Better Auth instance and helpers for pages and actions. | | `src/server/client-address.server.ts` | The client's address for Better Auth's rate limiter. | | `src/routes/api/auth/[...all]/route.ts` | Better Auth's HTTP API, for its browser client. | | `src/middleware.ts` | The session on `ctx.locals`. | | `src/routes/sign-in/page.stsx`, … | Form actions that post to Better Auth's handler, rate-limited. | | `src/islands/AccountMenu.stsx` | An island that reads the session with Better Auth's browser client. | ## Setup ```sh pnpm add better-auth @prisma/client @prisma/adapter-libsql @libsql/client pnpm add -D prisma ``` Prisma 7 talks to the database through a driver adapter; `@prisma/adapter-libsql` opens SQLite files with prebuilt binaries. For Postgres, swap it for `@prisma/adapter-pg` and the schema's `provider`. For a local Postgres without installing one or running Docker, `npx prisma dev` starts Prisma's local Postgres (`-d` keeps it in the background, `prisma dev stop` stops it) and prints its connection URLs: the `postgres://` one goes in `DATABASE_URL`. pnpm asks whether `prisma` and `@prisma/engines` may run install scripts: `allowBuilds` set to `false` for both works, since the CLI downloads its schema engine the first time it needs it (`prisma migrate`). `prisma.config.ts`, at the project root: ```ts import { defineConfig } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations" }, datasource: { url: process.env.DATABASE_URL ?? "file:./data/auth.db" }, }); ``` `prisma/schema.prisma` generates the client into `src/server/`, which makes it server-only, then lists Better Auth's tables (`npx auth generate` writes them from your Better Auth config): ```prisma generator client { provider = "prisma-client" output = "../src/server/generated/prisma" } datasource db { provider = "sqlite" } model User { id String @id name String email String emailVerified Boolean @default(false) image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] accounts Account[] @@unique([email]) @@map("user") } // Session, Account and Verification: see the sample's schema. ``` Add `src/server/generated/` to `.gitignore`. `prisma generate` reads `tsconfig.json`, which extends `.lumiose/tsconfig.generated.json`, so run `lumiose typegen` before it in a fresh checkout. The sample's scripts do: ```json { "scripts": { "dev": "lumiose typegen && prisma generate && prisma migrate deploy && lumiose dev", "build": "lumiose typegen && prisma generate && lumiose build", "typecheck": "lumiose typegen && prisma generate && lumiose check", "db:migrate": "prisma migrate deploy" } } ``` Create the first migration with `prisma migrate dev --name init` and commit `prisma/migrations`. | Variable | Value | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `BETTER_AUTH_SECRET` | Signs Better Auth's cookies and tokens. A random value of at least 32 bytes (`openssl rand -base64 32`); a dev-only one in `.env.development`. | | `BETTER_AUTH_URL` | The public URL (`https://example.com`), in production. Without it Better Auth takes the origin from each request, which suits dev. | | `DATABASE_URL` | `file:./data/auth.db` by default, relative to the working directory. | | `TRUSTED_PROXIES` | Behind a proxy: its address (`127.0.0.1` for nginx or Caddy on the same machine), so the rate limiter sees each client's address. See below. | ## Prisma as a server-only module ```ts // src/server/db.server.ts import { PrismaLibSql } from "@prisma/adapter-libsql"; import { env } from "lumiose"; import { PrismaClient } from "./generated/prisma/client.ts"; let client: PrismaClient | undefined; export function prisma(): PrismaClient { client ??= new PrismaClient({ adapter: new PrismaLibSql({ url: env("DATABASE_URL") ?? "file:./data/auth.db" }), }); return client; } ``` Files under `src/server/` and named `*.server.ts` are server-only: an island or any other module that reaches the browser and imports them, directly or through another module, fails `lumiose dev` and `lumiose build` with [LUM4003](../errors/LUM4003.md), and `lumiose check` reports it. The client is created on first use rather than at import, so `env()` reads the request's environment: in dev, that includes the `.env` files. ## The Better Auth instance ```ts // src/server/auth.server.ts import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { env, href, locals, redirect, url, type RequestContext } from "lumiose"; import { forwardedFor, parseTrustedProxies } from "./client-address.server.ts"; import { prisma } from "./db.server.ts"; function createAuth(trustedProxies: string[]) { return betterAuth({ secret: env("BETTER_AUTH_SECRET"), baseURL: env("BETTER_AUTH_URL"), database: prismaAdapter(prisma(), { provider: "sqlite" }), emailAndPassword: { enabled: true }, // The rate limiter (on in production) keys requests by client address. advanced: { ipAddress: { trustedProxies } }, }); } let instance: { auth: ReturnType<typeof createAuth>; trustedProxies: string[] } | undefined; function setup() { if (instance === undefined) { const trustedProxies = parseTrustedProxies(env("TRUSTED_PROXIES")); instance = { auth: createAuth(trustedProxies), trustedProxies }; } return instance; } export function auth(): ReturnType<typeof createAuth> { return setup().auth; } /** `{ user, session }` of a signed-in request. */ export type AuthSession = ReturnType<typeof createAuth>["$Infer"]["Session"]; declare module "lumiose" { interface Locals { /** Set by src/middleware.ts: the signed-in user and session, or `null`. */ session: AuthSession | null; } } /** The session of a page that needs one; without one, redirects to the sign-in page and back. */ export function requireSession(): AuthSession { const session = locals().session; if (session === null) { const { pathname, search } = url(); redirect(`${href("/sign-in")}?next=${encodeURIComponent(pathname + search)}`); } return session; } /** `next` when it is a path on this site, else `fallback`: never a redirect to another site. */ export function safeNext(next: string | null, fallback: string = href("/dashboard")): string { return next !== null && /^\/(?![/\\])/.test(next) ? next : fallback; } // authHandler(), authRequest(), authError() and redirectWithCookies(): below. ``` `auth()` creates the instance inside the first request, for the same reason as `prisma()`. It also keeps Better Auth from starting while static pages prerender, where there is no secret. ### Client addresses for the rate limiter In production, Better Auth limits requests per client address: 3 sign-in or sign-up attempts per 10 seconds, 100 requests per 10 seconds elsewhere. It reads the address from `X-Forwarded-For`, which a request handed to it from Lumiose does not carry by itself. The sample sets it from `ctx.platform.clientAddress`, the address the host saw: ```ts // src/server/client-address.server.ts /** `X-Forwarded-For` to hand to Better Auth, or `undefined` when the host knows no address. */ export function forwardedFor( headers: Headers, clientAddress: string | undefined, trustedProxies: readonly string[], ): string | undefined { // Without trusted proxies, the client's own X-Forwarded-For is ignored: it could pick any address. const chain = trustedProxies.length > 0 ? headers.get("x-forwarded-for") : null; const hops = [chain, clientAddress].filter((hop): hop is string => hop != null && hop !== ""); return hops.length === 0 ? undefined : hops.join(", "); } ``` ```ts // src/server/auth.server.ts, continued type AuthContext = Pick<RequestContext, "request" | "url" | "platform">; function clientHeaders(ctx: AuthContext, headers: Headers): Headers { const { trustedProxies } = setup(); const forwarded = forwardedFor(ctx.request.headers, ctx.platform.clientAddress, trustedProxies); if (forwarded === undefined) headers.delete("x-forwarded-for"); else headers.set("x-forwarded-for", forwarded); return headers; } /** Better Auth's HTTP API for a request, with the client's address for its rate limiter. */ export function authHandler(ctx: AuthContext): Promise<Response> { const headers = clientHeaders(ctx, new Headers(ctx.request.headers)); return auth().handler(new Request(ctx.request, { headers })); } ``` Behind a proxy, the address the host sees is the proxy's, and every visitor would share one bucket. Set `TRUSTED_PROXIES` to the proxy's address (or CIDR range, comma-separated): the proxy's `X-Forwarded-For` chain is then kept, and Better Auth, given the same list as `advanced.ipAddress.trustedProxies`, walks it from the right, skips the trusted hops and takes the client. On Cloudflare, `clientAddress` is already the visitor's (`CF-Connecting-IP`). ## The handler Better Auth's HTTP API (sessions, sign-in, sign-out and the rest) goes under `/api/auth/*`, where its browser client calls it. A catch-all endpoint passes the request through, with the client's address: ```ts // src/routes/api/auth/[...all]/route.ts import type { Endpoint } from "lumiose"; import { authHandler } from "../../../../server/auth.server.ts"; export const GET: Endpoint<"/api/auth/[...all]"> = (ctx) => authHandler(ctx); export const POST: Endpoint<"/api/auth/[...all]"> = (ctx) => authHandler(ctx); ``` The client posts JSON, which Lumiose's cross-site check leaves to the endpoint; Better Auth checks the `Origin` of those requests against `BETTER_AUTH_URL` (`403` otherwise). ## The session in middleware ```ts // src/middleware.ts import { getSessionCookie } from "better-auth/cookies"; import type { Middleware } from "lumiose"; import { auth } from "./server/auth.server.ts"; const middleware: Middleware = async (ctx, next) => { ctx.locals.session = null; // Without a session cookie the visitor is signed out: no database query. (This is also the case // while prerendering.) Better Auth's own routes read the session themselves. if (getSessionCookie(ctx.request) === null || ctx.url.pathname.startsWith("/api/auth/")) { return next(); } const { response: session, headers } = await auth().api.getSession({ headers: ctx.request.headers, returnHeaders: true, }); ctx.locals.session = session; const response = await next(); // Better Auth extends a session that passed its update age: send the renewed cookie along. const cookies = headers.getSetCookie(); if (cookies.length === 0) return response; const renewed = new Response(response.body, response); for (const cookie of cookies) renewed.headers.append("set-cookie", cookie); return renewed; }; export default middleware; ``` Every page, endpoint and action then reads `ctx.locals.session` (`locals().session` in a component). `getSessionCookie()` only checks that the cookie is there; `getSession()` verifies it against the database. ## Protected pages ```stsx // src/routes/dashboard/page.stsx import { href, response, Title } from "lumiose"; import { requireSession } from "../../server/auth.server.ts"; export default function Dashboard() { const { user, session } = requireSession(); response().setHeader("cache-control", "private, no-store"); return ( <main> <Title>Dashboard

Signed in as {user.name} ({user.email}).

Your session lasts until {session.expiresAt.toISOString().slice(0, 10)}.

); } ``` A visitor without a session gets a `302` to `/sign-in?next=%2Fdashboard`, and signing in brings them back. Protected pages render per request (`mode = "server"`, the sample's default): a `static` page cannot read the session ([LUM5001](../errors/LUM5001.md)). To guard a whole section instead, with its form actions and endpoints, add a `middleware.ts` to its folder that returns a redirect when `ctx.locals.session` is `null`: it runs after `src/middleware.ts` for every route below the folder ([Server](../concepts/server.md#concept)). ## Forms: sign-up, sign-in and sign-out The forms post to page actions, so they work without JavaScript, and Lumiose's cross-site check guards them like any form action. Sign-in and sign-up go on to Better Auth's HTTP handler, as its browser client would, so its rate limiter counts every attempt: it runs on HTTP requests only, and calls through `auth.api.signInEmail()` would skip it, leaving passwords open to guessing. ```ts // src/server/auth.server.ts, continued /** Posts `body` to a Better Auth endpoint (`/sign-in/email`) through its HTTP handler. */ export function authRequest( ctx: AuthContext, path: `/${string}`, body: unknown, ): Promise { const headers = new Headers({ "content-type": "application/json" }); for (const name of ["cookie", "origin", "referer", "user-agent"]) { const value = ctx.request.headers.get(name); if (value !== null) headers.set(name, value); } const request = new Request(new URL(`/api/auth${path}`, ctx.url), { method: "POST", headers: clientHeaders(ctx, headers), body: JSON.stringify(body), }); return auth().handler(request); } /** The message of a failed Better Auth response: "Invalid email or password", "Too many requests…". */ export async function authError(response: Response): Promise { const body = (await response.json().catch(() => null)) as { message?: unknown } | null; return typeof body?.message === "string" ? body.message : response.statusText; } /** A 303 redirect that sets the cookies of a Better Auth response. */ export function redirectWithCookies(location: string, headers: Headers): Response { const response = new Response(null, { status: 303, headers: { location } }); for (const cookie of headers.getSetCookie()) response.headers.append("set-cookie", cookie); return response; } ``` ```stsx // src/routes/sign-in/page.stsx import { actionData, fail, Title, type ActionContext, type ActionData } from "lumiose"; import { authError, authRequest, redirectWithCookies, safeNext } from "../../server/auth.server.ts"; export const action = async (ctx: ActionContext<"/sign-in">) => { const form = await ctx.formData(); const email = String(form.get("email") ?? "").trim(); const password = String(form.get("password") ?? ""); const response = await authRequest(ctx, "/sign-in/email", { email, password }); // A wrong password (401) or too many attempts (429): Better Auth's message. if (!response.ok) return fail(response.status, { email, message: await authError(response) }); return redirectWithCookies(safeNext(ctx.url.searchParams.get("next")), response.headers); }; export default function SignIn() { return ()} />; } function SignInForm(props: { failed: ActionData | undefined }) { return (
Sign in {/* No action attribute: the form posts to this URL, `?next=` included. */}

{props.failed?.message}

); } ``` - **Cookies.** Better Auth's response sets the session cookie; the action returns a redirect that carries its `Set-Cookie` lines. A plain `redirect()` would drop them, and the visitor would not be signed in. - **Errors.** A wrong password (`401`), a taken email (`422`), a short password (`400`) or too many attempts (`429`, "Too many requests. Please try again later.") come back as a status and a message, which `fail()` shows on the page. - **Sign-up** is the same with `authRequest(ctx, "/sign-up/email", { name, email, password })`, which signs the new user in. - **Sign-out** needs no limit, so its action calls the server API, on `/sign-out` so that a form on any page can post to it: ```ts export const action = async (ctx: ActionContext<"/sign-out">) => { if (ctx.locals.session === null) return redirectWithCookies(href("/"), new Headers()); const { headers } = await auth().api.signOut({ headers: ctx.request.headers, returnHeaders: true, }); return redirectWithCookies(href("/"), headers); // the headers clear the cookies }; ``` ## Better Auth's client in an island A prerendered page cannot know who is signed in, and an island can find out in the browser. Better Auth's browser client has no server imports, so islands can use it: ```ts // src/lib/auth-client.ts import { createAuthClient } from "better-auth/client"; export const authClient = createAuthClient(); // calls /api/auth/* on the page's origin ``` ```stsx // src/islands/AccountMenu.stsx (shortened), used as in the layout import { href, onMount, signal } from "lumiose"; import { authClient } from "../lib/auth-client.ts"; export function AccountMenu() { // `undefined` until the session has loaded: no links that may be wrong. const account = signal<{ name: string } | null | undefined>(undefined); onMount(() => // Called at once with the current value; the returned unsubscribe runs on unmount. authClient.useSession.subscribe(({ data, isPending }) => { if (!isPending) account.set(data === null ? null : { name: data.user.name }); }), ); async function signOut() { await authClient.signOut(); location.assign(href("/")); } return ( ); } ``` `authClient.useSession` is a nanostores atom: `subscribe` fetches the session and calls back on every change (after `authClient.signOut()` too), and `onMount` runs only in the browser and disposes the subscription with the island. Server-rendered pages can pass what they already know as props instead: `locals().session` is there during their render. With the client, the sample's island chunk is about 11 kB (brotli); an island that only needs the session can fetch `/api/auth/get-session` with `fetchJSON` instead. ## Sign-in by email code Instead of a password, the visitor enters their email and gets a one-time code, with Better Auth's [email OTP plugin](https://www.better-auth.com/docs/plugins/email-otp). The flow is two form actions, so it works without JavaScript: 1. `/sign-in` asks Better Auth for a code, stores the sign-in on the server and redirects to `/sign-in/code/`. The URL carries the id, never the email: URLs end up in the browser's history, in server and proxy logs and in analytics. 2. `/sign-in/code/[challenge]` finds the email by the id, signs in with the code and sets the session cookie. An unknown address gets a code too, and entering it creates the account: sign-up is the same flow. These pages replace the sample's sign-in and sign-up pages, and reuse `authRequest()`, `authError()`, `redirectWithCookies()` and `safeNext()` from above. ### The plugin ```ts // src/server/auth.server.ts import { emailOTP } from "better-auth/plugins"; import { sendMail } from "./mailer.server.ts"; function createAuth(trustedProxies: string[]) { return betterAuth({ // secret, baseURL, database and advanced as above; keep emailAndPassword for passwords too. user: { // Set by the onboarding form; until then, the guard below sends the account there. additionalFields: { onboardedAt: { type: "date", required: false, input: false } }, }, plugins: [ emailOTP({ expiresIn: 300, // seconds: the default allowedAttempts: 3, // wrong codes before the code is void: the default storeOTP: "hashed", // the database keeps a hash of the code async sendVerificationOTP({ email, otp, type }) { if (type !== "sign-in") return; // Not awaited: the response does not wait for the mail server (Better Auth's advice). sendMail({ to: email, subject: "Your sign-in code", text: `Your code is ${otp}. It expires in 5 minutes.`, }).catch((error: unknown) => console.error("The sign-in code was not sent", error)); }, }), ], }); } ``` Codes have 6 digits (`otpLength`). `input: false` keeps `onboardedAt` out of what a request may set. On a serverless host, which may stop work that outlives the response, see Better Auth's `advanced.backgroundTasks`. The schema gets the field (`npx auth generate` adds it) and a table of your own for the sign-ins in progress: ```prisma model User { // Better Auth's fields, as above, and: onboardedAt DateTime? } /// A sign-in in progress: the email a code went to, behind a random id. model SignInChallenge { id String @id email String expiresAt DateTime @@map("sign_in_challenge") } ``` ### A mailer for development ```ts // src/server/mailer.server.ts export interface Mail { to: string; subject: string; text: string; } export async function sendMail(mail: Mail): Promise { if (import.meta.env.DEV) { // Development: the server's console, and nowhere else. console.info(`\n[mail] to ${mail.to}: ${mail.subject}\n${mail.text}\n`); return; } // Production: your email provider's API, or SMTP. throw new Error("sendMail(): no mail provider is set up"); } ``` In `lumiose dev` the code shows in the terminal that runs it. **Never render a code on a page, in a response or in a URL, not even in development.** A code on the page proves nothing about the inbox, and a page that shows it in development is one wrong flag away from showing anyone the code for any address. `import.meta.env.DEV` is `false` in `lumiose build`, which drops the branch, and production without a provider fails loudly instead of skipping the mail. End-to-end tests read the code from the server's output, or from a test inbox of the mail provider. ### Asking for a code ```ts // src/server/sign-in-challenge.server.ts import { prisma } from "./db.server.ts"; /** As long as the code lives (`expiresIn`). */ const LIFETIME_MS = 5 * 60 * 1000; /** Stores a sign-in for `email`; returns its random id, which is all the URL carries. */ export async function createChallenge(email: string): Promise { const id = crypto.randomUUID(); const db = prisma(); await db.signInChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } }); await db.signInChallenge.create({ data: { id, email, expiresAt: new Date(Date.now() + LIFETIME_MS) }, }); return id; } /** The email of a sign-in that has not expired, or `undefined`. */ export async function challengeEmail(id: string): Promise { const challenge = await prisma().signInChallenge.findUnique({ where: { id } }); return challenge !== null && challenge.expiresAt > new Date() ? challenge.email : undefined; } export async function deleteChallenge(id: string): Promise { await prisma().signInChallenge.deleteMany({ where: { id } }); } /** Resolves once `Date.now()` reaches `deadline`. */ export function sleepUntil(deadline: number): Promise { return new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); } ``` ```stsx // src/routes/sign-in/page.stsx import { actionData, fail, href, redirect, Title, type ActionContext } from "lumiose"; import { authError, authRequest } from "../../server/auth.server.ts"; import { createChallenge, sleepUntil } from "../../server/sign-in-challenge.server.ts"; export const action = async (ctx: ActionContext<"/sign-in">) => { // Every answer takes this long, so the time Better Auth takes does not tell whether an // account has this email. const deadline = Date.now() + 1000; const form = await ctx.formData(); const email = String(form.get("email") ?? "") .trim() .toLowerCase(); const response = await authRequest(ctx, "/email-otp/send-verification-otp", { email, type: "sign-in", }); if (!response.ok) { // An invalid address (400) or too many requests (429): neither depends on an account. const message = await authError(response); await sleepUntil(deadline); return fail(response.status, { email, message }); } const challenge = await createChallenge(email); await sleepUntil(deadline); // The random id in the URL, never the email; `?next=` goes along. redirect(href("/sign-in/code/[challenge]", { challenge }) + ctx.url.search); }; export default function SignIn() { const failed = actionData(); return (
Sign in {/* No action attribute: the form posts to this URL, `?next=` included. */}

{failed?.message}

); } ``` - **The same answer.** With sign-up open, Better Auth sends a code to any address, so every valid address gets the redirect. With `emailOTP({ disableSignUp: true })`, an unknown address gets no mail and the same redirect. The errors left, an invalid address or too many requests, do not depend on an account either. - **The same time.** Every answer waits until a second has passed, so the time a user lookup or a database write takes does not tell either. Pick a delay above what the request takes in your setup. - **The id** has 122 random bits (`crypto.randomUUID()`), so it cannot be guessed. Whoever has the URL can try codes for its address, three per code, which is why the code page does not show the address. ### Signing in with the code ```stsx // src/routes/sign-in/code/[challenge]/page.stsx import { actionData, fail, href, Title, type ActionContext } from "lumiose"; import { authError, authRequest, redirectWithCookies, safeNext, } from "../../../../server/auth.server.ts"; import { challengeEmail, deleteChallenge } from "../../../../server/sign-in-challenge.server.ts"; export const action = async (ctx: ActionContext<"/sign-in/code/[challenge]">) => { const { challenge } = ctx.params; const email = await challengeEmail(challenge); if (email === undefined) return fail(410, { message: "The code has expired. Ask for a new one." }); const form = await ctx.formData(); const otp = String(form.get("code") ?? "").replace(/\s/g, ""); // Signs the account in, or creates it and signs it in: the response sets the session cookie. const response = await authRequest(ctx, "/sign-in/email-otp", { email, otp }); // A wrong or expired code (400), a fourth try (403), too many requests (429). if (!response.ok) return fail(response.status, { message: await authError(response) }); await deleteChallenge(challenge); return redirectWithCookies(safeNext(ctx.url.searchParams.get("next")), response.headers); }; export default function EnterCode() { const failed = actionData(); return (
Enter your code {/* Not the address: anyone with this URL could read it. */}

We sent a 6-digit code to the address you entered. It expires in 5 minutes.

{failed?.message}

Use another address, or get a new code
); } ``` - **Cookies.** Better Auth's response sets the session cookie, so the action returns `redirectWithCookies()`, as the password sign-in does. - **Errors.** A wrong code is "Invalid OTP" (`400`), an old one "OTP expired" (`400`). After three wrong codes the code is void: the next try gets "Too many attempts" (`403`), and the visitor asks for a new code. `autocomplete="one-time-code"` lets phones fill in the code from the mail. - **Cleanup.** The challenge goes once the sign-in succeeds; expired ones go with the next `createChallenge()`. ### Sign-up and onboarding Entering a code for an unknown address creates the account, with its email verified (the code proves it), an empty `name` and no `onboardedAt`. Middleware in a route group sends such accounts to onboarding, and visitors without a session to sign-in, for every page, action and endpoint in the group. A group folder adds no URL segment: `src/routes/(app)/dashboard/page.stsx` is `/dashboard` ([Server](../concepts/server.md#concept)). ```ts // src/routes/(app)/middleware.ts: every route in the (app) group import { href, type Middleware } from "lumiose"; const onboarded: Middleware = (ctx, next) => { const { session } = ctx.locals; // set by src/middleware.ts, which runs first const back = `?next=${encodeURIComponent(ctx.url.pathname + ctx.url.search)}`; if (session === null) return Response.redirect(new URL(href("/sign-in") + back, ctx.url), 303); if (!session.user.onboardedAt) { return Response.redirect(new URL(href("/onboarding") + back, ctx.url), 303); } return next(); }; export default onboarded; ``` ```stsx // src/routes/onboarding/page.stsx: outside the group, so the guard does not send it to itself import { actionData, fail, href, redirect, Title, type ActionContext } from "lumiose"; import { requireSession, safeNext } from "../../server/auth.server.ts"; import { prisma } from "../../server/db.server.ts"; export const action = async (ctx: ActionContext<"/onboarding">) => { const { session } = ctx.locals; if (session === null) redirect(href("/sign-in")); const form = await ctx.formData(); const name = String(form.get("name") ?? "").trim(); if (name === "") return fail(400, { name, message: "Enter your name." }); await prisma().user.update({ where: { id: session.user.id }, data: { name, onboardedAt: new Date() }, }); redirect(safeNext(ctx.url.searchParams.get("next"))); }; export default function Onboarding() { const { user } = requireSession(); const failed = actionData(); return (
Welcome

{failed?.message}

); } ``` After its code, a new account lands on `?next=` or `/dashboard`; the guard sends it to `/onboarding?next=%2Fdashboard`, and onboarding sends it back. `session.user.onboardedAt` is typed from `additionalFields`, and `getSession()` reads it from the database on each request. Keep Better Auth's `session.cookieCache` off (the default), or the guard sees the cached session, without `onboardedAt`, until the cache expires. ### Rate limits - **Per client.** The plugin allows 3 requests per 60 seconds per client address on `/email-otp/send-verification-otp` and on `/sign-in/email-otp`, in production (`emailOTP({ rateLimit: { window, max } })` changes both). The actions post through `authRequest()`, so the limiter counts them, keyed by the [client's address](#client-addresses-for-the-rate-limiter); a limited request gets `429`. - **Per code.** `allowedAttempts` caps the guesses: three tries at a 6-digit code. - **Per inbox.** Many client addresses can still send codes to one inbox. To cap that, count the email's recent challenges in the sign-in action and, past a limit, skip the request to Better Auth but answer the same. - **Several instances** need shared counts, as for passwords (see [Production](#production)). ## Production - Set `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` (the public `https://` URL: Better Auth then marks its cookies `Secure`) and `DATABASE_URL`, plus `ORIGIN` behind a proxy ([Deploy](../deploy/README.md)). - Apply migrations before starting the new build: `prisma migrate deploy` (the `prisma` CLI is a dev dependency, so run it where those are installed, such as CI or a release step). - Behind a proxy, set `TRUSTED_PROXIES` to its address, or the rate limiter puts every visitor in the proxy's bucket. The limiter is off in development (`lumiose dev`). - **Several instances.** The rate limiter keeps its counts in memory by default, per process: with several instances (a cluster, replicas behind a load balancer, serverless functions), each one counts on its own, and an attacker gets that many more attempts. Store them where every instance sees them, with Better Auth's `rateLimit: { storage: "database" }` (a `rateLimit` table: run `npx auth generate` and migrate) or `"secondary-storage"` (Redis or another store, through Better Auth's `secondaryStorage` option). - **Email codes.** Give `sendMail()` a mail provider (it throws without one), and set up SPF and DKIM for the sending domain, or the codes land in spam folders. ## Common mistakes - **Importing `auth.server.ts` or the Prisma client into an island.** `LUM4003`. Islands use the browser client, or call an endpoint. - **Calling `betterAuth()` at module scope with `env()` values.** In dev, `.env` values reach `env()` only inside a request, and a static page's prerender would start Better Auth without a secret. Create it on first use, as `auth()` does. - **Signing in with `auth.api.signInEmail()` in the action.** It works, but Better Auth's rate limiter never sees the attempt. Post to its handler, as `authRequest()` does. - **Redirecting with `redirect()` after sign-in.** The session cookie is in Better Auth's response headers; return a `Response` that sets them. - **Reading the session in a `static` page.** `locals()` throws `LUM5001` while prerendering; render the page per request, or read the session in an island. - **Showing the email code on the page in development.** Print it to the server's console only. - **Putting the email in the code page's URL** (`/sign-in/code?email=…`). Keep it on the server, behind a random id. - **Answering differently for an unknown address** ("No account with this email"), or faster. It tells anyone which addresses have accounts. - **`prisma generate` failing on a fresh checkout** with "File './.lumiose/tsconfig.generated.json' not found". Run `lumiose typegen` first. See also: [Server](../concepts/server.md), [Data and actions](../concepts/data-and-actions.md), [Islands](../concepts/islands.md), [Security](../concepts/security.md). # Icons with Lucide > Use Lucide, or any SVG icon set, as inline components compiled at build time, sized and coloured with CSS. [Lucide](https://lucide.dev) publishes every icon as an SVG file in the [`lucide-static`](https://www.npmjs.com/package/lucide-static) package. Import a file with `?component` and it becomes a Lumiose component, the same as an `.svg` file of your own (see [SVG imports](../concepts/svg.md)): the build optimises and sanitises it, and the icon renders as inline markup on the server, in the browser and in islands. Only the icons you import end up in the build. ## Install ```sh pnpm add -D lucide-static ``` A dev dependency is enough: the build inlines each imported icon, so neither the client nor the server build loads `lucide-static` at runtime. ## Use an icon The file name is the icon's name on lucide.dev, in kebab case: `icons/.svg`. ```tsx // src/components/Nav.stsx import House from "lucide-static/icons/house.svg?component"; import Search from "lucide-static/icons/search.svg?component"; export function Nav() { return ( ); } ``` `lumiose/env` types every `*.svg?component` import, from packages too: the props are those of ``, plus `title`. ## Size, colour and stroke Lucide's files are 24 × 24, drawn with `stroke="currentColor"` and `stroke-width="2"` on the root ``. Every one of these is a default that CSS or a prop replaces: - **Size.** Set `width` and `height` in CSS, as `.icon` does above, or pass both props (`width={20} height={20}`). Sizing in `em` makes an icon follow the text around it. - **Colour.** The stroke is `currentColor`, so the icon takes the `color` of its parent: the link's `rebeccapurple` above, and any `:hover` colour you give the link. - **Stroke.** `stroke-width` in CSS or as a prop (`stroke-width={1.5}`). The paths inherit it from the root. `class="icon"` receives the component's scope, so the scoped `.icon` rule applies to the icon's root ``. ## Labels An icon without `title`, `aria-label` or `aria-labelledby` is decorative and gets `aria-hidden="true"`, which is right next to visible text (the "Home" link). An icon-only control needs a name: `title="Search"` on the icon gives it a `` with `role="img"`, or label the control itself with ` ); } ``` The island renders on the server in the request's locale and runs again in the browser in the page's locale, so hydration finds the same text. Its chunk holds the two messages it calls, in the three locales, and none of the others: 1.8 kB with brotli in the sample, Paraglide's runtime included. ## i18next ### Setup ```sh pnpm add i18next ``` The sample keeps one JSON file per locale and namespace: `site` for the pages, `basket` for the island. `src/locales/en/basket.json`: ```json { "add": "Add a croissant", "count_one": "{{count}} croissant in your basket", "count_other": "{{count}} croissants in your basket" } ``` `src/locales/en/site.json` (shortened): ```json { "name": "Sunrise Bakery", "home": { "title": "Fresh bread every morning", "price": "A croissant costs {{price, currency(EUR)}}." } } ``` The options and the key types live in a module that the server and islands share: ```ts // src/lib/i18n.ts import i18next, { type TFunction } from "i18next"; import type basket from "../locales/en/basket.json"; import type site from "../locales/en/site.json"; export interface Namespaces { site: typeof site; basket: typeof basket; } declare module "i18next" { interface CustomTypeOptions { defaultNS: "site"; resources: Namespaces; } } export const options = { fallbackLng: "en", // Synchronous init: resources are inline, so `t` works right after init(). initAsync: false, // Lumiose escapes text when it renders it; escaping values here too would show `&`. interpolation: { escapeValue: false }, } as const; ``` With `CustomTypeOptions`, `lumiose check` reports a key that does not exist (`t("home.titel")`) and suggests the right one. ### A `t` per request The server has one instance with every locale's resources, created once and never switched to a language. Each request asks for a `t` fixed to its locale: ```ts // src/server/i18n.server.ts import i18next, { type TFunction } from "i18next"; import { locale } from "lumiose"; import { options, type Namespaces } from "../lib/i18n.ts"; import enBasket from "../locales/en/basket.json" with { type: "json" }; import enSite from "../locales/en/site.json" with { type: "json" }; // … es and fr const i18n = i18next.createInstance({ ...options, supportedLngs: ["en", "es", "fr"], ns: ["site", "basket"], defaultNS: "site", resources: { en: { site: enSite, basket: enBasket }, es: { site: esSite, basket: esBasket }, fr: { site: frSite, basket: frBasket }, }, }); void i18n.init(); /** `t` in the request's locale. */ export function getT(): TFunction<"site"> { return i18n.getFixedT(locale()); } /** One namespace in the request's locale, for an island's props. */ export function messages(ns: NS): Namespaces[NS] { return i18n.getResourceBundle(locale(), ns) as Namespaces[NS]; } ``` `getFixedT()` returns a function bound to a language without changing the instance, so two requests in different locales can render at the same time. `i18n.changeLanguage()` per request would not be safe: the instance is shared, and a request that awaits would continue in the language of whichever request changed it last. The module is server-only (`*.server.ts`): islands cannot import it ([LUM4003](../errors/LUM4003.md)), and the browser never downloads every locale. In a component, take a `t` and use it: ```stsx // src/routes/page.stsx import { Title } from "lumiose"; import { Basket } from "../islands/Basket.stsx"; import { getT, messages } from "../server/i18n.server.ts"; export const locales = "all"; export default function Home() { const t = getT(); return (
{t("name")}

{t("home.title")}

{t("home.price", { price: 1.2 })}

); } ``` `getT()` also works in middleware, actions and endpoints, which run in the request too (`locale()` is `ctx.locale` there). ### Islands receive their resources An island gets the namespace it needs, in the page's locale, as a prop (serialized into the page), and creates a small instance from it: ```ts // src/lib/i18n.ts (continued) export function islandT( locale: string, ns: NS, messages: Namespaces[NS], ): TFunction { const instance = i18next.createInstance({ ...options, lng: locale, ns: [ns], defaultNS: ns, resources: { [locale]: { [ns]: messages } }, }); void instance.init(); return instance.getFixedT(locale, ns); } ``` ```stsx // src/islands/Basket.stsx import { signal } from "lumiose"; import { locale } from "lumiose/router"; import { islandT, type Namespaces } from "../lib/i18n.ts"; export function Basket(props: { messages: Namespaces["basket"] }) { const t = islandT(locale(), "basket", props.messages); const count = signal(0); return (

{t("count", { count: count() })}

); } ``` The component runs once per island, so the instance is created once. The island's chunk carries i18next itself, about 12.5 kB with brotli in the sample, and no translations: those arrive as props, only the namespace this island uses and only in the page's locale. ## Search engines - **`hreflang` and canonical links** come from the locales a page declares: `/es/about` links to `/about` and itself, and not to a French page that does not exist. Each locale is canonical for itself. A `` the page renders with `` replaces Lumiose's. - **Absolute URLs.** Pages rendered per request use the request's origin (set `ORIGIN` behind a proxy, see [Deploy](../deploy/README.md)); prerendered pages use `lumiose({ site })`. Without `site`, a prerendered page's links are root-relative, which search engines ignore, and `lumiose build` warns. - **Translate the head too.** `` and `<Meta name="description">` take messages like any text. `<html lang>` follows the URL's locale. - **Translating some pages.** Declare `locales` per page or per layout. Choose `missing: "404"` when an untranslated URL should not exist, or `"redirect"` when visitors who guess or keep an old URL should land on the default locale's page; neither serves an untranslated duplicate. - **Detection** only redirects on `/` and first visits, with `Vary: Cookie, Accept-Language` and `Cache-Control: private, no-store`. Crawlers keep no cookies and usually send no `Accept-Language`, so they get each URL in its own locale and follow the `hreflang` links. Prerendered pages are files and never redirect. ## Testing Playwright's `locale` option sets the browser's `Accept-Language`, and the context's cookies show the detection cookie: ```ts test.describe("a first visit from a Spanish browser", () => { test.use({ locale: "es-ES" }); test("is redirected from / to /es", async ({ page }) => { await page.goto("/"); await expect(page).toHaveURL("/es"); const cookies = await page.context().cookies(); expect(cookies.find((cookie) => cookie.name === "locale")?.value).toBe("es"); }); }); ``` The samples' tests also check `<html lang>`, the `hreflang` and canonical URLs, the 404 or redirect of `/fr/about`, the switcher and its cookie, and the island's plural after a click. ## Common mistakes - **Changing a shared instance's language per request** (`i18next.changeLanguage()`, Paraglide's `setLocale()` on the server). Concurrent requests overwrite each other's locale. Use `getFixedT(locale())`, and `overwriteGetLocale(() => locale())` for Paraglide. - **Paraglide's own routing next to Lumiose's** (`url` strategy, `paraglideMiddleware()`, `localizeHref()`). Lumiose already does it; `href()` and `alternates()` build the links. - **`escapeValue: true` (i18next's default).** Lumiose escapes text, so an interpolated `&` would show as `&`. - **Importing `m` from `src/paraglide/messages.js` directly.** An island whose chunk never loads `src/lib/i18n.ts` keeps Paraglide's own `getLocale()`, which knows nothing of the URL's locale. Import messages from the module that calls `overwriteGetLocale()`. - **All locales in an island.** Importing every resource file into client code ships every translation. Pass the namespace in the page's locale as a prop, or let Paraglide tree-shake. - **A switcher that does not update the detection cookie**: the next visit to `/` goes back to the old locale. - **Linking an untranslated page in the current locale.** `href("/about")` on a French page is `/fr/about`, a 404; pick a locale from `alternates("/about")`. - **Hard-coded prefixes** (`"/es" + path`). They break for the default locale, which has none, and below a `base`; use `href(pattern, { locale })`. See also: [Internationalization](../concepts/i18n.md), [Islands](../concepts/islands.md), [Head and document](../concepts/head.md), [`lumiose/router`](../reference/router.md). <!-- source: guides/realtime.md --> # Realtime: server-sent events and WebSockets > Push live updates to the browser with server-sent events or WebSockets, with proxy configs and notes per platform. **Server-sent events** (SSE) are a streamed `GET` response: the server pushes, the browser's `EventSource` reads and reconnects by itself, and they pass through proxies and CDNs as plain HTTP. Use them when updates flow from the server to the page (feeds, notifications, progress). **WebSockets** carry messages both ways, and need a server that keeps connections open: `@lumiose/adapter-node` on a VPS, in Docker or on Coolify. Both are in the [ssr-realtime sample](https://github.com/itsjavi/lumiose/tree/main/examples/ssr-realtime), with unit and e2e tests. ## Server-sent events ### The stream An endpoint returns a `Response` whose body is a `ReadableStream` in the `text/event-stream` format. This helper writes the events, sends a comment every 15 s so proxies keep an idle connection open, and runs a cleanup exactly once when the stream ends: ```ts // src/server/event-stream.server.ts export type Send = (event: string, data: unknown, id?: string | number) => void; export function eventStream( request: Request, start: (send: Send, close: () => void) => () => void, options: { keepAliveMs?: number; retryMs?: number } = {}, ): Response { const encoder = new TextEncoder(); let stopped = false; let cleanup: (() => void) | undefined; let keepAlive: ReturnType<typeof setInterval> | undefined; let controller!: ReadableStreamDefaultController<Uint8Array>; const write = (text: string) => { if (!stopped) controller.enqueue(encoder.encode(text)); }; const stop = () => { if (stopped) return; stopped = true; clearInterval(keepAlive); request.signal.removeEventListener("abort", onAbort); cleanup?.(); }; const close = () => { if (stopped) return; stop(); controller.close(); }; const onAbort = () => close(); const body = new ReadableStream<Uint8Array>({ start(streamController) { controller = streamController; write(`retry: ${options.retryMs ?? 3000}\n\n`); // the browser's reconnection delay keepAlive = setInterval(() => write(": keep-alive\n\n"), options.keepAliveMs ?? 15_000); const send: Send = (event, data, id) => { const lines = [`event: ${event}`]; if (id !== undefined) lines.push(`id: ${id}`); lines.push(`data: ${JSON.stringify(data)}`); // JSON has no raw newlines: one data line write(`${lines.join("\n")}\n\n`); }; cleanup = start(send, close); if (stopped) cleanup(); // closed by `start` itself else if (request.signal.aborted) close(); else request.signal.addEventListener("abort", onAbort, { once: true }); }, cancel() { stop(); }, }); return new Response(body, { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache" }, }); } ``` **Disconnects.** When the browser closes the connection (the tab closes, `EventSource.close()`), the adapter aborts `request.signal` and cancels the body; either one runs the cleanup. Without it, a closed tab would leave its subscription, timers and database listeners behind for good. ### The endpoint ```ts // src/routes/api/events/route.ts import type { Endpoint } from "lumiose"; import { subscribe } from "../../../server/bus.server.ts"; import { eventStream } from "../../../server/event-stream.server.ts"; export const mode = "server"; export const GET: Endpoint<"/api/events"> = ({ request }) => eventStream(request, (send) => // The unsubscribe is the stream's cleanup: it runs when the client goes away. subscribe((message) => send("message", message, message.id)), ); ``` `subscribe` is the sample's in-process bus: a `Set` of listeners that a form action publishes to (`publish(text)` calls each one). It reaches the clients of one process. With several (a cluster, several containers, serverless instances), publish through a shared channel instead: Redis pub/sub, Postgres `LISTEN`/`NOTIFY` or a hosted service. ### The island ```stsx // src/islands/LiveFeed.stsx (shortened), used as <LiveFeed client:load /> import { onCleanup, onMount, signal } from "lumiose"; import type { Message } from "../lib/messages.ts"; export function LiveFeed() { const messages = signal<Message[]>([]); const state = signal<"connecting" | "open" | "reconnecting">("connecting"); onMount(() => { const source = new EventSource("/api/events"); source.addEventListener("open", () => state.set("open")); // EventSource reconnects by itself, after the stream's `retry:` delay. source.addEventListener("error", () => state.set("reconnecting")); source.addEventListener("message", (event) => { const message = JSON.parse(event.data) as Message; messages.update((list) => [...list.slice(-49), message]); }); // Closing the source ends the request, and the server's cleanup unsubscribes. onCleanup(() => source.close()); }); return ( <ol> {messages().map((message) => ( <li key={message.id}>{message.text}</li> ))} </ol> ); } ``` `onMount` runs only in the browser, so the server render shows an empty list. The sample's island also has a form that posts to the page's action with `submit()`: the page does not reload, so the stream stays open. **After a reconnect**, the browser sends the last `id:` it received as the `Last-Event-ID` request header; an endpoint that keeps recent events can send the missed ones first. The sample does not. **Compression.** The Node adapter compresses `text/event-stream` like any text response and flushes the compressor whenever the stream pauses, so every event reaches the browser at once (checked with brotli and gzip). ## WebSockets Lumiose has no WebSocket API of its own. On Node, `createServer()` returns the `node:http` server, and a WebSocket library attaches to its `upgrade` event. With [`ws`](https://github.com/websockets/ws): ```ts // src/server/chat-socket.server.ts (shortened) import type { IncomingMessage } from "node:http"; import type { Duplex } from "node:stream"; import { WebSocket, WebSocketServer } from "ws"; const sockets = new WebSocketServer({ noServer: true, maxPayload: 2048 }); sockets.on("connection", (socket) => { socket.on("message", (data, isBinary) => { if (isBinary) return; const payload = JSON.stringify({ text: data.toString().slice(0, 500), at: new Date() }); for (const client of sockets.clients) { if (client.readyState === WebSocket.OPEN) client.send(payload); } }); }); /** Upgrades requests to /ws and returns true; returns false for any other path. */ export function handleUpgrade( request: IncomingMessage, socket: Duplex, head: Buffer, origin: string | undefined = process.env.ORIGIN, ): boolean { const { pathname } = new URL(request.url ?? "/", "http://localhost"); if (pathname !== "/ws") return false; // Browsers send the page's origin with the handshake, and the same-origin policy does not apply // to WebSockets: refuse other sites (cross-site WebSocket hijacking). if (!sameSite(request, origin)) { socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); return true; } sockets.handleUpgrade(request, socket, head, (ws) => sockets.emit("connection", ws, request)); return true; } function sameSite(request: IncomingMessage, origin: string | undefined): boolean { const from = request.headers.origin; if (from === undefined) return true; // not a browser try { const host = origin === undefined ? request.headers.host : new URL(origin).host; return new URL(from).host === host; } catch { return false; } } ``` One `server.ts` serves the app in dev and in production: [`createDevHandler()`](../reference/vite.md#createdevhandler) mounts `lumiose dev` in it (HMR and the error overlay included), and `@lumiose/adapter-node`'s `createHandler()` serves the build. The chat's upgrades attach to the same server in both: ```ts // server.ts: `node server.ts --dev` in dev, `node server.ts` after `lumiose build` import { createServer } from "node:http"; import { createHandler } from "@lumiose/adapter-node"; import { handleUpgrade } from "./src/server/chat-socket.server.ts"; const dev = process.argv.includes("--dev"); const server = createServer(); // @lumiose/vite is a dev dependency: production never loads it. const handler = dev ? await (await import("@lumiose/vite/dev")).createDevHandler({ server }) : await createHandler({ dir: new URL("dist", import.meta.url) }); server.on("request", handler); server.on("upgrade", (request, socket, head) => { if (handleUpgrade(request, socket, head)) return; // Not the chat's path. In dev, Vite's hot reload upgrades on this server too: leave it alone. if (!dev) socket.destroy(); }); server.listen(Number(process.env.PORT ?? 3000)); ``` In the browser, an island opens `new WebSocket(url)` in `onMount` (with `wss:` on an `https:` page) and closes it in `onCleanup`, like the `EventSource` above. **In dev**, run `node server.ts --dev` instead of `lumiose dev`: the WebSockets work as in production, with no Vite plugin. `lumiose dev` and `lumiose preview` do not run `server.ts`, so they have no WebSockets. **Signing in.** The browser sends its cookies with the handshake: read the session from `request.headers.cookie` before calling `handleUpgrade`, and refuse with `401` when it is missing. Upgrades bypass Lumiose's middleware, which runs for HTTP requests only. **Several processes** do not share `sockets.clients`: broadcast through a shared channel (Redis pub/sub, for example), as with SSE. On `SIGTERM`, close the sockets (`client.close(1001)`) before the server, as the sample's `server.ts` does. ## Behind a proxy **Caddy** passes WebSocket upgrades and flushes streamed responses with the [VPS guide's](../deploy/vps.md#caddy) `reverse_proxy` alone. **nginx** needs a location that passes the upgrade on, next to the [VPS guide's](../deploy/vps.md#nginx) `location /`, whose `proxy_buffering off` already sends each event at once: ```nginx location /ws { proxy_pass http://my_app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # nginx closes a connection that stays quiet for proxy_read_timeout (60 s by default). proxy_read_timeout 1h; } ``` An event stream stays open past nginx's 60 s `proxy_read_timeout` thanks to the keep-alive comments (checked for 75 s through nginx and Caddy). A WebSocket that may stay quiet longer needs the longer timeout above, or pings. ## Platforms | Target | Server-sent events | WebSockets | | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Node adapter: [VPS](../deploy/vps.md), [Docker](../deploy/docker.md), [Coolify](../deploy/coolify.md) | Tested (the sample's e2e). | Tested: `ws` on `createServer().server`'s `upgrade` event. | | [Cloudflare Workers](../deploy/cloudflare.md) | A Worker can stream a response. Requests may run in different isolates, so to reach every client, publish through a Durable Object. Not tested with Lumiose. | Workers accept WebSockets with `WebSocketPair`; a Durable Object holds them for a room (with the WebSocket Hibernation API, idle sockets do not keep it running). Bind it in `wrangler.jsonc` and reach it through `ctx.platform.env`, or route `/ws` to its own Worker. Not tested with Lumiose. | | [Vercel](../deploy/vercel.md), [Netlify](../deploy/netlify.md) | A streaming function holds the connection until its maximum duration, then `EventSource` reconnects. Instances share no memory: publish through a hosted channel. Not tested with Lumiose. | Serverless functions cannot keep a WebSocket open. Use a hosted realtime service (Ably, Pusher, PartyKit, Supabase Realtime, Liveblocks, …) and connect islands to it directly. | ## Common mistakes - **No cleanup on disconnect.** A stream that ignores `request.signal` and `cancel()` keeps its subscription and timers after the tab closes, for every visitor who ever came. - **Reloading the page to post.** A plain form post reloads the page and reopens the stream; post with `submit()` from the island. - **Opening the `EventSource` or `WebSocket` during render.** Islands also render on the server; open them in `onMount`. - **Accepting any `Origin` on the WebSocket handshake.** Another site's page could open a socket with your visitor's cookies. Compare the origin, as `handleUpgrade` does. - **Expecting `lumiose dev` or `lumiose preview` to serve WebSockets.** They do not run `server.ts`: run `node server.ts --dev`, or `node server.ts` after a build. - **Destroying unknown upgrades in dev.** Vite's hot reload upgrades on the same server; destroy only in production, or leave upgrades you do not handle alone. - **An in-memory bus with several processes.** Each process only reaches its own clients. See also: [Data and actions](../concepts/data-and-actions.md) (endpoints, `submit()`), [Islands](../concepts/islands.md), [`@lumiose/adapter-node`](../reference/adapter-node.md), [Deploy](../deploy/README.md). <!-- source: guides/stripe.md --> # Payments with Stripe Checkout > Start Stripe Checkout from a form action, fulfill orders in a `route.ts` endpoint that verifies Stripe's webhook signature, and show the result on a return page. Lumiose has no payments module: the `stripe` package runs in server code, and that is where form actions and endpoints run. This guide uses Stripe's hosted Checkout page, so the browser loads no Stripe script and the forms work without JavaScript. | File | Role | | ---------------------------------------- | ----------------------------------------------------------------------- | | `src/server/stripe.server.ts` | The Stripe client and the products on sale. Server-only. | | `src/routes/pricing/page.stsx` | A form whose action creates a Checkout Session and redirects to it. | | `src/routes/checkout/success/page.tsx` | The return page: what the customer sees after paying. | | `src/routes/api/stripe/webhook/route.ts` | Stripe's events, with the signature verified: fulfillment happens here. | > **Status:** written against Stripe's API reference and the `stripe` package 23 (its types check > every example on this page); not run against a Stripe account. ## Setup ```sh pnpm add stripe ``` The server needs two secrets, never `PUBLIC_*` variables: | Variable | Where it comes from | | ----------------------- | --------------------------------------------------------------------------------------------- | | `STRIPE_SECRET_KEY` | The dashboard's API keys (`sk_test_…` while testing). | | `STRIPE_WEBHOOK_SECRET` | The webhook endpoint's signing secret (`whsec_…`), or the one `stripe listen` prints locally. | The pages and the endpoint below render per request (`export const mode = "server"`), or the whole project does with `lumiose({ mode: "server" })`: a `static` endpoint can only answer `GET` ([LUM5005](../errors/LUM5005.md)), and a form action needs a server anyway. ## The client The client and the price list live in a `*.server.ts` module, so neither the secret key nor the prices can reach browser code ([LUM4003](../errors/LUM4003.md)). It is created on first use: `env()` reads the adapter's environment, which is only there during a request on some hosts. ```ts check file=server/stripe.server.ts // src/server/stripe.server.ts import { env } from "lumiose"; import Stripe from "stripe"; let client: Stripe | undefined; export function stripe(): Stripe { const key = env("STRIPE_SECRET_KEY"); if (key === undefined) throw new Error("STRIPE_SECRET_KEY is not set."); return (client ??= new Stripe(key)); } /** What the store sells: the form posts a plan name, never a price. */ export const PLANS = { starter: { name: "Starter", price: "price_starter_monthly" }, team: { name: "Team", price: "price_team_monthly" }, } as const; export type Plan = keyof typeof PLANS; export function isPlan(value: unknown): value is Plan { return typeof value === "string" && Object.hasOwn(PLANS, value); } ``` ## Checkout from a form action The form posts the plan to the page's `checkout` action. The action checks it against `PLANS`, creates a Checkout Session and redirects (303) to the URL Stripe returns. Stripe sends the customer back to `success_url`, with `{CHECKOUT_SESSION_ID}` replaced by the session's id, or to `cancel_url`. ```stsx check file=routes/pricing/page.stsx // src/routes/pricing/page.stsx import { Title, actionData, fail, redirect, type ActionContext } from "lumiose"; import { PLANS, isPlan, stripe } from "../../server/stripe.server.ts"; export const mode = "server"; export const actions = { async checkout(ctx: ActionContext<"/pricing">) { const plan = (await ctx.formData()).get("plan"); if (!isPlan(plan)) return fail(400, { error: "Pick a plan." }); const session = await stripe().checkout.sessions.create({ mode: "subscription", line_items: [{ price: PLANS[plan].price, quantity: 1 }], success_url: new URL("/checkout/success?session_id={CHECKOUT_SESSION_ID}", ctx.url).href, cancel_url: new URL("/pricing", ctx.url).href, // Ties the payment to your user in the webhook. client_reference_id: ctx.locals.userId ?? undefined, metadata: { plan }, }); if (session.url === null) return fail(502, { error: "Checkout is not available." }); redirect(session.url, 303); }, }; export default function Pricing() { return ( <main> <Title>Pricing

Pricing

{Object.entries(PLANS).map(([id, plan]) => ( ))}

{actionData()?.error}

); } ``` `ctx.locals.userId` is whatever your middleware puts on `locals` (here the `Locals` of the [Server](../concepts/server.md) page); pass `customer` or `customer_email` instead when you keep Stripe customers. `mode: "payment"` sells one-off items; `"subscription"` needs recurring prices. To post without a page load, put the form in an island with `onSubmit={submit}`: `submit()` follows the redirect to Stripe like the plain form. The form above needs no JavaScript at all. ## The return page The return page shows the result. It does not fulfill the order: the customer may close the tab before it loads, and anyone can open the URL. Fulfillment happens in the webhook. ```tsx check file=routes/checkout/success/page.tsx // src/routes/checkout/success/page.tsx import { Title, notFound, url } from "lumiose"; import { stripe } from "../../../server/stripe.server.ts"; export const mode = "server"; export default async function CheckoutSuccess() { const id = url().searchParams.get("session_id") ?? notFound(); const session = await stripe().checkout.sessions.retrieve(id); const paid = session.payment_status === "paid" || session.payment_status === "no_payment_required"; return (
{paid ? "Thank you" : "Payment pending"}

{paid ? "Thank you!" : "Your payment is being processed"}

A receipt is on its way to {session.customer_details?.email ?? "your email"}.

); } ``` ## Webhooks in an endpoint Stripe posts events to a `route.ts` endpoint. The signature covers the exact bytes of the body, so read it with `request.text()` (never parse and re-serialize it), and verify it with `constructEventAsync`, which uses the Web Crypto API and runs on every host Lumiose deploys to. Answer quickly with a 2xx: Stripe retries failed deliveries, and the same event may arrive twice, so make fulfillment idempotent. ```ts check file=routes/api/stripe/webhook/route.ts // src/routes/api/stripe/webhook/route.ts import { env, type Endpoint } from "lumiose"; import type Stripe from "stripe"; import { stripe } from "../../../../server/stripe.server.ts"; export const mode = "server"; export const POST: Endpoint<"/api/stripe/webhook"> = async ({ request, platform }) => { const signature = request.headers.get("stripe-signature"); const secret = env("STRIPE_WEBHOOK_SECRET"); if (signature === null || secret === undefined) return new Response(null, { status: 400 }); let event: Stripe.Event; try { event = await stripe().webhooks.constructEventAsync(await request.text(), signature, secret); } catch { return new Response("Invalid signature", { status: 400 }); } switch (event.type) { case "checkout.session.completed": case "checkout.session.async_payment_succeeded": { const session = event.data.object; if (session.payment_status !== "unpaid") { // Slow work (emails, provisioning) may finish after the response. platform.waitUntil(fulfill(event.id, session)); } break; } case "checkout.session.async_payment_failed": // Tell the customer the delayed payment failed. break; } return new Response(null, { status: 200 }); }; /** Grants what was bought, once per event id. */ async function fulfill(eventId: string, session: Stripe.Checkout.Session): Promise { // if (await db.events.seen(eventId)) return; // await db.subscriptions.activate(session.client_reference_id, session.metadata?.plan); void [eventId, session]; } ``` Lumiose's cross-site check covers page form posts and form-encoded endpoint requests; Stripe posts JSON, so the endpoint answers it and the signature is the check. Keep the endpoint out of any middleware that requires a session. In the dashboard, add the endpoint's URL (`https://example.com/api/stripe/webhook`) with the events it handles. Locally, the Stripe CLI forwards them and prints the signing secret for `STRIPE_WEBHOOK_SECRET`: ```sh stripe listen --forward-to localhost:5173/api/stripe/webhook stripe trigger checkout.session.completed ``` ## Production - Set `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET` on the host, with live keys for production and a webhook endpoint per environment (each has its own signing secret). - Behind a proxy, set `ORIGIN` so `ctx.url` (and with it `success_url` and `cancel_url`) has the public origin ([Deploy](../deploy/README.md)). - Hosted Checkout is a redirect, so a strict CSP (`lumiose({ csp: "strict" })`) needs no change. Stripe.js (embedded Checkout, Elements) is a third-party script: allow its origins in your own policy ([Security](../concepts/security.md)). ## Common mistakes - **Fulfilling on the return page.** It may never load, and anyone can open it. Fulfill in the webhook, and use the return page to show the status. - **Trusting a price from the form.** Post a plan or product id and look its price up on the server, as `PLANS` does. - **Parsing the webhook body before verifying it.** `request.json()` and `JSON.stringify` change the bytes, and the signature check fails. Pass `await request.text()`. - **A static webhook endpoint.** Without `mode = "server"` (here or for the project), a `POST` export is [LUM5005](../errors/LUM5005.md). - **Importing the Stripe client into an island.** The secret key would ship to the browser; `*.server.ts` makes it [LUM4003](../errors/LUM4003.md). - **Doing slow work before answering the webhook.** Stripe waits a few seconds, then retries. Answer first and hand the rest to `platform.waitUntil()`, or queue it. # Content from Sanity > Read Sanity content in async components and prerender it, preview drafts on server-rendered routes behind a secret, and rebuild the static pages when editors publish. [Sanity](https://www.sanity.io) keeps the content and its editing studio; Lumiose reads it with `@sanity/client`, which runs in server code. Published content is read at build time, so the pages are static files; drafts are read per request on a separate, server-rendered preview route. | File | Role | | --------------------------------------------- | ------------------------------------------------------------------------- | | `src/server/sanity.server.ts` | The Sanity clients, the queries and the preview cookie. Server-only. | | `src/components/Story.stsx` | A story, rendered the same way for published content and drafts. | | `src/routes/stories/page.stsx` | The list of stories, prerendered. | | `src/routes/stories/[slug]/page.stsx` | One page per published story, prerendered with `staticPaths()`. | | `src/routes/api/preview/route.ts` | Turns preview on (a signed cookie) when the secret matches. | | `src/routes/preview/stories/[slug]/page.stsx` | The draft of a story, rendered per request for previewers. | | `src/routes/api/sanity/webhook/route.ts` | Optional: Sanity's publish webhook, verified, then the host's build hook. | > **Status:** written against Sanity's documentation and the `@sanity/client` 8 and > `@sanity/webhook` 4 packages (their types check every example on this page); not run against a > Sanity project. ## Setup ```sh pnpm add @sanity/client pnpm add @sanity/webhook # only for the webhook endpoint ``` | Variable | Use | | ----------------------- | ------------------------------------------------------------------------------ | | `SANITY_PROJECT_ID` | The project id, from the Sanity dashboard. | | `SANITY_DATASET` | Usually `production`. | | `SANITY_READ_TOKEN` | A token with read access to drafts, for previews only. Never in browser code. | | `SANITY_PREVIEW_SECRET` | A random string the studio's preview link carries (`openssl rand -base64 32`). | | `LUMIOSE_SECRET` | Signs the preview cookie ([Server](../concepts/server.md)). | The pages here are `static` (the project default) except the preview route and the endpoints, which say `export const mode = "server"`. A site with no server at all can skip previews and the webhook endpoint, and point Sanity's webhook straight at the host's build hook ([below](#rebuilding-when-content-changes)). ## The clients and queries One client reads published content through Sanity's CDN. The preview client reads drafts with a token, without the CDN. Both live in a `*.server.ts` module, so the token never reaches browser code ([LUM4003](../errors/LUM4003.md)), and are created on first use, when `env()` has the host's variables. ```ts check file=server/sanity.server.ts // src/server/sanity.server.ts import { createClient, type SanityClient } from "@sanity/client"; import { env, signedCookie } from "lumiose"; /** Set by /api/preview; the preview route renders drafts only while it is valid. */ export const previewCookie = signedCookie("sanity-preview", { maxAge: 60 * 60 }); export interface Story { title: string; slug: string; summary: string; publishedAt: string | null; } let published: SanityClient | undefined; function required(name: string): string { const value = env(name); if (value === undefined) throw new Error(`${name} is not set.`); return value; } /** Published content, through the CDN. With `preview`, drafts, with the read token. */ function sanity(preview = false): SanityClient { published ??= createClient({ projectId: required("SANITY_PROJECT_ID"), dataset: required("SANITY_DATASET"), apiVersion: "2026-10-01", useCdn: true, perspective: "published", }); return preview ? published.withConfig({ token: required("SANITY_READ_TOKEN"), useCdn: false, perspective: "drafts", }) : published; } const STORY = `{ title, "slug": slug.current, summary, publishedAt }`; export function listStories(): Promise { return sanity().fetch( `*[_type == "story" && defined(slug.current)] | order(publishedAt desc) ${STORY}`, ); } export function getStory(slug: string, preview = false): Promise { return sanity(preview).fetch(`*[_type == "story" && slug.current == $slug][0] ${STORY}`, { slug, }); } ``` Rich text (Portable Text) renders to HTML with a framework-agnostic library such as `@portabletext/to-html`, written with `innerHTML`; this guide keeps a plain `summary` field. ## Reading content Pages await their content in async components. `query()` caches each result for the request (or the build), so a layout and a page that need the same story share one fetch ([Async components](../concepts/async.md)). ```stsx check file=components/Story.stsx // src/components/Story.stsx import type { Story as StoryData } from "../server/sanity.server.ts"; export function Story({ story }: { story: StoryData }) { return (

{story.title}

{story.publishedAt && }

{story.summary}

); } ``` ```stsx check file=routes/stories/page.stsx // src/routes/stories/page.stsx: built once per deploy import { Title, href, query } from "lumiose"; import { listStories } from "../../server/sanity.server.ts"; export default async function Stories() { const stories = await query(["stories"], listStories); return (
Stories
); } ``` ```stsx check file=routes/stories/[slug]/page.stsx // src/routes/stories/[slug]/page.stsx: one file per published story import { Meta, Title, notFound, query, type PageProps } from "lumiose"; import { Story } from "../../../components/Story.stsx"; import { getStory, listStories } from "../../../server/sanity.server.ts"; export async function staticPaths() { return (await listStories()).map((story) => ({ params: { slug: story.slug } })); } export default async function StoryPage({ params }: PageProps<"/stories/[slug]">) { const story = (await query(["story", params.slug], () => getStory(params.slug))) ?? notFound(); return (
{story.title}
); } ``` ## Previewing drafts Drafts change on every keystroke and need a token, so they cannot be prerendered. A preview route renders them per request, for people who opened the studio's preview link. The link carries the secret; the endpoint checks it, sets a signed cookie and redirects to the preview page, which reads the cookie on every request. ```ts check file=routes/api/preview/route.ts // src/routes/api/preview/route.ts: GET /api/preview?secret=…&slug=… import { env, href, type Endpoint } from "lumiose"; import { previewCookie } from "../../../server/sanity.server.ts"; export const mode = "server"; export const GET: Endpoint<"/api/preview"> = async ({ url, cookies }) => { const secret = env("SANITY_PREVIEW_SECRET"); const slug = url.searchParams.get("slug"); if (secret === undefined || url.searchParams.get("secret") !== secret || slug === null) { return new Response("Invalid preview link", { status: 401 }); } await previewCookie.set(cookies, "1"); return Response.redirect(new URL(href("/preview/stories/[slug]", { slug }), url), 307); }; ``` ```stsx check file=routes/preview/stories/[slug]/page.stsx // src/routes/preview/stories/[slug]/page.stsx: the draft, rendered per request import { Meta, Title, cookies, notFound, query, response, type PageProps } from "lumiose"; import { Story } from "../../../../components/Story.stsx"; import { getStory, previewCookie } from "../../../../server/sanity.server.ts"; export const mode = "server"; export default async function StoryPreview({ params }: PageProps<"/preview/stories/[slug]">) { if ((await previewCookie.get(cookies())) !== "1") notFound(); response().setHeader("cache-control", "private, no-store"); const story = (await query(["story", params.slug, "drafts"], () => getStory(params.slug, true))) ?? notFound(); return (
{`Preview: ${story.title}`}

Preview of the latest draft.

); } ``` In the studio, point the preview link (the Presentation tool's preview URL, or a document action) at `https://example.com/api/preview?secret=&slug=`. To refresh the preview while an editor types, reload the page, or poll from an island that calls `invalidate()` on an interval. ## Rebuilding when content changes Published pages are files, so new content needs a new build. Most hosts give a build hook: a URL that starts a deploy when something posts to it (Netlify build hooks, Vercel deploy hooks, Cloudflare Pages deploy hooks, a CI workflow dispatch). **Without a server:** in Sanity's dashboard, create a webhook that posts to the build hook on create, update and delete, with the filter `_type == "story"`. No code runs in the app. **With a server:** point the webhook at an endpoint instead. It verifies Sanity's signature (set a secret on the webhook), so nobody else can trigger builds, keeps the build hook's URL off Sanity, and can skip changes that do not affect the site: ```ts check file=routes/api/sanity/webhook/route.ts // src/routes/api/sanity/webhook/route.ts import { SIGNATURE_HEADER_NAME, isValidSignature } from "@sanity/webhook"; import { env, type Endpoint } from "lumiose"; export const mode = "server"; export const POST: Endpoint<"/api/sanity/webhook"> = async ({ request, platform }) => { const secret = env("SANITY_WEBHOOK_SECRET"); const hook = env("BUILD_HOOK_URL"); const signature = request.headers.get(SIGNATURE_HEADER_NAME); const body = await request.text(); // the exact bytes Sanity signed if (secret === undefined || hook === undefined || signature === null) { return new Response(null, { status: 400 }); } if (!(await isValidSignature(body, signature, secret))) { return new Response("Invalid signature", { status: 401 }); } const { _type } = JSON.parse(body) as { _type?: string }; if (_type === "story") platform.waitUntil(fetch(hook, { method: "POST" })); return new Response(null, { status: 202 }); }; ``` The webhook posts JSON, which Lumiose's cross-site check leaves to the endpoint: the signature is the check. Set the webhook's projection to `{_type}` (or more) so the body carries what the endpoint reads. ## Common mistakes - **Reading drafts on a static page.** A prerendered page cannot read cookies ([LUM5001](../errors/LUM5001.md)) and would bake one moment's draft into a public file. Keep drafts on the `server` preview route. - **The read token in browser code.** It reads every draft; keep it in `*.server.ts`, and fetch in server components or endpoints. - **Expecting published edits to appear without a build.** The pages are files: trigger a build from the webhook, or make the route `server` and read through the CDN on every request. - **Verifying the webhook against re-serialized JSON.** Pass `await request.text()` to `isValidSignature`, then parse. - **A preview page without `noindex` and `no-store`.** Search engines and shared caches would keep a draft. The preview page sets both. # Tracing with OpenTelemetry > Trace requests, their phases and the browser's navigations with OpenTelemetry from `src/instrumentation.server.ts` and `src/instrumentation.client.ts`, and send them to OpenObserve, SigNoz or Datadog. Lumiose has no tracing of its own: its instrumentation files hand every phase of a request, and every client navigation, submission, fetch and hydration, to observers you write ([Server](../concepts/server.md#instrumentation), [Islands](../concepts/islands.md#concept)). This guide makes those observers OpenTelemetry spans and exports them over OTLP, which every backend below accepts. | File | Role | | ---------------------------------------- | ---------------------------------------------------------------------------- | | `src/instrumentation.server.ts` | Starts the Node SDK; a span per request and per phase; errors as spans. | | `src/instrumentation.client.ts` | A browser tracer; spans for navigations, submissions, fetches and hydration. | | `src/routes/api/otel/v1/traces/route.ts` | Relays the browser's spans to the backend with the server's credentials. | > **Status:** checked end to end on 2026-10-15 with a Lumiose app built with `lumiose build` and > served by `lumiose preview`, against OpenObserve and self-hosted SigNoz 0.129 (its last Docker Compose release) in Docker (server spans > nested by phase, failed requests, a continued `traceparent`, and browser navigation and > hydration spans through the relay). The Datadog setup follows Datadog's documentation; it was not > run. Packages: `@opentelemetry/api` 1.9, `@opentelemetry/sdk-node` and > `@opentelemetry/exporter-trace-otlp-http` 0.223, `@opentelemetry/sdk-trace-web` 2.12. ## Setup ```sh pnpm add @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-http pnpm add @opentelemetry/sdk-trace-web @opentelemetry/sdk-trace-base @opentelemetry/resources @opentelemetry/semantic-conventions ``` The Node SDK runs where the server runs on Node: `lumiose dev`, `lumiose preview`, `@lumiose/adapter-node` and the Docker and VPS targets. On Cloudflare Workers or Vercel's Edge runtime, start that platform's OpenTelemetry integration in `register()` instead; the wrappers below stay the same. ## Server spans `register()` starts the SDK once per server instance, before the first request. The `instrumentations` wrappers open a span per request (`SERVER` kind, named after the route pattern, as OpenTelemetry's HTTP conventions ask) and a child span per phase: each middleware file, the action, the endpoint, the render until the shell is ready, and each streamed boundary. The SDK's context manager makes each phase's span a child of the one around it, so a trace shows the request, then its middleware, then its render, then its streamed parts. A `traceparent` header from a proxy or another service continues its trace. `onRequestError` records unexpected errors with the phase and the `digest` that an `` fallback shows. ```ts check // src/instrumentation.server.ts import { ROOT_CONTEXT, SpanKind, SpanStatusCode, propagation, trace, type Span, type TextMapGetter, } from "@opentelemetry/api"; import type { RequestErrorInfo, ServerInstrumentation } from "lumiose"; export async function register() { const { NodeSDK } = await import("@opentelemetry/sdk-node"); const { OTLPTraceExporter } = await import("@opentelemetry/exporter-trace-otlp-http"); // The endpoint and headers come from the OTEL_EXPORTER_OTLP_* variables (see the backends). new NodeSDK({ serviceName: "my-app", traceExporter: new OTLPTraceExporter() }).start(); } const tracer = trace.getTracer("lumiose"); /** Reads a `traceparent` header, so a trace started by a proxy or another service continues. */ const headers: TextMapGetter = { get: (carrier, key) => carrier.get(key) ?? undefined, keys: (carrier) => [...carrier.keys()], }; type Result = { status: "success" } | { status: "error"; error: unknown }; /** Ends a phase's span: failed, with the exception, when the phase failed. */ function end(span: Span, result: Result): void { if (result.status === "error") { span.recordException(result.error instanceof Error ? result.error : String(result.error)); span.setStatus({ code: SpanStatusCode.ERROR }); } span.end(); } /** The endpoint that relays the browser's spans (below): no spans for the relay itself. */ const RELAY = "/api/otel/v1/traces"; /** A child span around one phase: `call()` runs it, inside the span. */ function phase( name: string, info: { route: string | undefined }, attributes: Record, call: () => Promise, ) { if (info.route === RELAY) return call(); return tracer.startActiveSpan(name, { attributes }, async (span) => end(span, await call())); } export const instrumentations: ServerInstrumentation[] = [ { request: (call, info) => { if (info.route === RELAY) return call(); const parent = propagation.extract(ROOT_CONTEXT, info.request.headers, headers); const method = info.request.method; const name = info.route === undefined ? method : `${method} ${info.route}`; const attributes = { "http.request.method": method, "url.path": new URL(info.request.url).pathname, ...(info.route === undefined ? {} : { "http.route": info.route }), }; return tracer.startActiveSpan( name, { kind: SpanKind.SERVER, attributes }, parent, async (span) => { const result = await call(); const status = result.status === "success" ? result.response?.status : undefined; if (status !== undefined) span.setAttribute("http.response.status_code", status); if (status !== undefined && status >= 500) span.setStatus({ code: SpanStatusCode.ERROR }); end(span, result); }, ); }, middleware: (call, info) => phase("middleware", info, { "code.file.path": info.file }, call), action: (call, info) => phase(`action ${info.name}`, info, {}, call), endpoint: (call, info) => phase(`${info.method} handler`, info, {}, call), render: (call, info) => phase("render", info, {}, call), stream: (call, info) => phase("stream", info, {}, call), }, ]; /** Unexpected errors, as spans of their own with the phase and the digest users see. */ export function onRequestError(error: unknown, info: RequestErrorInfo) { const span = tracer.startSpan(`error ${info.phase}`, { attributes: { "http.route": info.route ?? "", "lumiose.phase": info.phase, "lumiose.digest": info.digest ?? "", }, }); end(span, { status: "error", error }); } ``` ## Client spans `src/instrumentation.client.ts` runs on every page before islands hydrate and before the SPA router starts, so its tracer sees the first hydration. The `navigate` wrapper covers a client navigation of an SPA until the new page is on screen; `submit`, `fetch` and `hydrate` cover form submissions, `fetchJSON()` and `query()` requests, and each island. MPA pages have no client navigations: their page loads are the server's `request` spans. ```ts check // src/instrumentation.client.ts import { SpanStatusCode, trace, type Span } from "@opentelemetry/api"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { WebTracerProvider } from "@opentelemetry/sdk-trace-web"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import type { ClientErrorInfo, ClientInstrumentation } from "lumiose"; const provider = new WebTracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: "my-app-browser" }), // The app's own endpoint forwards the spans with the server's credentials (below). spanProcessors: [new BatchSpanProcessor(new OTLPTraceExporter({ url: "/api/otel/v1/traces" }))], }); provider.register(); const tracer = trace.getTracer("lumiose"); type Result = { status: "success" } | { status: "error"; error: unknown }; function end(span: Span, result: Result): void { if (result.status === "error") { span.recordException(result.error instanceof Error ? result.error : String(result.error)); span.setStatus({ code: SpanStatusCode.ERROR }); } span.end(); } function phase(name: string, attributes: Record, call: () => Promise) { return tracer.startActiveSpan(name, { attributes }, async (span) => end(span, await call())); } export const instrumentations: ClientInstrumentation[] = [ { // A client navigation of an SPA, until the new page is on screen. navigate: (call, info) => phase( `navigate ${info.to?.route ?? info.to?.url.pathname ?? ""}`, { "lumiose.navigation": info.type, "url.path": info.to?.url.pathname ?? "" }, call, ), submit: (call, info) => phase(`submit ${info.action}`, { "url.full": info.url }, call), fetch: (call, info) => phase( "fetch", "url" in info ? { "url.full": info.url } : { "lumiose.query": JSON.stringify(info.key) }, call, ), hydrate: (call, info) => phase(`hydrate ${info.island}`, {}, call), }, ]; export function onError(error: unknown, info: ClientErrorInfo) { const span = tracer.startSpan(`error ${info.phase}`, { attributes: { "lumiose.phase": info.phase, "lumiose.island": info.island ?? "" }, }); end(span, { status: "error", error }); } // Spans are batched: send the last ones when the page goes away. addEventListener("pagehide", () => void provider.forceFlush()); ``` The web SDK adds about 58 kB of JavaScript (16 kB brotli) to every page, since every page loads the file. The `@opentelemetry/api` calls in the wrappers do nothing until a provider is registered, so a site that only needs a sample can load the SDK with `import()` for some page loads and leave the rest without it. ## The relay endpoint Backends need credentials that must not ship to the browser, and most do not accept cross-origin requests. The browser exporter posts to the app instead, and this endpoint forwards the spans with the same `OTEL_EXPORTER_OTLP_*` settings the server uses. The server instrumentation above skips it, so relaying spans creates none. The body limit keeps it from relaying anything larger than a batch of spans. ```ts check file=routes/api/otel/v1/traces/route.ts // src/routes/api/otel/v1/traces/route.ts: the browser's spans, forwarded with the server's settings import { env, type Endpoint } from "lumiose"; export const mode = "server"; // Spans come in batches; anything larger is not a span batch. export const limits = { body: "256kb" }; /** `OTEL_EXPORTER_OTLP_HEADERS` (`key=value,…`, values URL-encoded), as OpenTelemetry reads it. */ function otlpHeaders(): Record { const pairs = (env("OTEL_EXPORTER_OTLP_HEADERS") ?? "") .split(",") .filter((pair) => pair.includes("=")); return Object.fromEntries( pairs.map((pair) => { const at = pair.indexOf("="); return [pair.slice(0, at).trim(), decodeURIComponent(pair.slice(at + 1).trim())]; }), ); } export const POST: Endpoint<"/api/otel/v1/traces"> = async ({ request }) => { const url = env("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") ?? `${env("OTEL_EXPORTER_OTLP_ENDPOINT") ?? "http://localhost:4318"}/v1/traces`; const response = await fetch(url, { method: "POST", headers: { ...otlpHeaders(), "content-type": request.headers.get("content-type") ?? "application/json", }, body: await request.arrayBuffer(), }); return new Response(null, { status: response.ok ? 204 : 502 }); }; ``` ## Backends The server exporter and the relay read OpenTelemetry's standard variables: | Variable | Use | | ------------------------------------ | --------------------------------------------------------------------------- | | `OTEL_EXPORTER_OTLP_ENDPOINT` | The backend's OTLP/HTTP base URL; traces go to `/v1/traces`. | | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | The full traces URL, when it is not `/v1/traces`. | | `OTEL_EXPORTER_OTLP_HEADERS` | `key=value` pairs, comma-separated, values URL-encoded (`%20` for a space). | | `OTEL_SERVICE_NAME` | Overrides `serviceName`. | ### OpenObserve OpenObserve takes OTLP/HTTP at `/api//v1/traces`, with HTTP Basic authentication (the user's email and password, or an ingestion token from the UI): ```sh OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://openobserve.example.com/api/default/v1/traces OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20Og==" ``` Locally, OpenObserve runs in one container; its UI is at `http://localhost:5080`: ```sh docker run -d -p 5080:5080 -e ZO_ROOT_USER_EMAIL=root@example.com -e ZO_ROOT_USER_PASSWORD=change-me \ public.ecr.aws/zinclabs/openobserve:latest ``` ### SigNoz Self-hosted SigNoz receives OTLP/HTTP on port 4318, without authentication: keep that port on a private network. ```sh OTEL_EXPORTER_OTLP_ENDPOINT=http://signoz-otel-collector:4318 ``` SigNoz Cloud takes the region's ingestion URL and the ingestion key as a header: ```sh OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest..signoz.cloud:443 OTEL_EXPORTER_OTLP_HEADERS=signoz-ingestion-key= ``` SigNoz installs with its `foundryctl` tool ([SigNoz docs](https://signoz.io/docs/install/docker/)); the UI is at `http://localhost:8080`. On a fresh install the collector starts receiving once the first user has signed up in the UI, which creates the organization. ### Datadog The Datadog Agent receives OTLP once its receiver is enabled, and forwards to Datadog with the Agent's API key, so the app needs no Datadog credentials. Enable the HTTP receiver on the Agent (`DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT=0.0.0.0:4318`, and expose the port), then point the app at it: ```sh OTEL_EXPORTER_OTLP_ENDPOINT=http://datadog-agent:4318 ``` The spans appear in Datadog APM under the service name. For the browser, Datadog's own RUM SDK started in `src/instrumentation.client.ts` is the usual choice; the relay above works too, through the Agent. ## Common mistakes - **Starting the SDK at module scope.** Start it in `register()`, which the server awaits before the first request; module code also runs while `lumiose build` loads the server. - **Exporting from the browser straight to the backend.** The credentials would ship in the bundle, and the backend must allow the page's origin. Relay through the app. - **High-cardinality span names.** Name spans after the route pattern (`GET /stories/[slug]`), not the URL; the URL goes in `url.path`. - **Expecting spans for static files.** The server serves them before the app, so no wrapper sees them; prerendered pages are static files. - **Changing the request in a wrapper.** Wrappers observe: they cannot change arguments, results or the response. Use middleware for that. # Error reporting with Sentry > Report server errors from `onRequestError` and browser errors from `onError` to Sentry, with source maps for the client and the server bundles. Sentry has no Lumiose SDK, and needs none: `@sentry/node` starts in `src/instrumentation.server.ts` and receives every unexpected error of a request, and `@sentry/browser` starts in `src/instrumentation.client.ts`, before any island hydrates, and receives the errors of islands, the SPA router and `submit()` ([Server](../concepts/server.md#instrumentation), [Islands](../concepts/islands.md#concept)). Sentry's Vite plugin uploads the source maps of both builds. | File | Role | | ------------------------------- | ---------------------------------------------------------------------- | | `vite.config.ts` | Hidden source maps, uploaded to Sentry after each build, then deleted. | | `src/instrumentation.server.ts` | Starts `@sentry/node`; reports request errors with their phase. | | `src/instrumentation.client.ts` | Starts `@sentry/browser`; reports island, router and submit errors. | > **Status:** checked on 2026-10-15 with a Lumiose app built with `lumiose build` and served by > `lumiose preview`, against a local stand-in for Sentry's ingest endpoint: a server render error > and an island error each arrived as one event with the tags below and debug IDs for the source > maps, and the build wrote hidden source maps for the client and the server. Not run against a > Sentry account (no upload of the maps). Packages: `@sentry/node` and `@sentry/browser` 10.76, > `@sentry/vite-plugin` 5.4. ## Setup ```sh pnpm add @sentry/node @sentry/browser pnpm add -D @sentry/vite-plugin ``` | Variable | Where | | ------------------------------ | ------------------------------------------------------------------------------------- | | `SENTRY_DSN` | The server, at run time. | | `PUBLIC_SENTRY_DSN` | The build, for the browser bundle (`import.meta.env` only sees `PUBLIC_*` variables). | | `SENTRY_AUTH_TOKEN` | The build, to upload source maps. A secret: never a `PUBLIC_*` variable. | | `SENTRY_ORG`, `SENTRY_PROJECT` | The build, with the token. | A DSN is not a secret (it only lets clients send events), so the same one may serve both sides. ## Source maps `build.sourcemap: "hidden"` writes a map next to every bundle of both builds without the `//# sourceMappingURL` comment, so browsers do not fetch them. The plugin, last in the list, runs after the client build and after the server build: it adds a debug ID to each bundle and its map, uploads the maps, and deletes them so they are not deployed. Without `SENTRY_AUTH_TOKEN` (on a laptop) it builds as usual, warns, and uploads nothing. ```ts check // vite.config.ts import lumiose from "@lumiose/vite"; import { sentryVitePlugin } from "@sentry/vite-plugin"; import { defineConfig } from "vite"; export default defineConfig({ // Source maps for Sentry only: "hidden" leaves out the //# sourceMappingURL comment. build: { sourcemap: "hidden" }, plugins: [ lumiose({ mode: "server" }), // Last: uploads the client's and the server's source maps after each build, then deletes them. sentryVitePlugin({ org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, authToken: process.env.SENTRY_AUTH_TOKEN, sourcemaps: { filesToDeleteAfterUpload: ["dist/**/*.map"] }, }), ], }); ``` ## Server errors `register()` starts the SDK once per server instance, before the first request. `onRequestError` receives what the request handler catches and Sentry's own Node integrations do not see: errors that render the `error` page or answer 500, errors of streamed `` boundaries, rejected `waitUntil()` work and errors an `` caught, each with the phase it happened in. `redirect()`, `notFound()` and `error(status)` are not reported. ```ts check // src/instrumentation.server.ts import * as Sentry from "@sentry/node"; import { env, type RequestErrorInfo } from "lumiose"; export function register() { Sentry.init({ dsn: env("SENTRY_DSN"), environment: env("SENTRY_ENVIRONMENT") ?? "production", // Errors only; tracing is in the OpenTelemetry guide. tracesSampleRate: 0, }); } export function onRequestError(error: unknown, info: RequestErrorInfo) { Sentry.captureException(error, { tags: { phase: info.phase, route: info.route ?? "(none)" }, extra: { url: info.request.url, method: info.request.method, digest: info.digest }, }); } ``` The `digest` is what an `` fallback shows in production: a user who quotes it leads you to the event. Sentry's Node SDK also sets up OpenTelemetry for its own tracing; to keep the [OpenTelemetry guide](opentelemetry.md)'s setup as well, follow Sentry's instructions for an existing OpenTelemetry setup (`skipOpenTelemetrySetup`). ## Browser errors The file's top-level code runs before any island hydrates, so the SDK is ready for hydration errors. `onError` receives what Lumiose catches: an island that fails to load or hydrate, or an event held until it hydrated that throws when replayed (`"hydrate"`), an async component or an `` (`"render"`), an effect or binding (`"effect"`), the SPA router (`"navigate"`) and `submit()` (`"submit"`), with the island's module and export. Sentry's own handlers still catch everything else on the page. ```ts check // src/instrumentation.client.ts import * as Sentry from "@sentry/browser"; import type { ClientErrorInfo } from "lumiose"; Sentry.init({ dsn: import.meta.env.PUBLIC_SENTRY_DSN, environment: import.meta.env.PROD ? "production" : "development", }); export function onError(error: unknown, info: ClientErrorInfo) { Sentry.captureException(error, { tags: { phase: info.phase, route: info.route ?? "(mpa)", island: info.island ?? "(none)" }, }); } ``` ## Production - On serverless hosts the function may stop before the SDK sends: call `ctx.platform.waitUntil(Sentry.flush(2000))` after reporting, or follow Sentry's guide for the platform. - With `lumiose({ csp: "strict" })`, the browser SDK posts events with `fetch`, which the strict policy does not restrict (it sets `script-src` and `style-src`, not `connect-src`). - Set `release` in both `init` calls, or let the plugin inject it from the Git commit, so events and source maps match. ## Common mistakes - **Starting the server SDK at module scope.** Start it in `register()`, which the server awaits before the first request. - **Reporting from middleware with `try`/`catch` around `next()`.** Render errors, streamed boundaries and endpoint 500s never reach it; `onRequestError` gets them. - **`SENTRY_DSN` in browser code.** It is undefined there; the browser bundle reads `import.meta.env.PUBLIC_SENTRY_DSN` at build time. - **Deploying the source maps.** Keep `filesToDeleteAfterUpload`, or anyone can read the original source. - **The plugin before `lumiose()`.** Put it last, so it sees the final bundles. # Product analytics with PostHog > Start PostHog in `src/instrumentation.client.ts`, count page views on MPA pages and SPA navigations (`afterNavigate`), send events from islands, and evaluate feature flags on the server. PostHog's browser SDK starts in `src/instrumentation.client.ts`, the one file every page loads, also pages without islands. A page view is a document load there, and an SPA's client navigation in `afterNavigate` ([`lumiose/router`](../reference/router.md#afternavigate)). Islands send events, and `posthog-node` evaluates feature flags while the server renders, so a page shows the right variant without a flicker. | File | Role | | ------------------------------- | ------------------------------------------------------------------------ | | `src/instrumentation.client.ts` | Loads and starts `posthog-js`; counts each document load as a page view. | | `src/components/PageViews.tsx` | In the outermost `spa` layout: counts client navigations. | | `src/components/PlanButton.tsx` | An island that sends an event. | | `src/server/posthog.server.ts` | The `posthog-node` client. Server-only. | | `src/routes/plans/page.stsx` | A server page that renders a feature flag's variant. | > **Status:** checked on 2026-10-15 with a Lumiose app built with `lumiose build` and served by > `lumiose preview`, against a local stand-in for PostHog's API: one `$pageview` per MPA page > load, per SPA document load and per SPA navigation, the island's event, and the server's flag > evaluation. Not run against a PostHog project. Packages: `posthog-js` 1.438, `posthog-node` 5.55. ## Setup ```sh pnpm add posthog-js posthog-node ``` | Variable | Where | | ----------------------------- | --------------------------------------------------------------------------- | | `PUBLIC_POSTHOG_KEY` | The build, for the browser (`phc_…`, the project API key: not a secret). | | `PUBLIC_POSTHOG_HOST` | The build: `https://us.i.posthog.com`, `https://eu.i.posthog.com` or yours. | | `POSTHOG_KEY`, `POSTHOG_HOST` | The server, for `posthog-node` (the same key). | ## Page views `posthog-js` is about 300 kB of JavaScript (85 kB brotli), so the file loads it with `import()` instead of a static import, which would hold back hydration on every page until it ran. Automatic page views are off: the file counts each document load (an MPA page, or the first page of an SPA) once. ```ts check // src/instrumentation.client.ts // posthog-js is large (about 85 kB brotli): load it without holding back hydration. void import("posthog-js").then(({ default: posthog }) => { posthog.init(import.meta.env.PUBLIC_POSTHOG_KEY, { api_host: import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com", // Page views are counted here and in , the same way on MPA and SPA pages. capture_pageview: false, person_profiles: "identified_only", }); // Every document load: an MPA page, or the first page of an SPA. posthog.capture("$pageview"); }); ``` An SPA navigates on the client, so no document loads: `afterNavigate` counts those navigations. Render this component once, in the outermost `spa` layout, where it stays mounted across navigations. Its `"enter"` call is the document load the file above already counted. ```tsx check file=components/PageViews.tsx // src/components/PageViews.tsx: in the outermost spa layout import posthog from "posthog-js"; import { afterNavigate } from "lumiose/router"; export function PageViews() { afterNavigate((navigation) => { // "enter" is the document load, which instrumentation.client.ts already counted. if (navigation.type === "enter" || navigation.to === null) return; posthog.capture("$pageview", { $current_url: navigation.to.url.href }); }); return null; } ``` ```stsx check file=routes/workspace/layout.stsx // src/routes/workspace/layout.stsx import type { LayoutProps } from "lumiose"; import { PageViews } from "../../components/PageViews.tsx"; export const mode = "spa"; export default function Workspace(props: LayoutProps<"/workspace">) { return (
{props.children}
); } ``` ## Events Islands import `posthog` and send events from handlers. They share the instance the instrumentation file starts, which is ready long before a person clicks. ```tsx check file=components/PlanButton.tsx // src/components/PlanButton.tsx: an island import posthog from "posthog-js"; export function PlanButton(props: { plan: string }) { return ( ); } ``` To tie events to your users, call `posthog.identify(userId)` in an island once the person is signed in, and `posthog.reset()` on sign-out. ## Feature flags A flag read in the browser arrives after the page shows, so the page would switch variants in front of the reader. Evaluate it on the server instead, while the page renders, with the same distinct id the browser uses (your user id once identified): ```ts check file=server/posthog.server.ts // src/server/posthog.server.ts import { env } from "lumiose"; import { PostHog } from "posthog-node"; let client: PostHog | undefined; export function posthog(): PostHog { const key = env("POSTHOG_KEY"); if (key === undefined) throw new Error("POSTHOG_KEY is not set."); return (client ??= new PostHog(key, { host: env("POSTHOG_HOST") ?? "https://us.i.posthog.com" })); } ``` ```stsx check file=routes/plans/page.stsx // src/routes/plans/page.stsx import { Title, locals } from "lumiose"; import { PlanButton } from "../../components/PlanButton.tsx"; import { posthog } from "../../server/posthog.server.ts"; export const mode = "server"; export default async function Pricing() { const user = locals().userId ?? "anonymous"; // Evaluated on the server, so the page renders the right variant without a flicker. const annual = await posthog().isFeatureEnabled("annual-pricing", user); return (
Plans

{annual ? "Pay yearly, save 20%" : "Plans"}

); } ``` `locals().userId` is whatever your middleware stores ([Server](../concepts/server.md)). The page is a `server` route, since a flag depends on the reader. `posthog-node` batches its own events (such as `$feature_flag_called`); on a serverless host, flush before the function ends with `ctx.platform.waitUntil(posthog().flush())`. ## Production - With `lumiose({ csp: "strict" })`, `posthog-js` is bundled with your code, so the policy allows it. Its extensions (surveys, session recording) load further scripts from PostHog's host, which a strict policy refuses: keep them off, or set your own policy for them ([Security](../concepts/security.md)). - Ad blockers often block PostHog's hosts: PostHog documents a reverse proxy, which in Lumiose can be middleware or an endpoint that forwards `/ingest/*` to PostHog. - Ask for consent where the law requires it before `posthog.init()` (or start with `opt_out_capturing_by_default: true` and call `posthog.opt_in_capturing()`). ## Common mistakes - **Leaving automatic page views on.** PostHog's history-based page views and the ones above would count SPA navigations twice; keep `capture_pageview: false`. - **`afterNavigate` in a page instead of the layout.** A page is replaced on navigation, so its hook would see only its own `"enter"`. Put the component in the outermost `spa` layout. - **A static `import posthog from "posthog-js"` in the instrumentation file.** Every page then waits for 300 kB of JavaScript before its islands hydrate. - **Reading flags in the browser for content above the fold.** Evaluate them on the server. - **The server key in browser code.** Only `PUBLIC_*` variables reach `import.meta.env`; keep `posthog-node` in `*.server.ts` modules. # Editors: VS Code, Neovim, Helix and Zed > Install the Lumiose VS Code extension, run the Lumiose language server in Neovim and Helix (file type, root markers, the server command), what works in Zed today, and how GitHub highlights `.stsx` files. Every editor runs the same Lumiose language server: TypeScript for `.ts`, `.tsx` and `.stsx` files, CSS features in ` ); } ``` ## Related - [JSX](../concepts/jsx.md): element props and `on:` events. - [JSX types](../reference/jsx.md#custom-elements): declaring custom elements. - [Islands](../concepts/islands.md): hydration and directives. # Deploy > Run a Lumiose build in production: what the build writes, the settings a server needs, and a guide for each hosting target. ## What to deploy `lumiose build` writes `dist/` ([Rendering modes](../concepts/rendering-modes.md)): - `dist/client`: the browser assets (content-hashed, under `assets/`), the files of `public/`, and the prerendered pages, with `.br` and `.gz` copies of every text file of 1 kB or more. - `dist/server/entry.js`: the request handler for server-rendered pages, endpoints and form actions. It bundles your server code and the Lumiose runtime; other npm packages stay imports, so production needs the app's `dependencies` installed. A site whose every route is prerendered builds no `dist/server`: any static host serves `dist/client`. Anything else needs a runtime for `dist/server/entry.js`. On Node, `@lumiose/adapter-node` serves both directories, from the `server.ts` that `create-lumiose --template ssr` writes ([reference](../reference/adapter-node.md)); the guides below run it on Docker, a VPS, Coolify, Fly.io, Render and Railway. `dist/server/entry.js` exports a web-standard `{ fetch }` ([the server build](../reference/server-entry.md)), which Bun and Deno serve as it is. For Cloudflare, Vercel and Netlify, a deploy adapter in the Vite config writes the platform's output after the build: `lumiose({ adapter: cloudflare() })`, `vercel()` or `netlify()`. Those platforms serve `dist/client` themselves and compress on their own, so the build then writes no `.br`/`.gz` copies. ## Production settings The Node adapter and the request handler read these environment variables: | Variable | Set it to | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NODE_ENV` | `production`. The adapter sets it when it is unset, before the build loads. | | `PORT` | The port to listen on. Default: `3000`. | | `HOST` | The address to listen on. Default: every address, which a container needs. Behind a proxy on the same machine, `127.0.0.1`. | | `ORIGIN` | The public origin, such as `https://example.com`, behind a proxy that terminates TLS or rewrites the host. Request URLs (`url()`, `ctx.url`) are built from it. | | `LUMIOSE_SECRET` | When the app signs cookies (`signedCookie()`): a random secret of at least 32 bytes (`openssl rand -base64 32`). Never commit it or bake it into an image. | ## Guides - [Docker](docker.md): A multi-stage Dockerfile: `node:24-alpine`, production dependencies only, a non-root user and a health check. - [VPS](vps.md): The Node adapter under systemd or pm2, behind Caddy or nginx, and what the adapter already does for compression and caching. - [Coolify](coolify.md): Deploy from the Dockerfile with Coolify's Dockerfile build pack. - [Cloudflare Workers](cloudflare.md): `@lumiose/adapter-cloudflare`: the Worker, static assets, bindings as `ctx.platform.env`. - [Vercel](vercel.md): `@lumiose/adapter-vercel`: the Build Output API, with a Node.js or Edge function. - [Netlify](netlify.md): `@lumiose/adapter-netlify`: the site and a Netlify Function. - [Bun](bun.md): `bun dist/server/entry.js`, static files included. - [Deno](deno.md): `deno serve dist/server/entry.js`, static files included. - [Fly.io](fly.md), [Render](render.md), [Railway](railway.md): The Node adapter on container and Node hosts. - [Static hosts](static.md): A fully prerendered site on Cloudflare, Netlify, GitHub Pages, S3 with CloudFront or any file server. - [AWS Lambda](aws-lambda.md): A handler from Lambda's events to the server build's `fetch`. ## Verification Each target's status says how far its guide has been checked: - **Verified on the platform**: deployed to the real service by hand, on the date given. - **Tested locally**: run on a development machine in the target's own runtime (a container, the platform's emulator), by the script named or by hand; not on the real service. - **Not tested**: written from the platform's documentation only. - **Planned**: no guide yet. | Target | Guide | Status | Checked | What was checked | | ------------------------ | ------------------------------------------- | -------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Docker | [Docker](docker.md) | Tested locally | 2026-10-12 | The Dockerfile on the page, on a `create-lumiose@0.2.0 --template ssr` project with packages from npm: builds, healthy, serves pages (brotli) and `immutable` assets, runs as `node`, stops in under a second. Docker 29.8, linux/arm64. The npm variant is not tested. | | Docker, bookshelf sample | [Docker](docker.md#the-bookshelf-sample) | Tested locally | 2026-10-12 | `examples/ssr-bookshelf/e2e/docker-smoke.ts` (local only, not in CI): health, a streamed page compressed on the fly, database queries, a sign-in form post, prerendered pages, `immutable` hashed assets served from their `.br` copies, the `node` user, no dev dependency in the image, a prompt stop. Docker 29.8, linux/arm64. | | VPS: systemd | [VPS](vps.md#systemd) | Tested locally | 2026-10-12 | The unit on the page ran the npm project under systemd 252 (Debian 12, in a privileged container): active, serving, stopped cleanly; `systemd-analyze verify` passes. Not on a VPS. | | VPS: pm2 | [VPS](vps.md#pm2) | Tested locally | 2026-10-12 | The ecosystem file on the page with pm2 6.0 (fork mode) in a container: serves, inherits the shell's variables, survives `pm2 save` and `pm2 resurrect`. `pm2 startup` and cluster mode are not tested. | | Caddy | [VPS](vps.md#caddy) | Tested locally | 2026-10-12 | The Caddyfile on the page (Caddy 2.11, `localhost` with Caddy's local CA instead of a public certificate) in front of the bookshelf container: HTTP to HTTPS redirect, HTTP/2, streamed chunks not held back, brotli and gzip passed through unchanged (with `encode` too), `immutable` assets, a form post and a cross-site `403`. | | nginx | [VPS](vps.md#nginx) | Tested locally | 2026-10-12 | The config on the page (nginx 1.31, a self-signed certificate in place of certbot's) in front of the bookshelf container: `nginx -t`, the same checks as Caddy (with `gzip on` too); streaming also with nginx's default `proxy_buffering on`. | | Coolify | [Coolify](coolify.md) | Not tested | | Not deployed to a Coolify instance yet. It builds the Dockerfile tested above; the steps follow Coolify v4's documentation. | | Cloudflare Workers | [Cloudflare](cloudflare.md) | Tested locally | 2026-10-13 | `examples/ssr-deploy`, `pnpm e2e:cloudflare` (local only, not in CI) in `wrangler@4.142.0 dev` (workerd): prerendered page and assets with `_headers` (defaults, `lumiose({ headers })`, `immutable` hashed assets, not for a `public/assets/` file), a streamed server page with a CSP nonce, endpoints with `ctx.platform` (a `vars` binding, `CF-Connecting-IP`), an npm package, a form post to a prerendered page and a cross-site `403`, the not-found page, `waitUntil`. `process.env` holds `vars` with `nodejs_compat`. | | Cloudflare, static site | [Static hosts](static.md#cloudflare) | Tested locally | 2026-10-12 | `examples/ssg-travel-journal`'s `dist/client` by hand in `wrangler@4.142.0 dev` (assets only, `not_found_handling: "404-page"`) and `wrangler pages dev`: pages, `_headers`, `404.html` with status 404. Both serve the `.br`/`.gz` copies as plain files, hence the advice to leave them out. | | Vercel, Node.js runtime | [Vercel](vercel.md) | Tested locally | 2026-10-12 | `pnpm e2e:vercel` (local only): `config.json` and `.vc-config.json` checked against the Build Output API v3, the function directory copied out of the repository (npm package traced in), then served by a Node.js harness that follows the routes and calls `handler(req, res)` as Vercel's launcher does: the same checks as Cloudflare. A harness, not Vercel's router or runtime. | | Vercel, Edge runtime | [Vercel](vercel.md#nodejs-or-edge) | Tested locally | 2026-10-12 | `pnpm e2e:vercel --edge`: the same validation and checks, with the bundled module (it imports only `node:async_hooks`) called in Node.js, not in Vercel's Edge runtime. | | Netlify | [Netlify](netlify.md) | Tested locally | 2026-10-12 | `pnpm e2e:netlify` (local only) with `netlify-cli@27.10.0 serve`, which runs the build and bundles both functions: the same checks as Cloudflare, except two the local server cannot show: it sends `max-age=0` for static files (the `immutable` rule is checked in `_headers`), and its invocations share no state, so `waitUntil` is not observed. | | Netlify, static site | [Static hosts](static.md#netlify) | Tested locally | 2026-10-12 | `examples/ssg-travel-journal`'s `dist/client` by hand with `netlify-cli@27.10.0 serve`: pages with and without the slash, `_headers`, `404.html` with status 404. | | Bun | [Bun](bun.md) | Tested locally | 2026-10-12 | `pnpm e2e:runtimes bun` (local only): `bun@1.4.2 dist/server/entry.js`, the same checks as Cloudflare (static files from `dist/client`, `ctx.platform.clientAddress` from Bun). The Dockerfile on the page is not tested. | | Deno | [Deno](deno.md) | Tested locally | 2026-10-12 | `pnpm e2e:runtimes deno` (local only): `deno@2.9.6 serve --allow-net --allow-read --allow-env`, the same checks. Deno Deploy is not tested. | | Fly.io | [Fly.io](fly.md) | Not tested | | Runs the Docker image tested above; `fly.toml` follows Fly's documentation. | | Render | [Render](render.md) | Not tested | | Settings from Render's documentation; the build and start commands are the Node adapter's, tested on a VPS. | | Railway | [Railway](railway.md) | Not tested | | Settings from Railway's documentation; the build and start commands are the Node adapter's. | | GitHub Pages | [Static hosts](static.md#github-pages) | Not tested | | The workflow follows GitHub's Pages actions. | | S3 and CloudFront | [Static hosts](static.md#s3-and-cloudfront) | Not tested | | The commands follow AWS's documentation. | | AWS Lambda | [AWS Lambda](aws-lambda.md) | Not tested | | Not run in Lambda. The handler on the page was called in Node.js 26 with Function URL (payload 2.0) events against the sample's build: an endpoint, a streamed page (buffered), a form post, the not-found page, a static file. | # Docker > A multi-stage Dockerfile for a Lumiose app: production dependencies only, a non-root user and a health check. This page is for an app with server routes and the `server.ts` that `create-lumiose --template ssr` writes (`@lumiose/adapter-node`'s `createServer()`). The same image runs on any host that runs containers: a [VPS](vps.md) with Docker, [Coolify](coolify.md), Kubernetes, a container platform. ## The Dockerfile Put it at the root of the project, next to `package.json` and `pnpm-lock.yaml`: ```dockerfile # syntax=docker/dockerfile:1 ARG NODE_VERSION=24 # Build: all dependencies, then `lumiose build`. Debian (glibc): the compiler ships native # binaries for Linux x64 and arm64 on glibc, and up to 0.2.0 for x64 only on musl (Alpine). FROM node:${NODE_VERSION}-slim AS build ENV PNPM_HOME=/pnpm CI=true RUN npm install --global pnpm@11 WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile COPY . . RUN pnpm build # Production dependencies only, on the runtime's base image (native packages match its C library). FROM node:${NODE_VERSION}-alpine AS prod-deps ENV PNPM_HOME=/pnpm CI=true RUN npm install --global pnpm@11 WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml* ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prod # Runtime: Node, the build output and production dependencies, as the unprivileged `node` user. FROM node:${NODE_VERSION}-alpine # tini passes `docker stop`'s SIGTERM on to Node, which ignores it as PID 1. RUN apk add --no-cache tini ENV NODE_ENV=production PORT=3000 HEALTHCHECK_PATH=/ WORKDIR /app COPY --from=prod-deps /app/node_modules node_modules COPY --from=build /app/dist dist COPY package.json server.ts ./ USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --start-interval=2s --retries=3 \ CMD ["node", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + process.env.HEALTHCHECK_PATH).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "server.ts"] ``` And a `.dockerignore`, so the build context leaves out what the image builds itself: ```text node_modules dist .lumiose .git .env* *.log ``` Build and run it: ```sh docker build -t my-app . docker run --rm -p 3000:3000 -e LUMIOSE_SECRET="$(openssl rand -base64 32)" my-app ``` `LUMIOSE_SECRET` is only needed when the app signs cookies (`signedCookie()`); pass every secret at run time like this, never as a build argument or an `ENV` line, which stay in the image. The other settings (`ORIGIN` behind a proxy, `PORT`, `HOST`) are in [Production settings](README.md#production-settings). ## How it works - **Three stages.** `build` installs everything and runs `lumiose build`. `prod-deps` installs only `dependencies`, so Vite, the compiler and the CLI never reach the image. The last stage copies `dist/`, those `node_modules` and `server.ts` onto a fresh `node:24-alpine`. The server build bundles your server code and the Lumiose runtime; other npm packages stay imports, which is why production dependencies are installed. - **Debian to build, Alpine to run.** `lumiose build` loads the compiler's native binary, published for Linux x64 and arm64 on glibc but only x64 on musl, so the build stage is `node:24-slim`, which builds on both architectures (an arm64 laptop or server included). The build output is JavaScript and static files, so it runs on Alpine. `prod-deps` uses the runtime's image so that a dependency with a native addon is built for musl. The release after 0.2.0 also publishes the arm64 musl binary (`@lumiose/compiler-linux-arm64-musl`); from that version on, the build stage can be `node:24-alpine` as well, and this Debian stage becomes optional. - **Non-root.** The app runs as `node` (uid 1000), and the files it runs are owned by root, so it cannot change them. A directory it writes to, such as a SQLite database's, must be created for it before `USER node` and mounted as a volume: `RUN mkdir /data && chown node:node /data`, then `docker run -v my-app-data:/data …`. - **Health check.** `HEALTHCHECK` fetches `HEALTHCHECK_PATH` (default `/`) with Node, since Alpine has no curl; any `2xx` answer is healthy. Point it at a cheap URL, such as a `GET` endpoint that returns `ok` without rendering a page: `docker run -e HEALTHCHECK_PATH=/healthz …`. `--start-interval` (Docker Engine 25 or later) checks every 2 s while the container starts. - **Signals.** Node as PID 1 ignores `SIGTERM`, so `docker stop` would wait 10 s and kill it. [tini](https://github.com/krallin/tini) passes the signal on, and Node exits at once. - **pnpm.** `npm install --global pnpm@11` installs pnpm; when `package.json` has a `packageManager` field (`pnpm@11.x.y`), pnpm switches to that version. The cache mounts keep the package store between builds. `pnpm-workspace.yaml*` copies the file when it exists (pnpm 11 keeps settings there). **With npm** instead of pnpm: drop the `npm install --global pnpm@11` lines, the `PNPM_HOME` variable and the cache mounts, copy `package-lock.json` instead of `pnpm-lock.yaml`, and run `npm ci`, `npm run build` and `npm ci --omit=dev`. This variant has not been tested. ## The bookshelf sample The repository's [bookshelf sample](https://github.com/itsjavi/lumiose/tree/main/examples/ssr-bookshelf) has a Dockerfile of the same shape, built from the monorepo instead of from npm: - The build context is the repository root (`docker build -f examples/ssr-bookshelf/Dockerfile .`). Its ignore file is `examples/ssr-bookshelf/Dockerfile.dockerignore`: BuildKit reads the ignore file named after the Dockerfile before the one at the context root. It allows only the workspace packages the sample uses. - A first stage compiles the compiler's native binary from the Rust sources, because the monorepo has no Linux binaries (npm installs them as `@lumiose/compiler-linux-*`). - pnpm installs with `--filter "@lumiose-examples/ssr-bookshelf..."`, and the production install with `--filter-prod`, which leaves out the workspace's dev dependencies. - The build seeds a throwaway database, because the genre pages are prerendered from it. The container seeds `/data/bookshelf.db` on its first start, and the health check calls `/api/me`. ```sh docker build -f examples/ssr-bookshelf/Dockerfile -t lumiose-bookshelf . docker run --rm -p 3000:3000 -e LUMIOSE_SECRET="$(openssl rand -base64 32)" \ -v bookshelf-data:/data lumiose-bookshelf ``` `node examples/ssr-bookshelf/e2e/docker-smoke.ts` builds the image and checks a running container: health, a server-rendered page compressed with brotli, a database query, a sign-in, prerendered pages, the `immutable` caching of hashed assets and their precompressed copies, the `node` user, no dev dependency in the image, and a prompt stop. It needs Docker, skips itself in CI, and is never run by CI: a cold build compiles the Rust compiler (about 1 minute on an 8-core arm64 machine). # VPS > Run a Lumiose app on your own server with the Node adapter under systemd or pm2, behind Caddy or nginx for TLS. This page covers an app with server routes and the `server.ts` that `create-lumiose --template ssr` writes. The examples call the app `my-app`, serve it as `example.com`, and keep it in `/srv/my-app`. To run it in Docker on the server instead, see [Docker](docker.md). ## Build and install The server needs Node 24 or later. Build on the server, or in CI and copy the output. The build loads the compiler's native binary, published for Linux x64 and arm64 on glibc and for x64 on musl (Alpine; arm64 too from the release after 0.2.0); on another platform, build in CI. ```sh pnpm install --frozen-lockfile pnpm build ``` To ship production dependencies only, build in CI (or with the [Docker](docker.md) image's `build` stage), copy `dist/`, `server.ts`, `package.json`, `pnpm-lock.yaml` and `pnpm-workspace.yaml` (if any) to `/srv/my-app`, and run `pnpm install --frozen-lockfile --prod` there. Then start it with `node server.ts`, which serves `dist/` on `PORT` (3000). ## What the adapter already does `createServer()` serves `dist/client` first, then the app ([`@lumiose/adapter-node`](../reference/adapter-node.md)): - **Compression.** Static files are sent from the `.br` and `.gz` copies that `lumiose build` writes next to every text file of 1 kB or more (brotli 11, gzip 9), at no CPU cost per request. Pages and endpoints with a text content type are compressed as they stream: brotli quality 4, else gzip 6. Every compressible response gets `Vary: Accept-Encoding`. - **Caching.** Content-hashed files under `assets/` (`loader-BzIcWyn5.js`) get `Cache-Control: public, max-age=31536000, immutable`; a new build changes their names. Every other file, prerendered pages included, gets `public, max-age=0, must-revalidate` with an `ETag` and `Last-Modified`, answered with `304` when unchanged. Pages rendered per request send what the app sets (`response().setHeader("cache-control", …)`). - **Headers.** `lumiose({ headers })` and the default `X-Content-Type-Options` and `Referrer-Policy` go on static files and pages alike. So the proxy only adds TLS, HTTP/2 and connection limits. Proxy every request to Node: a proxy that serves `dist/client` itself would have to repeat the caching rules and the headers above. With compression on in both, the proxy leaves the already encoded responses alone; or pass `compress: false` to `createServer()` and let the proxy compress. ## The process ### systemd Create a system user that owns nothing it runs: ```sh sudo useradd --system --home-dir /srv/my-app --shell /usr/sbin/nologin my-app ``` Put the secrets in `/etc/my-app.env`, one `KEY=value` per line, readable by root only (`sudo chmod 600 /etc/my-app.env`): ```sh LUMIOSE_SECRET=paste-the-output-of-openssl-rand-base64-32 ``` `/etc/systemd/system/my-app.service`: ```ini [Unit] Description=my-app (Lumiose) After=network-online.target Wants=network-online.target [Service] Type=simple User=my-app Group=my-app WorkingDirectory=/srv/my-app ExecStart=/usr/bin/node server.ts Environment=NODE_ENV=production HOST=127.0.0.1 PORT=3000 ORIGIN=https://example.com # Secrets as KEY=value lines (LUMIOSE_SECRET=…), readable by root only. EnvironmentFile=/etc/my-app.env Restart=on-failure RestartSec=2 # The app can read /srv/my-app and write only its state directory, /var/lib/my-app. StateDirectory=my-app NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true PrivateDevices=true [Install] WantedBy=multi-user.target ``` ```sh sudo systemctl daemon-reload sudo systemctl enable --now my-app journalctl -u my-app -f # logs sudo systemctl restart my-app # after deploying a new build ``` Set `ExecStart` to the path `command -v node` prints. `HOST=127.0.0.1` keeps the app off the public network: only the proxy reaches it. Point file databases and uploads at `/var/lib/my-app` (the `StateDirectory`): with `ProtectSystem=strict`, it and a private `/tmp` are the only places the app can write. ### pm2 `ecosystem.config.cjs`, next to `server.ts`: ```js module.exports = { apps: [ { name: "my-app", cwd: "/srv/my-app", script: "server.ts", // Node runs server.ts itself; pm2 6 would start .ts files with Bun. interpreter: "node", env: { NODE_ENV: "production", HOST: "127.0.0.1", PORT: "3000", ORIGIN: "https://example.com", }, }, ], }; ``` ```sh LUMIOSE_SECRET=… pm2 start ecosystem.config.cjs # the app inherits this shell's variables pm2 save && pm2 startup # restart it on boot pm2 logs my-app ``` `pm2 save` writes the process list with its environment, the secret included, to `~/.pm2/dump.pm2`, which `pm2 resurrect` (run at boot by the script `pm2 startup` installs) restores. This runs one process (pm2's fork mode); pm2's cluster mode has not been tested with Lumiose. ## The proxy Set `ORIGIN` to the public origin (`https://example.com`), as in the configs above. The app then builds request URLs (`url()`, `ctx.url`) with `https:` and your host, not the `http:` it receives from the proxy. Without `ORIGIN`, the URL comes from the `Host` header, which the proxy must pass through. The cross-site check of form posts compares the browser's `Origin` host with that URL's host. Behind a proxy, the app's `clientAddress` is the proxy's: read `X-Forwarded-For`, which both configs set, in middleware. ### Caddy `/etc/caddy/Caddyfile`: ```caddy example.com { reverse_proxy 127.0.0.1:3000 } ``` Caddy gets and renews the certificate, redirects HTTP to HTTPS, passes `Host` and sets `X-Forwarded-For`/`X-Forwarded-Proto`. It flushes streamed responses as they arrive, so `` chunks are not held back. It needs no `encode` directive: responses arrive compressed. ### nginx `/etc/nginx/sites-available/my-app` (linked into `sites-enabled`), with a certificate from certbot: ```nginx upstream my_app { server 127.0.0.1:3000; keepalive 16; } server { listen 80; listen [::]:80; server_name example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # The app accepts uploads up to 10 MB (lumiose({ limits })); nginx's default is 1 MB. client_max_body_size 10m; location / { proxy_pass http://my_app; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Send each streamed chunk (a boundary) to the browser as it arrives. proxy_buffering off; } } ``` `proxy_set_header Host $host` matters: nginx sends the upstream's name (`my_app`) as `Host` otherwise. `http2 on` needs nginx 1.25.1 or later; on older versions, write `listen 443 ssl http2`. `proxy_http_version 1.1` with an empty `Connection` header reuses connections to Node (`keepalive`). For WebSockets, add the `/ws` location from the [realtime guide](../guides/realtime.md#behind-a-proxy). `client_max_body_size` should match the largest body the app accepts ([Security](../concepts/security.md)), or nginx answers `413` first. # Coolify > Deploy to Coolify with its Dockerfile build pack: the port, a domain, secrets, the health check and a volume for data. [Coolify](https://coolify.io) builds the app's Dockerfile on your server and runs the image behind its proxy, which terminates TLS for the domain. Lumiose needs nothing Coolify-specific: the image is the one from [Docker](docker.md). The field names below follow Coolify v4's documentation; this guide has not been run on a Coolify instance yet (see the [verification table](README.md#verification)). ## 1. Add the Dockerfile Commit the [Dockerfile and `.dockerignore`](docker.md#the-dockerfile) at the root of the repository. Check that it builds locally (`docker build -t my-app .`): Coolify runs the same build. ## 2. Create the application In Coolify, **+ New**, pick the project and environment, connect the repository (a public URL, a Git App or a deploy key) and choose the **Dockerfile** build pack. Then, under **General**: | Field | Value | | ----------------------- | --------------------------------------------------------------- | | **Base Directory** | `/`, the build context (the directory with `package.json`) | | **Dockerfile Location** | `/Dockerfile`, relative to the base directory | | **Ports Exposes** | `3000`, the image's `PORT` | | **Domains** | `https://example.com`; Coolify's proxy gets the TLS certificate | Coolify sets `PORT` to the first exposed port and `HOST` to `0.0.0.0` when they are not set, which is what `createServer()` reads. ## 3. Environment variables Under **Environment Variables**: | Variable | Value | | ---------------- | ------------------------------------------------------------------------------------------------------------------- | | `ORIGIN` | The domain, `https://example.com`. The proxy speaks plain HTTP to the container, so request URLs (`url()`) need it. | | `LUMIOSE_SECRET` | When the app signs cookies: `openssl rand -base64 32`. Set **Build time** to **Not available during build**. | | Your own secrets | Database URLs, API keys: also **Not available during build**, since only the running server reads them. | New variables are available during the build by default; a secret that reaches the build can end up in the image's layers. Restart or redeploy after changing them. ## 4. Health check The image's `HEALTHCHECK` fetches `HEALTHCHECK_PATH` (`/` by default) inside the container. For a Dockerfile application, Coolify uses an image's `HEALTHCHECK` instead of the one in its **Healthcheck** settings, waits for the new container to be healthy before it replaces the old one, and its default proxy (Traefik) stops routing to a container that turns unhealthy. To check a cheaper URL than the home page, add a `GET` endpoint such as `/healthz` and set `HEALTHCHECK_PATH=/healthz` as a variable. A health check configured in Coolify's dashboard instead runs `curl` or `wget` in the container; Alpine has `wget`. ## 5. Persistent storage Files the app writes at run time (a SQLite database, uploads) are lost when Coolify replaces the container, unless they live on a volume. Create the directory for the `node` user in the Dockerfile, before `USER node`: ```dockerfile RUN mkdir /data && chown node:node /data ``` Then **Configuration**, **Persistent Storage**, **Add**, **Volume Mount**, with `/data` as the **Destination Path**, and point the app at it (`DATABASE_PATH=/data/app.db`, or whatever your app reads). Redeploy, and back the volume up outside the server. ## 6. Deploy **Deploy**, then read the build log (the three Dockerfile stages) and the runtime logs (the `Listening on …` line of `server.ts`). Pushes to the branch redeploy when automatic deployments are on. ## A monorepo Set **Base Directory** to the directory the Dockerfile's `COPY` lines are relative to, and **Dockerfile Location** to the Dockerfile's path from there. The bookshelf sample, for example, builds from the repository root with `/examples/ssr-bookshelf/Dockerfile` ([its notes](docker.md#the-bookshelf-sample)). # Cloudflare Workers > Deploy to Cloudflare Workers with `@lumiose/adapter-cloudflare`: the app runs as a Worker, the static files as assets. Tested locally in `wrangler dev` (workerd), not yet on Cloudflare itself: see the [verification table](README.md#verification). ## Set it up ```sh pnpm add -D @lumiose/adapter-cloudflare wrangler ``` ```ts // vite.config.ts import { cloudflare } from "@lumiose/adapter-cloudflare"; import lumiose from "@lumiose/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [lumiose({ adapter: cloudflare() })], }); ``` ```jsonc // wrangler.jsonc, next to package.json { "name": "my-app", "main": "dist/_worker.js", "compatibility_date": "2026-09-01", // AsyncLocalStorage, which Lumiose's request APIs use, and process.env for your variables. "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "dist/client" }, } ``` Then build and deploy: ```sh pnpm lumiose build pnpm wrangler deploy # or `wrangler dev` to try the build locally first ``` `lumiose build` warns, with a config to copy, when the project has no `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`. A site whose every route is prerendered builds no Worker: drop `main`, and Cloudflare serves the assets alone. ## What the build writes | File | What it is | | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dist/_worker.js` | The Worker: `dist/server/entry.js`'s `fetch`, called with `ctx.platform` (below). Wrangler bundles it, with the npm packages your server code imports, when it deploys. | | `dist/client` | The static assets. Cloudflare serves a request that matches a file without running the Worker. | | `dist/client/_headers` | `lumiose({ headers })` and Lumiose's default headers for the static files, plus a year of caching (`immutable`) for `/assets/*`, whose files Vite names by content hash, except files you put in `public/assets/` (`! cache-control`). The Worker's responses get the same headers from the handler. | | `dist/client/.assetsignore` | Only when prerendered pages have form actions: their HTML is left out of the assets, because Cloudflare answers a `POST` to a static file itself (`405`). The Worker renders those pages, fully resolved, and runs their actions. | Cloudflare compresses responses itself, so the build writes no `.br`/`.gz` copies, and the server build leaves out its own static file layer (no `node:fs`). ## The platform in your code Middleware, endpoints and actions get Cloudflare's arguments as `ctx.platform`: - `ctx.platform.env`: the Worker's bindings (KV, D1, R2, Durable Objects, variables, secrets). - `ctx.platform.waitUntil(promise)`: `ctx.waitUntil()`, for work after the response. - `ctx.platform.clientAddress`: the `CF-Connecting-IP` header. Type your bindings by augmenting `PlatformEnv` (with `@cloudflare/workers-types` or the types `wrangler types` generates): ```ts // src/routes/api/notes/route.ts import type { Endpoint } from "lumiose"; declare module "lumiose" { interface PlatformEnv { DB: D1Database; } } export const mode = "server"; export const GET: Endpoint<"/api/notes"> = async (ctx) => { const { results } = await ctx.platform.env.DB.prepare("select * from notes").all(); return Response.json(results); }; ``` Server components read them through middleware: copy what they need to `ctx.locals`. Variables and secrets are also in `process.env` with `nodejs_compat` (compatibility dates from 2025-04-01), so `env("NAME")` and `ctx.env.NAME` work as on Node. ## Trailing slashes Cloudflare's assets follow `assets.html_handling`. Match it to `lumiose({ trailingSlash })`: | `trailingSlash` | `html_handling` | `/about` | | -------------------- | --------------------------------- | ------------------------------ | | `"ignore"` (default) | `"auto-trailing-slash"` (default) | redirects to `/about/` (`307`) | | `"always"` | `"force-trailing-slash"` | redirects to `/about/` | | `"never"` | `"drop-trailing-slash"` | serves `about.html` | With `"ignore"`, Lumiose serves both forms itself, but Cloudflare redirects `/about` before the Worker runs. ## Good to know - Static files skip the Worker, so middleware does not run for them. Headers they need belong in `lumiose({ headers })`. - Streaming works: the shell of a page is sent first, `` content follows. - Unknown URLs reach the Worker, which renders your `not-found` page. Without a Worker (a static site), set `"not_found_handling": "404-page"` in `assets` to serve `404.html`. - The Worker's npm packages must run on Workers: with `nodejs_compat`, many Node.js APIs do, but not everything (native modules, the file system of a server). ## API ```ts import { cloudflare } from "@lumiose/adapter-cloudflare"; function cloudflare(): Adapter; // for lumiose({ adapter }) ``` # Vercel > Deploy to Vercel with `@lumiose/adapter-vercel`: the static files plus the app as a Node.js or Edge function. Tested locally with a harness for the Build Output API, not yet on Vercel itself: Vercel has no local runner for `.vercel/output`. See the [verification table](README.md#verification). ## Set it up ```sh pnpm add -D @lumiose/adapter-vercel ``` ```ts // vite.config.ts import { vercel } from "@lumiose/adapter-vercel"; import lumiose from "@lumiose/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [lumiose({ adapter: vercel() })], // or vercel({ runtime: "edge" }) }); ``` In the Vercel project, set the **Framework Preset** to "Other" and the **Build Command** to `lumiose build` (or `pnpm build`). Vercel deploys the `.vercel/output` directory that the build writes. From the command line, build and deploy the output as it is: ```sh pnpm lumiose build pnpm dlx vercel deploy --prebuilt ``` Add `.vercel/` to `.gitignore`. ## What the build writes | Path | What it is | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.vercel/output/static/` | `dist/client` (below Vite's `base`), without `_headers`. Served first. | | `.vercel/output/functions/__lumiose.func` | The server, for every path no static file answers. Not written when every route is prerendered. | | `.vercel/output/config.json` | The routes: `lumiose({ headers })` and Lumiose's default headers, a year of caching (`immutable`) for the files the build named by content hash (not `public/assets/` files), form posts to prerendered pages, the static files, then the function. | Prerendered pages also answer at their URL without the trailing slash (`/about` serves `about/index.html` with `trailingSlash: "ignore"`, `about.html` with `"never"`), so the usual links hit the static files. Vercel compresses responses itself: the build writes no `.br`/`.gz` copies. ## Node.js or Edge `vercel({ runtime })` picks where the function runs: - `"nodejs"` (default): a Node.js function (the build's Node.js major version, 22 at least), with response streaming. The function directory holds the server build and the npm packages it imports, found with `@vercel/nft`; native modules are copied as they are, so build on Linux (Vercel's build does) when your dependencies have native binaries. The project and its `node_modules` must be on one drive: the build stops with an error when the traced files share no common directory. - `"edge"`: one bundled module for Vercel's Edge runtime. npm packages are bundled with their `edge-light` and `worker` builds. The Edge runtime has no file system and only some Node.js modules (`async_hooks`, `buffer`, `events`, `util`, `assert`): the build stops with an error naming any other module your server code imports. ## The platform in your code - `ctx.platform.clientAddress`: the `X-Real-IP` header that Vercel sets. - `ctx.platform.waitUntil(promise)`: Vercel's `waitUntil` (the Edge function's context, or the request context of a Node.js function), for work after the response. - `ctx.platform.env` is empty: variables are in `process.env`, `env("NAME")` and `ctx.env`. Request URLs use the public origin from `X-Forwarded-Proto` and `X-Forwarded-Host`, which Vercel's proxy sets, so the cross-site check of form posts compares the right origins. ## Good to know - Static files skip the function, so middleware does not run for them. Headers they need belong in `lumiose({ headers })`. - Vercel applies the header routes to the function's responses too. When the app sets a header that `lumiose({ headers })` also sets for that path, which value wins on Vercel is not verified yet; the handler itself keeps the app's. - A `""` value, which removes a header in `lumiose({ headers })`, can only remove it everywhere (`/**`) on Vercel: routes cannot unset headers for some paths. ## API ```ts import { vercel } from "@lumiose/adapter-vercel"; function vercel(options?: { runtime?: "nodejs" | "edge" }): Adapter; // default "nodejs" ``` # Netlify > Deploy to Netlify with `@lumiose/adapter-netlify`: the static files are the site, the app runs as a Netlify Function. Tested locally with `netlify serve`, not yet on Netlify itself: see the [verification table](README.md#verification). ## Set it up ```sh pnpm add -D @lumiose/adapter-netlify ``` ```ts // vite.config.ts import { netlify } from "@lumiose/adapter-netlify"; import lumiose from "@lumiose/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [lumiose({ adapter: netlify() })], }); ``` ```toml # netlify.toml, next to package.json [build] command = "lumiose build" publish = "dist/client" ``` Push to a site connected to the repository, or deploy from the command line with `pnpm dlx netlify-cli deploy --build`. `netlify serve` runs the build and serves it locally. `lumiose build` warns when the project has no `netlify.toml`. **In a monorepo**, set the site's package directory to the app's directory: Netlify then runs the command from the repository root, so name the package and the full publish path: ```toml [build] command = "pnpm --filter my-app build" publish = "apps/my-app/dist/client" ``` ## What the build writes | Path | What it is | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dist/client` | The site (`publish`). Netlify serves it first. | | `dist/client/_headers` | `lumiose({ headers })` and Lumiose's default headers for the static files, plus a year of caching (`immutable`) for the files Vite names by content hash: `/assets/*`, or each of them when `public/assets/` has files of its own. | | `.netlify/v1/functions/lumiose-server.mjs` | The server: a Netlify Function (web `Request`/`Response`) for every path below Vite's `base` that no static file answers (`preferStatic`). Netlify bundles it with its npm packages when it deploys. | | `.netlify/v1/functions/lumiose-actions.mjs` | Only when prerendered pages have form actions: the same server for their `POST`s, which Netlify would otherwise answer from the static file (`405`). | Netlify compresses responses itself: the build writes no `.br`/`.gz` copies. Add `.netlify/` to `.gitignore`. ## The platform in your code - `ctx.platform.clientAddress`: the function context's `ip`. - `ctx.platform.waitUntil(promise)`: the context's `waitUntil`, for work after the response. - `ctx.platform.env` is empty: variables are in `process.env`, `env("NAME")` and `ctx.env`. ## Good to know - Static files skip the function, so middleware does not run for them. Headers they need belong in `lumiose({ headers })`. - Streaming works: the shell of a page is sent first, `` content follows. - `netlify serve` sends `cache-control: public, max-age=0` for static files whatever `_headers` says. On Netlify itself, `_headers` sets it, as Netlify documents (not verified yet). - A site whose every route is prerendered needs no function: the build writes none, and Netlify serves `404.html` for unknown URLs. ## API ```ts import { netlify } from "@lumiose/adapter-netlify"; function netlify(): Adapter; // for lumiose({ adapter }) ``` # Bun > Run a Lumiose build with Bun, which serves the server build's fetch handler and the static files as they are. Tested locally with Bun 1.4.2: see the [verification table](README.md#verification). ## Run it Build with Node or Bun, then start the server build from the project directory, so that the npm packages it imports resolve from `node_modules`: ```sh pnpm lumiose build NODE_ENV=production PORT=3000 bun dist/server/entry.js ``` Bun starts a server for a module whose default export has a `fetch` method, on `PORT` (default 3000). The handler serves the static files of `dist/client` (with their `.br`/`.gz` copies, `ETag`s and a year of caching for content-hashed assets), then the app, with `lumiose({ headers })`. `ctx.platform.clientAddress` is Bun's `server.requestIP(request)`. Bun does not compress responses on the fly: put a proxy or CDN that does in front for server-rendered pages ([VPS](vps.md#the-proxy)). ## In a container ```dockerfile FROM oven/bun:1.4 AS build WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile COPY . . RUN bun run build FROM oven/bun:1.4 WORKDIR /app ENV NODE_ENV=production PORT=3000 COPY --from=build /app/package.json /app/bun.lock ./ RUN bun install --frozen-lockfile --production COPY --from=build /app/dist ./dist USER bun EXPOSE 3000 CMD ["bun", "dist/server/entry.js"] ``` The image is not tested; the [Docker](docker.md) guide's Node image is. See also: [the server build](../reference/server-entry.md). # Deno > Run a Lumiose build with Deno: `deno serve` runs the server build's fetch handler and serves the static files as they are. Tested locally with Deno 2.9.6: see the [verification table](README.md#verification). ## Run it Build with Node (Deno reads the project's `package.json` and `node_modules`), then serve the server build from the project directory: ```sh pnpm lumiose build deno serve --allow-net --allow-read --allow-env --port 3000 dist/server/entry.js ``` `--allow-read` covers `dist/client` and `node_modules`, `--allow-env` the environment that `env()` and `ctx.env` read. The handler serves the static files of `dist/client` (with their `.br`/`.gz` copies, `ETag`s and a year of caching for content-hashed assets), then the app, with `lumiose({ headers })`. `ctx.platform.clientAddress` is the handler info's `remoteAddr.hostname`. The app's responses were not compressed in the local tests (streamed pages included): put a proxy or CDN that compresses in front ([VPS](vps.md#the-proxy)). **Deno Deploy** runs modules that export `{ fetch }`, but it is not tested: the server build imports npm packages from `node_modules` and reads `dist/client` from disk, which a deployment must include. See also: [the server build](../reference/server-entry.md). # Fly.io > Run a Lumiose app on Fly.io from the Docker guide's image, behind Fly's proxy. Not tested on Fly.io yet: the image is (see the [verification table](README.md#verification)). Add the Dockerfile and `.dockerignore` from [Docker](docker.md), then create the app with `fly launch` (it finds the Dockerfile; decline the database offers you do not need) and adjust the generated `fly.toml`: ```toml # fly.toml app = "my-app" primary_region = "ams" [env] NODE_ENV = "production" PORT = "3000" [http_service] internal_port = 3000 force_https = true auto_stop_machines = "stop" auto_start_machines = true min_machines_running = 0 [[http_service.checks]] grace_period = "10s" interval = "30s" method = "GET" timeout = "5s" path = "/" ``` Secrets go in with `fly secrets set LUMIOSE_SECRET="$(openssl rand -base64 32)"`, then `fly deploy`. Fly's proxy terminates TLS and forwards the `Host` header, so request URLs are right without `ORIGIN`; `ctx.platform.clientAddress` is the proxy's address, and the client's is in the `Fly-Client-IP` header. The adapter compresses responses itself; Fly's proxy passes them through. # Render > Run a Lumiose app on Render as a Node web service, with the Node adapter or the Docker guide's image. Not tested on Render yet: see the [verification table](README.md#verification). Create a **Web Service** from the repository with the Node runtime: | Setting | Value | | ------------- | ----------------------------------------------------------------- | | Build command | `corepack enable && pnpm install --frozen-lockfile && pnpm build` | | Start command | `node server.ts` | | Health check | `/` (or a cheap endpoint such as `/healthz`) | Set the environment variable `NODE_VERSION` to `24` (Lumiose needs Node 24 or later), and `LUMIOSE_SECRET` when the app signs cookies. Render sets `PORT`, which `createServer()` reads; its proxy terminates TLS and forwards the `Host` header, so `ORIGIN` is not needed. Or as a blueprint: ```yaml # render.yaml services: - type: web name: my-app runtime: node buildCommand: corepack enable && pnpm install --frozen-lockfile && pnpm build startCommand: node server.ts healthCheckPath: / envVars: - key: NODE_VERSION value: "24" - key: LUMIOSE_SECRET generateValue: true ``` With the Docker runtime instead, Render builds the Dockerfile from [Docker](docker.md) and runs it as is. A site whose every route is prerendered can be a **Static Site** instead: build command as above, publish directory `dist/client` (see [Static hosts](static.md)). # Railway > Run a Lumiose app on Railway with the Node adapter and Railway's Node builder, or from the Docker guide's image. Not tested on Railway yet: see the [verification table](README.md#verification). Create a service from the repository. Railway uses the `Dockerfile` when the repository has one ([Docker](docker.md)); otherwise it builds with Node and runs the `build` and `start` scripts of `package.json`: ```json { "scripts": { "build": "lumiose build", "start": "node server.ts" }, "engines": { "node": ">=24" } } ``` Or pin the commands in `railway.json`: ```json { "$schema": "https://railway.com/railway.schema.json", "build": { "buildCommand": "pnpm build" }, "deploy": { "startCommand": "node server.ts", "healthcheckPath": "/" } } ``` Railway sets `PORT`, which `createServer()` reads. Add `LUMIOSE_SECRET` in the service's variables when the app signs cookies, then generate a domain in the service's networking settings. # Static hosts > Deploy a fully prerendered site to GitHub Pages, Cloudflare, Netlify, S3 or any file server: `dist/client` is the whole site. `lumiose build` writes no `dist/server` when no route renders per request (no `server` route, no `spa` route rendered on the server per request, no form action): see [Rendering modes](../concepts/rendering-modes.md). What `dist/client` holds: - the pages as `path/index.html` (`path.html` with `lumiose({ trailingSlash: "never" })`), and `404.html`, rendered from your root `not-found` page; - `assets/`, named by content hash: cache them for a year (files you put in `public/assets/` are not hashed: keep them elsewhere in `public/`, or out of the long cache); - `_headers`: Lumiose's default headers and `lumiose({ headers })` in the format Netlify and Cloudflare read; - a `.br` and a `.gz` copy of every text file of 1 kB or more, for servers that serve precompressed files. Hosts that compress on their own serve them as plain files; leave them out of the upload there. Set Vite's `base` when the site is served below a path (`base: "/my-repo/"` for a GitHub Pages project site): links built with `href()` follow it. How far each host's steps were checked is in the [verification table](README.md#verification). ## Cloudflare Use the [Cloudflare adapter](cloudflare.md) even for a static site: it skips the precompressed copies and adds a year of caching for the hashed files of `/assets/` to `_headers`. Without a server, the build writes no Worker, and the wrangler config has assets only: ```jsonc // wrangler.jsonc { "name": "my-site", "compatibility_date": "2026-09-01", "assets": { "directory": "dist/client", "not_found_handling": "404-page" }, } ``` `pnpm wrangler deploy` uploads it. `not_found_handling: "404-page"` serves `404.html` with status 404 for unknown URLs. On Cloudflare Pages, `pnpm wrangler pages deploy dist/client` deploys the same directory (Pages reads `_headers` and serves `404.html` by itself); add a `dist/client/.assetsignore` with `*.br` and `*.gz` there, or build with the adapter. ## Netlify Use the [Netlify adapter](netlify.md) and the same `netlify.toml`: for a static site the build writes no function, and Netlify serves `404.html` and applies `_headers` by itself. ## GitHub Pages Deploy `dist/client` with GitHub's Pages actions (set the repository's Pages source to "GitHub Actions"). GitHub Pages serves `404.html` for unknown URLs and ignores `_headers`; it compresses on its own. ```yaml # .github/workflows/pages.yml name: Pages on: push: branches: [main] permissions: contents: read pages: write id-token: write jobs: deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lumiose build - run: find dist/client -name "*.br" -o -name "*.gz" | xargs rm -f - uses: actions/upload-pages-artifact@v3 with: path: dist/client - id: deployment uses: actions/deploy-pages@v4 ``` For a project site (`https://.github.io//`), build with `base: "//"` in `vite.config.ts`. ## S3 and CloudFront Upload `dist/client` to the bucket with a long cache for the hashed assets and revalidation for the rest, then let CloudFront compress (enable "Compress objects automatically") and answer unknown URLs with `404.html` (a custom error response for 403 and 404, status 404): ```sh aws s3 sync dist/client s3://my-bucket --delete \ --exclude "*.br" --exclude "*.gz" --exclude "_headers" \ --exclude "assets/*" --cache-control "public, max-age=0, must-revalidate" aws s3 sync dist/client/assets s3://my-bucket/assets --delete \ --exclude "*.br" --exclude "*.gz" --cache-control "public, max-age=31536000, immutable" ``` S3 serves `about/index.html` for `/about/` only from its website endpoint (or with a CloudFront function that appends `index.html`). Headers from `lumiose({ headers })` go into a CloudFront response headers policy. ## Any file server nginx, Caddy or another server can serve `dist/client` as it is, `.br`/`.gz` copies included (nginx's `brotli_static`/`gzip_static`, Caddy's `precompressed`). `lumiose preview` serves it locally the way static hosts do. # AWS Lambda > Run the server build on AWS Lambda behind a Function URL or API Gateway, through a small adapter from Lambda events to its fetch handler. Not tested on AWS yet: see the [verification table](README.md#verification). There is no `@lumiose/adapter-lambda`; this page shows the handler to write. The Lambda runs `dist/server/entry.js`'s `fetch`, which serves the static files of `dist/client` when the deployment package includes them, then the app, with `lumiose({ headers })`. A CDN in front serves them better: put `dist/client` on S3 behind CloudFront ([Static hosts](static.md#s3-and-cloudfront)), with a behavior that sends the other paths to the function. ```ts // lambda.ts: deploy it with dist/ and the production node_modules (Node.js 24 runtime) import app from "./dist/server/entry.js"; interface FunctionUrlEvent { rawPath: string; rawQueryString: string; headers: Record; cookies?: string[]; body?: string; isBase64Encoded: boolean; requestContext: { http: { method: string; sourceIp: string } }; } export async function handler(event: FunctionUrlEvent) { const { method, sourceIp } = event.requestContext.http; const headers = new Headers(event.headers); if (event.cookies?.length) headers.set("cookie", event.cookies.join("; ")); const url = `https://${event.headers.host}${event.rawPath}${event.rawQueryString ? `?${event.rawQueryString}` : ""}`; const body = event.body === undefined || method === "GET" || method === "HEAD" ? undefined : Buffer.from(event.body, event.isBase64Encoded ? "base64" : "utf8"); const response = await app.fetch(new Request(url, { method, headers, body }), { clientAddress: sourceIp, }); const bytes = Buffer.from(await response.arrayBuffer()); return { statusCode: response.status, headers: Object.fromEntries([...response.headers].filter(([name]) => name !== "set-cookie")), cookies: response.headers.getSetCookie(), body: bytes.toString("base64"), isBase64Encoded: true, }; } ``` This buffers each response: pages arrive whole, after their `` content resolves. Lambda's response streaming (`awslambda.streamifyResponse`, Function URLs only) can send the stream instead. `ctx.platform.waitUntil` falls back to letting the promise run, which Lambda may freeze once the response is returned: await such work in the handler instead. # `lumiose`: reactivity > Signatures of the reactive core: signals, computeds, effects, stores, context, ids and scoped state. All of these are exported by `lumiose` in the browser and on the server. On the server (the `node` entry) signals are plain cells, computeds evaluate once per request, and `effect`/`onMount` never run. Concepts: [Signals and reactivity](../concepts/signals.md). ## signal ```ts function signal(value: T, options?: SignalOptions): Signal; interface Signal { (): T; // read (tracked) set(value: T): void; update(fn: (value: T) => T): void; peek(): T; // read without tracking } interface SignalOptions { /** When a write counts as a change. Default Object.is; false: every write is a change. */ equals?: false | ((a: T, b: T) => boolean); } ``` ## computed ```ts function computed(fn: (previous: T | undefined) => T, options?: SignalOptions): Computed; type Computed = () => T; ``` Lazy (runs on first read), cached, glitch-free. `fn` must be pure: no signal writes (`LUM2002`). An unobserved computed unlinks itself from its sources. ## effect ```ts function effect(fn: EffectFn): () => void; type EffectFn = () => void | (() => void); ``` Runs `fn` after the DOM updates, then again whenever a signal it read changes. A returned function is a cleanup, run before the next run and on disposal. Returns a disposer. Owned by the current owner, so it is disposed with the component. The effects queued in a flush all run before the DOM bindings that their writes trigger; those writes then render together, and the effects they schedule run after that, in the same flush. So an effect sees the current value of a signal that an earlier effect of the same pass wrote, but not yet its DOM. ## onMount ```ts function onMount(fn: EffectFn): void; ``` Runs `fn` once, untracked, after the current owner's DOM is mounted. It may return a cleanup. ## onCleanup ```ts function onCleanup(fn: () => void): void; ``` Registers `fn` on the current owner: it runs when the owner is disposed (or, for an effect, before its next run). ## getAbortSignal ```ts function getAbortSignal(): AbortSignal; ``` An `AbortSignal` for the current owner. Aborted when the owner is disposed, when an effect re-runs, or when an async component run is superseded. On the server it is the request's signal. Throws outside an owner (with a message in development). Svelte has the same function. ## untrack ```ts function untrack(fn: () => T): T; ``` Runs `fn` without subscribing the current scope to the signals it reads. ## batch ```ts function batch(fn: () => T): T; ``` Defers dependents until `fn` returns, so they run once. Event handlers are batched automatically: `onX` handlers, `on:x` listeners and handlers passed in a spread, so the DOM is current right after the event. ## flush ```ts function flush(): void; ``` Runs pending computeds, DOM bindings and effects now, instead of in the next microtask. ## store ```ts function store(value: T): T; ``` Wraps a plain object or array in a deep, **mutable** reactive proxy, like Valtio's `proxy`, Vue's `reactive` or Solid's `createMutable`: you write to it directly (`state.count++`, `state.items.push(item)`), with no setter. Reads track per property, writes notify only on change, array methods work. Class instances, `Map`s, `Set`s and frozen objects are returned as is. ## snapshot ```ts function snapshot(value: T): T; ``` A deep plain copy of a store, of a value inside one (`snapshot(state.items)`), or of plain data that holds stores. Plain objects and arrays are copied; other values (`Date`s, `Map`s, class instances) are kept as they are, and shared or circular references stay shared. Use it where a proxy does not work: `structuredClone()` and `postMessage()` (to a worker or an AudioWorklet) throw on proxies, and a copy does not change when the store does (saving, undo history). Reads are not tracked: in an effect, read the properties it should react to. On the server it copies the plain object. ```ts const song = store({ title: "Demo", rows: [] as number[][] }); worker.postMessage({ type: "song", song: snapshot(song) }); ``` ## reconcile ```ts function reconcile(target: T, data: T, options?: ReconcileOptions): void; interface ReconcileOptions { key?: string; // identifies array items, default "id" } ``` Patches fresh `data` (a fetch result, a server payload) into a store, or into an object or array inside one (`reconcile(state.books, fresh)`), in place, with Solid's `reconcile` semantics: - Array items are matched by reference, or by `key` when the new item has it. Items without the key are merged into the item at the same index when that item has no key either; unmatched items are inserted as new values, and leftovers are removed. Keys should be unique: new items that share a key end up as the same store item. - Matched objects are merged deeply; an object whose `key` differs from the old one is replaced. - Keys missing from `data` are deleted. Primitives and other values (`Date`s, `Map`s, class instances) are replaced. Unchanged items keep their identity, so keyed list rows, their DOM and their local state survive, and only properties whose value changed notify, all in one batch. Reads are not tracked, so it is safe in an effect. `target` and `data` must be the same kind (both arrays or both plain objects), otherwise it throws a `TypeError` in development. Pass the store, not plain data: a plain object is patched without notifying anything (that is what happens on the server, where stores are plain objects). ```ts const books = store([]); reconcile(books, (await (await fetch("/api/books")).json()) as Book[]); const page = store({ rows: [{ slug: "intro", views: 1 }] }); reconcile(page.rows, [{ slug: "intro", views: 2 }], { key: "slug" }); ``` ## createContext, useContext ```ts function createContext(defaultValue: T): Context; function createContext(): Context; function useContext(context: Context): T; interface Context { (props: { value: T; children?: JSX.Element }): JSX.Element; // the provider component readonly id: symbol; readonly defaultValue: T; } ``` A context is also its provider component, as in React 19 and Solid 2: `…` provides `value` to everything rendered inside it, including content rendered later (conditionals, lists). `useContext` returns the value of the nearest provider above, or the default. A nested provider of the same context shadows the outer one for its subtree. Context survives `await` in async components. Islands start a new root ([LUM4010](../errors/LUM4010.md)). The provider reads `value` once, when it renders, like Solid's provider and like every component prop read in a component body: components run once. To share state that changes, provide a signal, a store or an object with getters, and read it where it is used. ```tsx const Theme = createContext("light"); const Count = createContext(signal(0)); {/* useContext(Theme) is "dark"; useContext(Count)() is reactive */} ; ``` ## useId ```ts function useId(): string; ``` Returns an id that is unique in the page, for `id`/`for`, `aria-labelledby`, `aria-describedby`, `aria-controls` and the like. Call it in a component's setup, once per id you need; each call returns the instance's next id. The id is a valid HTML id and CSS identifier (`l0-0-1-0`, `ls3`, `lc7`), so it also works in selectors and as a `view-transition-name`. Do not parse it: the format may change. - **Server and hydration.** Inside an island (and an SPA, which is one), the server builds the id from the island's id and the component instance's position in it, and hydration computes the same id, so the server's `for`/`id` pairs stay as they are. Outside islands, server ids follow the render tree, so the same page renders the same ids whatever order its async content resolves in. - **Browser only.** Content rendered in the browser after hydration, or by [`render()`](dom.md#render), gets ids from a counter. - **Several apps on one page.** Give each a prefix: `render(code, container, { idPrefix: "cart-" })`, or `renderToStream(root, { idPrefix })` on the server, which the page's islands reuse when they hydrate. Ids then start with the prefix. - **Async components.** Each run of an async component runs its body again, so a re-run after a signal it read changed gets new ids, as its other locals are new too. ```tsx check import { useId } from "lumiose"; export function EmailField(props: { label: string; hint: string }) { const id = useId(); const hintId = useId(); return (

{props.hint}

); } export function SignUp() { const title = useId(); return (

Sign up

); } ``` ## defineState ```ts function defineState(factory: () => T): () => T; ``` Scoped state: returns an accessor whose `factory` runs on the first call, once per scope, and returns the same instance after that. | Scope | One instance per | | -------------- | -------------------------------------------------------------------------------------- | | Browser | page: shared by every island and the SPA tree | | Server | request: middleware, endpoints, actions and the render share it; never across requests | | Tests (Vitest) | test: `@lumiose/vite` resets scoped state before each test | The factory runs outside the component tree: it sees no context and its reads are not tracked; in the browser its effects live as long as the page. On the server it may read request APIs (`cookies()`, `locals()`, …), which do not exist in the browser: guard them with [`isServer`](#isserver). Calling an accessor on the server outside a request or render (at module scope) throws. **Sent to the browser.** When islands or SPA routes import the state, a page that created it sends what the factory returned that the browser cannot rebuild: the returned signal or store itself, or the top-level signals and stores of the returned object. Computeds and functions are not sent; the factory runs again in the browser and the server values replace its signals and stores before any island reads them, so hydration matches. Values go once per page, in the chunk (shell or streamed boundary) where the state was first used, and must be plain data like island props ([LUM4011](../errors/LUM4011.md)). States that only server code imports are never sent. Everything a sent state holds is public: never put secrets in it. Define states at module scope with `export const x = defineState(…)` (or export the binding): `@lumiose/vite` names each one after its module and export, the way it names islands. States created elsewhere (inside a function) are never sent. ```ts import { computed, cookies, defineState, isServer, signal } from "lumiose"; export const prefs = defineState(() => { const theme = signal(isServer ? (cookies().get("theme") ?? "light") : "light"); return { theme, dark: computed(() => theme() === "dark") }; }); // prefs().theme(), prefs().dark(), prefs().theme.set("dark") ``` ## isServer ```ts const isServer: boolean; ``` `true` in the server entry of `lumiose` (the `node` condition), `false` in the browser entry (Solid's name). Use it to guard code that runs on both sides, such as a `defineState` factory that reads request APIs: `signal(isServer ? locals().user : null)`. The guard stays explicit: request APIs called in the browser still throw [LUM4003](../errors/LUM4003.md). It is a constant, so production client builds fold it: `if (isServer) { … }` and `isServer ? a : b` lose their server branch, and server-only strings in it never reach the bundle. The server branch must still type-check and import only what client code may import; server-only modules stay out of client graphs ([LUM4003](../errors/LUM4003.md)). ## isPending ```ts function isPending(): boolean; ``` Inside an async component (its body or its UI): whether a **re-run** is in flight. The first load is not a re-run (`` covers it). Reading it in the body does not re-run the component. Always `false` on the server. ## isBusy ```ts function isBusy(): boolean; ``` App-wide: whether anything is in flight: an async component run, a `` showing its fallback, a form submission (`submit()`), an SPA navigation or a request of the async cache (`fetchJSON`, `query`), background refetches included. Reactive. Always `false` on the server. ## resource ```ts function resource(fetcher: (signal: AbortSignal) => T | PromiseLike): Resource; interface Resource { (): T | undefined; // latest value, tracked loading(): boolean; error(): unknown; refetch(): void; // aborts a load in flight and loads again } ``` Low-level on-demand loading. `fetcher` runs now and whenever a signal it read synchronously changes; its `AbortSignal` aborts when superseded or disposed. The first load counts as pending for the nearest ``. Errors are not thrown: read `error()`. Browser only: on the server it stays loading. Prefer [async components](../concepts/async.md). ## optimistic ```ts function optimistic(source: () => T): Optimistic; interface Optimistic { (): T; // source() with the pending overlays applied in order, tracked set(value: T, promise: PromiseLike): void; update(fn: (value: T) => T, promise: PromiseLike): void; } ``` A value that follows `source` (a getter such as `() => props.likes`, or a signal), plus overlays held while their promises are pending. Each `set`/`update` applies at once and drops when its own promise settles, either way: on success the (refreshed) source shows, on failure the value rolls back. Concurrent overlays apply in call order over the latest source value, so an `update` is re-applied when the source changes. A rejection is not re-thrown; await the promise itself to handle it. Writes stop when the owner is disposed. On the server it reads `source()` and `set`/`update` do nothing. See [optimistic updates](../concepts/data-and-actions.md) in Data and actions. ## Example ```tsx check import { batch, effect, getAbortSignal, onCleanup, resource, signal, untrack } from "lumiose"; export function Clock() { const now = signal(new Date()); const timer = setInterval(() => now.set(new Date()), 1000); onCleanup(() => clearInterval(timer)); const first = signal("Ada"); const last = signal("Lovelace"); const rename = () => batch(() => { first.set("Grace"); last.set("Hopper"); }); effect(() => { console.log(`${first()} ${last()} at`, untrack(now).toISOString()); }); const user = resource((signal) => fetch(`/api/users/${first()}`, { signal }).then((res) => res.json() as Promise<{ id: string }>), ); return (

{now().toLocaleTimeString()} · {user.loading() ? "…" : user()?.id} {String(getAbortSignal().aborted)}

); } ``` # `lumiose`: components > Signatures of the built-in components, from Suspense and error boundaries to portals, head tags and view transitions. All are exported by `lumiose` in the browser and on the server. Concepts: [Components](../concepts/components.md), [Async components](../concepts/async.md), [Head and document](../concepts/head.md). ## Suspense ```ts function Suspense(props: SuspenseProps): JSX.Element; interface SuspenseProps { /** Shown while new content below is pending. */ fallback?: JSX.Element; children?: JSX.Element; } ``` Shows `fallback` while **new** async content below it is pending: the first load, and async components or resources that a branch mounts later. Re-runs of revealed content keep their stale UI (`isPending()` tells them apart). Hidden content stays alive. A shown fallback counts for `isBusy()`. On the server, a boundary marks content that streams after the shell; with prerendering it is resolved inline. ## ErrorBoundary ```ts function ErrorBoundary(props: ErrorBoundaryProps): JSX.Element; interface ErrorBoundaryProps { /** Rendered instead of the children after an error. */ fallback?: JSX.Element | ((error: unknown, reset: () => void) => JSX.Element); children?: JSX.Element; } ``` Renders `fallback` when its children throw while rendering, an effect below throws, or an async component below rejects. `reset()` disposes the fallback and renders the children again. On the server, an error in a streamed boundary renders the nearest `ErrorBoundary` in place. In production server rendering, a fallback receives a generic `Error` with a short `digest` (`An error occurred while rendering this part of the page (digest 1a2b3c4d).`) instead of an error that is not an `HttpError`, which could reveal a database message or a file path. The original error goes to the server log (the request handler's `onError`) with the same `digest` property, so a user's report can be matched to the log entry. `HttpError`s (`notFound()`, `error(403, …)`) reach the fallback as they are, development keeps the raw error, and boundaries that catch in the browser get the error itself (decision-29). ## Portal ```ts function Portal(props: PortalProps): JSX.Element; interface PortalProps { /** Where the content goes. Default: document.body. */ mount?: Node; children?: JSX.Element; } ``` Renders `children` into a `
` appended to `mount`, removed with the owner. Delegated events from the portal's content continue at the portal's position in the component tree. ## Dynamic ```ts function Dynamic(props: DynamicProps): JSX.Element; interface DynamicProps { /** A component or an intrinsic tag name. Nothing renders while it is nullish or false. */ component: Component | string | null | undefined | false; [prop: string]: unknown; } ``` Renders `component` with the other props, re-creating it only when `component` changes. With a tag name, the props are the element's written props. The text of a ` ``` ## How to fix it Move the markup and its script to an `.html` file, which runs its scripts as client modules, and import that instead (or import it next to the Markdown): ```html ``` Or keep the Markdown as content and put the behaviour in a component that renders it, such as an island in a `.stsx` route: ```stsx import Post from "../content/post.md"; import { Counter } from "../components/counter.stsx"; export default function Page() { return (
); } ``` ## Related - [Markdown and HTML imports](../concepts/content.md) - [Islands](../concepts/islands.md) # LUM1009: Content nested too deeply **Severity:** error · **Category:** file format (1xxx) > A Markdown or HTML import nests elements or block quotes deeper than 512 levels. **Reported by:** the Vite plugin (`lumiose({ md, html })`), in `lumiose dev` and builds, at the first element or line past the limit. ## What it means The HTML tree builder and the Markdown block parser get slower with every level of nesting, and a file with tens of thousands of unclosed `
`s or `>` markers would take seconds to compile (or stall the dev server on every save). No real document nests this deep, so Lumiose checks the depth before parsing and stops at 512 levels: elements that are opened and never closed, or block quote markers on one line. Elements whose end tag is optional (`

`, `

  • `, ``…), void elements and self-closing tags do not count. The component is empty until the file is fixed. ## Example ```html
    …
    ``` ## How to fix it Close the elements you open, or generate flatter markup. For a quote of a quote of a quote, a few levels are plenty. ## Related - [Markdown and HTML imports](../concepts/content.md) - [Security](../concepts/security.md) # LUM1010: Source nested too deeply **Severity:** error · **Category:** file format (1xxx) > Brackets in a `.stsx`/`.tsx` file are nested more than 1000 levels deep, so the compiler refuses the file. **Reported by:** the compiler, in `@lumiose/vite`, `lumiose check` and the language server. An error. ## What it means Parentheses, brackets, braces and template substitutions (`${…}`) are counted while the file is read; strings and comments do not count. Past 1000 open levels the file is not compiled: the compiler's passes recurse once per level, and a file nested tens of thousands of levels deep would exhaust the stack of the process that compiles it (the dev server, the language server). Real code stays far below the limit; generated code (a serialized tree, a minified data blob) may not. ## Example ```ts // Generated: 5000 nested arrays. export const tree = [[[[[[/* … */]]]]]]; ``` ## How to fix it Split the expression into named parts, or load deeply nested data as JSON (`import data from "./tree.json"`, or `fetch`) instead of writing it as code. ## Related - [LUM1001](LUM1001.md): syntax error. # LUM2001: Stale derived value **Severity:** warning · **Category:** reactivity (2xxx) > A value derived from a signal is computed once in setup and never updates. **Reported by:** the compiler. A warning. Does not apply to async components, whose body re-runs. ## What it means A component's body runs once. `const doubled = count() * 2` reads the signal at setup time and stores a number, so JSX that uses `doubled` shows the first value forever. A spread is a use too: `const attrs = { title: props.title }` spread as `
    ` keeps the first title. The compiler treats a zero-argument call of a local or imported binding (`count()`, `user.name()`) as a signal read, and so does a read of the component's props. Calls that can only run on the server are not reads, so server pages can store request data in constants: - `lumiose`'s request APIs: `url()`, `params()`, `locals()`, `cookies()`, `request()`, `response()` and `publicEnv()`. The accessors of `lumiose/router` (`url()`, `params()`, `searchParams()`, …) are reactive inside SPA routes and still count, except in the page or layout of a `static` or `server` route, where they read the request too. - `lumiose`'s control-flow APIs, which never return: `notFound()`, `redirect()` and `error()`, so `const project = getProject(slug) ?? notFound()` is fine. - Anything imported from a server-only module (`*.server.ts`, `src/server/**`), such as `queries().getBook(isbn)`. Data functions from a workspace package (`getDashboard()` from `@acme/data`) are not known to be server-only: re-export them from a module under `src/server/` (`export { getDashboard } from "@acme/data";`) and import them from there. ```tsx // src/routes/books/[isbn]/page.stsx (a server page): no warning const user = locals().user ?? null; const next = url().searchParams.get("next"); const featured = queries().featuredBooks(6); ``` A constant stored straight from a call of a function (`const count = signal(props.start)`) or of a member of an imported module (`const toasts = Toast.createToastManager()`) holds what a factory made, not a derived value: factories read their arguments on purpose. A call on an imported module still warns when its arguments read a signal or a prop (`const day = format.date(props.date)`). `lumiose`'s APIs that make a value or return a constant are never reads, wherever they appear: `useId()`, `ref()`, `signal()`, `createContext()` and `getAbortSignal()`, so `const context = { id: useId(), open: signal(false) }` is fine. Copying a signal's value into a constant warns too: `const n = count()` reads `count` once. The compiler reports the copies it can tell are reactive: - an accessor made in the same file with `signal()`, `computed()`, `resource()` or `optimistic()` (in the component or at the top level); - an accessor prop destructured from the props (`const { value } = props; const v = value()`), as it reports `const v = props.value()`; - `url()`, `searchParams()` and `isNavigating()` from `lumiose/router`: a navigation that only changes the query keeps the page and changes the URL, and every navigation changes `isNavigating()`. Other copies are not reported. A zero-argument call of an imported function (`const n = total()`) may be a signal of a store module, a plain function or a `defineState()` getter (`const cart = useCart()`), which the compiler cannot tell apart. A copy of `params()`, `route()` or `locale()` from `lumiose/router` (`const { id } = params()`) is current for the whole life of a page, since a page with other params is a new page; only a layout's code sees them change, and the compiler cannot tell it from a page's. A value derived from them (`const id = params().id`) still warns, as any derivation from a call does. In those cases, read the value in the JSX all the same (`{total()}`, `{params().id}` in a layout). `count.peek()` and `untrack(…)` read without tracking, so they are never reads for this diagnostic (or for [LUM2003](LUM2003.md)): `const start = count.peek()` keeps the first value on purpose. Destructuring the props object at the top of the body (`const { title, ...rest } = props`) is rewritten like a destructured parameter and stays reactive. Destructuring a nested prop (`const { name } = props.user`), or bindings you reassign later, still read once and warn. **In a keyed row.** A `.map()` callback with a `key` runs once per row, and the row stays when a new item with the same key replaces its item: the item updates in place (see [Lists and keys](../concepts/jsx.md#lists-and-keys)). A value the callback reads from the item outside JSX and functions is a copy taken when the row was created, so the compiler reports it where the JSX uses it: `const name = item.name`, `const label = format(item.price)`, or bindings destructured from the item with a rest element (`({ id, ...rest }) => …`). In a row, calls of your own functions count as reads too (a formatter of a field goes stale like the field). Not reported: copies of the key (`const id = item.id` with `key={item.id}`, or anything read from `item.id`), calls of `lumiose`'s APIs (`const open = signal(item.open)` keeps the first value on purpose, as do `untrack(() => …)` and functions), values only an event handler reads, and rows without a key, which a new item replaces. ```tsx
      {products().map((item) => { const price = formatPrice(item.price); // LUM2001: the row keeps the first price return (
    • {item.name}: {price}
    • ); })}
    ``` Read the item where it is used (`{formatPrice(item.price)}`), derive lazily (`const price = () => formatPrice(item.price)`, called as `price()`), or put the field in the key (``key={`${item.id}:${item.price}`}``) to make a new row when it changes. Pages and layouts whose route mode is `static` or `server` render once per request (or build), so their own props are plain values too: deriving from them is fine. Routes in `spa` mode, islands and other components re-render with new props and still get the warning. `@lumiose/vite`, `lumiose check` and the language server read the route modes the same way (`export const mode`, the nearest layout's, then `lumiose({ mode })`). ```tsx // src/routes/books/[slug]/page.stsx (mode `static` or `server`): no warning export default function Book(props: { params: { slug: string } }) { const title = props.params.slug.replaceAll("-", " "); return

    {title}

    ; } ``` ## Example ```tsx export function Counter() { const count = signal(1); const doubled = count() * 2; const n = count(); return ( ); } ``` ## How to fix it Derive with a function or `computed`, and call it where you need the value. A plain copy needs neither: call the signal itself in the JSX. ```tsx const doubled = () => count() * 2; // cheap derivations const total = computed(() => expensive(count())); // cached return ( ); ``` For a spread, make the object a function and call it in the spread: `const attrs = () => ({ title: props.title })` with `
    ` stays live. If you really want the initial value only, read it with `count.peek()` or `untrack(count)` to make that explicit (`const attrs = untrack(() => ({ title: props.title }))`). ## Related - [Signals and reactivity](../concepts/signals.md) - [Components](../concepts/components.md) # LUM2002: Signal write during render **Severity:** error · **Category:** reactivity (2xxx) > A signal is written while a component or computed is rendering. **Reported by:** the compiler. ## What it means Component setup and computeds run during render. Writing a signal there (`count.set(…)`, `items.push(…)` on a store) re-triggers bindings mid-render, can loop, and makes the output depend on render order. ## Example ```tsx export function Profile(props: { name: string }) { const visits = signal(0); visits.set(visits.peek() + 1); return

    {props.name}

    ; } ``` ## How to fix it Initialize the signal with the value instead (`signal(initial)`), derive with `computed`, or write in an event handler, `onMount` or an `effect`. ## Related - [Signals and reactivity](../concepts/signals.md) # LUM2003: Reactive early return **Severity:** error · **Category:** reactivity (2xxx) > A sync component returns early based on reactive state. **Reported by:** the compiler. An error when the condition reads a signal, a warning when it reads only props. Does not apply to async components, whose body re-runs. ## What it means The body of a sync component runs once, so `if (!user()) return ` is decided once and never re-evaluated: the component keeps showing whichever branch it took first. The compiler checks `if` statements and `switch` statements whose branches return. Their condition (a `switch`'s value and its `case` tests) can read reactive values directly or through a local declared from one (`const kind = props.kind`): both forms get the same diagnostic, and a read through a local also points at the local's declaration. - **A signal read is an error.** A zero-argument call such as `count()`, `store.count()` or an accessor prop (`props.user()`, or `user()` destructured from the props) reads a signal, and so does a local that copies one: `const n = count()` for a signal made in the same file, `const u = user()` for a destructured accessor prop, and `const busy = isNavigating()` for `url()`, `searchParams()` and `isNavigating()` from `lumiose/router` (see [LUM2001](LUM2001.md) for what counts as a copy). The component is written to react, and the early return never does. `count.peek()` reads without tracking, on purpose: it is not a read. - **A prop read is a warning.** `props.kind`, a destructured prop, or a method of a prop's value (`props.title.trim()`) only goes stale when the component renders in the browser (in an island or an SPA page) and a parent passes a value that changes, as in ``. The compiler cannot see the call sites from the component. A component that only renders on the server, or whose parents pass constants, can keep the early return. The default export of a page or layout whose route mode is `static` or `server` gets no diagnostic for its own props, nor for `lumiose/router`'s accessors, which read the request there: it renders once per request or build, so they never change (as for [LUM2001](LUM2001.md)). **In a keyed row** the `.map()` callback runs once per row too, and a kept row's item changes in place when a same-key item replaces it (see [Lists and keys](../concepts/jsx.md#lists-and-keys)). An early return on the item (`if (!item.visible) return null;`) is a warning, as for a prop: it keeps its first branch while the row stays. A condition on the key itself (`if (!item.id) …` with `key={item.id}`) is fine. ## Example ```tsx export function Account(props: { user: () => User | null }) { if (!props.user()) return ; return ; } ``` An error: `props.user()` reads a signal. A warning, since it reads only a prop: ```tsx export function Badge(props: { kind: "new" | "sale" }) { if (props.kind === "new") return ; return ; } ``` The same warning, through a local and a `switch`: ```tsx export function Badge(props: { kind: "new" | "sale" }) { const kind = props.kind; switch (kind) { case "new": return ; } return ; } ``` ## How to fix it Move the condition into the JSX, where it becomes a memoised branch: ```tsx export function Account(props: { user: () => User | null }) { return <>{props.user() ? : }; } export function Badge(props: { kind: "new" | "sale" }) { return <>{props.kind === "new" ? : }; } ``` In a keyed row, move the condition into the returned element, or put the field in the key so a change makes a new row: ```tsx
      {items().map((item) => ( ))}
    ``` For a warning on a component that only renders on the server, or whose props never change, the early return is fine as it is. Values that are not reactive, such as constants, request data (`url()`, `params()` from `lumiose`) and imports of server-only modules, never trigger the diagnostic. ## Related - [Components](../concepts/components.md) - [JSX: compiled idioms](../concepts/jsx.md) # LUM2004: Component called as a function **Severity:** error · **Category:** reactivity (2xxx) > A component is called as a function instead of rendered with JSX. **Reported by:** the compiler. ## What it means `{Card({ title })}` runs `Card` inside the caller: it gets no owner of its own, its props are not reactive getters, and cleanups, context and boundaries attach to the wrong component. ## Example ```tsx export function List(props: { items: string[] }) { return
      {props.items.map((item) => Item({ label: item }))}
    ; } ``` ## How to fix it Render it with JSX: ``. To choose a component at runtime, use ``. ## Related - [Components](../concepts/components.md) # LUM2005: Children inserted twice **Severity:** warning · **Category:** reactivity (2xxx) > `children` is inserted more than once. **Reported by:** the compiler. A warning. ## What it means `children` is lazy: each insertion evaluates it, creating a second set of nodes (or moving the first). Components below are instantiated twice, with separate state. Spreading the props onto an element without content of its own inserts `children` too (`
    `, [JSX](../concepts/jsx.md)), so it counts as one insertion. A rest that took `children` out (`{ children, ...rest }`) inserts none. ## Example ```tsx export function Tabs(props: { children?: JSX.Element }) { return (
    {props.children}
    ); } ``` ## How to fix it Insert `children` once. To render content several times, accept a render function and call it in each place: ```tsx export function Tabs(props: { renderTab: () => JSX.Element }) { return (
    {props.renderTab()}
    ); } ``` ## Related - [Components](../concepts/components.md) # LUM2006: Reactive index escapes its callback **Severity:** warning · **Category:** reactivity (2xxx) > The reactive index of a `.map()` row is used outside its row. **Reported by:** the compiler. A warning. ## What it means In `{items().map((item, i) => …)}` the compiler makes `i` a reactive index that belongs to the row and updates when the row moves. Storing it in an outer variable, or capturing it in a callback that outlives the row, keeps a stale index. ## Example ```tsx let lastIndex = 0; export function List(props: { items: string[] }) { return (
      {props.items.map((item, i) => { lastIndex = i; return
    • {item}
    • ; })}
    ); } ``` ## How to fix it Use the index only inside the row's JSX and handlers. To remember a position, store the item (or its key) and look it up when needed. **Not reported** when the list maps a module-level `const` array literal (also with `as const`) that the module never changes: no reassignment, no `X[i] = …`, no `push`, `sort`, `splice` or other mutating method. Its rows never move, so reading the index once is safe: ```tsx const CHANNELS = [{ name: "pulse" }, { name: "wave" }] as const; export function Mixer() { return (
      {CHANNELS.map((info, i) => { const ch = i; // stable: CHANNELS never changes return (
    • {info.name} {ch}
    • ); })}
    ); } ``` ## Related - [JSX: compiled idioms](../concepts/jsx.md) # LUM2007: Own signal read in an async body **Severity:** warning · **Category:** reactivity (2xxx) > An async component reads a signal it created in its own body. **Reported by:** planned (not reported by this version). A warning. ## What it means Signals read in an async component's body are tracked, and a change re-runs the component. Signals created in the body reset on each run. Reading such a signal in the body therefore re-runs the component whenever it changes, and the re-run re-creates the signal with its initial value: the update is lost. ## Example ```tsx export async function Book({ id }: { id: string }) { const book = await fetchJSON(`/api/books/${id}`); const open = signal(false); if (open()) console.log("open"); return ; } ``` ## How to fix it Read the signal in JSX, handlers or effects, which are tracked separately from the body. If the value should survive re-runs, create the signal in a parent and pass it down. ## Related - [Async components](../concepts/async.md) # LUM2008: Unsupported list key **Severity:** warning · **Category:** reactivity (2xxx) > The `key` of a `.map()` row cannot be compiled, so the list ignores it and matches rows by > reference. **Reported by:** the compiler. A warning. ## What it means `{items().map((item) =>
  • …
  • )}` compiles to a keyed list: the compiler moves the `key` into a key function, `(item) => item.id`, that the list calls for every item. The key function runs outside the row, so it gets only what the row callback receives: the item and the index. A key compiles when it reads only: - the item parameter, or the bindings of its destructuring pattern (`({ id }) =>
  • `); - the index parameter (``(item, i) =>
  • ``); - bindings it declares itself, such as a nested arrow's parameters (`key={row.map((p) => p.id).join("|")}`); - `const`s at the top of the callback that copy item fields without calls (`const id = item.id`, `const { id } = item`), which the compiler inlines; - anything declared outside the callback. A callback may return in several places, as long as every element it returns carries the same key (`if (!item.visible) return
  • …
  • `). The warning reports a key the compiler cannot use: - a key that reads another value computed in the callback (`const label = format(item.name)` then `key={label}`); - returned elements with different keys; - a constant key (`key="row"`), which every row would share; - a `key` on an element the callback does not return: inside a conditional (`item.done ? : `), a fragment, or a child of the row. Without a key, the list matches rows by reference: a row is kept only while its item is the same object, so new objects (a refetch, re-derived row data) rebuild their rows. ## Example ```tsx export function Products(props: { items: Product[] }) { return (
      {props.items.map((item) => { const slug = slugify(item.name); return
    • {item.name}
    • ; })}
    ); } ``` ## How to fix it Compute the key from the item and the index, in the `key` itself, or call a function declared outside the callback: ```tsx
      {props.items.map((item) => (
    • {item.name}
    • ))}
    ``` For a conditional row, return one element with the key and move the condition inside it: ```tsx
      {items().map((item) => (
    • {item.done ? : }
    • ))}
    ``` ## Related - [JSX: lists and keys](../concepts/jsx.md#lists-and-keys) # LUM3005: Unused scoped selector **Severity:** warning · **Category:** styles (3xxx) > A selector in a scoped `
    ); } ``` ## How to fix it Fix the selector or delete the rule. To style an element rendered by a child component, pass it a `class` (class passthrough carries your scope) or use `:deep(.child)`. For classes added from outside the scope at runtime, use `:global(.name)`. JSX returned by a helper function is not part of a nested block's subtree, even when the helper is declared inside the component and called from inside the styled element: ```stsx export function Form() { const field = (name: string) => ; return (
    {field("Title")}
    ); } ``` Move the block to the top level of the file (a file-level block styles every element of the file), or write the `
    ); } ``` ## How to fix it Fix the CSS at the reported position. Sass syntax (`$variables` with values, `@mixin`, `@include`) is not CSS; `$name` only reads JS constants. CSS nesting is supported. ## Related - [Styles](../concepts/styles.md) # LUM3015: Invalid CSS ignored **Severity:** warning · **Category:** styles (3xxx) > Lightning CSS dropped an invalid rule, declaration or selector from a ` ``` ## How to fix it Fix or remove the reported rule or selector (`.card:hover`). If it targets a newer browser feature on purpose, check the configured browser targets. ## Related - [Styles](../concepts/styles.md) # LUM3016: Invalid `:global()` or `:deep()` selector **Severity:** error · **Category:** styles (3xxx) > A `:global()` or `:deep()` selector is malformed. **Reported by:** the CSS compiler (`lumiose_css`), in `@lumiose/vite`, `lumiose check` and the language server. ## What it means `:global(sel)` leaves `sel` unscoped, and `:deep(sel)` scopes only the part of the selector before it so that `sel` can match inside child components. Both need a selector argument, and a selector may contain at most one `:deep()`, not nested in another. ## Example ```stsx ``` ## How to fix it Give the pseudo-class an argument (`:global(.toast)`), and use a single, top-level `:deep()` per selector (`.list :deep(.item .icon)`). For app-wide CSS, prefer a ` ``` ## How to fix it Correct the value against the property's syntax (`color: navy`, `width: 10px`, `display: flex`). For an auto-repeat grid, give the other tracks a fixed size (`12rem repeat(auto-fit, minmax(9rem, 1fr))`) or move the flexible track into the `minmax()`. If the value is valid newer CSS that is still reported, check the configured browser targets. ## Related - [LUM3015](LUM3015.md): invalid rules and selectors that Lightning CSS drops - [Styles](../concepts/styles.md) # LUM3018: Unitless number from a `$` reference **Severity:** warning · **Category:** styles (3xxx) > A `$` reference resolves to a plain number where the property needs a length, angle or time. **Reported by:** the CSS compiler (`lumiose_css`), in `@lumiose/vite`, `lumiose check` and the language server. A warning; the CSS is emitted unchanged. ## What it means Numbers are inserted without a unit: with `const gap = 8`, `gap: $gap` becomes `gap: 8`. CSS requires a unit on every non-zero length, angle and time. - **Lengths:** Lightning CSS happens to read the bare number as pixels and ships `gap: 8px`, but that is not CSS, and the same constant stays a bare number where a number is allowed (custom properties, `line-height`), so the output depends on where it is used. - **Angles and times:** there is no conversion; browsers ignore `rotate: 45` and `transition: opacity 200`. - **Math functions:** `calc($gap + 1px)` and `min(100%, $max)` mix a number with a length, which is invalid, so browsers ignore the declaration. Zero is fine (`margin: $none` with `const none = 0`), and so are properties that take numbers (`line-height`, `z-index`, `flex-grow`) or unitless SVG lengths (`stroke-width`). ## Example ```stsx const gap = 8; const angle = 45; const duration = 200; ``` ## How to fix it Multiply the constant by the unit, which folds at build time: `gap: calc($gap * 1px)` ships as `gap: 8px`, and `rotate: calc($angle * 1deg)` as `rotate: 45deg`. Inside a math function, multiply in place: `min(100%, $max * 1px)`. For times, choose the unit: `calc($duration * 1ms)` or `calc($duration * 1s)`; the warning has no automatic fix for them. Or keep the unit in the constant: `const gap = "8px"`, or `` `${space}px` `` from a number. ## Related - [LUM3019](LUM3019.md): a unit written after the reference with a space - [Styles](../concepts/styles.md) # LUM3019: Unit separated from a `$` reference **Severity:** warning · **Category:** styles (3xxx) > A unit is written after a numeric `$` reference with whitespace in between, so it does not attach to the number. **Reported by:** the CSS compiler (`lumiose_css`), in `@lumiose/vite`, `lumiose check` and the language server. A warning; the CSS is emitted unchanged. ## What it means A `$` reference ends at the end of its name, so the unit must be separated from it: with `const gap = 8`, `margin: $gap px` becomes `margin: 8 px`, a number followed by the keyword `px`. CSS units must follow their number without whitespace, so browsers ignore the declaration. (`$gappx` would read a constant named `gappx`.) ## Example ```stsx const gap = 8; ``` ## How to fix it Multiply the constant by the unit: `margin: calc($gap * 1px)` ships as `margin: 8px`. Inside a math function, multiply in place: `calc($gap * 1px + 1px)`. Or keep the unit in the constant: `const gap = "8px"`. ## Related - [LUM3018](LUM3018.md): a unitless number where a length, angle or time is required - [Styles](../concepts/styles.md) # LUM3020: Unknown CSS property **Severity:** warning · **Category:** styles (3xxx) > A declaration's property is not a CSS property, so browsers ignore the declaration. **Reported by:** the CSS compiler (`lumiose_css`), in `@lumiose/vite`, `lumiose check` and the language server. A warning; the CSS is emitted unchanged. ## What it means Lightning CSS keeps a declaration with an unknown property name as it is, and browsers skip it, so a typo such as `colr: red` silently does nothing. A property is known when it is in MDN's list of CSS properties (standard, experimental, non-standard and obsolete ones) or Lightning CSS knows it. When a known property is one or two edits away, the help names it and the fix replaces it. Not checked: custom properties (`--brand`), vendor-prefixed properties (`-webkit-line-clamp`, `-moz-…`), at-rule descriptors (`@font-face`, `@page`, `@property`) and names that are not plain identifiers. ## Example ```stsx ``` ## How to fix it Correct the name (`color`, `background-color`): the fix does it for close matches. For a value you read with `var()`, name it as a custom property (`--card-color`). If the property is real but newer than Lumiose's list, the warning is safe to ignore; report it so the list can be updated. ## Related - [LUM3017](LUM3017.md): a known property with a value browsers ignore - [Styles](../concepts/styles.md) # LUM3021: Unsafe `$` constant value **Severity:** error · **Category:** styles (3xxx) > A `$` constant used in a style block holds text that could end its declaration, its rule or the ` ); } ``` ## How to fix it Keep each constant to one CSS value (`"red"`, `"1px solid #000"`, `"\"Inter\", sans-serif"`). For several declarations, use several constants, or set a custom property from a `style` prop and read it with `var()`. ## Related - [LUM3008](LUM3008.md): a `$` reference that is not a constant - [Styles](../concepts/styles.md) # LUM4001: Island component is not exported **Severity:** error · **Category:** islands and boundaries (4xxx) > A component used as an island (`client:*`) is not exported from its module. **Reported by:** the compiler. ## What it means An island's server record names its module and export (`/src/Counter.stsx#Counter`), and the browser imports that export to hydrate it. A component that is not exported cannot be imported. ## Example ```stsx function Counter() { return ; } export default function Page() { return ; } ``` ## How to fix it Export the component: `export function Counter() { … }`. It may stay in the same file. ## Related - [Islands](../concepts/islands.md) # LUM4002: Non-serializable island prop **Severity:** error · **Category:** islands and boundaries (4xxx) > An island prop (or SPA route data) is not serializable. **Reported by:** `lumiose check` and the language server, at the prop, when its declared type cannot be serialized; otherwise the server runtime, with the path of the offending value. ## What it means Island props travel from the server to the browser as data-only JSON. Supported: JSON values plus `undefined`, `NaN`/`Infinity`, `BigInt`, `Date`, `URL`, `Map`, `Set` and `FormData` (its text entries; files are left out). Functions, symbols, class instances, promises and cycles cannot be serialized. SPA routes get the same rule for their data, so `request`, `cookies` and `locals` are not passed in. ## At check time With a `client:*` directive, the JSX types check each prop against its serializable form: a prop whose declared type is a function, a symbol, a promise or an object with methods (a class instance) only accepts `undefined`. `lumiose check` and the editor report the TypeScript error as LUM4002 on the prop: ```text src/routes/page.stsx:2:36 - error LUM4002: Island prop `onSave` is not serializable: it is a function. Island props are sent to the browser as data. ``` The same component without a directive is a server component and takes its props as declared. Props typed `JSX.Element` (slots), `unknown` or `any` are not checked; values TypeScript cannot see through (a class instance without methods, a cycle) are still reported by the server when the page renders. ## Example ```stsx export default function Page() { return save(text)} />; } ``` ## How to fix it Pass data only. Do the work inside the island (it can call an endpoint with `fetchJSON` or post a form with `submit()`), or convert values to plain objects before passing them. ## Related - [Islands](../concepts/islands.md) - [Data and actions](../concepts/data-and-actions.md) # LUM4003: Server-only module in a client graph **Severity:** error · **Category:** islands and boundaries (4xxx) > Server-only code is reachable from the browser. **Reported by:** `lumiose build` and `lumiose dev` (`@lumiose/vite`), when the client graph reaches a server-only module: the build fails, and dev fails the module's request (the error overlay), with the chain of importers from the client entry. `lumiose check` and the language server report it at the import site, for the graphs of island components and of modules that import `lumiose/dom` (the editor follows unsaved edits of open files). At runtime, server-only APIs (`request()`, `cookies()`, `env()`, `renderToString()`, …) throw it in the browser build. ## What it means `*.server.ts` modules (any `*.server.*` file of the project), everything under `src/server/`, and the request APIs only exist on the server. Importing them into code that reaches the browser (islands, SPA routes, client entries, and everything they import, directly or not) would ship server code and secrets in the client bundle, or fail at runtime. Server code (pages rendered on the server, endpoints, actions) imports them freely; stylesheets of `.server.stsx` files are fine. Every way into the client graph counts: imports (static or dynamic, also `?raw` and `?url`), web workers, and `new URL("./db.server.ts", import.meta.url)`, which would ship the file as an asset. Files are matched by their real path (a symlink to a server-only file is server-only) and case-insensitively; a sibling workspace folder outside the Vite root follows the same rule from its own `package.json` folder. Dependencies in `node_modules` are not checked. ```text LUM4003: src/server/db.server.ts is server-only, but client code imports it: src/components/Likes.stsx → src/lib/likes.ts → src/server/db.server.ts ``` ## Example ```stsx import { db } from "../server/db.server.ts"; export function Likes() { return ; } ``` ## How to fix it Keep the server code on the server and call it over HTTP: expose an endpoint (or a form action) and call it with `fetchJSON` or `submit()` from the island. In client code, read public config from `import.meta.env.PUBLIC_*` instead of `env()`. For the current URL, params and route in client code, import `url()`, `params()` and `route()` from `lumiose/router` instead of `lumiose`. Those of `lumiose` read the request, so they are server-only and throw this error in the browser; those of `lumiose/router` are reactive, follow client navigations, and also work during server rendering. Code that runs on both sides, such as a `defineState()` factory, may read request APIs only on the server: guard them with `isServer` (`signal(isServer ? locals().user : null)`). The browser receives the server's values. `isServer` is a constant, so client builds drop the guarded branch. ## Related - [Islands](../concepts/islands.md) - [Server](../concepts/server.md) - [Data and actions](../concepts/data-and-actions.md) # LUM4004: Browser API at module scope **Severity:** warning · **Category:** islands and boundaries (4xxx) > A module that runs on the server uses a browser API at module scope. **Reported by:** the compiler, for `.stsx`/`.tsx` modules: `@lumiose/vite` when it compiles a module for the server (`lumiose dev` and `lumiose build` print the warning), and `lumiose check` and the language server for every module except client entries (modules that import `lumiose/dom`) and `spa` routes with `export const ssr = false`. A warning. ## What it means Components are imported on the server for SSR. `window`, `document`, `localStorage` and similar globals do not exist there, so touching them while the module loads crashes the server render. ## Example ```tsx const saved = localStorage.getItem("theme"); export function ThemeToggle() { return ; } ``` Reported: `window`, `document`, `localStorage`, `sessionStorage`, `navigator`, `location`, `history`, `matchMedia`, `requestAnimationFrame`, `customElements`, `IntersectionObserver` and similar browser-only globals, when they are evaluated at module scope. Not reported: code inside a function (a component, an effect, an event handler, `onMount`) or a class, a `typeof window` check, code guarded by one (`typeof window !== "undefined" && …`, `if (typeof document === "object") { … }`), and a local or imported binding with the same name. Plain `.ts` modules are not analysed. ## How to fix it Read browser APIs inside `onMount`, an effect or an event handler, which only run in the browser: ```tsx export function ThemeToggle() { const theme = signal("light"); onMount(() => theme.set(localStorage.getItem("theme") ?? "light")); return ; } ``` ## Related - [Islands](../concepts/islands.md) - [Signals: onMount](../concepts/signals.md) # LUM4006: Hydration mismatch **Severity:** error · **Category:** islands and boundaries (4xxx) > The DOM the server rendered does not match what the client renders while hydrating. **Reported by:** hydration (`lumiose/dom/hydrate`, run by the island loader), as a console warning in dev, in two cases: the server HTML does not match the client render (the island is then rendered on the client instead), or an async component's await has no recorded value (the client awaits it again). In production there is no warning; the same fallbacks apply. ## What it means Hydration claims server-rendered nodes in order. If the client renders a different structure (other elements, a different branch, a different list length), or an async component's recorded await does not match its replay key, claiming fails. ## Example ```tsx export function Greeting() { return

    {Math.random() > 0.5 ? Hi : Hello}

    ; } ``` ## How to fix it Render the same output on both sides for the same props: move non-deterministic values (`Math.random()`, `Date.now()`, `window` sizes, locale-dependent formatting) into `onMount` or effects, or compute them on the server and pass them as props. Keep awaits in a stable order. ## Related - [Islands](../concepts/islands.md) - [Rendering modes: hydration](../concepts/rendering-modes.md) - [Async components](../concepts/async.md) # LUM4007: Non-serializable awaited value **Severity:** error · **Category:** islands and boundaries (4xxx) > A value awaited inside an island is not plain data. **Reported by:** the server runtime, with the path of the offending value. ## What it means Values awaited in an island's async components are recorded during SSR and replayed during hydration, so they are serialized into the page. A cached value (`fetchJSON`, `query(key, fn)`) that an island awaited is sent once with the page, for the browser cache. A `Response`, a class instance, a function or a stream cannot be. ## Example ```tsx export async function Stock({ sku }: { sku: string }) { const res = await fetch(`/api/stock/${sku}`); const data = await res.json(); return

    {data.count}

    ; } ``` ## How to fix it Await plain data: `const data = await fetchJSON(`/api/stock/${sku}`)`. For `query()`, map the result to plain objects inside its function. Remember that every value awaited inside an island ends up in the HTML; never await secrets there. ## Related - [Async components](../concepts/async.md) - [Islands](../concepts/islands.md) # LUM4008: Module-scope signal written during SSR **Severity:** warning · **Category:** islands and boundaries (4xxx) > A module-scope signal is written during a server render. **Reported by:** planned (not reported by this version). A warning. ## What it means Modules are loaded once per server process, so a signal declared at module scope is shared by every request. Writing it during SSR leaks one user's state into another user's page. ## Example ```tsx const currentUser = signal(null); export function Header(props: { user: string }) { currentUser.set(props.user); return

    {currentUser()}

    ; } ``` ## How to fix it Define state that the page or its islands share with `defineState()`: its factory runs once per request on the server (and once per page in the browser), and islands receive the server's values. ```ts import { defineState, isServer, locals, signal } from "lumiose"; // Seeded from the request on the server; the browser gets the server's value. export const currentUser = defineState(() => ({ name: signal(isServer ? (locals().user ?? null) : null), })); ``` Request state can also live in components, props or context (`` and `useContext`), or in `locals` on the server. Module-scope signals are fine for browser-only state. ## Related - [Sharing state](../concepts/state.md) - [Signals and reactivity](../concepts/signals.md) - [Server](../concepts/server.md) # LUM4009: Unsafe URL blocked in a URL attribute **Severity:** warning · **Category:** islands and boundaries (4xxx) > A dynamic value for a URL attribute would run script, so Lumiose rendered a placeholder instead. **Reported by:** the runtime (`lumiose/dom` and `lumiose/server`), as a console warning in development. The value is blocked in production too, without the warning. ## What it means A URL attribute (`href`, `src`, `action`, `formaction`, `xlink:href`, `poster`, `data`, `cite`, `background`, …) got a value from an expression whose scheme is `javascript:`, `vbscript:` or `data:text/html`. Such a URL runs script in your page when it is followed, which is how injected links turn into cross-site scripting. Lumiose writes `about:blank#blocked` instead, on the server and in the browser alike (decision-29). The check ignores case, leading spaces and control characters, and tabs or newlines inside the scheme, as browsers do. Only dynamic values are checked. A string literal in your JSX (`
    `) is written as it is. ## Example ```tsx export function Profile(props: { website: string }) { // A user who saves "javascript:alert(document.cookie)" as their website gets a dead link. return Website; } ``` ## How to fix it - Run code with an event handler, not a script URL: `; } ``` ## How to fix it - Pass the value as an island prop: ``. Props are sent to the browser, so they must be plain data (`LUM4002`). - Provide the context inside the island, in the island component or a component it renders. - Share state between islands with `defineState()`: one instance per page in the browser, one per request on the server, with the server's values sent to the islands (see [Sharing state](../concepts/state.md)). Contexts provided inside the island work as usual on both sides. ## Related - [Islands](../concepts/islands.md) - [Sharing state](../concepts/state.md) # LUM4011: Non-serializable scoped state value **Severity:** error · **Category:** islands and boundaries (4xxx) > A scoped state (`defineState`) that the page sends to the browser holds a value that is not plain > data. **Reported by:** the server runtime, with the path of the offending value, when the page sends the state: the shell fails (the error page renders), or a streamed boundary keeps its fallback and the error goes to `onError`. ## What it means A state defined with `defineState()` that islands or SPA routes import is sent to the browser with the page: the server's values replace what the factory creates in the browser, so islands start from the same values the server rendered. Only the signals and stores the factory returns are sent, and they must hold plain data, like island props: strings, numbers, booleans, `null`, arrays, plain objects, `Date`, `Map`, `Set`, `BigInt` and `URL`. A class instance, a function or a promise cannot be. ## Example ```ts // lib/session.ts, imported by an island import { defineState, isServer, locals, signal } from "lumiose"; export const session = defineState(() => ({ // A class instance from the auth library: not plain data user: signal(isServer ? locals().user : null), })); ``` ## How to fix it - Keep plain data in the state's signals and stores: `signal(isServer ? { id: user.id, name: user.name } : null)`. - Derive the rest: computeds and functions the factory returns are not sent; they are created again when the factory runs in the browser. - State that only server code uses belongs in a module that no island imports, or in a server-only module (`*.server.ts`): it is never sent. Everything a sent state holds is visible in the page; never put secrets in it. ## Related - [Sharing state](../concepts/state.md) - [Islands](../concepts/islands.md) - [LUM4002](LUM4002.md): non-serializable island prop # LUM5001: Request API used during prerender **Severity:** error · **Category:** routing and server (5xxx) > A request API is used while a route is prerendered: a `static` route, or an `spa` page with > `export const prerender = true`. **Reported by:** `lumiose build`, while it prerenders `static` routes and `spa` pages with `prerender`. ## What it means `static` routes (the default) and `spa` pages with `prerender` are rendered at build time, where there is no request: no cookies, headers, query string or `locals`. Using `request()`, `cookies()`, `locals()`, `response()` or `url().searchParams` there (or `ctx.request`, `ctx.cookies` and `ctx.locals` in a static endpoint or `headers(ctx)`) cannot work, and the build stops. Middleware still runs for prerendered routes and may read the (empty) request; only route code is checked. ## Example ```tsx export default function Home() { const theme = cookies().get("theme"); return

    Home

    ; } ``` ## How to fix it Render the route per request: `export const mode = "server";` (for an `spa` page, remove `prerender` or set `export const prerender = false`). Or keep the route prerendered and move the request-dependent part into an island (in an `spa` page, `onMount()` or an event handler) that reads it in the browser or calls an endpoint. ## Related - [Rendering modes](../concepts/rendering-modes.md) - [Server](../concepts/server.md) # LUM5002: Invalid route file **Severity:** error · **Category:** routing and server (5xxx) > A file under `src/routes` is not a valid route file. **Reported by:** the route scanner (`lumiose check`, typegen, dev server). ## What it means Route files use the Next.js App Router names: `page.stsx`/`page.tsx`, `route.ts`, and the `layout`, `not-found` and `error` component files, plus `middleware.ts` (or `.js`) for folder middleware. Any other file in `src/routes` is a colocated module that the router ignores, and so is everything below a private `_folder`. The message says which rule a route file broke, for example: - a special file with the wrong extension: `page.ts`, `layout.ts`, `route.tsx` (an endpoint is `route.ts`) or `middleware.tsx` (folder middleware is `middleware.ts`); - `src/routes/middleware.ts`: the app's middleware, which runs for every request, is `src/middleware.ts`; a `middleware.ts` in a route folder runs for that folder's routes; - a Next.js special file Lumiose does not have: `loading`, `template`, `default` or `global-error`, a route file below an `@slot` folder (parallel routes) or a `(.)`, `(..)` or `(...)` folder (intercepting routes); - an invalid segment folder (`[]`, `[a-b]`, `post-[id]`, a broken `(group`, unsafe characters), a rest segment that is not the last one, or a param used twice; - `export const mode` that is not a string literal of `static`, `server` or `spa`, or `ssr` that is not `true`/`false`, or `let` instead of `const`; - `export const locales` that is not a list of locale literals, `"all"` or a function, or that lists a locale `lumiose({ i18n })` does not configure ([Internationalization](../concepts/i18n.md)); - a warning for an export that has no effect: `ssr = false` outside `spa` routes, `prerender` in an endpoint or in a layout or page whose mode is `static` or `server` (a `spa` layout's `prerender` applies to the pages below it), or `locales` without `lumiose({ i18n })`. ## Example ``` src/routes/blog/loading.stsx Lumiose has no loading files src/routes/@modal/page.stsx Lumiose has no parallel routes src/routes/api/route.tsx an endpoint is a route.ts file src/routes/middleware.ts the app's middleware is src/middleware.ts ``` ## How to fix it Follow the `help` text. Show pending UI with `` instead of `loading`, use a layout instead of `template`, rename the folder or file, and declare `export const mode = "server";` with a literal. To keep a file next to the routes without the router reading it, give it another name or move it into a `_folder`. ## Related - [Routing](../concepts/routing.md) - [Rendering modes](../concepts/rendering-modes.md) # LUM5003: Duplicate route **Severity:** error · **Category:** routing and server (5xxx) > Two route files produce the same URL pattern. **Reported by:** the route scanner (`lumiose check`, typegen, dev server). ## What it means Each URL must come from one file. Groups add no segment, a folder holds a page or an endpoint, and an optional segment also serves the URL without it, so different files can collide. ## Example ``` src/routes/blog/page.stsx src/routes/blog/route.ts both are /blog src/routes/(shop)/cart/page.stsx src/routes/cart/page.stsx both are /cart src/routes/docs/page.stsx src/routes/docs/[[...path]]/page.stsx both serve /docs ``` ## How to fix it Delete or move one of the files. Param names do not distinguish routes: `[id]/page.stsx` and `[slug]/page.stsx` in the same folder also collide. For an optional segment, drop the page that duplicates it (`docs/[[...path]]` already renders `/docs`), or use `[...path]`, which needs at least one segment. ## Related - [Routing](../concepts/routing.md) # LUM5004: Head or status change inside a streamed boundary **Severity:** error · **Category:** routing and server (5xxx) > The status, headers, cookies or head changed after the SSR shell was sent. **Reported by:** the server runtime. ## What it means A `server` response starts with the shell: everything outside ``. Once it is sent, the status line, headers (including `Set-Cookie`) and `` are on the wire. Content inside a streamed `` boundary that calls `response().setStatus()`, `setHeader()`, `setStreaming()`, `cookies().set()`, `redirect()`, or renders ``/`<Meta>`/`<Head>` is too late. ## Example ```tsx export default function Product() { return ( <Suspense fallback={<p>Loading…</p>}> <Details /> </Suspense> ); } async function Details() { const product = await fetchJSON<Product>("/api/product"); return <Title>{product.name}; } ``` ## How to fix it Decide status, headers, cookies and head content in the shell: in middleware, in `headers(ctx)`, or in components outside `` (an async component without a boundary holds the shell until it resolves). Keep `` for content that can stream. ## Related - [Rendering modes: shell first, then stream](../concepts/rendering-modes.md) - [Head and document](../concepts/head.md) - [Server](../concepts/server.md) # LUM5005: Non-GET method in a static endpoint **Severity:** error · **Category:** routing and server (5xxx) > A `static` endpoint exports a method other than `GET`. **Reported by:** the route scanner (`lumiose dev`, `lumiose check`, `lumiose build`). ## What it means `static` endpoints (the default mode) are prerendered by `lumiose build`: their `GET` response is written to a file in `dist/client`, such as `sitemap.xml`. A file can only answer `GET`, so a `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` or `OPTIONS` export would never run in production. ## Example ```ts // src/routes/api/subscribe/route.ts export const GET: Endpoint = () => Response.json({ open: true }); export const POST: Endpoint = async (ctx) => { await subscribe(await ctx.request.formData()); return new Response(null, { status: 204 }); }; ``` ## How to fix it Serve the endpoint per request: `export const mode = "server";` (or set it on a layout of its folder). Keep the endpoint `static` only if it serves `GET` alone. ## Related - [Rendering modes](../concepts/rendering-modes.md) - [Routing](../concepts/routing.md) # LUM5006: Prerendered page that renders only in the browser **Severity:** error · **Category:** routing and server (5xxx) > An `spa` page's `prerender` is `true`, set on the page or inherited from a layout, but its `ssr` is `false`. **Reported by:** the route scanner (`lumiose dev`, `lumiose check`, `lumiose build`). ## What it means The two options ask for opposite things. `prerender = true` renders the page's HTML at build time, and the browser hydrates it. `ssr = false` renders the page only in the browser: the server sends an empty shell and never loads the page's modules. The `prerender = true` can come from the page itself or from the nearest layout that sets it. The `ssr = false` can come from the page itself, from a layout above it, or from the project default `lumiose({ ssr: false })`. When both are inherited, the error is reported once, on the deeper of the two layouts. When the page sets `ssr = false` itself, it is reported on the page. ## Example ```stsx // src/routes/layout.stsx export const mode = "spa"; export const ssr = false; ``` ```stsx // src/routes/page.stsx export const prerender = true; export default function Home() { return

    Home

    ; } ``` A browser-only page below a prerendering layout: ```stsx // src/routes/layout.stsx export const mode = "spa"; export const prerender = true; ``` ```stsx // src/routes/editor/page.stsx export const ssr = false; ``` ## How to fix it - **To prerender the page,** let it render on the server: remove `export const ssr = false`, or set `export const ssr = true` on the page. Then move browser-only code (`window`, `document`, `AudioContext`, `localStorage`) out of module scope and component setup, into event handlers or `onMount()`. - **To keep the client-rendered shell,** remove `export const prerender = true`, or, when it comes from a layout, set `export const prerender = false` on the page (or on a layout between the two). A shell without params is already written to a static file by `lumiose build`. `lumiose({ prerender: true })` together with `lumiose({ ssr: false })` is rejected by the plugin for the same reason. With only `lumiose({ prerender: true })`, a page that sets `ssr = false` stays a shell without this error. ## Related - [Rendering modes](../concepts/rendering-modes.md#prerendered-spa-routes) - [LUM5001](LUM5001.md): request API used during prerender. # LUM5007: Route param named `locale` **Severity:** error · **Category:** routing and server (5xxx) > A route param is named `locale`, which `href()` reserves with `lumiose({ i18n })`. **Reported by:** the route scanner (`lumiose check`, typegen, dev server), only with `lumiose({ i18n })`. ## What it means With `lumiose({ i18n })`, the router handles the locale prefix of every URL (`/es/about`), and `href(pattern, { locale })` uses the `locale` key to pick the URL's locale. A `[locale]` (or `[[locale]]`) folder would give a route a param with the same name, which `href()` could not fill. ## Example ``` src/routes/[locale]/about/page.stsx "/[locale]/about" has a param named "locale" ``` ## How to fix it Remove the `[locale]` folder: `src/routes/about/page.stsx` already serves `/about`, `/es/about` and every other locale it declares with `export const locales`, and `locale()` reads the request's locale. If the param means something else, rename it (`[lang]`, `[region]`). ## Related - [Internationalization](../concepts/i18n.md) - [Routing](../concepts/routing.md) # LUM5008: Folder middleware covering prerendered routes **Severity:** warning · **Category:** routing and server (5xxx) > A folder `middleware.ts` covers routes that are prerendered at build time. **Reported by:** the route scanner (`lumiose check`, `lumiose routes`, the dev server and `lumiose build`), once per middleware file, listing the prerendered routes it covers. ## What it means Folder middleware runs whenever the app handles a route, the same rule `src/middleware.ts` follows: per request for `server` routes, for every route in `lumiose dev`, and at build time for prerendered ones (`static` pages and endpoints, and `spa` pages with `prerender`). Production serves a prerendered route's file without the app, so its middleware never sees those requests. That is fine for headers and the HTML it shapes at build time, but a check meant to guard the route (auth, a feature flag, a redirect) protects nothing in production, although it seems to work in `lumiose dev`. ## Example ``` src/routes/admin/middleware.ts redirects visitors who are not signed in src/routes/admin/page.stsx mode "static" (the default): prerendered, served as a file ``` ## How to fix it Render the routes the middleware protects per request: `export const mode = "server"` in the page, in a layout above it, or for the whole app with `lumiose({ mode: "server" })`. If the middleware only adds headers or rewrites HTML at build time, move the prerendered routes out of its folder, or let the warning stand. ## Related - [Server](../concepts/server.md) - [Rendering modes](../concepts/rendering-modes.md) - [Routing](../concepts/routing.md) # LUM5009: Boundary still pending when streamTimeout expired **Severity:** error · **Category:** routing and server (5xxx) > A `` boundary was still waiting for data when `lumiose({ streamTimeout })` expired. **Reported by:** the server runtime, to the `` around the waiting component (or to the boundary itself), to `onRequestError` with phase `"stream"`, and to the server log. ## What it means A page render waits for its pending `` boundaries for `streamTimeout`, 30 seconds by default. When it expires, every async component still waiting fails with this error: the nearest `` around it renders its fallback into the stream. A boundary with no `` inside keeps its `` fallback. Then the response ends, so a data source that never answers cannot hold the connection open. In production the `` fallback receives a generic error with a digest, as for any error (the log has the original). A response that does not stream (`response().setStreaming(false)`) waits for the same timeout; there a boundary with no `` fails the page, as any error inside it would. ## Example ```tsx export default function Dashboard() { return ( Loading…

    }> The report is taking too long. Try again later.

    }>
    ); } async function Report() { const report = await fetchJSON("/api/report"); // the service never answers return ; } ``` ## How to fix it Find the data source that did not answer in time (`onRequestError` receives the route). Give it its own timeout or cache, render an `` around the component so readers see a useful fallback, or raise the limit with `lumiose({ streamTimeout: "60s" })`. `streamTimeout: false` waits as long as the client stays connected. Prerendering ignores the timeout. ## Related - [Rendering modes: shell first, then stream](../concepts/rendering-modes.md) - [Server](../concepts/server.md) - [`lumiose()` options](../reference/vite.md) # LUM6001: tsconfig missing options required by Lumiose **Severity:** warning · **Category:** tooling and config (6xxx) > The project's `tsconfig.json` lacks an option that type checking `.stsx` files depends on. **Reported by:** `lumiose check`, on the option's key in the tsconfig. A warning: the check still runs, but some files are typed wrongly or not at all. ## What it means `lumiose check` type-checks `.stsx` files through generated shadow TSX files in `.lumiose/shadow` (spec §4.7). That only works when the tsconfig sets: - `"jsx": "preserve"` and `"jsxImportSource": "lumiose"`, so JSX is typed as Lumiose JSX; - `rootDirs` pairing the sources with their shadows (`["src", ".lumiose/shadow/src"]`), so `import "./Card.stsx"` resolves to the shadow. The warning also fires when `rootDirs` pairs only `src` but a Lumiose package the project uses (a linked workspace package) has `.stsx` files: they are outside the pair and are not type-checked; - `include` covering `.lumiose/shadow` (routes and other files nothing imports) and `.lumiose/types` (route types); - `types` containing `"lumiose/env"` (Lumiose's globals). ## Example ```json { "compilerOptions": { "strict": true, "jsx": "react-jsx" }, "include": ["src"] } ``` ## How to fix it Extend the tsconfig that `lumiose check` generates, which holds all of these options and pairs linked packages too (decision-25), and add your own options on top: ```json { "extends": "./.lumiose/tsconfig.generated.json", "compilerOptions": { "strict": true } } ``` Or set the option named in the message, as its help line says. ## Related - [Compiler and tooling](../concepts/compiler.md) - [JSX reference](../reference/jsx.md) # Diagnostics > Every `LUMxxxx` code the Lumiose tools report, with its long-form explanation. Codes are stable forever and never reused. Print one with `lumiose explain LUM1004`, or open `errors/LUM1004.md`. Each diagnostic carries `{ code, severity, message, span, labels, help, fix?, docs }`; `lumiose check --format json` prints them. ## File format (1xxx) | Code | Severity | Title | | --------------------- | -------- | ------------------------------------ | | [LUM1001](LUM1001.md) | error | Syntax error | | [LUM1002](LUM1002.md) | error | Unsupported by this compiler version | | [LUM1003](LUM1003.md) | warning | Scanner ambiguity | | [LUM1004](LUM1004.md) | error | Unclosed `