@thelifeisyours/friv (0.5.0)

Published 2026-10-01 01:28:22 +02:00 by thelifeisyours

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

Canvas debugging view that maps a React dev server's pages as live, interactive windows

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
Details
npm
2026-10-01 01:28:22 +02:00
2
MIT
latest
265 KiB
Assets (1)
friv-0.5.0.tgz 265 KiB
Versions (16) View all
0.5.0 2026-10-01
0.4.0 2026-09-30
0.3.0 2026-09-30
0.2.2 2026-09-30
0.2.1 2026-09-30