Chrome's Network panel does not list a request that mock.intercept() answers
because the network never sees it. The @schmock/devtools package reports each
request in a collapsed console group and a custom track in the Performance
panel. Its service-worker relay also lists them in the
Network panel.
bun install @schmock/devtoolsimport { schmock } from '@schmock/core'
import { devtoolsPlugin } from '@schmock/devtools'
const mock = schmock()
mock('GET /api/users', [{ id: 1, name: 'Alice' }])
mock.pipe(devtoolsPlugin())
mock.intercept()The plugin reports every fetch the mock handles, whether it was answered,
failed or aborted. It never changes a response because its process hook
passes the response on exactly as it found it.
One collapsed group per request:
Schmock GET /api/users?page=2 → 200 (1.4 ms)
Request { method: 'GET', url: 'http://localhost:5173/api/users?page=2', headers: {…} }
Response { status: 200, headers: {…}, body: [{…}] }
The title has four parts:
- The badge shows the track name (
Schmockby default). Its color indicates the outcome: green below 400, amber for 4xx, red for 5xx and failures, grey for aborts. - The method and URL show the path and query for a request to the page's own origin. Any other origin keeps the absolute URL.
- The outcome shows the status,
failed: <message>, oraborted. - The duration displays with one decimal place.
The Request inside the group holds the method, the absolute URL, the headers
and the body if one exists. Either the Response (status, headers, body), the
error the fetch rejected with (logged through console.error), or
Aborted by the client comes after it. Header names are lowercased. Bodies
display as their values before serialization, such as parsed JSON, instead of
bytes.
Each request also appears as a performance.measure() entry on a custom track
named Schmock. Follow these steps to see the track:
- Use Chrome 128 or later. Other browsers keep the measures but show no custom track (see Other browsers).
- In the Performance panel, open Capture settings (the gear icon) and turn on Show custom tracks.
- Start a recording, let the app make its requests, and stop.
Entries appear only during a recording. Each entry spans from the fetch call
to the moment its outcome settles. The tooltip displays GET <url> → 200.
Selecting an entry lists the Method, URL, Outcome and Duration. The entry uses
a color from the DevTools palette based on its outcome:
| Outcome | Color |
|---|---|
| status below 400 | primary |
| 4xx | tertiary |
| 5xx, or failed | error |
| aborted | secondary |
These measures stay in the page's User Timing buffer. This means
performance.getEntriesByType('measure') and any PerformanceObserver can see
them. The plugin never clears them.
The console group and the Performance track sit beside the Network panel. The
relay puts mocked requests in it. startServiceWorkerRelay() registers a
service worker, schmock-sw.js, that answers the page's fetch and XHR
requests. The worker sends each request over a MessageChannel to the page,
where your mock.intercept() leases answer it as before, and hands the
response back to the browser. Chrome then lists the request as a native row
with Size (ServiceWorker). The filter is:service-worker-intercepted shows
only those rows.
XHR is mocked too. Fetch interception never sees an XHR, but the worker does.
A piped devtoolsPlugin() still reports relayed requests, so each one gets a
console group and a Performance entry next to its Network row.
Behaviour marked observed on this page was measured in headless Chrome on
2026-10-03, unless it names another browser; the rest follows from the code. Mocked fetch and XHR requests
both showed as Network rows served by the service worker (observed).
1. Serve the worker script. Copy schmock-sw.js into the directory your
dev server serves at the site root:
npx schmock-devtools init publicThe command creates the directory if it is missing, replaces an older copy,
and prints the next step. Run it again after each upgrade of
@schmock/devtools so the script matches the page library.
To copy the script during the build instead, point your bundler's copy plugin
at node_modules/@schmock/devtools/dist/schmock-sw.js. The package does not
export the script, so a deep import such as
import swUrl from '@schmock/devtools/dist/schmock-sw.js?url' is refused.
Copy it; don't import it.
2. Start the relay before the app renders.
import { schmock } from '@schmock/core'
import { devtoolsPlugin, startServiceWorkerRelay } from '@schmock/devtools'
const mock = schmock()
mock.pipe(devtoolsPlugin())
mock('GET /api/users', [{ id: 1, name: 'Alice' }])
mock.intercept()
await startServiceWorkerRelay()
startApp()Await it before the app makes its first request. Until the relay is live, a
fetch is answered in the page with no Network row, and an XHR goes to the
network unmocked. The relay serves every lease on the page, whether it was
taken before or after the relay started, so the order relative to
mock.intercept() does not matter.
Once the worker is installed the wait is short. A relay that falls back with
"timeout" or "not-controlled" holds the first render for up to timeout
(5 s by default).
Where the script lives. A service worker controls only the pages under its
scope, and the default scope is the script's directory: /schmock-sw.js
covers the whole origin, /mocks/schmock-sw.js only pages under /mocks/.
Serve the script at or above the pages that start the relay, normally at /.
A page outside the scope falls back at once with "not-controlled" and
registers nothing (observed). Registering with a scope wider than the script's directory rejects with a
SecurityError (observed), and the relay falls back with
"registration-failed", unless the server sends a Service-Worker-Allowed
header.
Secure context. Service workers need https or localhost. In Chrome an
insecure page has no navigator.serviceWorker at all, so it falls back with
"unsupported", not "insecure-context" (observed).
function startServiceWorkerRelay(options?: ServiceWorkerRelayOptions): Promise<ServiceWorkerRelay>
interface ServiceWorkerRelayOptions {
url?: string // where the app serves schmock-sw.js (default: '/schmock-sw.js')
scope?: string // registration scope (default: the browser's, the script's directory)
timeout?: number // ms for the whole start, and for stop()'s acknowledgement (default: 5000)
}url and scope resolve against the page's base URL (document.baseURI, or
location.href without a document). The worker is registered with
updateViaCache: 'none', so a script copied again installs on the next load.
timeout is one deadline for the whole start: the lookup of an existing
registration, the registration, activation, taking control of the page and
the handshake. It also bounds how long stop() waits for the worker. A
deadline that expires while the relay waits for control gives
"not-controlled". Anywhere else it gives "timeout".
The promise rejects only for a caller error:
- invalid options reject with a
SchmockErrorwith the codeDEVTOOLS_CONFIG_INVALID; - a relay with other options that is already starting or running rejects with
DEVTOOLS_RELAY_ALREADY_STARTED(see Starting twice).
Every failure on the browser's side resolves with a relay that fell back instead, and mocking continues in the page.
Options are checked first, before the relay looks for service-worker support, in this order:
optionsmust be an object when given;urlandscopemust be non-empty strings;timeoutmust be a positive finite number;urlandscopemust resolve against the page URL.
Only the first invalid option is reported. context.option names it and
context.received holds its value, as in
startServiceWorkerRelay: timeout must be a positive finite number (received 0).
interface ServiceWorkerRelay {
readonly active: boolean
readonly fallbackReason: RelayFallbackReason | undefined
stop(): Promise<void>
}activeistruewhile the worker relays this page's requests. Read it live: it turnsfalsewhile the relay reconnects after a worker update, and staysfalseafter a fallback orstop().fallbackReasonsays why the relay fell back. It isundefinedwhile the relay starts, while it is active or reconnecting, and afterstop().stop()handsfetchback to in-page interception at once, then tells the worker to forget this page and waits for its acknowledgement, up totimeout. From then on afetchis answered in the page with no Network row, and XHR is no longer mocked. A request the worker relayed before it processed the goodbye is still answered by the mock.stop()is idempotent. On a relay that fell back it resolves at once and clearsfallbackReason. It never unregisters the worker (see Unregistering).
After stop() or a fallback, calling startServiceWorkerRelay() again starts
a new relay.
Worker updates. When a new version of the worker takes control of the
page, the relay releases its hold, so fetch is answered in the page
meanwhile, and repeats the handshake with a fresh timeout. During that,
active is false and fallbackReason is undefined. The relay goes live
again, or falls back: "protocol-mismatch" or "timeout" for Schmock's
script, "not-controlled" when another script took control.
Page lifecycle. A page that unloads tells the worker to forget it. A page that enters the back/forward cache keeps its relay and needs no new handshake when it comes back.
A relay that cannot go live resolves with active: false and a
fallbackReason, and logs one console.warn. Mocking goes on in the page:
fetch is answered there with no Network row, and XHR is not mocked. Every
warning starts with Schmock relay: . In the texts below, <url> is the
script's absolute URL, such as http://localhost:5173/schmock-sw.js, and
<publicDir> is printed as is.
| Reason | When | Warning |
|---|---|---|
unsupported |
No navigator.serviceWorker: Node, a browser without service workers, an insecure page in Chrome, or a sandboxed iframe without allow-same-origin, where reading it throws (observed). |
Schmock relay: service workers are unavailable here, so mocked requests stay in the page and do not appear in the Network panel. |
insecure-context |
The browser exposes service workers on a page that is not a secure context. Chrome does not, so there the reason is unsupported. |
Schmock relay: service workers need a secure context (https or localhost), so mocked requests stay in the page. |
scope-taken |
Another script is registered at exactly the relay's scope. Nothing is registered. | Schmock relay: <scriptURL> already controls this scope; Schmock will not replace it. Unregister it while developing, or give Schmock its own scope. |
registration-failed |
The browser refused the registration (script not served, script error, scope wider than the script's directory), or the worker failed to install. | Schmock relay: could not register <url> (<message>). Run "npx schmock-devtools init <publicDir>" and serve the file at <url>. |
not-controlled |
The URL the page was loaded at is outside the scope: the script's directory, or the scope option. Nothing is registered and nothing waits. Not checked for a page the Schmock worker already controls, or for a srcdoc, blob or about:blank document. |
Schmock relay: <url> can only control pages under <scope>, and this page is <pageUrl>. Serve the script at or above this page, or pass a scope that covers it. |
not-controlled |
The worker could not take control of the page within timeout, and no worker controls it. |
Schmock relay: this page is not controlled by the Schmock service worker (a hard reload bypasses service workers); reload normally. |
not-controlled |
Another worker still controls the page when timeout expires, or took it over after a worker update. |
Schmock relay: this page is controlled by <scriptURL>, which Schmock will not replace. Unregister it while developing, or give Schmock a scope that covers this page. |
protocol-mismatch |
The served script speaks another relay protocol: it was copied from an incompatible version. | Schmock relay: <url> speaks relay protocol <n>, this page expects <m>. Run "npx schmock-devtools init <publicDir>" again. |
timeout |
The deadline expired while the relay looked up, registered or activated the worker, or the worker never answered the handshake. | Schmock relay: the service worker did not get ready within <timeout> ms, so mocked requests stay in the page. |
Two other messages leave the relay running:
- A script copied from another
@schmock/devtoolsversion that speaks the same protocol warnsSchmock relay: <url> comes from @schmock/devtools <version>, this page uses <version>. Run "npx schmock-devtools init <publicDir>" to update it.and the relay goes live. - A relayed request whose mock fails, for example through a hook that throws
with no
errorFormatter, logsSchmock relay: <METHOD> <request url> failed in the mock, so the page receives a network error.throughconsole.error, with the original error as the second argument.
A page has one relay. A call while a relay is starting or active:
- with the same options returns the same promise, and so the same relay. The
options are compared after resolving:
url,scopeandtimeout.{}and{ url: '/schmock-sw.js' }are the same.{}and{ scope: '/' }are not, because an omitted scope is left to the browser; - with other options rejects with a
SchmockErrorwith the codeDEVTOOLS_RELAY_ALREADY_STARTED. Itscontextholdsrunningandrequested, both resolved. Callstop()on the running relay first.
Code that runs twice in one page, such as a module re-run by hot module
replacement, gets the same relay back. The unsupported and
insecure-context fallbacks keep nothing: each call warns again and returns a
new relay object.
Each recipe assumes schmock-sw.js is served at /.
Use the snippet in Setup: mock.intercept(), then
await startServiceWorkerRelay(), then start the app.
import { createRoot } from 'react-dom/client'
import { schmock } from '@schmock/core'
import { devtoolsPlugin, startServiceWorkerRelay } from '@schmock/devtools'
import { SchmockProvider } from '@schmock/react'
import { App } from './App'
const mock = schmock()
mock.pipe(devtoolsPlugin())
mock('GET /api/users', [{ id: 1, name: 'Alice' }])
await startServiceWorkerRelay()
createRoot(document.getElementById('root')!).render(
<SchmockProvider mock={mock}>
<App />
</SchmockProvider>,
)SchmockProvider takes its lease when it renders, and the relay routes it.
The provider needs no new prop.
import { createApp } from 'vue'
import { schmock } from '@schmock/core'
import { devtoolsPlugin, startServiceWorkerRelay } from '@schmock/devtools'
import { schmockPlugin } from '@schmock/vue'
import App from './App.vue'
const mock = schmock()
mock.pipe(devtoolsPlugin())
mock('GET /api/users', [{ id: 1, name: 'Alice' }])
const app = createApp(App)
app.use(schmockPlugin, { mock })
await startServiceWorkerRelay()
app.mount('#app')provideSchmockInterceptor answers inside Angular's interceptor chain, so the
request never leaves Angular and never reaches the worker. Replace it with a
mock.intercept() lease and start the relay before bootstrapping:
import { provideHttpClient, withFetch } from '@angular/common/http'
import { bootstrapApplication } from '@angular/platform-browser'
import { schmock } from '@schmock/core'
import { devtoolsPlugin, startServiceWorkerRelay } from '@schmock/devtools'
import { AppComponent } from './app/app.component'
const mock = schmock()
mock.pipe(devtoolsPlugin())
mock('GET /api/users', [{ id: 1, name: 'Alice' }])
mock.intercept({ baseUrl: '/api' })
startServiceWorkerRelay()
.then(() =>
bootstrapApplication(AppComponent, {
providers: [provideHttpClient(withFetch())],
}),
)
.catch((err) => console.error(err))Under the relay both HttpClient backends work: the default XHR backend's
requests reach the worker as XHRs, and withFetch() sends them through
fetch. Keep withFetch() anyway. If the relay falls back, fetch is still
mocked in the page and XHR is not mocked at all. The
Angular guide lists what changes when you
leave provideSchmockInterceptor.
| Aspect | Fetch mode (default) | Relay mode |
|---|---|---|
| Network row for a mocked request | none | one, Size (ServiceWorker) (observed for fetch and XHR) |
| XHR mocked | no | yes |
| Scripts, styles, images, fonts, navigations | never seen | never relayed (scripts and images untouched, observed) |
| Request no mock answers | one row | two rows: the page's, served by the worker, and the worker's own fetch (observed) |
The mock fails, for example a hook throws with no errorFormatter |
fetch rejects with that error |
fetch rejects with a TypeError and XHR fires error; the original error is logged |
Relative fetch and an origin-form baseUrl naming the page origin |
no match | matches |
| Abort | the mock ends the request with request:end 499 |
the row is canceled and fetch rejects with an AbortError; the mock did not see the abort (observed) |
| Lease restored, or the mock reset, after the call | the leases and routes of the call answer | the request is routed when it reaches the page, a task or more later, by the leases and routes of that moment: a lease restored right after the call no longer answers it |
A mocked redirect (3xx with Location) |
fetch receives the 3xx |
the browser follows it as it would a server's: the follow-up request goes through the relay and reaches the network unless a route answers it; redirect: 'manual' gives status 0 (opaqueredirect) and redirect: 'error' rejects (observed) |
| Headers a route sees | the ones the page set | those plus browser defaults; only accept: */* was added (observed) |
response.url |
the URL without its fragment | the URL without its fragment (observed) |
response.type |
default |
basic, cross-origin included (observed) |
Cross-origin request a lease answers (origin-form baseUrl for another origin) |
readable | readable, custom headers such as x-total-count included (observed) |
Bodiless POST (fetch or XHR) |
no body | no body (observed) |
| Explicit empty request body | empty | empty |
| Before the relay is live | n/a | fetch answered in the page, XHR not mocked |
| Performance entry vs Network row | no row | the entry is shorter by the worker round trip |
The Performance entry starts when the page receives the request from the worker, so it is usually a few milliseconds shorter than the Network row.
- Only requests from a page that started the relay. The worker keeps a list of the pages (tabs, iframes, workers) that said hello, and sends each request back to the page that made it. One tab's mock never answers another tab. Another page under the same worker that never started the relay is untouched (observed).
- Only requests with an empty destination:
fetch()and XHR, and alsonavigator.sendBeacon()andEventSource. Navigations, scripts, styles, images and fonts are never relayed, so apassthrough: falselease cannot answer the app's own assets with 404s. - Not
only-if-cachedrequests whose mode is notsame-origin.
The worker stores its list of pages in Cache Storage, in a cache named
schmock-relay-v<protocol> (schmock-relay-v1 today), so the relay survives
Chrome stopping an idle worker. Right after a restart the worker holds
requests until it has read the list back. If Cache Storage fails, it keeps the
list in memory.
-
Bypass for network. With Bypass for network checked under Application → Service workers, Chrome skips the worker. The page still holds the relay, so its
fetchrequests reach the real network unmocked, and so do its XHRs. The page cannot detect this. Update on reload is harmless. -
Another service worker. The relay never replaces the app's own worker, such as a PWA's or MSW's:
- at exactly the relay's scope, it falls back with
"scope-taken"and registers nothing; - at a parent scope (the app's worker at
/, the relay givenscope: '/app/'), there is no conflict: Schmock registers its own narrower registration, and pages under/app/are then controlled by Schmock's worker instead of the app's. The script must sit under/app/, or the server must allow the scope withService-Worker-Allowed; - at a narrower scope (the app's worker at
/app/, the relay at/), the app's worker keeps the/app/pages. The relay waits the fulltimeout, then falls back with"not-controlled"and names that worker.
Unregister the app's worker while developing, or give Schmock a scope that covers the page more closely than the app's worker does.
- at exactly the relay's scope, it falls back with
-
Redirects. A route that answers 3xx with a
Locationis followed by the browser under the relay, as a server's redirect would be. In fetch mode the caller receives the 3xx itself. Mock the target too, or the follow-up request reaches the network (observed). -
A miss can be routed twice around a worker update or
stop(). While the relay reconnects after a worker update, and afterstop()until the worker acknowledges it,fetchis answered in the page. A fetch no lease answers passes through, the worker still relays it back, and the mock is consulted again:request:start,request:notfoundandrequest:endfire twice. -
Hard reload. Shift+Reload loads the page without its service worker, so the relay asks the active worker to claim the page. After a hard reload the relay re-claimed the page and came back active, with no warning (observed). If a claim does not take, the relay falls back with
"not-controlled"and the "reload normally" warning. -
Synchronous XHR.
xhr.open(method, url, false)deadlocks: the main thread blocks while the worker waits for the page to answer. It is not supported under the relay. -
Cleared Cache Storage. App code that deletes every cache also deletes the worker's list of pages. Once the worker restarts, it forgets the page while the page still holds the relay, and the page's
fetchrequests and XHRs reach the network unmocked, silently. Reload the page, or callstop()and start the relay again. -
A canceled request may still run in the mock. Aborting a relayed request cancels its Network row (
net::ERR_ABORTED, canceled) and the page'sfetchrejects with anAbortError. The mock did not see the abort, though: norequest:end499 was recorded (observed). The mock may run the request to completion, anddevtoolsPlugin()would then report it as answered. In fetch mode the mock gets the abort and ends the request with 499. -
A page that dies mid-request. If a tab closes or crashes after the worker relayed one of its requests, no answer comes, and the request stays pending in the worker until Chrome stops the worker. Nothing is visible.
-
Two rows for a request no mock answers. The page's row, served by the worker, comes with the worker's own fetch to the network (observed). Hide the second with the filter
-is:service-worker-initiated. Onlyfetchand XHR requests from relaying pages that no lease answered get two rows. -
Two copies of
@schmock/core. If the bundle holds two copies, nothing is relayed: XHRs pass through to the real network (observed). A monorepotsconfigpathsalias caused this during verification: it can point@schmock/coreor@schmock/core/adapterat source while@schmock/devtoolsresolves the publisheddist, which brings its own copy. The relay holds and routes through that other copy, which has none of the app's leases, sorelay.activestill readstruewhile the app's copy keeps answeringfetchin the page with no Network row. Make sure the bundle resolves one copy of@schmock/core.
Start the relay behind your bundler's development flag, as with the plugin:
if (import.meta.env.DEV) {
const { devtoolsPlugin, startServiceWorkerRelay } = await import('@schmock/devtools')
mock.pipe(devtoolsPlugin())
await startServiceWorkerRelay()
}npx schmock-devtools init public puts the script in a directory that the
production build copies too. A script that no page registers does nothing. To
keep it out of production output, copy it only in development builds.
stop() leaves the worker registered: unregistering would affect every other
tab, and the worker leaves pages that never started the relay to the network.
To remove it, use Unregister under Application → Service workers, or:
for (const registration of await navigator.serviceWorker.getRegistrations()) {
if (registration.active?.scriptURL.endsWith('/schmock-sw.js')) {
await registration.unregister()
}
}The relay uses only standard service-worker APIs, so it is not tied to Chrome. On 2026-10-04 we ran it in Playwright's WebKit 26.6 (the engine behind Safari) and Firefox 155 with the same checks as Chrome, and observed:
- The relay went live and the worker took control of the page.
- The browser reported every mocked
fetchand XHR, POSTs with a body included, as answered by the service worker. No mocked request reached the server, and requests no mock answered reached it with their bodies intact. - The console groups were logged.
- Aborting a relayed
fetchcanceled it in the page, but the mock still ran the route to completion, as in Chrome.
What differs:
- Firefox and Safari lack the custom Performance track because it is a
Chrome DevTools extension. They keep the
performance.measure()entries as plain User Timing entries, without theSchmocktrack or its colors. - Firefox's Network Monitor and Safari's Web Inspector mark service-worker
responses their own way. The network labels
is:service-worker-intercepted,-is:service-worker-initiatedand the(ServiceWorker)Size belong to Chrome. Neither UI was inspected, and Safari itself, as opposed to Playwright's WebKit build, was not run.
function devtoolsPlugin(options?: DevtoolsPluginOptions): Plugin
interface DevtoolsPluginOptions {
console?: boolean // one collapsed console group per request (default: true)
performance?: boolean // one Performance-panel track entry per request (default: true)
track?: string // track name, also the console badge (default: 'Schmock')
trackGroup?: string // group the track under this name in the Performance panel
}mock.pipe(devtoolsPlugin({ track: 'Users API', trackGroup: 'My app' }))The plugin uses the name devtools. It reads options once upon creation.
Changing the options object afterwards has no effect. Give each mock its own
plugin with a different track to get one track per mock. A shared trackGroup
keeps those tracks together.
Creating the plugin with invalid options throws a SchmockError with the code
DEVTOOLS_CONFIG_INVALID. The plugin enforces these rules:
optionsmust be an object when given;consoleandperformancemust be booleans;trackandtrackGroupmust be non-empty strings.
The plugin checks options in this order and reports only the first invalid one.
The context.option field names the invalid option, while context.received
holds its value. The resulting message looks like
devtoolsPlugin: track must be a non-empty string (received "").
The plugin reports any request that the mock settles through mock.intercept().
This behavior covers the leases taken by React's SchmockProvider and Vue's
schmockPlugin. The plugin reports each request once with one of three
outcomes:
| Outcome | When |
|---|---|
| answered | The mock answered. The report shows the response the caller received after beforeResponse and errorFormatter. With passthrough: false, this includes the 404 for an unrouted request and the 400 for a malformed JSON body. A route that throws is answered too, with core's 500 response or with what errorFormatter makes of the error. |
| failed | The caller's fetch rejected. This happens if a beforeRequest or beforeResponse hook throws and no errorFormatter replaces the error, or if the formatter itself throws. |
| aborted | The caller aborted the request while this mock was answering. |
The plugin does not report these requests:
- requests passed through to the network, because the Network panel already shows them;
mock.handle()calls and transports built on them, includingmock.listen(), the Express adapter, the CLI, and Angular'sprovideSchmockInterceptor;- requests that reached the mock before the plugin was piped or before the
last
reset()call.
Only the mock that answers reports the request when several mocks intercept. A
fetch is never reported twice. This remains true even if another mock missed it
first or the same mock holds several leases. The plugin reports an abort only
if this mock was the one answering it. It reports nothing if an abort lands
while a mock is still deciding, such as inside an async beforeRequest.
Calling reset() removes every piped plugin, including this one, but keeps
intercept() leases active. This means requests are still mocked but no longer
reported. You must pipe the plugin again after each reset:
mock.reset()
mock.pipe(devtoolsPlugin())
mock('GET /api/users', [{ id: 1, name: 'Alice' }])The plugin does not report a request that is in flight when reset() runs.
The provideSchmockInterceptor transport answers through mock.handle() inside
Angular's interceptor chain. The plugin never sees these requests. You can
report Angular traffic by replacing the interceptor with
provideHttpClient(withFetch()) and mock.intercept(). The
Angular guide contains the recipe and explains what changes
when you switch.
The plugin exists for development use. You should load it behind your bundler's development flag so production builds leave it out:
if (import.meta.env.DEV) {
const { devtoolsPlugin } = await import('@schmock/devtools')
mock.pipe(devtoolsPlugin())
}The import.meta.env.DEV variable is Vite's flag. Other bundlers have an
equivalent flag, such as process.env.NODE_ENV !== 'production'.
devtoolsPlugin() returns an ordinary plugin built on the onExchange hook.
You can read Observing exchanges to learn
how to send mocked traffic somewhere else.