-
Notifications
You must be signed in to change notification settings - Fork 41
example devtools instrumentation
Build a third-party devtool (SQL inspector, network logger, perf profiler) that attaches to a window registered by another plugin without reaching into iframe globals.
The shell exposes three primitives that compose into a complete devtool:
| Primitive | Purpose |
|---|---|
wp.os.devtools.addRequestHeader(windowId, name, value) |
Contribute an HTTP header to every fetch / XHR / sendBeacon from the target window. Multiple devtools can contribute the same header — the shell joins values per RFC 7230 (v1, v2, v3). |
wp.os.devtools.onRequest(windowId, cb, { observe }) |
Subscribe to every completed request from the target window. observe: true includes request + response headers in the payload. |
wp.os.devtools.debug.{startSession, publish, subscribe} |
Generic per-session pub/sub bus, server- and client-side. PHP capture publishes via openstation_debug_publish(); the JS shell polls and replays. |
Plus a server-side helper:
$session_id = openstation_debug_session_for_request();
if ( '' !== $session_id ) {
// request came from an instrumented window
}The end goal: when the user clicks the bug-icon dropdown on any window's title bar and chooses "Attach SQL Inspector," every SQL query that window's iframe triggers shows up in a separate inspector window.
add_action( 'init', function () {
$session_id = openstation_debug_session_for_request();
if ( '' === $session_id || ! current_user_can( 'manage_options' ) ) {
return;
}
if ( ! defined( 'SAVEQUERIES' ) ) {
define( 'SAVEQUERIES', true );
}
add_action( 'shutdown', function () use ( $session_id ) {
global $wpdb;
if ( empty( $wpdb->queries ) ) {
return;
}
foreach ( $wpdb->queries as $q ) {
openstation_debug_publish( $session_id, 'query', array(
'sql' => $q[0],
'time' => $q[1],
'caller' => $q[2],
) );
}
} );
}, 1 );wp.os.ready( () => {
wp.os.registerTitleBarButton( {
id: 'sql-inspector/attach',
label: 'Devtools',
icon: 'dashicons-bug',
match: () => true, // every window gets the bug
onClick: ( win ) => {
const sessionId = wp.os.devtools.debug.startSession();
// Attach the session header to every outgoing request.
const stop = wp.os.devtools.addRequestHeader(
win.id,
'X-WP-Debug-Session',
sessionId,
);
// Open (or focus) the inspector window.
const inspector = wp.os.registerWindow( {
id: `sql-inspector-${ win.id }`,
title: `SQL — ${ win.config.title }`,
icon: 'dashicons-search',
native: true,
width: 640,
height: 480,
onClose: () => stop(), // remove the header on close
render: ( body ) => {
body.innerHTML = `
<os-stack gap="8" style="padding:8px;">
<os-cluster gap="6">
<os-badge tone="success">Attached</os-badge>
<span style="opacity:0.7">${ win.config.ownerHandle || 'core' }</span>
</os-cluster>
<!--
row-height="22" is fixed-row-height mode (fast path).
Each row MUST fit in 22px or it clips silently. For
variable-content rows (header line + body block,
expandable details), set `auto-row-height` instead —
the component will measure each row and use cumulative
offsets for the virtualizer.
-->
<os-log id="log" row-height="22" max-rows="2000"></os-log>
</os-stack>
`;
const log = body.querySelector( '#log' );
log.renderRow = ( ev ) => {
const row = document.createElement( 'div' );
row.innerHTML = `
<os-code copy>${ ev.payload.sql }</os-code>
<span style="opacity:0.6;margin-inline-start:8px">
${ ev.payload.time.toFixed( 4 ) }s
</span>
`;
return row;
};
wp.os.devtools.debug.subscribe(
sessionId,
'query',
( ev ) => log.push( ev ),
);
},
} );
},
} );
} );That's the whole thing. The pattern generalises: swap 'query' for 'rest_timing', 'log', or any channel name your capture publishes on.
If two devtools both want X-WP-Debug-Session on the same window, the shell concatenates with , — neither overwrites the other. When each devtool calls its disposer, the contribution drops; only when the last contributor goes away is the header removed entirely.
const stopA = wp.os.devtools.addRequestHeader( 'win-1', 'X-Trace', 'a' );
const stopB = wp.os.devtools.addRequestHeader( 'win-1', 'X-Trace', 'b' );
// Iframe sees: X-Trace: a, b
stopA();
// Iframe sees: X-Trace: b
stopB();
// Iframe sees: header removedDefault onRequest payloads carry only the privacy-conscious summary (method, url, status, duration, failed). Pass { observe: true } to opt in to header capture for the duration of your subscription:
wp.os.devtools.onRequest( windowId, ( req ) => {
console.log( req.method, req.url, req.requestHeaders, req.responseHeaders );
}, { observe: true } );The shell aggregates: as long as any subscriber for the window has observe: true, the iframe runs in observation mode. When the last observer disposes, the iframe falls back to summary mode automatically.
Every window registered via openstation_register_window( $id, $args ) (with 'script' => 'my-plugin-handle' in $args) carries that handle through to Window.config.ownerHandle. Devtools read it for attribution:
wp.os.registerTitleBarButton( {
id: 'sql-inspector/attach',
match: ( win ) => win.config.ownerHandle !== 'core-stuff',
// ...
} );When the URL is the only attribution surface (a window backed by a core admin page), ownerHandle is undefined — fall back to win.config.url parsing.
The debug bus exposes a single REST route: GET /desktop-mode/v1/debug?sessionId=…&since=…&channel=… (or channels[]=…&channels[]=…). Permission: logged-in admin (manage_options); override via the openstation_debug_rest_permission filter. The openstation_debug_publish action fires synchronously on every publish for observability widgets that don't want to round-trip through the poll loop.
wp.os.devtools.debug.subscribe() is the supported path — it polls, dedupes, and replays events to your callback. If you need to bypass the poll loop (custom UI cadence, batched drains on demand, integration with a non-shell consumer), use wp.apiFetch rather than rolling your own fetch():
const events = await wp.apiFetch( {
path: `/desktop-mode/v1/debug?sessionId=${ sid }&channels[]=query&since=${ cursor }`,
} );wp.apiFetch handles two things you'd otherwise re-derive: nonce attachment and URL composition under both pretty-permalink (/wp-json/) and ugly-permalink (?rest_route=/) installs. Hand-built fetch( restUrl + 'desktop-mode/v1/debug?sessionId=…' ) works on pretty-permalink sites and silently breaks on ugly-permalink sites — the URL ends up with two ? separators, WordPress routes to the homepage, the response is HTML, and JSON.parse throws.
The shell's own poll loop uses WHATWG URL + searchParams for the same reason — both permalink schemes round-trip cleanly.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config