The Dev Server
plumix dev runs the same worker your deploy runs, plus a set of surfaces that exist only while you are developing: a debug bar carrying the request’s SQL, a captured request history, an error page carrying stacks. Those answer only to requests that arrived over loopback.
Overview
Section titled “Overview”Two gates stand in front of every development surface, and both have to be open.
The first is the build. process.env.PLUMIX_DEV is a literal the Vite plugin substitutes — "1" under plumix dev, empty under plumix build — so each surface sits inside a branch that is dead code in a production bundle. The modules behind it are not merely unreachable in a deploy; they are absent from it.
The second is the request. A dev server is routinely reachable from somewhere other than the machine running it: a tunnel opened to test a webhook, a container bound to 0.0.0.0, a forwarded codespace port. The environment variable says nothing about who is on the other end of the socket, so core reads the request’s Host as a proxy for the interface it arrived on and serves a development surface only when that host is loopback — localhost, 127.0.0.0/8 or [::1].
Off-loopback the surfaces are not merely refused, they are absent: the debug bar is not injected, the history route 404s, and a failure renders your theme’s own error page instead of the dev one. What is withheld is the disclosure, not the site.
Quickstart
Section titled “Quickstart”-
Develop on the machine running the server. Nothing to configure —
http://localhost:5173is loopback, so the bar, the history and the dev error page are all there. -
Reach it from somewhere else and they go quiet. A phone on the LAN, a tunnel hostname, a forwarded codespace port: the pages render, the development surfaces do not.
-
Open them for that session, deliberately:
Terminal window PLUMIX_DEV_ALLOW_REMOTE=1 pnpm devEvery surface behaves as it does over loopback, for anyone who can reach the port. Set it when you are reviewing on a phone or demoing through a tunnel; leave it unset the rest of the time.
What sits behind the development gate
Section titled “What sits behind the development gate”- The debug bar, injected into every rendered page — its panels carry the request’s queries, the resolved template, the mail it sent and the full telemetry tree. The Mail panel lists each mail by name, recipient and subject, and opens it with an HTML tab, shown in a sandboxed iframe, and a text tab.
/_plumix/debug/requestsand/_plumix/debug/requests/<id>, the captured history the bar’s switcher reads, holding the same detail for every recent request./_plumix/debug/mail, the mail gallery, which previews every declared mail, and/_plumix/debug/mail/sent, the mail the dev server caught as JSON. Mail covers both.- The dev error page, which renders the exception, its stack and the request context that led to it. Off-loopback the theme’s
server-errortemplate answers instead, exactly as it does in a deploy. - The dev server’s own endpoints — the source-excerpt reader behind the error page’s frames, the sourcemap resolvers behind the island overlay, and the browser-errors-to-terminal sink. These are Vite middlewares that answer ahead of the worker, and the source reader hands back any file under Vite’s fs allowlist, so it is the widest of the set.
- Every plugin route registered
auth: "development"— the@plumix/plugin-ogcard preview at/_plumix/og/previewis the one that ships.
Registering a development route
Section titled “Registering a development route”A plugin route that exists only while you are developing declares auth: "development", and core applies both gates to it:
import { definePlugin } from "plumix/plugin";
export const preview = definePlugin("preview", { setup: (ctx) => { if (!process.env.PLUMIX_DEV) return; ctx.registerRoute({ method: "GET", path: "/inspect", auth: "development", handler: () => new Response("what the renderer sees"), }); },});The process.env.PLUMIX_DEV branch is what keeps the handler and its imports out of a production bundle; auth: "development" is what decides, per request, whether the route answers. Off-loopback it returns 404 rather than 401 — the existence of the route is itself development detail.
Adding to the debug bar and the error page
Section titled “Adding to the debug bar and the error page”A plugin adds its own dev tooling with three registrations in setup. Each takes an id, and an id that core or another plugin already registered fails boot:
ctx.registerDebugPaneladds a panel to the debug bar. Itsrender(snapshot)reads the request’s recorded data, so a past request in the switcher replays like the current one.ctx.registerErrorPaneladds a section to the dev error page. Itsvisible(error)decides which errors get the section, and every error does when it is unset.ctx.registerErrorHintadds a how-to-fix hint to the dev error page, shown for each error itsmatches(error)accepts, after core’s own hints.
import { definePlugin } from "plumix/plugin";
export const queue = definePlugin("queue", { setup: (ctx) => { if (!process.env.PLUMIX_DEV) return; ctx.registerErrorHint({ id: "queue-full", matches: (error) => error instanceof Error && error.message.includes("queue full"), title: "Drain the queue", body: "Start the worker with `pnpm queue:work`, then reload.", }); },});Outside plumix dev the three registrations do nothing, and the process.env.PLUMIX_DEV branch keeps the item and its imports out of a production bundle. A render, visible or matches that throws hides only its own item, and the failure goes to the site’s logger.
Opening a frame in your editor
Section titled “Opening a frame in your editor”Each frame on the dev error page carries an Open in editor link. It is a plain anchor to your editor’s URL scheme, such as vscode://file/…, so the operating system hands the file and line to the editor with no round-trip to the server.
PLUMIX_EDITOR picks the scheme. When it is unset, the scheme is VS Code, so the link works with no configuration:
PLUMIX_EDITOR=cursor pnpm devThe built-in keys are vscode, vscode-insiders, cursor, windsurf, zed, idea, phpstorm, webstorm and sublime. A value that is not one of them falls back to VS Code. off or none drops the link and leaves each frame as a button that shows its source.
For an editor without a key, pass a URL template carrying {file}, {line} and {column}. The dev server uses any value containing {file} verbatim, and {column} is 1 when a frame has none:
PLUMIX_EDITOR='myeditor://open?path={file}&line={line}&col={column}' pnpm devContainers and remote machines
Section titled “Containers and remote machines”A link uses the frame’s path as the dev server sees it. Sometimes the server runs where your editor cannot see the same filesystem, such as in a Docker container, on a remote machine over SSH or in a devcontainer. That path then does not exist on the editor’s machine, and the link opens nothing.
PLUMIX_EDITOR_PATH_MAP takes one from=>to mapping. from is the path prefix on the server, and to is the matching path on the editor’s machine. The dev server rewrites from to to before it builds the link:
# The server sees /workspace; the repository is at /Users/me/proj on your machine.PLUMIX_EDITOR_PATH_MAP='/workspace=>/Users/me/proj' pnpm devA frame at /workspace/src/theme.tsx:12 then links to /Users/me/proj/src/theme.tsx:12. The prefix matches only on a path boundary, so /workspace rewrites /workspace/src/a.ts and leaves /workspace-other/a.ts alone. The dev server leaves a path outside the prefix as it is and ignores a trailing slash on either side. It ignores a value missing => or with an empty side entirely.
On a Windows editor host, write the to side with forward slashes, as in /workspace=>C:/Users/me/proj. Editors accept them, and the link would percent-escape a backslash.
The dev server reads both variables when it builds its bundle, like PLUMIX_DEV_ALLOW_REMOTE, so restart after changing one. plumix build substitutes them as empty, and the dev error page is not in a production bundle at all.
Why the host, and what it does not cover
Section titled “Why the host, and what it does not cover”The Host header is the honest client’s statement of what it dialled. A browser reaching an exposed dev server sends the exposed name, which is not loopback, so the surfaces stay closed. It also covers DNS rebinding, where the address resolves to 127.0.0.1 but the Host remains the attacker’s domain.
What it does not cover is a non-browser attacker already on your network, who can forge Host: localhost and be believed. That is an accepted residual rather than an oversight: the check is a floor under an environment variable, not a substitute for not exposing the port. Do not expose plumix dev to a network you do not control.
A tunnel configured to rewrite the host — ngrok --host-header=rewrite and its equivalents — presents every request as loopback, so the surfaces stay open through it. That is the tunnel telling the server it is being reached locally, and nothing here can see past it. Prefer a tunnel that forwards the original host, which is the default for both ngrok and cloudflared.
It also does less than the MCP gate it is modelled on, which pairs the same Host check with an Origin allowlist. The surfaces here are GET-shaped disclosure, so the same-origin policy already stops a page in your browser from reading one — but a POST development route is permitted by the model, and its handler is yours to write. Treat a development route as reachable by any page open in the developer’s browser.
The MCP endpoint is gated separately and more strictly, because what it grants is tool access rather than disclosure. Off-loopback it falls back to bearer-token authentication instead of closing, and PLUMIX_DEV_ALLOW_REMOTE does not reach it. Configuration covers that endpoint.
Related
Section titled “Related”The build-time half of the gate is the Vite plugin’s define, described in Project Structure. The route-auth model a "development" route joins — "public", "authenticated" and capability gates — is in Access & Identity. The card preview that motivated the second gate is documented in OG Cards.
Next steps
Section titled “Next steps”Read Configuration for the MCP endpoint’s own dev relaxation and the dev options, and Deploy for what changes when the same worker is built rather than served.