Skip to content

DevTools Adapter ​

The DevTools adapter provides Vue-style reactive introspection for KickJS applications. It exposes debug endpoints that let you inspect routes, DI container state, request metrics, and application health — all powered by the reactivity module.

Quick Start ​

ts
import { bootstrap } from '@forinda/kickjs'
import { DevToolsAdapter } from '@forinda/kickjs-devtools'

bootstrap({
  modules: [UserModule, ProductModule],
  adapters: [DevToolsAdapter()],
})

DevTools endpoints are now available at /_debug/* — when NODE_ENV is development or test. Under any other value, including an unset NODE_ENV, the adapter stays off; pass enabled: true to mount it anyway (a staging box, say).

kick add devtools (and kick new with DevTools selected) writes the dev-only form below instead, so the package never reaches the production bundle — as a spread in the adapters list, with the flag's type referenced from src/devtools.d.ts:

ts
adapters: [
  ...(__KICKJS_DEVTOOLS__
    ? [(await import('@forinda/kickjs-devtools')).DevToolsAdapter()]
    : []),
],

Stripping DevTools from production bundles ​

The runtime enabled flag above keeps the DevTools adapter inert in prod, but the import of @forinda/kickjs-devtools is still in the bundle — code, dependencies, and all. To drop the package entirely from the production output, gate the import behind the __KICKJS_DEVTOOLS__ build-time flag injected by @forinda/kickjs-vite:

ts
/// <reference types="@forinda/kickjs-vite/globals" />
import { bootstrap } from '@forinda/kickjs'

const adapters = [/* prod adapters… */]

if (__KICKJS_DEVTOOLS__) {
  const { DevToolsAdapter } = await import('@forinda/kickjs-devtools')
  adapters.push(DevToolsAdapter({ basePath: '/_debug' }))
}

bootstrap({ modules: [UserModule, ProductModule], adapters })

Vite/Rollup substitutes __KICKJS_DEVTOOLS__ with true (during vite dev) or false (during vite build) and tree-shakes the unreachable branch — including the dynamic import('@forinda/kickjs-devtools') chunk — out of the prod bundle entirely.

The kickjsVitePlugin() registers the flag by default. Override per build via the env var KICKJS_DEVTOOLS=0|1 or per project via plugin options:

ts
// vite.config.ts
import { kickjsVitePlugin } from '@forinda/kickjs-vite'

export default defineConfig({
  plugins: [
    kickjsVitePlugin({
      // Force-disable for a "no-devtools" build profile
      devtools: { enabled: false },
      // Or rename the global to avoid collisions:
      // devtools: { flagName: '__APP_DEVTOOLS__' },
      // Or skip the plugin entirely:
      // devtools: false,
    }),
  ],
})

Resolution order: explicit enabled option → KICKJS_DEVTOOLS=0|1|true|false env → Vite command ('serve' → true, 'build' → false).

A build without DevTools also drops your @forinda/kickjs-devtools-kit imports and the tabs they define. A build that keeps it — devtools: { enabled: true } or KICKJS_DEVTOOLS=1 — keeps them too. To run the dashboard in production, keep it in the build, pass DevToolsAdapter({ enabled: true, secret }), and serve it on a private port if the app has a management port.

Endpoints ​

GET /_debug/routes ​

Lists all registered routes with their HTTP method, path, controller, handler, middleware, and resolved route flags. A handler with @FileUpload also reports upload (mode, fieldName, maxCount; never allowedTypes, which may be a function).

json
{
  "routes": [
    {
      "method": "GET",
      "path": "/api/v1/users",
      "controller": "UserController",
      "handler": "getAll",
      "middleware": ["authGuard"],
      "flags": {}
    },
    {
      "method": "GET",
      "path": "/api/v1/health",
      "controller": "HealthController",
      "handler": "live",
      "middleware": [],
      "flags": { "auth.public": true }
    },
    {
      "method": "POST",
      "path": "/api/v1/login",
      "controller": "AuthController",
      "handler": "login",
      "middleware": ["validate"],
      "flags": { "rate.limit": { "rpm": 10 } }
    }
  ]
}

flags holds only the flags in force: one turned off at the method (@Public(false)) is absent rather than false, matching what every other consumer sees. The dashboard's Routes tab shows the same values in a Flags column, so "why does this endpoint not require auth" is answerable from the route list instead of by reading the controller.

