@thelifeisyours/friv (0.5.0)
Installation
@thelifeisyours:registry=https://git.tliy.no/api/packages/thelifeisyours/npm/npm install @thelifeisyours/friv@0.5.0"@thelifeisyours/friv": "0.5.0"About this package
friv
A canvas debugging view for React dev servers. Maps every page of a running app as a live, interactive window on a pannable canvas, arranged as a tree by the links between them, and outlines the windows that actually changed when you edit a file.
The windows are real pages, not screenshots. Hover, click, right-click, type and scroll inside them.
Try it
Two terminals, from the repo root:
pnpm example # demo app on :5173
pnpm build && pnpm friv http://localhost:5173 --root examples/vite-react
Then open http://127.0.0.1:4477/__friv/.
Edit examples/vite-react/src/App.tsx and watch the affected windows outline in
orange. Change the text under /products and only that window lights up; change
the shared button label and all of them do.
Clicking a link inside a window does not navigate that window. It moves you to the window for that route, opening and mapping one first if the route was not already on the canvas. The same applies to the tree on the left.
There is a Next.js example too:
pnpm --filter example-next-app dev # Next app on :3000
pnpm friv http://localhost:3000 --root examples/next-app --port 4478
Letting an agent drive the canvas
The package ships an MCP server, friv-mcp, so a coding agent can start the
canvas and point it at whatever it is working on. .mcp.json registers it for
this repo; elsewhere use the friv-mcp binary, with FRIV_URL set if the
server does not listen on the default port.
| Tool | Purpose |
|---|---|
friv_start |
Launch friv against a dev server on an unusual port |
friv_stop |
Stop a server it started |
friv_status |
What is being mapped, how many routes, whether anyone is watching |
friv_list_routes |
List mapped pages with titles |
friv_focus |
Move the canvas to a route, mapping it first if unknown |
friv_set_follow |
Make the canvas track whichever page a change affects |
friv_open_canvas |
Open the canvas in the user's browser |
friv_rescan |
Re-crawl after adding or removing pages |
friv_login |
Open a window to sign in, so mapped pages are the signed-in ones |
friv_set_params |
Give real values for dynamic route segments |
friv_set_live |
Change how many pages stay loaded at once |
friv_refresh_shots |
Throw away idle-window screenshots so they are retaken |
friv_issues |
Read failures reported by friv installs |
friv_check_update |
Compare the installed friv against the registry |
friv_update |
Install the newest friv and restart the canvas |
friv_status mentions a newer version when one exists, so an agent notices
without polling. Updating is never automatic: the tools report the version and
ask, and only friv_update installs anything. After it runs, the MCP server
itself stays on the old build until the editor reconnects it, and it says so
rather than offering the same update again.
Focusing a window does nothing visible if no browser has the canvas open, so
the tools report that rather than claiming success: with no server reachable
they fail with the command to start one, and with no viewer they say so and
point at friv_open_canvas.
Follow mode moves the view on its own. It is exact when the changed file is a route file, since the route comes from the path. Otherwise it relies on pages reporting their own DOM changes, so it moves only when exactly one page reacts, and only among the windows currently live — a page evicted to a screenshot has no document to report with.
Apps that need a signed-in user
Most real apps show nothing useful to a visitor who is not signed in: pages redirect to a login screen, so the canvas maps the login screen over and over instead of the app.
friv keeps a browser profile in node_modules/.cache/friv/profile and uses it
for the crawler, the screenshots and the test window alike. Sign in once and
the session is reused, across restarts, because it lives on disk rather than in
the process.
Ask the agent to sign you in, or drive it yourself:
curl -X POST localhost:4477/__friv/api/login -H 'content-type: application/json' -d '{"path":"/login"}'
A window opens on the dev server's login page. Sign in there, then say you are done: friv closes the window, drops the screenshots it took as a visitor, and re-crawls with the session. Because the profile holds cookies, local storage and IndexedDB, this works for auth that keeps its tokens in IndexedDB — Firebase among them — which a Playwright storage-state file does not capture.
Routes that still land somewhere else are recorded rather than mapped as
themselves: friv_list_routes marks them redirects to /login, so it is clear
the app sent the crawler away rather than that the page is blank.
Plenty of apps never redirect, and render a login screen or a not-found page in
place at the original url. friv asks for an address that cannot exist, keeps
what the app renders for it, and compares every mapped page against that, so
those show as shows the not-found page. A page carrying a password field
shows as shows a login form. The probe runs after the crawl, so it cannot
change what the crawl saw.
Chromium allows one process per profile directory. While the sign-in window is open, crawls and screenshots run as short-lived tabs inside it rather than in a browser of their own, and only one page at a time to keep the flicker down. If a browser friv opened earlier is still holding the profile, friv says so and carries on signed out rather than failing to map anything.
To script the sign-in instead, point --login at a module whose default export
drives a Playwright page. It runs once before the first crawl, against the same
stored profile, so a later run finds the session already there:
export default async function login(page, { target }) {
await page.goto(`${target}/login`)
await page.fill('input[name=email]', process.env.APP_EMAIL)
await page.fill('input[name=password]', process.env.APP_PASSWORD)
await page.click('button[type=submit]')
await page.waitForURL('**/dashboard')
}
Keep credentials in environment variables, never in the script.
Reporting friv's own failures
packages/worker is a Cloudflare Worker backed by D1 that collects crashes
from friv installs, so a problem can be looked at rather than guessed at.
Reporting is off unless you point friv at a collector. There is no default endpoint.
pnpm friv http://localhost:5173 \
--report https://friv-events.<subdomain>.workers.dev/ingest \
--report-key <ingest key>
Only friv's own failures are collected: server exceptions, crawl failures and errors thrown by the canvas bundle. A framed page runs in its own document, so nothing from the project being mapped is captured. Before anything leaves the process, messages and stacks are stripped of filesystem paths and urls, leaving the last two path segments of a stack frame and nothing that names a machine, a project or a host. Nothing carries source contents or page routes.
An agent reads the collector with friv_issues, which needs FRIV_REPORT_URL
and FRIV_READ_KEY on the MCP server.
Running the collector
cd packages/worker
pnpm wrangler d1 create friv-events # put the id in wrangler.toml
pnpm wrangler secret put INGEST_KEY
pnpm wrangler secret put READ_KEY
pnpm db:remote
pnpm deploy
The Worker's own root path serves a dashboard: grouped or individual events, a time range, and a ten second refresh. It asks for the read key once and keeps it in the browser, so the key never appears in a url or a server log.
Locally, pnpm db:local then pnpm dev runs it against a SQLite file with the
development keys in wrangler.toml. /ingest takes a batch and /events and
/summary read it back, all three behind a bearer key; /summary groups by a
fingerprint so a recurring fault is one row with a count.
Use it on your own project
friv publishes as a single package with no workspace dependencies. It lives in
the Gitea package registry, so map the scope in the consuming project's
.npmrc:
@thelifeisyours:registry=https://git.tliy.no/api/packages/thelifeisyours/npm/
The credential goes in your user ~/.npmrc, never the project one — pnpm
refuses to expand environment variables in registry credentials that come from
a committed file, so that a checked-in .npmrc cannot leak a token to an
attacker-controlled registry:
npm config set //git.tliy.no/api/packages/thelifeisyours/npm/:_authToken <token>
Then:
pnpm add -D @thelifeisyours/friv
pnpm friv http://localhost:5173
--root defaults to the current directory, and a Next.js dev server is
detected automatically.
A dev server serves every module as its own request, so a large app can cost
hundreds per page. --live <n> sets how many pages stay loaded and interactive
at once, four by default. Lower it if the browser starts reporting
ERR_INSUFFICIENT_RESOURCES; the rest of the canvas still shows screenshots.
friv_set_live changes it without a restart.
A window counts as loaded once its page stops asking for things, not when the
load event fires, so a page that renders from data is captured and attributed
after it has actually drawn.
friv opens real pages against a real dev server, which for most apps means real
requests to a real backend. --ignore keeps it away from routes that should
not be touched, comma separated, where * spans one segment and ** any
number:
pnpm friv http://localhost:5173 --ignore "/admin/**,/orders/*/print,/logout"
The package ships the canvas prebuilt, so there is no build step in the consuming project.
Routes that take a parameter
A placeholder is not an address, so friv never visits one: /project/$projectId
would ask the app for a project literally called $projectId. Instead it keeps
the route as a template and waits for a real value. friv_status lists the
templates still waiting, and values come from friv.config.json, --param, or
friv_set_params at runtime:
{ "params": { "projectId": ["42", "43"], "pathUri": ["demo-offer"] } }
pnpm friv http://localhost:5173 --param projectId=42 --param pathUri=demo-offer
Every combination is mapped, so two parameters with two values each give four
windows. All four naming conventions are understood: $name, [name],
:name and {name}.
Configuration file
Anything passed on the command line can live in friv.config.json,
friv.config.js or .frivrc.json in the project root, with command-line flags
winning:
{
"live": 2,
"ignore": ["/admin/**", "/logout"],
"login": "./scripts/friv-login.mjs",
"params": { "projectId": ["42"] }
}
To let an agent drive it, register the bundled MCP server:
claude mcp add friv -- npx --package @thelifeisyours/friv friv-mcp
Name the package explicitly. A bare npx friv-mcp only finds the binary inside
a project that already depends on friv; anywhere else npx goes to the public
registry looking for a package called friv-mcp, which is not this one.
Register it on the command line as above rather than through Claude Code's
/mcp dialog. The dialog stores what you type as a single argument, so the
whole command arrives as one string and the server fails to start with
EUNKNOWNCONFIG. If that has already happened, remove the entry and add it
again with claude mcp add.
Nothing else is needed. Start your Vite or Next dev server, then ask Claude to
show you a page — the tools find the dev server on the usual ports and bring
the canvas up themselves. friv_start exists for pointing at a dev server on
an unusual port.
Publishing
Tagging v* runs .gitea/workflows/publish.yml, which builds, verifies the
tarball installs into a scratch project, and publishes using the
REGISTRY_TOKEN secret. By hand:
pnpm build
pnpm --filter @thelifeisyours/friv publish --no-git-checks
How it works
friv serves its own UI under /__friv and reverse-proxies everything else to
your dev server, so framed pages keep their own absolute URLs and stay
same-origin with the canvas. Frame-blocking headers are stripped and a small
bridge script is injected to report titles, navigation and DOM changes.
Routes are discovered twice: seeded from the filesystem and confirmed by a
Playwright crawl that records the real links between pages. The graph, window
positions and hidden flags persist in an embedded Postgres (PGlite) under
node_modules/.cache/friv.
A framed page also reports its router's own route table when the app exposes
the router on window — TanStack's debugging guide suggests
if (import.meta.env.DEV) window.router = router, and friv reads
routesByPath from it. That is exact where source scanning guesses, and it is
opt-in: without that line nothing is reported and discovery falls back to the
filesystem.
Seeding knows the Next App and Pages routers, and for a Vite project it prefers
TanStack Router's generated routeTree.gen.ts over scanning source. The
generated FileRoutesByFullPath interface lists every real URL with layouts
already resolved, so a deeply nested page is mapped at the address it actually
has rather than at a fragment like /activity that belongs to a parent. React
Router path: literals are the fallback when there is no generated tree.
Change attribution works two ways. When a changed file is itself a route file,
the route is derived from its path and only that window is outlined. That
covers app/products/page.tsx, pages/about.tsx and TanStack's
src/routes/..., including the flat convention where dots separate segments
(dashboard.settings.activity.tsx) and the prefixes that render no page of
their own (__root, -components/, _layout). For everything else — shared components, stylesheets,
anything a client-side router owns — no import graph can say which route is
affected, so each framed page reports its own DOM mutations and the canvas
correlates them against file-change events.
Playwright drives your installed Chrome or Edge through playwright-core, so
installing friv does not download a browser.
Layout
| Package | Role |
|---|---|
packages/server |
The published friv package: CLI, MCP server, proxy, crawler, storage |
packages/ui |
The canvas, built into the server package |
packages/worker |
Cloudflare Worker collecting friv's own failures |
Status
Verified against both examples: route discovery, live interactive windows, change outlining, link interception, hide/show, the screenshot tier for idle windows, dragging, layout orientation and persistence.
The scripts/ directory holds the checks used for that. Most run against a
live server — vite-check, next-check, link-check, drag-check,
frozen-check, evict-check, layout-check, smoke — while
tanstack-check, route-for-file-check, dynamic-route-check,
landing-check, session-check, profile-contend-check,
profile-busy-check, headed-spawn-check, runtime-api-check and
router-routes-check stand up their own fixtures and need nothing running.
session-check is the one that proves the login story: it serves a gated app,
confirms the gated routes record a redirect while signed out, signs in, and
checks the session survives a full browser restart so those routes then map as
themselves with their real titles. profile-busy-check covers the awkward
case, holding the profile open with another browser and checking friv still
maps pages and says why the session is not in use.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @electric-sql/pglite | ^0.5.8 |
| @fastify/http-proxy | ^11.3.0 |
| @fastify/static | ^8.2.0 |
| @modelcontextprotocol/sdk | ^1.20.2 |
| chokidar | ^4.0.3 |
| citty | ^0.1.6 |
| drizzle-orm | ^0.45.3 |
| fastify | ^5.6.1 |
| picocolors | ^1.1.1 |
| playwright-core | ^1.63.0 |
| ws | ^8.18.3 |
| zod | ^3.25.76 |
Development Dependencies
| ID | Version |
|---|---|
| @types/node | ^24.13.3 |
| @types/ws | ^8.18.1 |
| typescript | ~6.0.2 |