hrserve
v1.3.0
Published
A development server that serves web pages and automatically patches file changes without full page reloads using Playwright.
Maintainers
Readme
hrserve
A development server that serves web pages and automatically patches file changes without full page reloads using Playwright.
Instead of running an HTTP server, hrserve launches a Chromium browser and intercepts its network requests: GET requests under the base URL are answered from a local directory, and every served file is watched. When a file changes, the running page is patched in place over the Chrome DevTools Protocol — no reload, no lost state.
📚 Full documentation — including MCP setup for Claude Code, Cursor and VS Code.
CLI Usage
npx hrserve [dir] --url http://localhost:3000/Options:
--url: Base URL of the page (default:http://localhost:3000/)--mock-dir: Directory of file-based mock API routes, run in-process--mock-path: Path glob handled by--mock-dir/--proxy(default:/api/**)--proxy: Send--mock-pathrequests without a mock route to this origin--profile: Start from a saved profile's cookies and storage--save-profile: On Ctrl-C, save this session's cookies and storage under this name--devtools, -d: Run with devtools initially open--verbose, -v: Run with verbose logging--width, -w: Width of the browser window--height, -h: Height of the browser window--script-reload: How to apply changed JavaScript —auto(default),evaluate,importoroff(see JavaScript hot reload)
Programmatic Usage
import { chromium } from "playwright";
import { createServer } from "hrserve";
async function example() {
// Create browser
const browser = await chromium.launch({
headless: false,
});
// Create server
const server = createServer(browser);
// Listen for patch events
server.on("patch", ({ fileName, url, mimeType }) => {
console.log(`File patched: ${fileName} (${mimeType})`);
});
// Start serving
const page = await server.serve({
url: "http://your-project-host.com/",
dir: "./public",
width: 1200,
height: 800,
});
}API
createServer(browser)
Creates a new hrserve instance.
Parameters:
browser: A Playwright browser instance
Returns: Server object with the following methods:
server.serve(options)
Starts serving files and watching for changes. Resolves with the Playwright Page after the initial navigation.
Parameters:
options.url: The base URL to serveoptions.dir: Directory to serve files from (optional when every rule sets its owndir)options.rules: Ordered routing rules — see Routing rulesoptions.scriptReload: How changed JavaScript is applied — see JavaScript hot reload (default:"auto")options.width: Browser window width (default: 1280)options.height: Browser window height (default: 720)options.profile: Name of a saved profile to start from (default: a fresh session)options.watch: How long a file must stop changing before hrserve reacts —{ stabilityThreshold, pollInterval }in ms (default:{ stabilityThreshold: 50, pollInterval: 10 }). Applies to served files and mock handlers alike. Raise it for a network filesystem or an editor that saves in several visible steps; the threshold is added to the latency of every patchoptions.verbose: Log request routing and CDP events (default: false)
server.saveProfile(name)
Snapshots the current session's cookies and storage as a named profile. Resolves with a summary (name, parent, capturedAt, origins, cookieDomains).
server.close()
Stops watching files. Does not close the browser — the caller owns it.
server.on(event, handler)
Listen for server events.
Events:
'patch': Emitted once per file change. Handler receives{ fileName, url, mimeType, applied, reason }.applied: falsemeans the change was seen but not put into the page —reasonsays why (css-invalid,stylesheet-not-loaded,hot-update-threw,cancelled-by-page, …)'request': Emitted for every intercepted request, with{ url, method, kind }wherekindis the routing decision (file,mock,proxy,pass,fallback, …)'new-resource': Emitted when a served file starts being watched. Handler receives{ url, mimeType }
Routing rules
By default every GET under the base URL is served from dir. Pass rules to mix local files with real network traffic — an ordered list, first match wins:
await server.serve({
url: "https://app.example.com/",
dir: "./dist",
rules: [
{ match: "/assets/**", action: "serve", dir: "./dist/assets" },
{ match: "/api/**", action: "proxy", target: "https://staging-api.example.com" },
{ match: "/health", action: "upstream" },
{ match: "**", action: "serve" },
],
});match is a glob (picomatch syntax) tested against the request path relative to the base URL, so with a base of https://app.example.com/ the rule /api/** matches https://app.example.com/api/users. It defaults to **. An optional methods: ["POST"] narrows a rule to specific HTTP methods.
Actions:
serve— answer fromdir(falling back to a directory listing or 404 page). OnlyGET/HEAD; other methods go to the network. Served files are watched and patched as usual.dirdefaults to the top-leveldir, and mirrors the URL space beneath it.upstream— let the request through to the real network, untouched.proxy— send the request totarget, preserving path, query, method, headers and body. hrserve performs this request itself and returns the result as if it came from the page's own origin, so the page is not subject to CORS. A path prefix on the target is kept: targethttps://example.com/v2+ request/api/users→https://example.com/v2/api/users. If the target is unreachable the page gets a 502.mock— answer from file-based API routes executed in this process (see below). If no mock route matches the path, the request falls through to the next rule.
Requests matching no rule go to the network, so a rule list without a ** entry is an overlay rather than a full server.
Mock API routes
Point a mock rule at a directory of route files and hrserve runs them in-process — no port, no spawned server, no framework:
npx hrserve ./public --url http://localhost:3000/ --mock-dir ./mocks --proxy https://staging-api.example.comawait server.serve({
url: "http://localhost:3000/",
dir: "./public",
rules: [
{ match: "/api/**", action: "mock", dir: "./mocks" },
// anything the mocks don't cover reaches the real API
{ match: "/api/**", action: "proxy", target: "https://staging-api.example.com" },
{ match: "**", action: "serve" },
],
});The directory mirrors the URL space, following Next.js file conventions, so you can point it at a mocks/ folder or straight at a real Next app's app/ or pages/ directory:
| File (relative to the mock dir) | URL |
|---|---|
| api/users/route.ts | /api/users |
| api/users/[id]/route.ts | /api/users/:id |
| api/files/[...path]/route.ts | /api/files/* (one or more segments) |
| api/docs/[[...slug]]/route.ts | /api/docs and /api/docs/* |
| api/users.ts (pages style) | /api/users |
| api/posts/index.ts | /api/posts |
Resolution follows Next's priority — static beats dynamic beats catch-all — so /api/users wins over /api/[id]. Route groups (admin) and parallel routes @modal don't affect the URL, and App Router UI files (page, layout, loading, …) plus _-prefixed files are ignored.
App Router style — a route.ts exporting HTTP method functions, using Web Request/Response:
const todos = [{ id: 1, title: "write tests" }];
export function GET() {
return Response.json(todos);
}
export async function POST(request: Request) {
const { title } = await request.json();
const todo = { id: todos.length + 1, title };
todos.push(todo); // module state survives between requests
return Response.json(todo, { status: 201 });
}
// Dynamic segments arrive as params; both styles work
export async function PATCH(request: Request, ctx: { params: { id: string } }) {
const { id } = await ctx.params; // Next 15 style
return Response.json({ id });
}Pages Router style — a default export taking (req, res), with req.query, req.body (JSON and urlencoded bodies are parsed) and res.status().json()/.send()/.setHeader()/.redirect().
TypeScript handlers run through jiti, so no build step is needed. HEAD falls back to GET, OPTIONS is answered automatically, and an unexported method returns 405 with an Allow header.
Hot reload: editing a handler takes effect on the next request. Module-level state (an in-memory list, a counter) is preserved between requests and reset when the file changes. Adding or deleting route files re-scans automatically.
This is a mock layer, not a Next.js runtime — middleware.ts, the edge runtime, ISR/SSG, and next/headers-style request context are out of scope.
Parallel sessions and MCP (for agents)
Because an hrserve origin is a name inside a browser context, not a socket, several sessions can serve the same URL simultaneously. That removes the usual blocker for running many agents at once: git worktrees handle the code, but conventional dev servers still need a port each. Here every worktree is http://app.hrserve.test/, in its own isolated context.
import { SessionManager } from "hrserve/dist/lib/session-manager.js";
const manager = new SessionManager({ browser });
await manager.start({ name: "feature-a", dir: "~/wt/feature-a" });
await manager.start({ name: "feature-b", dir: "~/wt/feature-b" }); // same URL, no conflict
// Sign in once, then start every worktree's session already authenticated
await manager.start({ name: "feature-c", dir: "~/wt/feature-c", profile: "app-login" });
await manager.get("feature-c").saveProfile("app-login-with-cart"); // snapshot, never overwritesEach session buffers its own console output, request log and patch history.
MCP server
npx hrserve mcp # stdio MCP server; --headed to watch the browserRegister it with an MCP-capable agent and it can serve a worktree and then verify its own edits — the thing an agent otherwise can't do:
| Tool | What it answers |
|---|---|
| serve_start / serve_list / serve_stop | session lifecycle, one per worktree (pass profile to start signed in) |
| profile_list / profile_save | reuse a sign-in across sessions — see Session profiles |
| page_screenshot | "what does it look like now?" |
| page_console | "did my change break anything?" (console + uncaught errors) |
| page_network | "why did that request return that?" — each entry labelled served-local, mocked, proxied, upstream or blocked |
| patch_history | "did my edit reach the page?" — with applied and, when false, the reason (invalid CSS, stylesheet not loaded, the new script source threw) |
| wait_for_patch | block until the next patch lands, instead of polling |
| page_dom | text or HTML snapshot for non-visual assertions |
| page_eval | run an expression in the page |
| page_reload, set_viewport | discard patched state; check a responsive layout |
⚠️ Trust model: page_eval runs arbitrary JavaScript in the page and mock handlers are ordinary modules executed in this process, so an MCP client with access to this server can run code on your machine. Only connect clients you would already trust with a shell.
JavaScript hot reload
Chromium removed LiveEdit in Chrome 145 (announcement), so a running script's body can no longer be swapped in place. hrserve instead re-runs the new source, picking the mechanism from how the browser parsed the file:
| File | Mechanism | Effect |
|------|-----------|--------|
| classic <script> | indirect eval in global scope | var, function and window.* assignments are replaced |
| <script type="module"> | import() of a cache-busted URL | the module re-executes; its dependencies stay cached |
Top-level side effects therefore run again, exactly as in classic module-replacement HMR. The script-patch event fires before the new source runs, so page code can dispose of the old version first:
window.addEventListener("script-patch", (event) => {
const { scriptUrl, mode } = event.detail;
teardown(); // remove listeners, cancel timers, unmount
// Optional: receive the result of the re-run — the module namespace in
// "import" mode, the script's completion value in "evaluate" mode.
event.detail.accept((exports) => render(exports.App));
// Optional: handle the update entirely yourself and stop hrserve re-running it.
// event.preventDefault();
});
// Fires when the new source (or an accept handler) throws. The old version is
// still the one running.
window.addEventListener("script-patch-error", (event) => {
console.warn(event.detail.scriptUrl, event.detail.message);
});event.detail.mode is "evaluate", "import", or "none" when hrserve will not run anything — because scriptReload is "off", or because the browser never reported the file as a script (a worker entry point, say). The event is dispatched in all three cases, so it remains a reliable "this file changed" signal.
Set scriptReload on serve() to override the mechanism: "auto" (default), "evaluate", "import", or "off" to only dispatch the event.
Limitations. Re-running is not a substitute for a module-graph-aware HMR runtime:
- ES module bindings are fixed at link time, so modules that already imported the changed one keep the old values. Only the fresh namespace passed to
accept()sees the new ones. Re-import is most useful for leaf modules and entry points. - A top-level
const/letin a classic script keeps its own scope on re-run (this is what lets the file re-run at all), so other scripts still see the original value. Usewindow.*orvarfor values that must be shared. - Re-running duplicates side effects that are not cleaned up in
script-patch— repeated event listeners, remounted components, andcustomElements.definethrowing on a second call.
Session profiles
Every session starts in a fresh browser context, which is usually what you want — but not when getting to the interesting page means logging in or clearing a captcha by hand. A profile saves that work so later sessions can start from it.
npx hrserve ./public --url https://app.example.com/ --save-profile prod-login
# ... log in in the browser window, then press Ctrl-C to capture ...
npx hrserve ./public --url https://app.example.com/ --profile prod-login # already logged in
npx hrserve profiles # list what's savedProgrammatically:
const server = createServer(browser);
await server.serve({ url: "https://app.example.com/", dir: "./dist", profile: "prod-login" });
// ...do more manual steps in the page...
await server.saveProfile("prod-login-2fa");Profiles are immutable snapshots
A profile is one JSON file holding Playwright's storageState: cookies, per-origin localStorage and IndexedDB. Sessions read profiles and never write back — saveProfile() is the only way state is persisted, and it always writes a new name.
That single rule is what makes branching trivial and safe:
serve(--save-profile A) # fresh; log in by hand → A
serve(--profile A) # B starts from A
serve(--profile A) → save as C # C starts from A too, concurrently, and adds more
serve(--profile C) # D starts from CTwo sessions can run from the same profile at the same time without interfering, and starting from A gives the same result no matter what C did afterwards. There is no fork command because forking is "start from X, save as Y". Each profile records the parent it branched from, shown by hrserve profiles — that lineage is descriptive only; every snapshot is complete on its own.
Two things to know
- A profile is a credential file. It contains live session cookies, so profiles are stored per-user outside your project —
$XDG_DATA_HOME/hrserve/profiles/(default~/.local/share/hrserve/profiles/), mode0600. Never commit one. - Profiles are origin-scoped.
storageStatebelongs to the origins it was captured on, and hrserve deliberately serves at arbitrary origins — a profile captured onhttps://app.example.comdoes nothing for a session served athttp://app.hrserve.test/. hrserve warns when the profile doesn't cover the URL you're serving, because the alternative is a silently logged-out page.
A profile carries what storageState carries. It does not capture sessionStorage, service worker caches, HTTP auth, WebAuthn credentials or browser extensions — those need a persistent browser user-data directory, which cannot be shared between concurrent sessions and so is deliberately out of scope here.
Supported File Types
- CSS: Live updates via
CSS.setStyleSheetText— no page reload (changes are validated first; invalid CSS is not applied) - JavaScript: The new source is re-run or re-imported, with
script-patchas the cleanup hook — see JavaScript hot reload - HTML: Full DOM replacement via
DOM.setOuterHTML - Images: Automatic image reload with cache busting (PNG, JPG, GIF, SVG, WebP) — covers
<img>,srcset,<picture>sources, CSSbackground-imageand friends, inline styles, SVG<image>, favicons,<object>/<embed>and<input type="image">
Requests under the base URL that do not map to a served file fall back to serve-handler for directory listings and 404 pages. Requests outside the base URL go to the network as usual.
Development
npm ci
npx playwright install chromium # needed once for browser tests
npm run build # type-check and compile to dist/
npm test # unit + browser tests
npm run lint # biome
npm run dev:ts # run the CLI from TypeScript sourcesSee AGENTS.md for architecture notes.