The list covers everything the app serves except the dashboard itself: routes adapters add (/metrics, a webhook) are listed under the adapter's name with "source": "adapter", and each route says which listener serves it, "port": "app" or "management". The top-level port is where the dashboard is. With a management port, the Routes tab badges its routes mgmt; a route on the other port than the dashboard's can't be sent from the runner, which calls the dashboard's own origin.

GET /_debug/container ​

Shows all DI container registrations with their scope and instantiation status.

json
{
  "registrations": [
    { "token": "UserService", "scope": "singleton", "instantiated": true },
    { "token": "ProductService", "scope": "singleton", "instantiated": false }
  ],
  "count": 2
}

GET /_debug/requests ​

The last requestLog requests (default 200), oldest first. ?since=<seq> returns only the newer ones. The dashboard's own /_debug calls aren't logged. Query strings, headers and bodies aren't recorded.

json
{
  "requests": [
    {
      "seq": 41,
      "at": 1790000000000,
      "method": "GET",
      "path": "/api/v1/users/42",
      "route": "/api/v1/users/:id",
      "status": 500,
      "durationMs": 6.6,
      "requestId": "6dda727c-…",
      "error": { "name": "TypeError", "message": "users store unavailable" }
    }
  ]
}

GET /_debug/metrics ​

Live request metrics powered by reactive refs and computed values.

json
{
  "requests": 1542,
  "serverErrors": 3,
  "clientErrors": 28,
  "errorRate": 0.0019,
  "uptimeSeconds": 3600,
  "startedAt": "2026-03-20T10:00:00.000Z",
  "routeLatency": {
    "GET /api/v1/users": {
      "count": 500,
      "totalMs": 2500,
      "minMs": 2,
      "maxMs": 45
    }
  }
}

GET /_debug/health ​

Deep health check with computed status derived from reactive error rate.

json
{
  "status": "healthy",
  "app": "ready",
  "errorRate": 0.0019,
  "uptime": 3600,
  "adapters": {
    "DevToolsAdapter": "running"
  }
}

Returns 200 when healthy, 503 when degraded (error rate exceeds threshold). app is the application's status; the dashboard also receives every change to it as an app:status event on its live connection.

GET /_debug/startup ​

Where the last startup's time went: each phase and each adapter hook, in the order they ran. Adapter hooks are named <Adapter>.<hook>.

json
{
  "totalMs": 412.6,
  "steps": [
    { "name": "PrismaAdapter.beforeMount", "ms": 287.1 },
    { "name": "middleware", "ms": 4.2 },
    { "name": "modules", "ms": 61.9 },
    { "name": "routes", "ms": 12.4 },
    { "name": "error handlers", "ms": 0.3 },
    { "name": "listen", "ms": 1.8 },
    { "name": "ready", "ms": 0.4 }
  ]
}

The same data is available in code as app.getStartupTimeline(). When startup takes longer than a second, the app logs its three slowest steps.

GET /_debug/ws ​

WebSocket stats when WsAdapter is active. Shows namespaces, connections, message counts, and rooms.

json
{
  "enabled": true,
  "totalConnections": 42,
  "activeConnections": 12,
  "messagesReceived": 1580,
  "messagesSent": 3200,
  "errors": 0,
  "namespaces": {
    "/ws/chat": { "connections": 8, "handlers": 10 },
    "/ws/notifications": { "connections": 4, "handlers": 4 }
  },
  "rooms": {
    "/ws/chat": ["room:general", "room:support"]
  }
}

Returns 404 if no WsAdapter is registered.

GET /_debug/state ​

Full reactive state snapshot — everything in one endpoint.

json
{
  "reactive": {
    "requestCount": 1542,
    "errorCount": 3,
    "clientErrorCount": 28,
    "errorRate": 0.0019,
    "uptimeSeconds": 3600,
    "startedAt": "2026-03-20T10:00:00.000Z"
  },
  "routes": 12,
  "container": 8,
  "routeLatency": {}
}

GET /_debug/config (opt-in) ​

