Diagnostics

LUM4003: Server-only module in a client graph

Server-only code is reachable from the browser.

Severity: error · Category: islands and boundaries (4xxx)

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 <button onClick={() => db.like()}>Like</button>;
}

#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.