Reference
@lumiose/vite
The Vite 8 plugin: compiles .stsx and .tsx files, reports diagnostics in the overlay and reloads on edit.
Install it next to lumiose and Vite: pnpm add -D @lumiose/vite vite. Both are peer
dependencies, so the plugin, the compiled modules and its Vitest setup share the app's one copy of
lumiose.
#lumiose()
import lumiose from "@lumiose/vite";
function lumiose(options?: LumioseOptions): Plugin[];
interface LumioseOptions {
/** Modules compiled by Lumiose. Default: .stsx and .tsx files. */
include?: RegExp;
/** Modules left to Vite. Default: node_modules, except the Lumiose packages installed there. */
exclude?: RegExp;
/** The project's default route mode; a layout's or route's `export const mode` overrides it. */
mode?: "static" | "server" | "spa"; // default "static"
/** Whether spa routes render their content on the server (false: the shell only). */
ssr?: boolean; // default true
/** Whether spa pages that render on the server are prerendered at build time; a layout's or page's `export const prerender` overrides it. */
prerender?: boolean; // default false, and not with ssr: false
/** Trailing slashes of page URLs: "always"/"never" redirect to the canonical form. */
trailingSlash?: "ignore" | "always" | "never"; // default "ignore"
/** The origin check of form posts (on by default); false turns it off. */
csrf?: false | { allowedOrigins?: readonly string[] };
/** `.svg?component` imports as inline components; false leaves them to Vite (URLs). */
svg?: boolean | { optimize?: boolean | SvgoConfig; currentColor?: boolean }; // default true
/** `.md` imports as components; the hook validates or transforms each file's frontmatter. */
md?: boolean | { frontmatter?(data: Record<string, unknown>, ctx: { file: string }): unknown }; // default false
/** `.html` imports as components. */
html?: boolean; // default false
/** View Transitions (not under reduced motion): between MPA pages, and for SPA navigations. */
viewTransitions?: boolean; // default false
/** Speculation rules that prefetch the same-origin links of MPA pages. */
prefetch?: "hover" | "viewport" | "none"; // default "hover"
/** A strict Content Security Policy: nonces per response, hashes for prerendered pages. */
csp?: "strict";
/** How scoped <style> blocks mark and match their elements (decision-85). */
scopedStyles?: {
strategy?: "attribute" | "class"; // default "attribute": data-lum-<hash>; "class": lum-<hash>
specificity?: "default" | "zero"; // default "default": +0,1,0; "zero": :where(), +0
namespace?: string; // default "lum"
};
/** Response headers: a record for every response, or rules by path (below base). */
headers?: Record<string, string> | { path: string; headers: Record<string, string> }[];
/** Request body limits, in bytes or "512kb"/"1mb": larger requests get 413. */
limits?: { body?: number | string; upload?: number | string }; // default { body: "1mb", upload: "10mb" }
/** How long a page render waits for pending <Suspense> boundaries: milliseconds, "30s", "500ms", "2m", or false. */
streamTimeout?: number | string | false; // default "30s"
/** The deploy adapter: cloudflare(), vercel() or netlify() from their @lumiose/adapter-* packages. */
adapter?: Adapter; // default: none, the Node build
/** Locale routing: unprefixed default locale, prefixed others, per-page locale declarations. */
i18n?: {
locales: string[]; // URL path segments, such as "en" or "pt-BR"
defaultLocale: string; // served without a prefix
missing?: "404" | "redirect"; // default "404"
detect?: false | { cookie?: string; acceptLanguage?: boolean }; // default false
};
/** The site's URL, for the absolute URLs of prerendered pages (hreflang, canonical); a path is the base. */
site?: string; // such as "https://example.com" or "https://example.com/docs"
/** The in-page inspector of `lumiose dev` (Alt+Shift+L, Alt+click to the source); dev only. */
inspector?: boolean; // default true
}The project options (mode, ssr, prerender, trailingSlash, csrf, viewTransitions,
prefetch, headers, limits, streamTimeout, adapter, i18n, site, inspector) are
read by lumiose dev, lumiose build and lumiose routes/check from the plugin's api.options.
Invalid values and unknown option names throw when the config loads, as does prerender: true
with ssr: false (prerendered SPA routes). csp: "strict" is
described in Security: nonces on every inline script and style Lumiose
writes and on those written in JSX, static style strings as nonce'd rules, a Content-Security-Policy header for pages rendered per request, and a hash policy
<meta> in prerendered pages. allowedOrigins takes full
origins such as "https://app.example.com", without a path or trailing slash.
Scope markers (scopedStyles, decision-85). How elements carry the scope of <style> blocks
and how scoped selectors match it: data-lum-<hash> attributes with a +0,1,0 bump by default;
strategy: "class" uses lum-<hash> classes merged into each element's class, specificity: "zero" wraps the marker in :where(), and namespace replaces lum. Every compile of the app
uses it, so templates and CSS agree. See Choosing the scope marker.
Response headers (headers). Declare them once: a record applies to every response; rules
apply to the paths they match, below Vite's base (* matches one segment, ** any number,
later rules win for the same header). lumiose dev, lumiose preview and @lumiose/adapter-node send
them on every response, static files included, and lumiose build writes them to
dist/client/_headers, the file Netlify and Cloudflare Pages read (** becomes *, a *
segment a :name placeholder). Headers that middleware, endpoints or response().setHeader() set
win. Two headers are sent by default, as Rails and Django do: X-Content-Type-Options: nosniff and
Referrer-Policy: strict-origin-when-cross-origin; give them another value in headers, or ""
to remove one (_headers gets ! name).
Locales (i18n) and site (site). With i18n, route files stay unprefixed: the
default locale is served at /about, the others under their prefix (/es/about), and a page
exists in the locales it declares with export const locales. missing decides what a page
requested in another locale answers, detect redirects visitors to their locale on entry only. See
Internationalization. site is the site's URL ("https://example.com"):
prerendered pages have no request, so their absolute hreflang and canonical URLs are its origin
followed by the href() path, which already has Vite's base and the trailing-slash policy;
without site they are root-relative, and lumiose build warns. Pages rendered per request use
the request's origin. A path in site ("https://example.com/docs", trailing slash optional) is
the app's base: it becomes Vite's base when the config sets none, and a config base that
differs from it is an error naming both, so the path is never counted twice.
In-page inspector (inspector, decision-59). lumiose dev mounts the inspector on every page
from its dev client: Alt+Shift+L or the corner button opens the component tree, signals, islands
and why something updated, and Alt+click opens an element's source through Vite's open-in-editor
endpoint (Dev tools). In dev, the plugin also compiles every element that renders a box (not <title>, <meta> and
the like) with data-lumiose-source="<file>:<line>:<column>", signal and computed variables with their names, and
component registrations with their locations, and the runtime records for the inspector behind the
import.meta.env.LUMIOSE_INSPECTOR define. Builds and Vitest get none of it. inspector: false
removes all of it from lumiose dev too.
Body limits (limits). body caps URL-encoded forms, JSON and any other non-multipart
request body (default "1mb"), upload caps multipart/form-data bodies (default "10mb"). A
route's export const limits = { upload: "100mb" } overrides them for its requests. Sizes are
bytes or strings with b, kb, mb or gb (1024-based); a size past 2^53 - 1 bytes is an
error. See Security.
Streaming timeout (streamTimeout). How long a page render waits for its pending
<Suspense> boundaries, from the start of the render: milliseconds, or a string with ms, s or
m ("30s", the default). Once it expires, async components still waiting fail with
LUM5009: the nearest <ErrorBoundary> renders its fallback into the
stream, a boundary without one keeps its <Suspense> fallback, onRequestError receives the error
with phase "stream", and the response ends. It applies to lumiose dev and the build output on
every host, also to responses that response().setStreaming(false) resolves in full; prerendering
ignores it. false waits as long as the client stays connected. Values past 2^31 - 1 ms are an
error. See Rendering modes.
lumiose({
headers: [
{ path: "/**", headers: { "cross-origin-opener-policy": "same-origin" } },
{ path: "/admin/**", headers: { "x-robots-tag": "noindex" } },
],
});| Deployment | Where the headers come from |
|---|---|
lumiose dev, lumiose preview |
lumiose({ headers }) |
@lumiose/adapter-node |
lumiose({ headers }), from the build (config.headers of dist/server/entry.js) |
dist/server/entry.js (Bun, Deno) |
lumiose({ headers }), from the build, on static files and the app's responses |
| Cloudflare, Netlify adapters | dist/client/_headers for static files; the build's handler for the rest |
| Vercel adapter | config.json routes |
| Netlify, Cloudflare Pages | lumiose({ headers }), as dist/client/_headers |
| Other static hosts and CDNs | The host's own header configuration, copied from lumiose({ headers }) |
Per-response headers that depend on the request belong in middleware or response().setHeader().
Deploy adapters (adapter, decision-45). After prerendering, lumiose build hands the build to
the adapter, which writes the platform's output: cloudflare() from
@lumiose/adapter-cloudflare (guide), vercel({ runtime }) from
@lumiose/adapter-vercel (guide), netlify() from
@lumiose/adapter-netlify (guide). These platforms serve dist/client
themselves and compress on their own, so the build writes no .br/.gz copies and the server
build has no static file layer. Without an adapter, the output is the Node build that
@lumiose/adapter-node, Bun and Deno serve (Deploy). The adapter packages
have named exports only:
import { cloudflare } from "@lumiose/adapter-cloudflare";
lumiose({ adapter: cloudflare() });MPA navigation (Routing): the page documents that lumiose dev and
lumiose build render get, in <head>:
viewTransitions: true:<style>@media (prefers-reduced-motion:no-preference){@view-transition{navigation:auto}}</style>, on every page,spapages included (their client navigations are not cross-document: the SPA router swaps their pages insideviewTransition()instead).prefetch: a<script type="speculationrules">on every page exceptspapages, whose<Link prefetch>prefetches instead. It matches the links below Vite'sbase(href_matches), except the URLs of endpoints, withmoderateeagerness for"hover"(the pointer rests on the link, or it is pressed) andeagerfor"viewport"(as soon as the browser sees fit: desktop Chromium on a brief hover, mobile Chromium as links enter the viewport);"none"emits nothing. On a page with<Link prefetch>links (markeddata-l-prefetch), the rules give each level its ownselector_matchesrule, and the default's rule skips links marked otherwise; with"none", only such pages get rules (lumiose/router).
Both are inline elements: a Content-Security-Policy you set without 'unsafe-inline' needs
'inline-speculation-rules' in script-src for the rules, and the hash of the <style> in
style-src.
What it does:
- Compiles Lumiose modules before Vite's own transform, which is told to skip them. Server consumers
(SSR) get
lumiose/servercode; everything else getslumiose/domcode. - Resolves
lumioseto itsbrowserentry in client environments and itsnodeentry in server environments, and keeps everylumioseentry out of dependency pre-bundling, so compiled code and the runtime share one module instance. Under Vitest, tests in a DOM environment (happy-dom,jsdom) get thebrowserentry too (see Testing). - Turns compiler diagnostics into Vite warnings and errors with code frames (the dev overlay).
- Names scoped state: each module-scope
defineState()binding imported fromlumiosegets an id after its module and export (/src/lib/cart.ts#cart), as island refs do, where client code (islands, SPA routes) reaches it; pages send only those states' values (defineState). Under Vitest it adds a setup file that resets scoped state before each test. - Loads
.svg?componentimports (./icon.svg?component, or a package'slucide-static/icons/house.svg?component) as inline components for the environment's target, optimised with SVGO (optimize:truefor Lumiose's defaults,preset-defaultplusconvertStyleToAttrs; your SVGO config; orfalse). Scripts, event handlers andjavascript:URLs are always removed and ids prefixed per file.currentColor: truereplaces every colour withcurrentColor(SVGO'sconvertColors, before other plugins). Every other.svgimport (bare,?url,?inline,?raw) is Vite's. See SVG imports. - With
md/html, loads project.mdand.htmlfiles as static components: their source becomes the Lumiose module<file>.md.stsx(.html.stsx), compiled like any.stsxfile, withfrontmatterfrom<file>.md?lumiose-frontmatter,headings, relative asset URLs as?urlimports, and its<script>elements loaded as a?clientscript from<file>.html?lumiose-script, without an island. HTML entries (index.html, HTML build inputs) andpublic/files stay Vite's. See Markdown and HTML imports. - Loads
?clientimports (./menu.element.ts?client): client environments get the module itself and a default export that does nothing, or, when the importer only calls the import conditionally (never as a statement of its own), a default export that imports the module on its first call; server environments get a default export that adds the module's script to the page being rendered, without running the module. Builds build each script that the project scan finds as a chunk of its own: every one of the app's, and those of the Lumiose packages' modules that the app's modules can use. See Islands ("Scripts of server-rendered components"). - Hot updates: an edited component re-renders in place, in every instance, and the rest of the page
keeps its DOM and state (
lumiose devregisters each named component of client code withlumiose/dom/hot). Only the components whose code changed re-render; an edit outside every component of the file (a constant, a helper) re-renders all of the file's components. Components hydrated in an island or on the SPA's first page update the same way once hydrated. A module whose exports are not all such components (a constant or signal export, a changed export list, an async or anonymous default component) falls back to the app entry: a client module that importsrenderfromlumiose/domaccepts updates of every module it reaches and re-renders the app into the same container (component state resets; module-level signals of unchanged modules survive), else the page reloads. - Style blocks become
<file>?lumiose&type=style&lang.cssmodules handled by Vite's CSS pipeline. Modules that$references import constants from are tracked as dependencies of that CSS module. A style-only edit, or an edit to such a constants module, hot-updates the CSS without remounting.
Status: compilation, environments, diagnostics, HMR, style-block CSS,
.svg,.mdand.htmlimports and the project options above are available. The designed optioncssis not.
#Lumiose packages
A Lumiose package is a dependency that lists lumiose in its dependencies or
peerDependencies (any dependency field), found from the app's package.json and, transitively,
from the Lumiose packages themselves. @lumiose/ui and any component library built for Lumiose
are; Lumiose's own tooling (@lumiose/cli, @lumiose/vite, the adapters, the editor plugins) is
not. The plugin treats their modules like the app's own, whether the package is linked
("@acme/ui": "workspace:*") or installed in node_modules from npm or a tarball:
- Compiled with the app's options. Their
.tsxand.stsxmodules go through the Lumiose compiler for the server or the browser, with the app's targets, CSP mode and dev tools, inlumiose devand builds alike. The defaultexcludeleaves every other package innode_modulesto Vite; an explicitexcludereplaces that rule, soexclude: /node_modules/compiles no installed package. - Scanned with the project. Builds find their island call sites, stylesheets and
client scripts (
?client) before the server graph exists, so their islands get chunks and their pages load their scripts. An installed package is scanned whole,dist/included, but not the packages in its ownnode_modules; a linked one without its build output (dist/,build/) and nested projects (a folder with apackage.json,tsconfig.jsonorvite.config.*). A build builds only the client scripts of the package modules the app's modules can run: it follows their imports by name, through re-exports, soimport { Tabs } from "@lumiose/ui"builds the script of Tabs and not those of the other components. A module that runs builds all its scripts, whichever export the app uses. A package that may resolve its modules differently in the browser and on the server (exportsorimportsconditions such asbrowserornode, or abrowserfield) builds all of its scripts. When the imports cannot be followed (a package module with animport()of a computed specifier, orimport.meta.glob()), the build builds every script, as it would without the scan. - One
lumiose. They are bundled with the server code (resolve.noExternal) and kept out of Vite's dependency pre-bundling (optimizeDeps.exclude), so their modules and the app's share one runtime instance, and Vite never compiles their JSX for React. Their CommonJS dependencies, which the browser can only import pre-bundled, are added to the browser'soptimizeDeps.includefor you ("@acme/ui > some-cjs-dep"); ES-module dependencies are served as they are. - Stable names. Island refs and scope tokens name their modules package-relatively
(
@acme/ui/dist/Nav.tsx#Nav), wherever the package manager put them (node_modules/.pnpm/…included). - Their warnings stay theirs. Compiler warnings in an installed package's files (
LUM2001, …) are the package's to fix, not the app's:lumiose devsays once per package that it has some, and a build ends with one line per package, its codes and their counts, instead of each warning. A linked package is being worked on, so its warnings show like the app's. Errors always show.
Publishing a component library. Ship the sources (.tsx, .stsx, .ts, .css) and point
exports at them, with lumiose as a peer dependency: no build step, and the app's compiler
compiles them for its own server and browser targets. List client scripts and stylesheets in
sideEffects (["**/*.css", "**/*.script.ts"]), so bundlers keep their side-effect imports.
@lumiose/ui is published this way (dist/ holds a copy of src/ without tests).
#createDevHandler()
import { createDevHandler } from "@lumiose/vite/dev";
function createDevHandler(options?: DevHandlerOptions): Promise<DevHandler>;
interface DevHandlerOptions {
/** Your HTTP server: Vite's HMR WebSocket upgrades there. Default: HMR on a port of its own. */
server?: import("node:http").Server;
root?: string; // default: the current directory
configFile?: string | false; // default: vite.config.* in the root
mode?: string; // Vite mode (.env files), default "development"
routesDir?: string; // default "src/routes"
logLevel?: LogLevel;
customLogger?: Logger;
}
interface DevHandler {
(req: IncomingMessage, res: ServerResponse, next?: (error?: unknown) => void): void;
readonly vite: ViteDevServer; // Vite in middleware mode
close(): Promise<void>; // stops Vite (watcher, HMR); your server stays open
}lumiose dev inside your own Node server (decision-52). It starts Vite in middleware mode with the
project's vite.config.ts and returns a handler of the same kind as @lumiose/adapter-node's
createHandler(): a request listener, or connect/Express
middleware (errors go to next). Everything lumiose dev does works through it: modules and
assets, the routes, the error overlay, HMR, in-place updates after server-only edits and typegen.
Pass your server as server so HMR shares its port: Vite takes only its own upgrades
(Sec-WebSocket-Protocol: vite-hmr) and leaves the others to your upgrade listeners, so
leave an upgrade alone, rather than destroying it, when your code does not handle it in dev.
lumiose dev stays the default; this is for projects that own their server. @lumiose/vite is a
dev dependency: import it only in dev, and production never loads Vite.
// 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";
const dev = process.argv.includes("--dev");
const server = createServer();
const handler = dev
? await (await import("@lumiose/vite/dev")).createDevHandler({ server })
: await createHandler();
server.on("request", handler);
server.listen(3000);#Example
// vite.config.ts
import lumiose from "@lumiose/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [lumiose({ mode: "server", exclude: /[\\/](node_modules|legacy)[\\/]/ })],
});