The Inertia DevTools browser extension shows each request an Inertia
application answered: what kind of visit it was, which component rendered,
each prop with its kind and where it came from, and the request and
response themselves. The extension reads that from the server, so the
server has to record it. Once the application calls
InertiaConfig::devtools(DevToolsConfig::new()), Suprnova records in local
development (APP_ENV=local) and serves entries to the extension, as
inertia-laravel 3.5.1's DevTools does. An application that never calls it
records nothing and serves no entries.
Enable DevTools
Opt in with InertiaConfig::devtools(DevToolsConfig::new()). A new
project's bootstrap makes this call, and its .env sets APP_ENV=local.
With enabled unset, a configured instance records only when APP_ENV
is set and names local; an unset APP_ENV records nothing. Once opted
in, enabled(..) and INERTIA_DEVTOOLS_ENABLED decide outright, and a
builder call wins over the variable. Without the configuration call,
there is no recording or entries endpoint, even with the variable set:
use ;
Inertia::install and Inertia::middleware put DevToolsMiddleware
outermost in the Inertia stack when DevTools is enabled, inside the
session middleware you registered before them. Requests outside the stack,
such as an API group without it, are not recorded.
Every setting reads the variable Laravel's config/inertia.php reads, and
a builder call wins over the variable:
| Setting | Variable | Default |
|---|---|---|
enabled(bool) |
INERTIA_DEVTOOLS_ENABLED |
unset: only when APP_ENV names local |
ttl_hours(hours) |
INERTIA_DEVTOOLS_TTL_HOURS |
24 |
prune_interval_secs(seconds) |
INERTIA_DEVTOOLS_PRUNE_INTERVAL_SECONDS |
300 |
limit(entries) |
INERTIA_DEVTOOLS_LIMIT |
100, 0 keeps every entry |
gate(ability) |
INERTIA_DEVTOOLS_GATE |
none |
storage_path(path) |
- | storage_path("inertia-devtools") |
except(patterns) |
- | _inertia/devtools*, _suprnova/* |
redact_keys(keys) |
- | password, password_confirmation, current_password, token, _token, access_token, refresh_token, secret, client_secret, api_key |
redact_headers(names) |
- | cookie, set-cookie, authorization, proxy-authorization, x-xsrf-token, x-csrf-token |
INERTIA_DEVTOOLS_ENABLED reads true, 1, on and yes as on, and
false, 0, off and no as off; an empty value leaves the decision to
the environment.
An except pattern is matched against the request path as
Request::is matches it: * matches any characters, and a leading slash
is ignored. The default skips the extension's own endpoints and the
framework's tooling routes, such as /_suprnova/health.
What an entry holds
Each recorded request is one entry. Its JSON is Laravel's, key for key, because the extension reads it:
__meta- the entryid(a ULID),tabUuid,batchIdandvisitIdfrom the extension'sX-Inertia-Devtools-Tab,-Parentand-Visitheaders (batchIdfor an Inertia visit only), the UTCtimestampandutime, themethodand fullurl, thecomponent, therequestType, thestatus, theredirectLocation(X-Inertia-Location, elseLocationon a3xx), andserverTimingMs.http- the request and response headers, and the request and response bodies.propsandpropValues- each prop of a rendered page with its kind, and the value the client received. See Props and where they come from.route- thenameof the route that matched, its pattern asuri, themethodfor a rendered page, and the handler's type name asaction(app::controllers::users::index). The name belongs to the route's method as well as its pattern, soGET /usersnamedusers.indexandPOST /usersnamedusers.storeeach record their own.renderSource- the file and line of the call that built the page.componentPath- the page file underInertiaConfig::pages_dir, when there is one.
The requestType is decided in this order:
precognitionfor a request with aPrecognitionheader.- For a request that is not an Inertia visit,
initialwhen it rendered a page andhttpwhen it did not. deferredwith the extension'sX-Inertia-Devtools-Deferredheader.pollwith itsX-Inertia-Devtools-Pollheader.partialfor a partial reload (X-Inertia-Partial-Component).prefetchfor a prefetch (PurposeorSec-Purpose: prefetch).navigatefor any other Inertia visit.
The request body is recorded only for an Inertia visit when the request
writes (POST, PUT, PATCH, DELETE); any other write records
{"status": "omitted", "reason": "non-inertia-request"}. A JSON body is
recorded as the value it parses to, {}, [] and null included, and as
its text when it does not parse. Otherwise the query and form input are
recorded; else the raw text, empty when the body has no bytes, and
binary when it is not UTF-8.
DevTools reads the body before the handler, up to the framework's request
body cap (suprnova::http::body::global_max_request_body_bytes, 8 MiB
unless your bootstrap sets another), whether or not the request declares
its length. The handler then reads the copy kept on the request. A body
over the cap records too-large and stays whole for the handler, which
answers its own 413, or reads all of it under a larger cap of its own,
such as a FormRequest with max_body_bytes. A body that fails to arrive
records unreadable, and the handler meets the same error only when it
reads the body, so a handler that ignores its body answers as it does with
DevTools off.
DevTools never reads a multipart upload itself. The upload's extractor
authorizes the request before any byte of the body is read, then parses
the body and hands DevTools what it parsed: a text part as text, a part
that is not text as [UNSERIALIZABLE], and a file as its name, size
and mimeType, never its bytes, after the query. A name sent more than
once, or one that ends in [], is the list of its parts, so every file
of a photos[] field is listed. An upload no extractor
read, because authorization refused it or the handler never asked for it,
records not-read with the declared length, and one whose parse failed
partway records unparsed:
The raw text of a multipart body is never kept, so a password in an upload form that failed to parse is not stored.
The response body of a rendered page is its page object. Any other
response records its text when its Content-Type is textual (JSON,
text/*, XML or JavaScript), decoded when it is JSON, up to 256,000
bytes; otherwise the reason is non-textual, streamed or too-large.
A page is recorded only once its response is built, so when the page's
document fails, such as a root template that fails to render, the entry
records the error response the client got, with no component and no
props or propValues.
Headers and the id tag
Every recorded response carries:
X-Inertia-Devtools-Id- the entry's id, which the extension fetches.X-Inertia-Devtools-Parent-Out- the incomingX-Inertia-Devtools-Parentof an Inertia visit, else the entry's own id. A prefetch is always its own parent. The extension sends the value back on the next request of the same batch, so the follow-ups of a visit, a redirect included, attach to it.X-Inertia-Devtools-Base-Path- the public root, when the application is not served at the host's root.
The first visit of a page has no response headers the extension can see
once it attaches, so a 200 HTML document that rendered a page for a
request that is not an Inertia visit gets the id in the document, before
its last closing body tag:
The closing tag is found in any case and with space before its >, so a
root template that ends with </BODY> or </Body > is tagged too. A
document with no closing body tag gets the tag at its end. A
Content-Length the handler set on the page is dropped with the tag
added, and the server sends the length of the tagged document.
Under a public root the tag also carries
data-inertia-devtools-base-path="/billing". An Inertia visit's JSON and a
plain HTML page get no tag.
Props and where they come from
Each prop of a rendered page is listed under props with shared and
inertiaType, and with each of these that applies:
inertiaType-always,defer,optional,merge,scrolloronce, read in that order from the prop's flags, so a prop built with.merge().once()ismerge. A deferred prop isdefer, with itsdeferGroup, only on the visit the extension marks as loading deferred props; reloaded by hand it is a plain prop.reset- the client named the prop inX-Inertia-Reset.once- the client keeps the prop across visits.mergeDirection-appendorprepend, for a prop that merges.deepMerge- the prop merges deeply, or matches items withmatch_on.rescued- the prop's deferred resolver failed and was rescued.shareSource- for a shared prop, the file and line of theInertia::share,Inertia::share_many,Inertia::share_data,App::inertia_share,App::inertia_share_lazyorApp::inertia_share_oncecall that shared it, or of theInertiaConfig::hookscall that installed the middleware hooks that shared it.renderSource- for any other prop, the line that names its key below the render call:"users":ininertia_response!, or("users",in a builder call. When no line there names the key, as for a typed props struct defined in another file or a source file that is not on disk, it is the render call's own file and line.
A prop your middleware hooks share has the InertiaConfig::hooks call
that installed them as its shareSource: Rust has no reflection that
finds the share method's line. Keys from share_provider and
register_inertia_shared providers are marked shared with no source.
errors is listed as an always prop every page shares.
Every top-level key is listed. A dotted key that nests is listed under its
path only when it carries something besides being a prop. propValues
holds the value of each listed path as the client received it, after
partial-reload filtering, so a prop the visit withheld has no value.
The entry endpoints
The extension reads entries from two endpoints, which DevToolsMiddleware
answers before any route:
GET /_inertia/devtools/entries- every entry's__meta, newest first.component=Homekeeps one component's entries,type=navigate,partialkeeps those request types,exclude=polldrops these, thenoffsetandlimit(at least 1) page through the rest.GET /_inertia/devtools/entries/{id}- the stored entry, or404 {"message": "Not found."}for an id that is not a ULID or names no entry.
When APP_ENV names the local environment every request is admitted,
so a broken gate never locks you out of your own tools. Anywhere else, an
unset APP_ENV included, a request is admitted only when the configured
gate ability allows the signed-in user, else the answer is
403 {"message": "Forbidden."}. The gate is asked about the
user and () as the resource; a guest is asked about as ():
use Gate;
// Admins only. A guest has no gate of this name, which denies.
;
An endpoint request reflashes the session, so the flash data a POST
left for the page after its redirect still reaches that page when the
extension fetches the entry first. It never becomes the session's
previous URL, so a later back() does not send the visitor to it. It is
never recorded, whatever except says. With
InertiaConfig::register_globally(false), the stack on your route groups
records their routes and Inertia::install registers a global
DevToolsMiddleware that answers only the endpoints.
Storage and redaction
Each entry is one JSON file, <storage_path>/<id>.json, written to a
temporary file and renamed into place. _meta.json lists every entry's
__meta, newest first. Each rewrite holds a lock on _meta.lock, so
several processes can record into one directory, and renames a complete
new list over _meta.json, so an interrupted rewrite leaves the previous
list in place. When _meta.json is missing, empty or not a JSON list, it
is rebuilt from the entry files before it is read or rewritten, so the
next recorded request, prune and tab limit still see every stored entry.
A .gitignore in the directory keeps it out of your repository.
After a request, entries older than ttl_hours are pruned when the last
prune, noted in _last_prune, is at least prune_interval_secs old. A
browser tab keeps its newest limit entries.
Before an entry is written, the value of every key named by
redact_keys is replaced by [REDACTED] at any depth: in the request
and response bodies, in prop values, and in the page object. So are the
query parameters of the same names in the entry's URLs, and the values of
the redact_headers headers. Names are compared without case, so
Password is caught by password. A name is also caught by any of its
parts split on [, ] and .: the multipart field user[password], the
prop path auth.password and the query parameter data[0][token] are all
redacted, while a longer word such as passwords is kept.
A URL is any string in the entry that holds a ? and starts with a
scheme (https:), / or ?, such as a redirect_to form field or a
prop. A string under a url or redirectLocation key, and the value of
a Location, X-Inertia-Location, Referer or Content-Location
header, is a URL whatever it starts with, and so are the <...> targets
of a Link header and the target of a Refresh header. So a redirect to
/reset?token=abc is stored with location: /reset?token=%5BREDACTED%5D,
and a header that is not a URL is stored as sent. A header value or
multipart field that is not text is stored as [UNSERIALIZABLE], and the
rest of the entry is kept.
Recording never breaks a response
Recording observes the response and changes nothing of it beyond the
DevTools headers and the id tag. A failure while recording drops that
entry. A failure to write the store is logged at warn once and pauses
recording into that directory for 30 seconds; it is logged again only
after a write has succeeded. The response goes out with its headers
either way.
Why Suprnova diverges
- The configuration call is the install. Laravel records once its
DevTools package is installed. Here an application opts in with
InertiaConfig::devtools(DevToolsConfig::new()), so an application that upgrades the framework records nothing until it asks. - Sources come from the compiler. Laravel finds a render or share call
by walking a backtrace and reflects on a route's controller. Here the
calls take
#[track_caller], so a source is exact; the cost is that a hook-shared prop names theInertiaConfig::hookscall rather than the hooks'sharemethod, a provider's keys have no source, androutehas noactionSource. _suprnova/*replacestelescope*andhorizon*in the defaultexceptlist: those are Laravel packages, and_suprnova/is where the framework's own tooling routes live.- There is no
devtools.middlewaresetting. The endpoints run inside the Inertia stack, inside the session your bootstrap registered above it, which is what Laravel's defaultwebgroup gives them. - A request body is read up to the request body cap. Symfony keeps
every body in memory; a Rust request streams its body to the handler.
DevTools reads a body first up to the framework's request body cap, the
most an extractor reads, and records a larger one as
too-large, leaving it for the handler. A multipart upload is never read by DevTools: its extractor summarizes what it parsed, after the request is authorized, so an upload no extractor read isnot-read, and one whose parse failed isunparsed. Multipart field names are kept as sent, not nested: the files ofphotos[]are listed underphotos[]. - Redaction matches key parts and the URLs in headers. Laravel's
RedactsSensitiveDatamatches a key whole and redacts no URL in a header. Suprnova also redacts a key when any bracketed or dotted part of it names a redaction key, such asuser[password]orauth.password, and redacts the query of every URL in headers and bodies,LocationandRefererincluded. The extension's store is a file on disk, and a reset token in aLocationheader is as sensitive as one in the body. - The gate takes a resource. Suprnova's gate is typed by user and
resource, so the ability is defined for
(User, ()), and a guest is()where Laravel passesnull. - A prop value is never
[UNSERIALIZABLE]. A Suprnova prop is a JSON value once it resolves, so the marker appears only for header values and multipart text that are not UTF-8. - The write breaker is per directory, where Laravel's is one static per process: a failing directory does not pause recording into another.
- DevTools reads
APP_ENVitself. Laravel's unsetAPP_ENVisproduction, while Suprnova'sEnvironment::detectreads an unset one aslocal. After the application opts in, DevTools reads the variable itself and records only when it is set tolocalifenabledis unset.enabled(..)andINERTIA_DEVTOOLS_ENABLEDdecide outright. A new project's bootstrap opts in and its.envsetsAPP_ENV=local; an application that never callsdevtools(..)records nothing and serves no entries.
Next
- Inertia Responses - the middleware stack DevTools sits in, and the prop flags it classifies
- Authorization - defining the gate ability
- HTTP Tests -
TestClient, whose requests DevTools records like any other, so a test can read the entry a page left
