@crosspane/agent
v0.8.2
Published
Tiny in-page debugging agent — capture console/errors/network in webviews and locked-down environments, export or live-stream to the crosspane dashboard
Maintainers
Readme
@crosspane/agent
Tiny in-page debugging agent for the places devtools can't reach — in-app webviews, in-app browsers, kiosks, and security-hardened builds where remote inspectors are blocked outright.
Captures console output, uncaught errors, unhandled rejections, fetch/XHR and navigations. Stream it live to the crosspane dashboard, or export a capture file when the network isn't an option.
Zero dependencies. ~5 KB gzipped.
Install
npm install @crosspane/agentUse
Call it once, as early as your app can run code. Anything before the call is not hooked — requests are partly recovered from resource timing afterwards, but console logs from before the call are gone for good.
| Your setup | File | Where in it |
|---|---|---|
| Next.js (App Router) | a new app/crosspane.tsx with 'use client', imported from app/layout.tsx | top level of that module — see below |
| Next.js (Pages Router) | pages/_app.tsx | top of the file, outside the component |
| Vite (React/Vue/Svelte/Solid) | src/main.ts / src/main.tsx | first lines, before createApp / createRoot |
| Create React App | src/index.tsx | first lines, before createRoot |
| SvelteKit | src/routes/+layout.svelte | inside <script>, guarded by browser |
| Astro | your base layout's <script> | before other scripts |
| No bundler | a <script> in <head> | before your other scripts — see below |
// src/main.tsx — Vite, CRA. First lines of the file.
import { initCrosspane } from '@crosspane/agent'
initCrosspane({
label: 'checkout webview',
serverUrl: import.meta.env.VITE_CROSSPANE_URL, // omit entirely on localhost
})
// ...your own imports and createRoot() belowNext.js App Router needs one extra step. app/layout.tsx is a server component: calling
initCrosspane() there runs it on the server, where there is no page to hook — it does not
crash, it silently does nothing, which is harder to notice than a crash.
// app/crosspane.tsx
'use client'
import { initCrosspane } from '@crosspane/agent'
// Top level, not inside the component and not in useEffect — a module runs as soon as the
// client bundle loads, while useEffect waits for React to mount and misses your early logs.
initCrosspane({
label: 'checkout webview',
serverUrl: process.env.NEXT_PUBLIC_CROSSPANE_URL,
})
export function Crosspane() {
return null
}Then render <Crosspane /> once anywhere inside <body> in app/layout.tsx. Calling
initCrosspane() twice is safe — the second call returns the same agent rather than hooking
anything again.
const agent = initCrosspane({ label: 'checkout webview' })
// Offline mode: wire this to a debug gesture or hidden QA menu
agent.exportFile() // downloads <label>.crosspane.json
await agent.copyCapture() // or put it on the clipboard — see the crosspane README,
// "Getting captures off a locked device"Then run the hub and open the dashboard:
npx crosspane --host 0.0.0.0Drop the exported .crosspane.json into the dashboard to replay it — same UI, no
server connection needed.
Without a bundler
If you can't run a bundler — injecting into a page through a proxy, a kiosk build, a plain static page — use the prebuilt single-file bundles (~5 KB gzipped):
<!-- ES module -->
<script type="module">
import { initCrosspane } from 'https://unpkg.com/@crosspane/agent/dist/crosspane-agent.esm.js'
initCrosspane({ label: 'kiosk display' })
</script>
<!-- or a classic script tag: exposes window.crosspane -->
<script src="https://unpkg.com/@crosspane/agent/dist/crosspane-agent.global.js"></script>
<script>
crosspane.initCrosspane({ label: 'kiosk display' })
</script>Under a strict CSP, self-host the file instead of loading it from a CDN.
Shipping safely
This is a debug-build feature. The cleanest approach is to let your bundler drop it from production entirely:
if (process.env.NODE_ENV !== 'production') {
const { initCrosspane } = await import('@crosspane/agent')
initCrosspane({ label: 'checkout webview' })
}Or gate it at runtime — with enabled: false the agent installs no hooks at all,
leaving console, fetch and XMLHttpRequest untouched:
initCrosspane({ enabled: () => user.isInternal })Response bodies are not captured unless you pass captureBodies: true.
API
initCrosspane(options?: {
label?: string // shown in the dashboard (default: document.title)
enabled?: boolean | (() => boolean)
serverUrl?: string // usually omit — resolved automatically (see below)
bufferSize?: number // ring buffer size (default: 2000 events)
captureBodies?: boolean // capture response bodies (default: false)
bodyPreviewLimit?: number // default: 2048 chars
maxTextLength?: number // per console/error entry (default: 10000 chars)
}): CrosspaneAgent| Method | Description |
|---|---|
| agent.capture() | Returns a SessionCapture object (the ring buffer contents) |
| agent.exportFile() | Downloads it as .crosspane.json. Silently does nothing in webviews whose host app doesn't implement downloads |
| agent.copyCapture() | Puts the capture JSON on the clipboard; resolves to false if it couldn't. Works on non-secure origins (http://<lan-ip>), where navigator.clipboard is undefined. Needs a user gesture |
| agent.dispose() | Restores console/fetch/XHR, closes the live connection |
| agent.session | Session metadata (id, label, userAgent, platform) |
| agent.enabled | false when gated off |
| agent.live | true if a hub address was resolved — not that the hub answered. false means offline capture only. To confirm sessions arrive, watch the hub's terminal for ● session · <label> |
Where the hub address comes from
One address, and nothing varies between localhost, a phone, a deployed page and production:
initCrosspane({
label: 'checkout webview',
serverUrl: process.env.NEXT_PUBLIC_CROSSPANE_URL, // Vite: import.meta.env.VITE_CROSSPANE_URL
})NEXT_PUBLIC_CROSSPANE_URL=https://crosspane.example.comJust the address, like any other base URL. The same value is fine in every environment including
production: sending sessions needs no credential, while reading them needs a token that stays
on the developer's machine. Run crosspane --tunnel --write-env and even this is filled in for you
during local and on-device development.
On localhost you can omit it entirely — the agent looks for a hub on http://localhost:7788 by
itself. Unset and not on localhost means offline capture only; the agent never guesses.
Who actually streams
Every install with that address streams, which is what you want in a dev or QA build. If the same
build reaches people you don't want sending you logs, gate on something the app already knows —
with enabled: false the agent installs no hooks at all:
initCrosspane({ serverUrl: HUB, enabled: () => user.isQA })That is the right gate for a webview the app opens itself: there is no address bar in one, so nothing URL-based can work there.
For a page with no user model (a static site, a kiosk), isDebugActivated gates on a link instead
— open it once with ?__crosspane=on, clear with ?__crosspane=off:
import { initCrosspane, isDebugActivated } from '@crosspane/agent'
initCrosspane({ serverUrl: HUB, enabled: isDebugActivated })agent.live tells you which state you ended up in. agent.copyCapture() works regardless — no
address, certificate or origin involved.
Notes
- Calling
initCrosspanetwice returns the same agent — hooks are never installed twice, so hot reloads and duplicated bundles are safe. - Call
initCrosspaneas early as possible — anything logged before it runs isn't captured. - The ring buffer keeps the last N events, so a crash still leaves you the moments before it. Events are buffered whether or not the live connection is up.
- Under a strict CSP you need
connect-srcto allow the hub for live mode. Bundling the agent (rather than loading it from a CDN) avoidsscript-srcproblems entirely. - Breakpoints are not possible from inside the page — JavaScript can't pause itself. When a remote inspector is available, use it; this agent is for when it isn't.
License
MIT
