Skip to content

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.

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.

  1. Develop on the machine running the server. Nothing to configure — http://localhost:5173 is loopback, so the bar, the history and the dev error page are all there.

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

  3. Open them for that session, deliberately:

    Terminal window
    PLUMIX_DEV_ALLOW_REMOTE=1 pnpm dev

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

  • 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/requests and /_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-error template 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-og card preview at /_plumix/og/preview is the one that ships.

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.registerDebugPanel adds a panel to the debug bar. Its render(snapshot) reads the request’s recorded data, so a past request in the switcher replays like the current one.
  • ctx.registerErrorPanel adds a section to the dev error page. Its visible(error) decides which errors get the section, and every error does when it is unset.
  • ctx.registerErrorHint adds a how-to-fix hint to the dev error page, shown for each error its matches(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.

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:

Terminal window
PLUMIX_EDITOR=cursor pnpm dev

The 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:

Terminal window
PLUMIX_EDITOR='myeditor://open?path={file}&line={line}&col={column}' pnpm dev

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:

Terminal window
# The server sees /workspace; the repository is at /Users/me/proj on your machine.
PLUMIX_EDITOR_PATH_MAP='/workspace=>/Users/me/proj' pnpm dev

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

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.

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.

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.