Skip to content

@whisq/devtools is a runtime hook that attaches a read-only inspection surface to globalThis.__WHISQ_DEVTOOLS__. A browser extension or a console script can read the hook to inspect signals, components, and a logged event stream. Apps opt in by calling register / log functions themselves — there is no auto-instrumentation.

Ships as a separate package, versioned lockstep with @whisq/core:

Terminal window
npm install --save-dev @whisq/devtools
import { connectDevTools, disconnectDevTools } from "@whisq/devtools";
import type { DevToolsHook, SignalInfo, DevToolsEvent } from "@whisq/devtools";
function connectDevTools(): void;
function disconnectDevTools(): void;

Wire the hook once, guarded by the dev flag, before mount():

src/main.ts
import { mount } from "@whisq/core";
import { connectDevTools } from "@whisq/devtools";
import { App } from "./App";
if (import.meta.env.DEV) connectDevTools();
mount(App({}), document.getElementById("app")!);

Then register the signals and components you want to inspect. No magic — you pick what’s interesting:

stores/todos.ts
import { signal } from "@whisq/core";
export const todos = signal<Todo[]>([]);
if (import.meta.env.DEV) {
// Safe: globalThis.__WHISQ_DEVTOOLS__ exists after connectDevTools().
globalThis.__WHISQ_DEVTOOLS__?.registerSignal("todos", todos);
}

After connectDevTools(), the hook lives on the global object under __WHISQ_DEVTOOLS__ and implements the DevToolsHook interface:

interface DevToolsHook {
version: string;
// Signals
registerSignal(name: string, signal: ReadonlySignal<unknown>): void;
unregisterSignal(name: string): void;
getSignals(): SignalInfo[];
// Components
registerComponent(name: string): void;
unregisterComponent(name: string): void;
getComponents(): string[];
// Event log
logEvent(type: string, data: Record<string, unknown>): void;
getEvents(): DevToolsEvent[];
clearEvents(): void;
}
interface SignalInfo {
name: string;
value: unknown;
}
interface DevToolsEvent {
type: string;
data: Record<string, unknown>;
timestamp: number;
}
MethodUse
registerSignal(name, signal)Add a named signal to the hook’s ledger.
unregisterSignal(name)Remove a named signal — useful in HMR boundaries or component unmount.
getSignals()Snapshot of all registered signals. Reads via signal.peek(), so inspection does not subscribe the reader.
registerComponent(name)Mark a component as active.
unregisterComponent(name)Remove an active component.
getComponents()Snapshot of active component names.
logEvent(type, data)Append a timestamped event to the log (type + free-form data).
getEvents()Snapshot of the event log (copy; mutating the return doesn’t touch the hook).
clearEvents()Empty the event log.

Note on version: the hook reports its own internal protocol version (currently "0.0.1-alpha.0"), independent of the @whisq/devtools package version. That string is what browser extensions check for compatibility.

Removes globalThis.__WHISQ_DEVTOOLS__. Useful when an HMR or test run re-initialises the app:

import { connectDevTools, disconnectDevTools } from "@whisq/devtools";
// Register teardown BEFORE connecting — the dispose hook runs on every HMR
// cycle, and we want it ready for the first hot update after this module load.
if (import.meta.hot) {
import.meta.hot.dispose(() => disconnectDevTools());
}
if (import.meta.env.DEV) connectDevTools();

Without teardown, a hot reload can leave a stale hook pointing at disposed signals — getSignals() would then .peek() values that no longer fire subscribers.

  • Dev builds only. Guard with import.meta.env.DEV (or your bundler’s equivalent) so the hook doesn’t ship to production.
  • Before mount(). Component setup functions can then call registerComponent(...) / registerSignal(...) during first render.
  • Once, at module scope. Calling it again overwrites the hook — idempotent in practice but wasteful.

There is no official Whisq browser extension yet. The hook is designed to be forward-compatible with one — the version field and the shape of DevToolsHook are the contract a future extension will consume. Until then, the hook is inspectable from the browser console:

// In the browser console, on a running dev build:
__WHISQ_DEVTOOLS__?.getSignals();
__WHISQ_DEVTOOLS__?.getComponents();
__WHISQ_DEVTOOLS__?.getEvents();
  • Passive surface. connectDevTools() only exposes the hook object. It does not monkey-patch signal(), wrap component(), or intercept effects. What the hook knows is exactly what you’ve told it.
  • No subscriptions. getSignals() reads via .peek(), so nothing in the hook tracks dependencies — inspecting from the console won’t re-run effects.
  • No-op on double-register. registerSignal(name, …) with an existing name replaces the previous entry via Map.set — no throw, no warn. Same for registerComponent (Set.add ignores duplicates).
  • getSignals() / getEvents() return copies. getEvents() spreads into a new array, so mutating the return doesn’t touch the hook’s internal log. Registered signals are held by reference, but the SignalInfo snapshot is fresh on every call.

Docs current to v0.1.0-alpha.9 . All releases →