The app's configuration. Variables matching configPrefixes show their value; the other variables the app declares (its env schema and settings sections) are listed as [REDACTED]; everything else in the environment — the shell's, the platform's — is left out, names included. origins says where each variable the app declares (its env schema and settings sections) gets its value: env, file (a _FILE secret), default, provided, or unset. It lists names only, never values.

json
{
  "config": {
    "APP_NAME": "my-api",
    "APP_PORT": "3000",
    "NODE_ENV": "development",
    "DATABASE_URL": "postgres://db.example/my-api",
    "JWT_SECRET": "[REDACTED]"
  },
  "origins": {
    "env": [
      { "name": "PORT", "origin": "default" },
      { "name": "JWT_SECRET", "origin": "env" }
    ],
    "sections": [
      {
        "prefix": "DB",
        "vars": [
          { "name": "DB_URL", "origin": "file" },
          { "name": "DB_POOL_MAX", "origin": "default" }
        ]
      }
    ]
  }
}

Configuration ​

ts
DevToolsAdapter({
  // Base path for debug endpoints (default: '/_debug')
  basePath: '/_debug',

  // Mount the dashboard (default: only when NODE_ENV is 'development' or 'test')
  enabled: true,

  // Expose sanitized env vars at /_debug/config (default: false)
  exposeConfig: true,

  // Env var prefixes to expose (default: ['APP_', 'NODE_ENV'])
  configPrefixes: ['APP_', 'DATABASE_', 'NODE_ENV'],

  // Error rate threshold for health degradation (default: 0.5)
  errorRateThreshold: 0.5,

  // Custom callback when error rate exceeds threshold
  onErrorRateExceeded: (rate) => {
    slackWebhook.send(`Error rate: ${(rate * 100).toFixed(1)}%`)
  },

  // Recent requests the Requests tab keeps (default: 200; 0 keeps none)
  requestLog: 200,

  // Serve on the management port instead of the app's (default: false)
  management: true,
})

On a private port ​

To use the dashboard outside development without exposing it, serve it on the app's management port — reachable inside your network, not through the ingress:

ts
bootstrap({
  modules,
  management: { port: 9090 },
  adapters: [
    DevToolsAdapter({ management: true, enabled: true, secret: getEnv('DEVTOOLS_TOKEN') }),
  ],
})

Keep a secret: anything that can reach the port can otherwise read the app's routes, container and requests. Without a management port, management: true leaves the dashboard on the app's port.

Accessing Reactive State Programmatically ​

The adapter exposes its reactive state as public properties, so you can compose with it:

ts
const devtools = DevToolsAdapter()

// Read reactive values
console.log(devtools.requestCount.value)
console.log(devtools.errorRate.value)

// Watch for changes
import { watch } from '@forinda/kickjs'

watch(devtools.errorRate, (rate) => {
  if (rate > 0.1) pagerDuty.alert('High error rate')
})

// Subscribe directly
devtools.requestCount.subscribe((newCount) => {
  prometheus.gauge('http_requests_total').set(newCount)
})

How It Works ​

