@locksyk/webview-debug-panel
v0.2.0
Published
Devtools stand-in for embedded webviews (Teams tabs, Electron, kiosks): a floating panel recording fetch exchanges with Symfony profiler links, plus captured console output and uncaught errors. Vue 3.
Downloads
306
Maintainers
Readme
@locksyk/webview-debug-panel
Devtools for places that have none: embedded webviews (a Microsoft
Teams tab, Electron, a kiosk). A floating bug button opens a drawer with
two tabs - every recorded fetch exchange (method, path, status, timing,
clipped request/response bodies, and a link to the request's Symfony
profiler entry when the backend sends X-Debug-Token-Link), and
captured console output plus uncaught errors.
// main.ts - before your router mounts (it may strip the query string)
import '@locksyk/webview-debug-panel/style.css'
import { detectDebugFlag } from '@locksyk/webview-debug-panel'
detectDebugFlag()The stylesheet is a separate import because the package ships built: without it the panel renders, and renders unstyled.
<!-- App.vue -->
<script setup lang="ts">
import { DebugPanel } from '@locksyk/webview-debug-panel'
</script>
<template>
<DebugPanel /> <!-- renders nothing unless debug mode is on -->
</template>// your fetch wrapper
import { autoEnableFromResponse, isRecording, recordApiExchange } from '@locksyk/webview-debug-panel'
autoEnableFromResponse(response) // dev backend (X-Debug-Token) switches the panel on
if (isRecording()) {
void recordApiExchange(method, path, startedAt, response.clone(), requestBody)
}Enable explicitly by opening any page with ?debug=1 (persisted for
the session; ?debug=0 turns it off), or automatically when the
backend advertises the Symfony profiler.
Two switches: recording and debug mode
Capture and the devtools UI are separate, because a user reporting a bug needs the first without the second.
- Recording (
isRecording(),debugState.recording) is capture.startRecording()turns it on for everyone - call it at boot if users are to report bugs - andstopRecording()turns it off again. - Debug mode (
isDebugEnabled(),debugState.enabled) is the devtools UI:?debug=1, or a dev backend advertising the profiler. It implies recording, so a developer needs neither call.
Recording an app asked for is pinned: the panel's Off switch leaves debug mode without stopping capture, so a bug report started earlier still has its contents. Debug mode's own recording does stop there.
Nothing is captured while recording is off - recordApiExchange returns
immediately, so the isRecording() guard above is an optimisation
(it skips response.clone()) rather than a correctness requirement.
Styling uses CSS custom properties with neutral fallbacks - set
--white, --mist, --hairline, --tide to blend with your design
system.
Customising the panel
Nothing below is required: <DebugPanel /> on its own is the two-tab
drawer described above.
The button. icon is the character on the fab, and it is reactive -
change it whenever you like. When a character is not enough, the #fab
slot replaces its content entirely and receives { errorCount, open }.
The error badge stays on top either way.
<DebugPanel :icon="hasErrors ? '\u{1F525}' : '\u{1F41E}'" />
<DebugPanel>
<template #fab="{ errorCount }">
<BugIcon :alert="errorCount > 0" />
</template>
</DebugPanel>The tabs. tabs is the whole tab set, in order. The ids api,
console and bug render the built-in views; any other id renders the
matching #tab-<id> slot, which receives { tab, close }. Listing an id is what
puts it in the drawer, so you can reorder the built-ins, drop them, or
override one with a slot of the same name. A tab may be a bare string or
{ id, label, count } - count is the number in brackets after the
label, and the built-ins count their own entries unless you say
otherwise. The #actions slot adds buttons to the header, next to Clear
and Off.
<DebugPanel :tabs="[{ id: 'timings', label: 'Timings' }, 'bug', 'api', 'console']">
<template #tab-timings="{ close }">
<MyTimings @done="close" />
</template>
<template #actions>
<button class="quiet" @click="copySnapshot">Copy</button>
</template>
</DebugPanel>Somebody else's shell. ApiLogView and ConsoleLogView are exported
on their own, so the logs can sit inside your own dialog with no
DebugPanel in sight. Neither scrolls by itself - the container decides
how tall it is. openDebugPanel(tab?) and closeDebugPanel() drive the
built-in drawer from anywhere (a menu item, a keyboard shortcut), and
debugState.open / debugState.tab are readable if you want to mirror
its state.
<MyModal title="What went wrong">
<BugReportForm />
<ApiLogView />
</MyModal>Modals
The panel teleports to <body>, so a modal that inerts the page by
walking document.body.children meets one element rather than two.
Whether it skips that element is up to your modal, and every
implementation names the exception differently - so this package
supplies no name of its own. hostAttrs puts whatever yours looks for
on the host:
<!-- whatever attribute your modal leaves un-inerted -->
<DebugPanel :host-attrs="{ 'data-modal-persistent': '' }" />That matters because the moment a bug report is most wanted is while a
dialog is showing the error, not after dismissing it. The host stacks
at var(--z-debug-panel, 200); set that property, or pass
:z-index="...", when 200 is not above your overlays.
Nothing is imported in either direction: an attribute you name and a z-index you choose are the whole agreement, which is what lets this package cooperate with a modal it has never heard of.
teleport controls where the panel mounts: true (the default) for
<body>, a selector string for somewhere else, false to leave it
where it is written - handy in tests, and for an app that positions the
panel inside its own layout.
Report a bug
bug is the third built-in tab: a box for what went wrong, screenshots
(a file picker, which is a phone's camera roll; a clipboard paste, which
is where a macOS screenshot lands; or a drop), and the recording. Where
a report goes is the one thing this package cannot know, so
submitReport is the whole integration - an endpoint, an issue tracker,
a chat webhook, your choice. Return a string to show the person a
reference; throw to show them why it failed.
<DebugPanel always :tabs="['bug', 'api', 'console']" :submit-report="fileReport" />async function fileReport({ note, screenshots, snapshot, text }: BugReport) {
const form = new FormData()
form.set('note', note)
form.set('snapshot', JSON.stringify(snapshot))
form.set('text', text)
screenshots.forEach((shot) => form.append('screenshots[]', shot))
const { reference } = await postSomehow('/api/bug_reports', form)
return reference // shown to the reporter
}A tab listed without a handler says so rather than pretending: a Send button that discards is worse than no tab.
The draft outlives the panel being closed - somebody writing a report
closes it to re-read the error, or to go and take the screenshot - and
the sentence outlives a reload too. The screenshots cannot: a File is
not serialisable.
BugReportView is exported on its own if you would rather build the
tab yourself, and the pieces underneath are public as well:
snapshotDebug(context?, { redact }) returns everything recorded as
plain JSON, and formatDebugSnapshot() renders it as text for a ticket
or a chat message.
What a report must not carry
A recording of an app's API traffic contains what that traffic is authenticated with: a session token in the response that minted it, a single-use enrolment token in the request that spends it, a WebAuthn challenge, whatever this particular app calls its own.
So snapshotDebug() redacts - on the way out, not on the way in.
Showing a token in the drawer is no worse than a browser's network tab,
which is what this package stands in for, and an API tab that lies
about what was sent is a poor devtool. Sending it somewhere is the
different act, and that is where the scrubbing happens.
Values are replaced when their key looks secret (token matches
access_token, X-Auth-Token, enrolmentToken - case and separators
ignored), in JSON at any depth, in query strings, in form bodies, and
for bare JWTs anywhere. A body too long to have survived as parseable
JSON falls back to a text pass, because a clipped body is exactly when
a token would otherwise walk out.
None of it is a security boundary. It is pattern-matching over somebody else's JSON, and a secret under a name nobody thought of goes straight through - which is why the app gets a say:
configureRedaction({
// Your own names, added to the built-in list.
keys: ['employeeNumber'],
// The last word on an exchange. Return it changed, or null to keep it
// out of reports entirely.
exchange: (e) => (e.path.startsWith('/api/keys') ? null : e),
})The hook is handed what is already redacted, never the original, so an app's rule can only ever tighten this.
Two escape hatches, in opposite directions:
redactReport: falseon the panel (orsnapshotDebug(ctx, { redact: false })) sends the recording as it happened - reasonable when the report goes somewhere already trusted with the traffic, such as the server that served it.configureRedaction({ atRecord: true })scrubs as exchanges are recorded, so the buffer never holds the secret at all. The drawer is then redacted too, and the original is gone for good - including forredact: false, which has nothing left to hand back.
Users reporting bugs are not in debug mode, so the fab would normally be
hidden from them and there would be nothing recorded to attach.
startRecording() at boot fixes the second half, and always shows the
button regardless of debug mode; pair it with a tab set that only grows
when debug mode is on.
// main.ts
detectDebugFlag()
startRecording() // everyone, so a report has something to attach<DebugPanel
always
icon="!"
:tabs="isDebugEnabled() ? ['bug', 'api', 'console'] : ['bug']"
:submit-report="fileReport"
/>The Off switch (which turns debug mode off for the session) only appears
when debug mode is on, so an always panel shows it to developers and
hides it from everyone else.
Building
npm run build produces dist/: an ES module, the stylesheet, type
declarations and sourcemaps. That directory is what the package
exports, so a checkout consumed through a file: link has to be built
before the app that links it - npm run watch while working on both.
The sources ship alongside it, so a stack trace through the panel lands
in readable code, which is the least a debugging tool can do.
vue is a peer dependency and stays external.
License
GPL-2.0-only.
