npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

lucarne

v1.6.1

Published

Self-hostable browser sessions you can drive (Playwright/CDP), watch and control (porthole), and record — on your own machine and your own IP.

Readme

lucarne

CI npm license: MIT

Self-hostable browser sessions you can drive, watch, and record — on your own machine and your own IP.

A browser session in lucarne exposes three surfaces at once:

  • drive — a CDP endpoint; point Playwright at it (chromium.connectOverCDP(session.cdpUrl))
  • watch + control — a porthole URL; open it or <iframe> it to see the browser and take over by hand
  • record — an ambient rolling recording in the session's data dir

It's the missing middle between headless automation (drivable, but you can't watch) and remote desktop (watchable, but not cleanly drivable) — a real browser an agent can operate and a human can supervise, running where you choose.

Who it's for: agent builders who need a browser an agent drives while a human can watch and take over, and anyone automating their own logged-in accounts on their own machine and IP (no cloud browser farm, no handing your cookies to a third party).

        ┌──────────────── lucarne engine ────────────────┐
 you ──▶ │  porthole (watch + control) ◀── browser ──▶ CDP │ ◀── Playwright / your agent
        │                              │                  │
        │                              └──▶ recording      │
        └───────────────────────────────────────────────┘

The monorepo

lucarne is an npm-workspaces monorepo (packages/*) — this engine package is its first, published citizen, and four newer packages build a browse/interact/record/retrieve stack on top of it:

| Package | What it is | |---|---| | lucarne | the engine (this README) — sessions you can drive (CDP), watch + control (porthole), and record. | | lucarne-records | the one provenance record language for the platform — a normalized cross-site schema (Profile/Post/Comment) plus a dependency-free node:fs record store + query API. | | lucarne-interact | non-bot-like interaction: act + observe/record, screen + wire sensors, zero synthetic requests — a human-paced ACT plane (open/snap/scroll/activate/type/send) over a session's cdpUrl, with an enforced pause after every verb, plus a passive, read-only OBSERVE/recall half that only records what a genuine session organically loads. | | lucarne-widget | the reusable glassmorphic in-page widget infrastructure — mount a durable, draggable, namespaced glass panel inside a session's page; stream state in, drain intents out. | | lucarne-corpus-mcp | an optional, thin, read-only stdio MCP bin over a lucarne-records store — answers get_profile/get_post/get_comments/search/get_timeline from what's already been captured; a miss says so structurally instead of fetching. |

Each package is independently published/versioned. lucarne-interact and lucarne-widget depend on this engine package (the HTTP client, for /inject); lucarne-corpus-mcp depends on lucarne-records for its schema and store. See each package's own README for its install, usage, and Charter/Security posture sections.

Install

npm install lucarne                 # the engine + `lucarne` CLI
npm install playwright              # only if you'll DRIVE sessions over CDP (most people will)

Requires Node ≥ 22. Prerequisites by what you use:

  • native backend → Google Chrome installed (or point LUCARNE_CHROME / chromePath at a Chromium binary).
  • docker backend → Docker (lucarne build-image once).
  • recording (on by default) → ffmpeg on the engine host. No ffmpeg ⇒ no recordings (everything else still works).

Driving is vanilla Playwright against the session's cdpUrl, so playwright is a peer you install yourself — npm install lucarne alone does not pull it in.

Quickstart

npx lucarne serve                       # start the engine on :7800
npx lucarne create -b native -p alpha   # mint a session -> { cdpUrl, viewUrl }
npx lucarne open alpha                  # watch + control it in your browser

Drive that same session with vanilla Playwright — nothing custom:

import { chromium } from "playwright";

const res = await fetch("http://127.0.0.1:7800/sessions", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ profile: "alpha", backend: "native" }),
});
const session = await res.json();

const browser = await chromium.connectOverCDP(session.cdpUrl);   // ← drive
const page = browser.contexts()[0].pages()[0];
await page.goto("https://example.com");
// meanwhile, open session.viewUrl to watch + take over

Or use the typed client against a running daemon:

import { LucarneClient } from "lucarne";
import { chromium } from "playwright";

const lucarne = new LucarneClient({ baseUrl: "http://127.0.0.1:7800", token: process.env.LUCARNE_TOKEN });
const session = await lucarne.create({ profile: "alpha", backend: "native" });
const browser = await chromium.connectOverCDP(session.cdpUrl);   // drive with Playwright

The client covers the full API — create/list/get/status/act/login/tabs/logs/content/activity/screenshot/pdf/recordings/exportContext/… .

The full API is described by an OpenAPI 3.1 spec at /openapi.json, with a Swagger UI at /docs. There's also a stdlib-only Python client (clients/python/lucarne.py) and an MCP server (lucarne-mcp, stdio) that exposes lucarne as agent tools — point any MCP client at it with LUCARNE_URL / LUCARNE_TOKEN. Per-session knobs include mobile, quality, proxy, geo, metadata, maxLifetimeMs/inactivityMs, and engine-level maxConcurrent + cors.

Or embed the engine directly, no daemon:

import { Lucarne } from "lucarne";

const engine = new Lucarne();
const session = await engine.create({ profile: "alpha", backend: "native" });
console.log(session.cdpUrl, session.viewUrl);

Recipes

Concrete jobs, each a runnable example in examples/:

  • Operate your own logged-in accounts — a durable profile (seed it from your real Chrome once) stays logged in across runs; drive it with Playwright. → drive.ts, Profiles
  • Agent computer-use with a human watching — high-level act (click/type/scroll/ screenshot) over the same input plane the porthole shows, plus an actor-tagged activity feed so the agent knows what the human just did and yields instead of fighting. → computer-use.ts
  • Supervised login (secret never leaves the host) — store a credential encrypted at rest; the daemon injects username/password/TOTP server-side, so the agent logs in without ever seeing the secret. → supervised-login.ts
  • Record everything for audit / replay — sessions record by default; pull the mp4 segments or open the built-in player. → record-and-replay.ts
  • Embed the porthole in your own UI — single-origin viewUrl, drop it in an <iframe> (read-only or with URL-bar controls). → embed-porthole.html
  • From Python, or any MCP agent — stdlib Python client, or the lucarne-mcp stdio server. → python_drive.py, mcp-config.json

Use it from Python

The daemon is a Node CLI, but you only ever talk to it over HTTP + CDP, so the language you drive from is your choice. There's a stdlib-only Python client on PyPI:

npm install -g lucarne && lucarne serve          # the daemon (Node ≥ 22), once
pip install lucarne playwright                   # the client + Playwright to drive cdpUrl
from lucarne import LucarneClient
from playwright.sync_api import sync_playwright

luc = LucarneClient("http://127.0.0.1:7800")
s = luc.create(profile="demo", backend="native")
with sync_playwright() as p:
    page = p.chromium.connect_over_cdp(s["cdpUrl"]).contexts[0].pages[0]
    page.goto("https://example.com")

The Python client covers health/create/list/get/destroy/act/content; for the rest of the surface, call the HTTP API directly (see API / /openapi.json). Full example: python_drive.py.

MCP server

lucarne-mcp is a stdio MCP server that exposes lucarne as agent tools — give your AI assistant (Claude Desktop, etc.) a browser. Start a daemon first (lucarne serve); the MCP server is a thin bridge to it over LUCARNE_URL, so sessions outlive the agent. native sessions need Chrome installed.

{
  "mcpServers": {
    "lucarne": {
      "command": "npx",
      "args": ["-y", "lucarne-mcp"],
      "env": { "LUCARNE_URL": "http://127.0.0.1:7800", "LUCARNE_TOKEN": "" }
    }
  }
}

Tools: lucarne_create, lucarne_list, lucarne_destroy, lucarne_act (click/move/type/key/scroll/screenshot), lucarne_content (rendered HTML). LUCARNE_TOKEN: "" = no auth (fine on loopback); set it if you started serve with a token. (Restart your MCP client after editing its config.) The MCP lane is deliberately thin and act is coordinate-based — for selector-driven automation, drive the session's cdpUrl with Playwright instead.

Backends

A backend is only an isolation strategy. Drive, watch (porthole), and record are shared engine code over CDP — identical for every backend. Both backends just spawn an isolated Chrome and expose CDP; the engine does the rest, so a session behaves the same whichever backend it ran on.

| | native | docker | |---|---|---| | isolation | local process + own profile | container (process + fs + net) | | Chrome | real local Chrome, off-screen | Linux Chrome in a container | | fingerprint | real (your actual machine) | Linux / no-GPU (bot-detectable) | | IP | your residential IP | your residential IP | | needs | Google Chrome | Docker + lucarne build-image |

Shared by both (engine-side, over CDP): the porthole (CDP screencast → JPEG frames over a WebSocket → canvas — survives reverse proxies/tunnels, unlike MJPEG), and recording (CDP screencast → ffmpeg, hardware-encoded on macOS; needs ffmpeg on the engine host). The container is therefore tiny — just Chrome + Xvfb + a CDP bridge, no VNC/GStreamer stack.

The porthole has full input fidelity — modifiers, virtual key codes, editing shortcuts (select-all / copy / cut / paste / undo via CDP commands), clipboard paste (text pasted in the porthole lands in the focused field), drag, double/triple-click, right-click, scroll, touch (phone gestures → Input.dispatchTouchEvent), and IME composition (CJK input commits through Input.imeSetComposition + insertText).

Use native when you're operating your own accounts (real fingerprint + IP matter, isolation-from-your-main-browser is enough). Use docker when you want stronger sandboxing and don't mind the occasional "verify new device".

By default native is headful (a real, off-screen window — the authentic lane). Pass headless: true (or LUCARNE_HEADLESS=1, or per session create({ headless: true })) to run --headless=new instead — no window and no focus steal, ideal for servers, CI, or when you don't need to watch it on this machine.

Build the docker image once:

npx lucarne build-image     # builds lucarne-browser:latest from the bundled Dockerfile

Profiles (stay logged in)

A named profile is durable: its cookies, logins, localStorage and extensions live under ~/.lucarne/profiles/<name> (override the root with LUCARNE_HOME) and persist across sessions — so an agent operating your accounts stays logged in. An anonymous session (no profile) is ephemeral and wiped on stop. Durable profiles graceful-shutdown so writes flush to disk.

await engine.create({ profile: "alpha" });                    // durable, reused by name
await engine.create({ profile: "alpha", seedFromChrome: true }); // first run: seed from your real Chrome
await engine.create({ profile: "alpha", seedFrom: "/path/to/Chrome" }); // …or any user-data-dir
await engine.create({ persist: false });                       // one-off, ephemeral

Seeding copies cookies/logins/storage only on a profile's first creation — it never clobbers an established profile. On the same machine the OS-keychain key is shared, so seeded cookies decrypt and you start authenticated.

Load custom unpacked extensions with create({ extensions: ["/path/to/ext"] }) (native backend) — a persistent/seeded profile also brings its own installed extensions along.

Durable sessions survive a daemon restart: their specs persist to LUCARNE_HOME/sessions.json, and lucarne serve re-spawns them on startup from the on-disk profile (login state intact). A clean close() keeps them; an explicit destroy / DELETE /sessions/:id forgets them so a restart won't bring them back.

API

const engine = new Lucarne(options?);
await engine.listen();                          // start the HTTP control API
const s = await engine.create({ profile, backend });  // -> Session
engine.list();                                  // -> Session[]
engine.get(id);                                 // -> Session | undefined
await engine.destroy(id);
await engine.close();                           // stop API + tear down all sessions

Session = { id, backend, cdpUrl, viewUrl, createdAt, metadata? }.

HTTP control API (what the CLI talks to):

POST   /sessions                          CreateSessionOptions -> Session
         {profile?, backend?, persist?, seedFrom?, seedFromChrome?, headless?, extensions?, mobile?,
          quality?, proxy?, geo?, activity?, metadata?, maxLifetimeMs?, inactivityMs?}
GET    /sessions                          -> Session[]
DELETE /sessions                          -> { released }   (release-all)
GET    /sessions/:id                      -> Session
GET    /sessions/:id/status               -> SessionStatus   (uptime, idle, dims, limits)
POST   /sessions/:id/touch                -> { ok }   (reset the inactivity clock)
GET    /sessions/:id/context              -> { cookies, localStorage, sessionStorage, origin }   (export)
POST   /sessions/:id/context              {cookies?, localStorage?}  -> { ok }   (import)
GET    /sessions/:id/tabs                 -> { active, tabs:[{id,url,title}] }
POST   /sessions/:id/tabs/:targetId       -> { ok }   (point porthole at that tab)
GET    /sessions/:id/logs[?kind=&limit=]  -> LogEntry[]   (network/console/browser)
GET    /sessions/:id/logs?stream=1        -> text/event-stream   (live SSE)
GET    /sessions/:id/content              -> text/html   (rendered outerHTML)
GET    /sessions/:id/activity[?format=&stream=1]  -> { now, recent }  (agent-readable: what the human/agent did)
GET    /sessions[?meta.key=val]           -> Session[]   (filter by user metadata)
PUT/GET/DELETE /credentials/:name         -> store creds (GET is blurred — never returns secrets)
GET    /credentials/:name/totp            -> { code }   (RFC 6238 TOTP)
POST   /sessions/:id/login                {credential, userSelector?, passSelector?, totpSelector?, submitSelector?}
POST   /sessions/:id/inject               {id, source, bypassCSP?} | {id, remove:true}  -> { ok, id|removed }  (sticky script injection — see Security)
GET    /sessions/:id/inject               -> { ids: string[] }   (currently-registered, policy-accepted injection ids)
POST   /sessions/:id/act                  {action:"click|move|type|key|scroll|screenshot", x?,y?,...}  (computer-use; coordinate-based — for selector-driving use Playwright over cdpUrl)
GET    /sessions/:id/replay               -> text/html   (recording player)
PUT/GET/DELETE /extensions/:name/:file    -> upload/manage extensions; create({extensions:["name"]})
GET    /openapi.json  ·  GET /docs        -> OpenAPI 3.1 spec + Swagger UI

Credentials are encrypted at rest (AES-256-GCM under a machine-local key) and the secret never leaves the engine: POST /sessions/:id/login injects username/password/TOTP into the page server-side, so the agent logs in without ever seeing the password. Per-session quality (1–100) controls screencast/recording JPEG size. DELETE /sessions/:id -> { ok } POST /sessions/:id/upload {path, selector?} -> { ok } (inject a host file into ) GET /sessions/:id/downloads -> string[] (captured download filenames, oldest first) GET /sessions/:id/downloads/:file -> application/octet-stream DELETE /sessions/:id/downloads/:file -> { ok } GET /sessions/:id/screenshot -> image/png (current page) GET /sessions/:id/pdf -> application/pdf GET /sessions/:id/recordings -> string[] (segment filenames, oldest first) GET /sessions/:id/recordings/:file -> video/mp4 GET /health -> { ok, sessions } (no token needed; ids only when authed) GET /profiles -> [{ name, active }] (durable profiles on disk) DELETE /profiles/:name -> { ok } (refused while a session is live) GET /files | PUT/GET/DELETE /files/:name -> durable global workspace GET /sessions/:id/files | PUT/GET/DELETE .../files/:name -> per-session scratch workspace


`Session = { id, backend, cdpUrl, viewUrl, createdAt, metadata? }`. Recording is on by default
(`record: false` or `LUCARNE_RECORD=0` to disable), a rolling buffer of `retentionMin`
minutes (default 60) of one-minute segments. The **semantic activity log** is off by default;
set `LUCARNE_ACTIVITY=1` (or per session `create({ activity: true })`) to make sessions capture
it by default.

## Security

`lucarne` binds to `127.0.0.1` by default — keep it there unless you add a token.

- **CDP is full, unauthenticated control of the browser.** It always binds **loopback only** — for both backends, independent of the daemon's `--host` (the `docker` backend publishes with `-p 127.0.0.1:<port>:9222`, the native backend never passes `--remote-debugging-address`). Never expose a `cdpUrl`; drivers/agents run on the same host.
- **Token enforced off loopback.** Set `LUCARNE_TOKEN` (or `new Lucarne({ token })`) to require `Authorization: Bearer <t>` / `?token=<t>` on the control API **and** the porthole (HTTP + WebSocket). `lucarne serve` **auto-provisions and prints a token** whenever you bind off loopback (`--host` ≠ loopback, or `--tunnel`), so a remotely-reachable daemon is never unauthenticated — set your own with `export LUCARNE_TOKEN=$(openssl rand -hex 32)` to override.
- **Loopback CSRF/DNS-rebinding guard.** In the default tokenless loopback mode the daemon rejects requests with a non-loopback `Host` or a cross-origin `Origin` (403), so a malicious web page can't drive your localhost daemon. (A token, when set, is the auth instead.)
- **`?interactable=0`** drops input for *that* porthole connection server-side — but it is a per-connection mode, **not a capability boundary**: the same token can open an interactable socket or call `/act`. For a true read-only handoff, don't share the token. `?controls=1` adds a URL bar + back/forward/reload.
- **`/login` is not a confidentiality boundary against the *caller*.** It injects a stored secret server-side so the agent needn't *handle* it, but a caller that can drive the browser can render the value into a page and read it back — treat API access as access to the credentials, as below.
- **File access is confined.** Navigation refuses `file://`/`chrome://` (no host-file read via `/content`/`/screenshot`), and `/upload` only accepts paths inside the session's `/files` workspace — stage a file there first.
- **`/inject` grants arbitrary script execution on every page of the session, CSP included.** `POST /sessions/:id/inject {id, source, bypassCSP?}` registers a *sticky* injection (`Page.addScriptToEvaluateOnNewDocument`) that re-runs on every reload, every newly opened tab, and — because it's persisted into the session spec — every daemon restart. `bypassCSP:true` disables the page's Content-Security-Policy for the session (`Page.setBypassCSP`, held on a live per-page CDP session for as long as the page is open), which is exactly as strong as running the script with devtools open: it can read/exfiltrate anything the page can, override page behavior, and defeat CSP protections the site relies on. Treat calling `/inject` as equivalent to `/act`/`/content` access plus standing devtools-level control — anyone who can call it can call it with *any* source, not just yours. There is no built-in content policy: an optional `injectPolicy(id) => boolean` hook (default **permissive** — every id accepted) lets an embedder restrict *which ids* may be registered (e.g. a shell-only allow-list), but the engine itself doesn't inspect or restrict `source` — that's the caller's responsibility. An injected `source` may execute **twice per document** — once at document-start (where `document.documentElement`/`body` may still be `null`) and again at load — so sources **must be null-safe and idempotent**.
- Sessions run real browsers logged into real accounts — treat access to `lucarne` as access to those accounts.

### Exposing it (remote / from your phone)

`lucarne serve --tunnel` exposes the daemon through a tunnel **you already have
installed** — it shells out to the binary, prints the public token-gated `viewUrl`, and
**auto-provisions a token** (a tunneled daemon is never left unauthenticated):

```sh
lucarne serve --tunnel ngrok           # or: --tunnel cloudflared
# → lucarne tunnel: https://ab12.ngrok-free.app  (token-gated)
#   phone view:    https://ab12.ngrok-free.app/sessions/<id>/view/?token=…&controls=1

Any other tunnel works via --tunnel-cmd — the command just has to print its public https://… URL (tailscale, ssh -R, a corporate/relay client). lucarne sets LUCARNE_LOCAL_URL/LUCARNE_PORT in its environment:

lucarne serve --tunnel-cmd "ssh -R 80:localhost:7800 my.relay.example"

lucarne shells out to a tunnel you installed and bundles none — no extra dependency, no vendor lock-in (so a private relay is just a --tunnel-cmd). Prefer this loopback + tunnel posture over binding directly; if you must, serve --host 0.0.0.0 --port <n> needs a token and your own TLS. Never tunnel a cdpUrl — only the viewUrl.

Run it as a service (systemd)

Durable sessions survive a daemon restart, so a unit + Restart=always is enough. Keep the token in a 0600 env-file, not inline (so it isn't in systemctl show):

# /etc/systemd/system/lucarne.service
[Service]
EnvironmentFile=/etc/lucarne.env          # LUCARNE_TOKEN=...  (chmod 0600)
Environment=LUCARNE_HOME=/var/lib/lucarne
ExecStart=/usr/bin/lucarne serve
Restart=always
User=lucarne

[Install]
WantedBy=multi-user.target

Health-probe it at GET /health ({ ok, sessions }, no token needed). The docker backend is selected per session — lucarne create -b docker -p alpha (or {"backend":"docker"}).

Status & testing

1.0 — the API is stable and follows SemVer: no breaking changes to the documented surface without a major bump. New capabilities land in minor releases; read CHANGELOG.md before upgrading.

Every feature lands with a committed, re-runnable acceptance proof that asserts real behavior — a rendered JPEG frame, real-Chrome state, a valid mp4, an RFC TOTP vector — never an HTTP 200 (see ROADMAP.md "Proof of completion" and CONTRIBUTING.md). The suite (npm test) runs the native backend against real Chrome and is enforced in CI on Linux (google-chrome-stable + ffmpeg, under xvfb). The docker backend is smoke-tested when Docker is available (building the ~700 MB image per CI run is intentionally not gated); the native lane is the primary, fully-proven path. A separate docker CI lane (.github/workflows/docker.yml) builds the image and drives a real container (npm run test:docker) — on demand, weekly, and whenever docker-relevant code changes — so the docker backend is proven too, just not on every push.

Why "lucarne"

A lucarne is a small window set into a roof or a spire — a little opening that lets you see into (and out of) something much larger. That's the porthole onto a browser session.

License

MIT © Aaron Volter