The DevToolsAdapter uses three layers:

  1. Reactive primitives (ref, computed, watch) from @forinda/kickjs/reactivity
  2. Middleware that increments reactive counters on each request (phase: beforeGlobal)
  3. Routes at /_debug/*, registered through the engine-neutral adapter HTTP surface, that read reactive state and return JSON. They run the same on Express, Fastify, and h3

Because the state is reactive, the computed values (error rate, uptime) are always consistent and only recalculate when their dependencies change.

Browser Dashboard ​

The dashboard needs a browser from 2023 or later (Chrome / Edge 110, Firefox 115, Safari 16).

When you visit /_debug in a browser, the DevTools adapter serves a single-page dashboard built with Solid + Tailwind. It connects to the JSON endpoints documented above and adds live UI on top — there's nothing to install client-side.

Connection state ​

A pill in the global header shows what the dashboard is doing:

StateMeaning
Live (green pulse)Subscribed to /stream SSE — metrics + container changes push in real time
Polling (amber pulse)SSE dropped, falling back to a 5-second /health + /metrics poll
Connecting… (grey)First request hasn't returned yet
Disconnected (red)The app isn't answering (restarting, crashed) — a banner shows and the dashboard keeps retrying

The trailing Updated HH:MM:SS timestamp is the last successful refresh, so you can tell at a glance the page isn't frozen.

Tabs ​

Each tab subscribes to a slice of the shared store; nothing owns its own polling loop.

TabWhat it shows
OverviewRequests, server errors, p95 latency and heap, each with the last minute as a sparkline; the latest failed requests with their errors (click one to open it in Requests); app status, uptime, WebSocket and adapters with status dots. Default tab on first visit.
RuntimeThe Node process: engine, Node version, PID and uptime; heap, process memory, event-loop delay (with GC ticks) and CPU over the last minute; memory health — heap growth per minute, GC reclaim, share of the heap limit, open handles by type — and Heap snapshot / Run GC. Growth is judged after startup settles, so a fresh process reads sampling, not a leak. Below, Plugged in: plugins, adapters and context contributors side by side. Each plugin / adapter card shows its version, the lifecycle hooks it implements (beforeStart, middleware, shutdown, …), the counters and state its introspect() reports, and the DI tokens it provides and requires — hover a token to light up every card that touches it, click it to open it in Container. Contributors show their ctx key, source and what they run after.
RoutesEvery route, grouped by controller, with its flags — searchable, filterable by method. Click a controller to collapse it (remembered; Collapse all folds the list); a search shows every match. Selecting one opens the API runner beside the list.
SocketsWsAdapter's namespaces with their open connections and @OnMessage events. Select one for a client: Connect (the query string and subprotocols take from the API runner's active environment, e.g. token= or protocols bearer, ), pick an event, type its data as JSON, Send, and watch sent and received frames. Query, event and data are remembered per namespace. Browsers can't set headers on a WebSocket, so authenticate the way your app reads it: subprotocols, the query, or cookies.
RequestsThe app's recent requests, newest first — status, method, path (long id segments cut short, full path on hover), duration, and the error a failed one threw. Filter by status class; select one for its route and request ID, and Replay in runner to reopen it with the same path params.
MetricsOne row per route: calls, share of 5xx, p50 / p95 / p99 and max, sortable by any column. Pick a percentile to bar it against the slowest route; durations turn amber, orange and red past 200 ms, 500 ms and 1 s. Expand a row for its latency histogram, ok / 4xx / 5xx counts, and Try in runner.
ContainerEvery DI registration — search, filter by kind and scope (each chip shows how many it would match; empty ones are hidden), show only tokens never resolved, and sort by resolves, last resolve or name. Rows show a status dot, kind (factory / instance for tokens registered that way), resolve count and when the token was last resolved. Beside the list, Details shows the selected token's dependencies and dependents (click to follow), resolve stats and @PostConstruct outcome; Graph draws the dependency graph in columns from what nothing depends on (usually controllers) to leaves. With a token selected the graph narrows to its chain — what it depends on and what depends on it, all the way — and frames it; Whole graph shows every token. Drag tokens to arrange them (whole-graph positions are remembered per browser; Reset layout undoes it), drag the background or scroll to pan, ⌘/Ctrl + scroll or pinch to zoom, Fit to frame everything. Edges that close a cycle are dashed red.
DatabaseThe queries kick/db reports while DevTools is installed. Slowest groups statements that differ only in their values (calls, failures, mean, p95 with a bar, total time — sortable); Recent is the raw log. Select either for the full SQL, numbered parameters and the error. Durations turn amber past 50 ms.
JobsTwo views. Scheduled: every @Cron job the container can resolve, whatever schedules it (KickCronAdapter, your own croner adapter, a serverless trigger) — its schedule and timezone, the next run (when the app has croner), the last run with its outcome, duration and error, and runs / failures since start. Run now starts one immediately (it does the real work, so it asks twice). Queues: queued jobs, through whatever runs them. Pick a queue, then a state (each with its count) to page through its jobs; select one for its data, result, failure reason, stack traces and attempts. Retry, Remove, Retry all failed, Clean a state and Pause / Resume appear when the job tool supports them — destructive ones take a second click. See Job management.
ActivityThe live event-bus stream, newest first: kick/db queries, queue jobs, and anything emitted on DEVTOOLS_BUS. Toggle namespaces (the part of the type before :, each chip with its count) or search type and payload; error and warning events are tinted. Scrolling down holds the list still, and N new jumps back to the latest. Select an event for its full payload.

Job management ​

The Jobs tab's Queues view works with any job tool that exposes a JobInspector from its adapter or plugin as jobInspector(). QueueAdapter from @forinda/kickjs-queue does for BullMQ; RabbitMQ, Kafka and Redis pub/sub have no job store, so their queues are listed without jobs.

A bring-your-own runner implements the same few methods over its own API — only queues, jobs and job are required, and each optional action shows up as a button when present:

ts
import type { JobInspector } from '@forinda/kickjs-devtools-kit'

const inspector: JobInspector = {
  queues: async () => [{ name: 'email', counts: { failed: await boss.getQueueSize('email') } }],
  jobs: async (queue, state, { start, end }) => /* jobs in `state`, newest first */ [],
  job: async (queue, id) => /* one job, or null */ null,
  retry: async (queue, id) => boss.retry(queue, id),
}

// In your adapter:
build: () => ({ jobInspector: () => inspector /* , … */ })

The tab reads GET /_debug/jobs, /_debug/jobs/list, /_debug/jobs/job and runs actions through POST /_debug/jobs/action, all behind the DevTools token.

Custom tabs ​

An adapter or plugin adds its own sidebar tab with devtoolsTabs(). The descriptor type and defineDevtoolsTab come from the kit, which @forinda/kickjs doesn't depend on — add it to the package that declares the tab:

pnpm add @forinda/kickjs-devtools-kit
ts
import { defineAdapter } from '@forinda/kickjs'
import { defineDevtoolsTab } from '@forinda/kickjs-devtools-kit'

export const AuditAdapter = defineAdapter({
  name: 'AuditAdapter',
  build: () => ({
    devtoolsTabs() {
      return [
        defineDevtoolsTab({
          id: 'audit',
          title: 'Audit',
          // Or an iframe of a page the app serves: { type: 'iframe', src: '/_audit/panel' }
          view: { type: 'html', html: '<p>12 events today</p>' },
        }),
      ]
    },
  }),
})

The tab appears at the bottom of the sidebar. kick g adapter and kick g plugin write this hook commented out. A tab's view is one of:

view.typeShows
htmlMarkup, injected as-is — only markup you control
iframeA page at src. On the app's own origin it gets the dashboard token as ?token=, so it can call /_debug; elsewhere it doesn't
launchButtons; each runs its run() on the server and shows what it returns
moduleA browser module from the app's own origin, mounted into the tab

Buttons — run() executes in the app process, behind the DevTools token:

ts
view: {
  type: 'launch',
  actions: [
    { id: 'flush', label: 'Flush cache', run: async () => ({ removed: await cache.clear() }) },
  ],
}

A module tab is for live content. Serve a file and point the tab at it:

ts
build: () => ({
  beforeMount({ http }) {
    http.serveStatic('/_audit', new URL('./panel', import.meta.url).pathname)
  },
  devtoolsTabs: () => [
    defineDevtoolsTab({ id: 'audit', title: 'Audit', view: { type: 'module', src: '/_audit/tab.js' } }),
  ],
}),
js
// panel/tab.js — the default export is a defineDevtoolsRenderTab(...) spec, or just a render function
export default {
  render(el, { bus, config }) {
    const list = document.createElement('ul')
    el.append(list)
    const off = bus.on('audit:entry', (entry) => {
      const li = document.createElement('li')
      li.textContent = `${entry.actor} ${entry.action}`
      list.prepend(li)
    })
    return off // runs when the tab closes
  },
}

The server emits audit:entry on the same bus — @Inject(DEVTOOLS_BUS) bus (from @forinda/kickjs-devtools-kit/bus/token), then bus.emit('audit:entry', { actor, action }).

render(el, props) gets the dashboard's event bus, config (theme, panelHeight) and the page's query; what it returns runs when the tab unmounts. A module from another origin is refused — it would run with the dashboard's token.

API runner ​

Selecting a route on the Routes tab opens the runner beside the list — drag the divider to resize; the width is remembered. The bar at its top shows the method and resolved URL with Send. It sends a request to that route, from the browser, on the same origin — so it behaves the same on Express, Fastify and h3, and needs no extra endpoint. Each part of the request is a collapsible section:

  • Path params — one field per :param; the resolved URL updates as you type.
  • Query and Headers — key/value rows. Untick one to switch it off, ✕ removes it, drag ⠿ to reorder (or focus it and press ↑ / ↓) (a later header replaces an earlier one of the same name). The lock marks a value secret: masked until the eye shows it. The code snippet still holds the real value, since it's the request as sent. The expand button opens the whole value — a JWT — in a box that wraps. Under Headers, the environment's default headers are listed with a tick each: untick one to leave it off this route only (a login route without the global Authorization), tick it to send it. The tick is the route's own and wins over everything else.
  • Body — for POST / PUT / PATCH / DELETE, either Raw text (JSON gets Content-Type: application/json) or Form data: multipart/form-data rows that are text fields or file pickers. A route with @FileUpload opens in form mode with its declared field ready, and says how many files it takes. Picked files are kept in memory only, so pick them again after reopening; the snippets use -F 'field=@file' (curl) and a FormData (fetch). Hover the raw body for Format (pretty-prints JSON; a body with an unquoted isn't JSON and is left alone) and Copy.
  • Environment — which environment the request uses: the active one, or one this route is pinned to. Manage… opens them.
  • Settings — the public flags, CSRF cookie and header names, OpenAPI URL and editor link. They apply to every route, not just the open one, and save as you type in this browser (localStorage). Fill empty inputs from OpenAPI is the exception: it fills only the open route.
  • Code snippet — the request as curl or fetch, rendered so you can read and select it; Copy is a shortcut.
  • Save to variable — a JSON path and a variable name; set it up before you send. See Environments.
  • Response — status, time, headers and the body (JSON pretty-printed). Hover the body for Copy: the whole body, formatted, even when the view is truncated.
  • History — the last 30 requests across all routes, with status and time. Click one to reopen its route with the inputs it was sent with. Entries keep as written, not their values.

The login route's Headers: the environment's Authorization starts unticked on a public route, X-Tenant is sent, and the curl snippet shows only what is sent

The header names the handler (UsersController.list): click it to open the handler in your editor. The dashboard asks the app where the class is declared under src/, then follows a vscode://file{file}:{line} link. For another editor, change Editor link under Settings — e.g. cursor://file{file}:{line}, windsurf://file{file}:{line}, or idea://open?file={file}&line={line}.

Prefill ​

A route opened for the first time is prefilled from its own request schemas — the body, query and params it validates — so no Swagger adapter is needed. When the Swagger adapter serves a spec (/openapi.json by default; change OpenAPI spec URL in settings if yours differs) and documents the route, the runner reads the operation instead, for its summaries:

  • a route opened for the first time gets its query parameters as rows (switched on when required) and an example JSON body built from the request schema — example, default or the first enum value where the schema gives one, a blank of the right type otherwise;
  • the summary shows under the route, path-param descriptions show as placeholders, and query parameters are listed with their descriptions;
  • Fill empty inputs from OpenAPI (under Settings) applies it later. It only fills empty fields and adds missing query rows — it never overwrites what you typed.

Environments ​

Like environments in Postman or Insomnia: named sets of what requests carry — dev / stage / prod, or anonymous / member / admin. Manage… next to the runner's environment opens them in a sheet of their own, where each has:

  • Default headers — sent with every route using it, unless the route unticks one under its Headers; on a public route Authorization starts unticked. An Authorization: Bearer , a tenant header. Leave Authorization out for an anonymous environment.
  • Variables — in any param, query, header or body value. A with no value is left as written and flagged under the URL, naming the environment.
  • Param mappings — a variable named like a path or query param (tenantId for :tenantId) fills it whenever it's left empty; a mapping covers names that differ (:tenantId ← orgId). What you type always wins.

One environment is active; the runner's picker can pin a route to another — a public route to anonymous, an admin route to admin — and the pin is remembered per route. Add, duplicate, rename, make active or delete them in the sheet; deleting one sends its pinned routes back to the active one.

  • Save to variable in a route's runner reads a JSON path (accessToken, data.token, items[0].id) and sets a variable in the environment the route uses — log in under admin and only admin gets the token. It belongs to the route: once set on your login route, every 2xx response from it saves the variable again, and other routes don't carry it. Save now copies from the response on screen. A saved variable starts out secret.
  • Together: on your login route, save data.accessToken as token, and with Authorization: Bearer as a default header every route using that environment sends it (public routes only when ticked); logging in again updates it everywhere.

Animated: an environment with Authorization: Bearer {{token}} as a default header; the login route's response saved to {{token}} with Save to variable; then the profile route sent with the token filled in, answering 200

The token variable marked secret, shown with the eye and opened in full with the expand button

  • Everything in the sheet saves as you type — there is no Save button. Without Remember on this browser it lasts for this browser tab only.
  • Remember on this browser (in the sheet) keeps environments in localStorage, so they survive closing the tab. They often hold tokens: leave it off on a shared machine. Switching it moves them rather than copying. Headers and variables from before environments existed become the first one, dev.

It handles the framework's conventions for you:

  • CSRF — for unsafe methods it reads the _csrf cookie and sends it as x-csrf-token, the csrf() / csrfGuard() defaults. Change the names under Settings if your app overrides them.
  • Public routes — on a route carrying a public route flag, the default Authorization header starts unticked, so you see the route work without credentials; tick it under Headers to send it anyway. The flag name defaults to auth.public; list several (comma-separated) if your app uses more than one. These are the names your app's routes already carry (shown beside each route in the list): the setting only picks the starting tick, and adds nothing to the request.
  • Data-changing requests — DELETE, PUT and PATCH need a second click before they are sent. They run against whatever the app is connected to.
  • The devtools token is never sent to your routes.

Per-route inputs are saved in localStorage. Routes mounted through a hand-built router carry no route metadata, so they aren't listed.

Layout ​

The sidebar is a column of icons — hover for the tab's name, and the small numbers are counts (routes, DI tokens). Sockets, Database, Jobs and Activity are dimmed until something reports to them. The arrow at its foot shows labels instead; the choice is remembered. When the app stops answering (a restart, a crash), a banner says so and the dashboard keeps retrying until it's back.

The gear in the header holds Density (spacing and type scale) and Fonts: name fonts installed on your computer for the interface and for code and editors (request and response bodies, snippets, the graph), comma-separated. Yours come first and the dashboard's stay as the fallback, so a name that isn't installed changes nothing; leave a field empty for the default. Both are remembered per browser.

Command palette ​

Press ⌘K / Ctrl+K (or / outside a text field), or click Search in the header. Type to find a tab, a route (opens it in the API runner), or a DI token (opens its detail), or to switch theme and density. Arrow keys move, Enter runs, Escape closes.

Token details ​

Selecting a token — in Container or from the command palette — shows its kind, scope and status, what it depends on and what uses it (click either to follow the chain), how often and when it was resolved, and its @PostConstruct outcome.

Beginner-friendly tooltips ​

Most metric labels carry a small ⓘ icon — hover for a one-line definition. Denser panels (Memory's "Leak risk", PostConstruct status) open a modal with the full explanation, severity bucket boundaries, and worked examples. Wording lives in lib/info.tsx's METRIC_DEFS registry.

Auth gate ​

If the server runs DevToolsAdapter({ requireToken: true }) and you open the dashboard without a ?token=… query param OR the kickjs_devtools_token cookie, a paste-token modal appears. The token is validated against /health, then persisted to a 30-day cookie. Subsequent visits skip the prompt.

VSCode extension ​

The same /_debug/* JSON endpoints power the KickJS DevTools VSCode extension — install it, run KickJS: Connect to App… from the palette, and the Activity Bar gets Health / Routes / DI Container tree views without leaving the editor. Click a route (or its inline Open Handler button) to jump to the handler — the path comes from the app, and the extension maps it into your workspace when the app runs elsewhere (a container, another checkout). When the server requires a token, run KickJS: Set DevTools Token… to paste it.

Security ​

  • DevTools is off by default unless NODE_ENV is development or test — an unset NODE_ENV keeps it off
  • Config endpoint is opt-in and redacts all variables not matching your prefix list
  • Consider adding authentication middleware if exposing in staging environments
  • The browser dashboard's auth gate (above) is the front door for requireToken: true mounts; the token is sent as x-devtools-token header on every request
  • With a secret set, only the dashboard page and its static bundle (/_debug/assets/*) load without the token; every /_debug/* data endpoint requires it

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.