From 92b2d77e83ad9ff657cd5afacfc0f8d4885cb140 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 29 Sep 2026 01:00:33 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(observe):=20the=20"request"=20record?= =?UTF-8?q?=20=E2=80=94=20a=20server-function=20request=20left,=20at=20the?= =?UTF-8?q?=20send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "call" record is delivered at settle, so a network panel (solid-start- devtools) reading it alone cannot show a call that is still in flight, or one that never settles. The "request" record is the pending row: one per server-function request the browser SENT, delivered from the transport's send — after argument serialization and prepareRequest, immediately before the configured fetch receives the final address and init. Shape (`CallRequestEvent`, on `HostRecordTypes` beside "call"): `{ side: "client", id, name?, at, method: "GET" | "POST", origin? }` — id, name, method and origin by CallEvent's rules (origin the same object, read at dispatch); `at` is performance.now() at the send, not the call's dispatch (CallEvent.at is when the call was made; the gap is what building the request cost). `side` is the client's today; the server half (the request arrived, ahead of its "invocation") is additive later, as on "frame". Named `CallRequestEvent`, not `RequestEvent`: that name is the server request-scope event (`InvocationLive.event`), exported from both entries already. Live identity is the join: `observeCall` now creates ONE `CallLive` at the observation's start (`{ args }`), sets `live.request` at the send and `response`/`result`/`error` at settle onto the same object — the "request" listener's live IS the "call" listener's live, by identity, so a consumer joins them with no id-plus-time match and no sequence number. Documented on `CallLive` as a guarantee. Gating, every gate read once at the call's start: the observation exists when observed("call") || observed("request"); "request" is emitted only under observed("request"), "call" only under observed("call") (explicit now — a "request" listener alone gets no settle record, nor does a "call" listener that arrived mid-call). The request is reconstructed when observed("call","bodies") || observed("request","bodies"), before the "request" record is delivered; the response clone stays the "call" listener's opt-in. Emits nothing: a call whose argument serialization threw (nothing was sent — only its "call" settle), a call an integration answered locally (intercept — neither record). A hung fetch emits "request" alone; a deferred result emits "request" at the send and "call" at handoff. Prod: 0 B — everything sits behind IS_OBSERVE; the prod artifacts are byte-identical and every size scenario unchanged. Not in the diagnostics capture's RECORD_TYPES (settled summaries only); no performance-tracks change. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/observe-request-record.md | 10 + documentation/solid-2.0/08-dev-diagnostics.md | 20 +- packages/web/server-functions/src/client.ts | 8 +- packages/web/src/client.ts | 10 +- packages/web/src/index.server.ts | 2 + packages/web/src/observe.ts | 185 +++++++-- packages/web/test/client-records.spec.tsx | 381 +++++++++++++++++- packages/web/test/observe.type-tests.ts | 53 ++- 8 files changed, 611 insertions(+), 58 deletions(-) create mode 100644 .changeset/observe-request-record.md diff --git a/.changeset/observe-request-record.md b/.changeset/observe-request-record.md new file mode 100644 index 000000000..7c1a5fcbe --- /dev/null +++ b/.changeset/observe-request-record.md @@ -0,0 +1,10 @@ +--- +"@solidjs/web": patch +--- + +New **`"request"` record** on `OBSERVE.records` (`CallRequestEvent`, `CallRequestListener`; augmenting `HostRecordTypes` through `solid-js` beside `"call"`): one per server-function request the browser **sent**, delivered at the send — after argument serialization and `prepareRequest`, immediately before the transport's `fetch` receives the request — so a network panel can show a call that is still in flight, or one that never settles, which the `"call"` record (delivered at settle) cannot. `{ side: "client", id, name?, at, method: "GET" | "POST", origin? }`: `id`, `name`, `method` and `origin` as on the call's `CallEvent`; `at` is `performance.now()` at the send (not the call's dispatch — `CallEvent.at` is when the call was made, and the gap is what building the request cost); `side` is the client's today, the server half (the request arrived, ahead of its `"invocation"`) additive later, the way `"frame"` has two halves. + +- **`CallLive` is one object across a call's records** — documented and pinned: the `live` handed to the `"request"` listener is, by identity, the `live` handed to the `"call"` listener at settle, filled in as the call proceeds (`args` from the start, `request` at the send, `response` and `result`/`error` at settle). An in-process consumer joins a call's request to its settle by identity — the way `origin` joins a record to the interaction's — with no id-plus-time join and no sequence number. +- **Gating**, every gate read once at the call's start: the observation exists when either `"call"` or `"request"` is observed; `"request"` is delivered only when `observed("request")`, `"call"` only when `observed("call")` (explicit now — a `"request"` listener alone gets no settle record, not even a `"call"` listener that subscribed mid-call). `bodies` on a `"request"` subscription governs `live.request` alone: the request is reconstructed when `observed("call", "bodies") || observed("request", "bodies")`, set on `live` before the `"request"` record is delivered; the response clone stays the `"call"` listener's opt-in. +- **What emits nothing**: a call that failed before its request was built (argument serialization threw) emits no `"request"` — nothing was sent — only its `"call"` settle; a call whose fetch never settles emits `"request"` only; a call an integration answered locally (`intercept`) emits neither. A deferred/streaming result emits `"request"` at the send and `"call"` at handoff, as today. A throwing `"request"` listener is reported and cannot break the call. +- Production artifacts are byte-identical: everything lives behind `IS_OBSERVE`. Not in the diagnostics capture's `RECORD_TYPES` (capture artifacts keep settled summaries only); no performance-tracks change. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index 2ecc05a88..bd084491a 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -772,14 +772,15 @@ const off = OBSERVE.records.subscribe("invocation", (event, live) => { … }); OBSERVE.records.observed("invocation"); // true while a listener is subscribed — the emitters' pre-check // The bodies opt-in (`RecordSubscribeOptions`): a listener that reads the live handles an // emitter pays per record to take — the "call" record's request reconstruction and unread -// response clone — asks for them; `observed(type, "bodies")` is that emitter's second gate. +// response clone, the "request" record's request — asks for them; `observed(type, "bodies")` +// is that emitter's second gate. OBSERVE.records.subscribe("call", (event, live) => { … }, { bodies: true }); OBSERVE.records.observed("call", "bodies"); // true while a listener asked for bodies ``` The channel is `@solidjs/signals`'s, created once per **process** and registered on `globalThis` under `Symbol.for("@solidjs/signals/observe/records")`. Two consequences an observer can rely on: it exists as soon as `import { OBSERVE } from "solid-js"` (or from the core) resolves — an APM's `init()` can subscribe before the runtimes that emit have loaded, and without importing them — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set across both copies. The channel is created by whichever copy touches the key first, and its capabilities are that copy's — the packages version together, and with two copies at different versions the behaviour falls back to the older one's (a copy from before the `bodies` option, reached first, knows no bodies set and takes bodies for every `"call"` listener). The same registration is how the wire layers emit: `@solidjs/web`'s server-function client is bundled without a framework import (a router or a non-Solid caller can use it), so it reaches the channel by the registered name rather than importing `solid-js`. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod artifacts fold the channel and every emit site out, and an emitter with no listener reads no clock. Listeners are observers: a throwing listener is reported through `console.error` and the call, the render, the stream and the other listeners are unaffected; nothing a listener does reaches the result. Delivery allocates nothing — the channel keeps one listener array per type, replaced (never mutated) on subscribe and unsubscribe, so an emit in progress finishes over the array it started with and a listener unsubscribing mid-delivery neither skips nor double-calls anyone that round. A subscription is the channel's, not any emitter's: it outlives the attribution engine's `enable()`/`disable()` cycles and is dropped only by the function `subscribe` returned. This is the seam for tooling that watches the app — APM adapters, devtools — and deliberately not a policy hook: `configureServerFunctionsServer({ wrapInvocation })` remains the single, last-writer-wins wrap around execution for code that must **change** a call, and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there. -The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares `RecordTypes`, extending `HostRecordTypes`, with the attribution engine's ten entries on it directly — the engine ships in that package, behind its own entry — `rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"render"`, `"call"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with the engine's records and every loaded runtime's from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Six runtime records so far, beside the engine's ten (described under [Run attribution](#run-attribution--why-did-this-run); none is emitted until `attribution.enable()`). `OBSERVE.records.observed(type)` is the one gate an emitter of either kind consults before building a record. +The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares `RecordTypes`, extending `HostRecordTypes`, with the attribution engine's ten entries on it directly — the engine ships in that package, behind its own entry — `rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"render"`, `"call"`, `"request"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with the engine's records and every loaded runtime's from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Seven runtime records so far, beside the engine's ten (described under [Run attribution](#run-attribution--why-did-this-run); none is emitted until `attribution.enable()`). `OBSERVE.records.observed(type)` is the one gate an emitter of either kind consults before building a record. The **`"boundary"` record** (from `solid-js`, server) is one `` boundary that **waited** during a server render: @@ -831,10 +832,21 @@ const off = OBSERVE.records.subscribe("call", (event, live) => { One record per call, delivered when the caller's await settles: `durationMs` is the request built and sent, the response received and decoded (or claimed by the configured `responseHandler`) — the whole wait the caller saw — so against the server's `"invocation"` of the same `id` the difference is the wire. `method` is `"GET"` for a GET-encoded read (`GET(fn)`), `"POST"` otherwise; `status` is the response's HTTP status once one arrived and absent when the fetch itself rejected (`live.response` likewise). `name` is the function's source name from the reference's metadata (`ServerFunctionMetadata.name`): the compiled function's name, which the compiler seeds in development builds only, or an explicit label (`withMeta`, the `name` argument to `createServerReference`), which survives to production — a label for a panel, not an identity: not unique, and absent when the reference carries none. `outcome: "error"` carries the value as thrown to the caller — a decoded server error, or the transport's own failure — in `live.error`. `deferred: true` marks a streaming result (a `live()` source, a generator), timed to handoff. A call an integration answered locally (a handler's `intercept`) made no request and emits nothing. Emitted by the observe and dev artifacts of the server-function client (`@solidjs/web/server-functions/client`, the `observe`/`development` export conditions). -The live handles are a body viewer's (a devtools network panel), and are taken only for a listener that **asked for them** — `OBSERVE.records.subscribe("call", fn, { bodies: true })`: each costs the call a reconstruction and a transient double-buffer of the payload, which a consumer that reads ids, statuses and timings (an APM adapter, the performance tracks) never pays. Whether bodies are taken is read once, as the call starts (`records.observed("call", "bodies")`), from the union of the `"call"` listeners installed — so a listener that did not ask, sharing a call with one that did, receives the same `live` as it does, the clone included. The option belongs to the listener function: it is read at the function's first subscription to the type, subscribing the same function again changes nothing (one entry per function), and either disposer removes it. With no such listener `live.request` is absent and `live.response` is the transport's own object — status and headers readable, body consumed by its decode. With the opt-in, `live.request` is the request as dispatched — the final url and `RequestInit`, transport headers and the `prepareRequest` hook applied — built into a `Request` of the listener's own at the send, so its headers and body are readable in full without touching what the transport sent. It carries the body only when the body has a shape a second `Request` can hold without a competing consumer — a `string`, `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a view of one — and is built without it otherwise (a `ReadableStream` or an async iterable, the transport's streaming-upload contract: reconstructing one would consume it ahead of the send). It is absent when the call failed before the request was built (argument serialization threw), when the address is relative and there is no `location` to resolve it against (absent beats a URL that was never sent), and when the reconstruction itself fails (an init the `Request` constructor rejects but the configured `fetch` tolerates — an invalid header name, say): nothing taken for the record may fail the call, and nothing is logged. `live.response` is then a `clone()` taken as the response arrived, before the transport's decode, so its body is whole and unread while the caller still gets its result from the original — except where the clone would be a branch nobody drains, where it is the transport's own object: an event-stream response (a `live()` source, `text/event-stream` — a connection that stays open for the page's life, whose clone would buffer every event ever sent), a deferred result (`deferred: true` — a generator's stream, served for the stream's life; its clone, taken before the result's shape was known, is cancelled at settle so its branch stops buffering), and a response the `clone()` refused (one a configured `fetch` handed over already read, claimed by the `responseHandler`). A response the `responseHandler` claimed whose result is not a deferred body — a non-live `application/x-frame-stream` frame render the frames transport claims, say — keeps its clone, so under the opt-in the clone's branch buffers that render until the listener reads it or drops the record; live frames are `text/event-stream` and are never cloned. +The live handles are a body viewer's (a devtools network panel), and are taken only for a listener that **asked for them** — `OBSERVE.records.subscribe("call", fn, { bodies: true })`: each costs the call a reconstruction and a transient double-buffer of the payload, which a consumer that reads ids, statuses and timings (an APM adapter, the performance tracks) never pays. Whether bodies are taken is read once, as the call starts (`records.observed("call", "bodies")` — and, for the request alone, `records.observed("request", "bodies")`, see the `"request"` record below), from the union of the listeners installed — so a listener that did not ask, sharing a call with one that did, receives the same `live` as it does, the clone included. The `live` is **one object per call**, across its records: the `CallLive` the `"call"` listener receives at settle is, by identity, the one the call's `"request"` listener received at the send, filled in as the call proceeded (`args` from the start, `request` at the send, `response` and `result`/`error` at settle) — so an in-process consumer joins the two the way `origin` joins a record to the interaction's, by identity, with no id-plus-time match and no sequence number. The option belongs to the listener function: it is read at the function's first subscription to the type, subscribing the same function again changes nothing (one entry per function), and either disposer removes it. With no such listener `live.request` is absent and `live.response` is the transport's own object — status and headers readable, body consumed by its decode. With the opt-in, `live.request` is the request as dispatched — the final url and `RequestInit`, transport headers and the `prepareRequest` hook applied — built into a `Request` of the listener's own at the send, so its headers and body are readable in full without touching what the transport sent. It carries the body only when the body has a shape a second `Request` can hold without a competing consumer — a `string`, `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a view of one — and is built without it otherwise (a `ReadableStream` or an async iterable, the transport's streaming-upload contract: reconstructing one would consume it ahead of the send). It is absent when the call failed before the request was built (argument serialization threw), when the address is relative and there is no `location` to resolve it against (absent beats a URL that was never sent), and when the reconstruction itself fails (an init the `Request` constructor rejects but the configured `fetch` tolerates — an invalid header name, say): nothing taken for the record may fail the call, and nothing is logged. `live.response` is then a `clone()` taken as the response arrived, before the transport's decode, so its body is whole and unread while the caller still gets its result from the original — except where the clone would be a branch nobody drains, where it is the transport's own object: an event-stream response (a `live()` source, `text/event-stream` — a connection that stays open for the page's life, whose clone would buffer every event ever sent), a deferred result (`deferred: true` — a generator's stream, served for the stream's life; its clone, taken before the result's shape was known, is cancelled at settle so its branch stops buffering), and a response the `clone()` refused (one a configured `fetch` handed over already read, claimed by the `responseHandler`). A response the `responseHandler` claimed whose result is not a deferred body — a non-live `application/x-frame-stream` frame render the frames transport claims, say — keeps its clone, so under the opt-in the clone's branch buffers that render until the listener reads it or drops the record; live frames are `text/event-stream` and are never cloned. `origin` is what the call ran for, when the attribution engine is enabled and knows: the interaction whose handler made it (`{ kind: "interaction", name: "click", target, at }`), the navigation whose data needed it (`{ kind: "navigation", name: "/users/:id", …, interaction }` — a `createAsync` calling the server inside the recompute the location write caused), an effect or action frame, an async landing's recompute calling again. It is read at dispatch through `OBSERVE.attribution.currentOrigin()` and is the engine's **own** origin object — the same one `InteractionEvent.origin`, `NavigationEvent.origin` and `HoldEvent.origin`/`.interaction` carry — so an observer puts the call under the interaction's record by identity, not by a time window. A call after an `await` in a handler carries none (the escape a write there has); without an engine the field is absent. +The **`"request"` record** (from `@solidjs/web`, client) is one server-function request that **left** the browser — the `"call"` record's opening, delivered at the send: + +```js +const off = OBSERVE.records.subscribe("request", (event, live) => { + // event: { side: "client", id, name?, at, method: "GET" | "POST", origin? } + // live: the call's own CallLive — { args, request? } now; response, result | error at settle +}); +``` + +It exists because the `"call"` record is delivered at settle, and a network panel reading it alone has nothing to show for a call still in flight, and nothing ever for one that hung: this record is the pending row. One per request **sent**, delivered from the transport's send — after the arguments were serialized and `prepareRequest` had its say, immediately before the configured `fetch` receives the final address and `RequestInit`. `id`, `name`, `method` and `origin` are the call's, by the same rules as `CallEvent`'s (`origin` is the same object — read at dispatch, where the handler's frame is still open; by the send an async `prepareRequest` may have closed it). `at` is `performance.now()` **at the send**, not at the call: `CallEvent.at` is when the call was made, and the gap between the two is what building the request cost (serialization, the hook's await); the call's span always covers it (`CallEvent.at + durationMs ≥` the request's `at`). Nothing of the settle is on it — no `durationMs`, `outcome` or `status`; those are the `"call"` record's. The join to that record is the **`live`**: the object handed here is the object handed to the `"call"` listener at settle (see above), so a panel keeps the pending row by it and fills the row in when the settle record arrives with the same object — no id-plus-time match (a function called twice in flight is two live objects), no sequence number. A `"request"` listener's own `{ bodies: true }` governs `live.request`: the request is reconstructed when either a `"call"` or a `"request"` listener asked (`observed("call", "bodies") || observed("request", "bodies")`), under the same rules as above, and is set on `live` **before** this record is delivered, so the listener reads headers and body at the send; the response clone stays the `"call"` listener's opt-in alone. A reconstruction that failed leaves `live.request` absent and the record is delivered regardless — the request was sent either way. What emits nothing: a call that failed before its request was built (argument serialization threw) sent nothing and emits no `"request"`, only its `"call"` settle with `outcome: "error"`; a call an integration answered locally (a handler's `intercept`) emits neither. A call whose fetch never settles emits `"request"` alone; a deferred or streaming result emits `"request"` at the send and `"call"` at handoff. Every gate is read once, as the call starts: the observation exists when either record is observed, `"request"` is delivered only while `observed("request")` and `"call"` only while `observed("call")` were true then — a `"call"` listener subscribing mid-call hears nothing of that call. `side` says which end recorded it: only `"client"` exists today — the server half (the request **arrived**, ahead of its `"invocation"`) is additive later, `side: "server"`, the way the `"frame"` record has two halves. A throwing listener is reported and cannot break the call. Emitted by the observe and dev artifacts of the server-function client, beside the `"call"` record; not part of the diagnostics capture's tables (`@solidjs/diagnostics` keeps settled summaries only). + The **`"frame"` record** (from `@solidjs/web`, both sides) is one frame stream — a server component rendered to the frame transport ([RFC 11](11-server-components.md)) — from its `start` chunk to its `complete`, as **produced** on the server or as **applied** on the client, `side` saying which: ```js @@ -1100,7 +1112,7 @@ createRoot(() => { - `"labels"` — no value previews anywhere (`prev`/`value` absent, sentences say `wrote "page"` and `signal "n" write` without the `1 → 2`); element text kept only on a `button` or an `a` (`button#save "Save"` stays, `div#card "Personal note"` becomes `div#card`) — the control's label, never a cell's content. What `@solidjs/web/performance-tracks` used to apply by hand in observe builds. - `"none"` — **the observe default**: no previews, no element text on any target (`button#save`); the sentences name the node and the element and nothing the person typed or read. -**The default is the build tier's** — `"full"` in dev builds, `"none"` in observe builds (the literal is folded per build; the observe engine ships `"none"` only). An observe build is a production artifact: it carries no user data unless a holder asks, and a holder that wants more says so — `attribution.enable({ values: "labels" })` for the interaction's control label, `"full"` for everything. Across holds the **least permissive** level wins: a diagnostics panel asking for `"full"` beside an APM adapter asking for `"none"` gets `"none"` until the adapter releases — a production holder can rely on its level regardless of who else is on the engine. A holder that names no level asks for the tier's default, so in an observe build it tightens to `"none"` beside anyone, while a single holder passing `"full"` there gets `"full"`; an explicit `"full"` never loosens what another holder demanded. The level applies from the moment it is in effect (a record built before a stricter hold was taken keeps what it carried). An observe-tier consumer that ships records off the machine should treat its level as its export contract — pass it explicitly rather than relying on the default, and never scrub after the fact. Outside the level: `ChangeOrigin.to`/`from`/`params` and `NavigationEvent.to`/`from`/`params` (`NavigationHop` too) — concrete paths and the values a route pattern bound (`/users/42`, `{ id: "42" }`), while `name` is the pattern; the verdict sentences name the navigation with them. `data.error` on the server error findings (`SSR_RENDER_ERROR_CONTAINED`, `SERVER_ERROR_SANITIZED` on either road) — the error **as thrown**, message and own properties, deliberately unsanitized: the wire got the generic message so the observer could see the real one, which means a driver's connection string or a query lands here, and an exporter treats it as it treats any captured exception. Dev-only checks may put the offending value on `data` (`PRELOAD_DESCRIPTOR_INVALID`'s `data.value`, `HEAD_TAG_INVALID`'s `data.detail`) — dev tier, never exported. Everything else is safe by construction: `RerunEvent` has names and numbers only; the runtimes' records (`"call"`, `"invocation"`, `"render"`, `"boundary"`, `"frame"`, `"recovery"`) never put arguments, results, thrown values, requests or responses on the record — those ride the `live` argument beside it, in-process only — and carry ids, methods, addresses, statuses, counts and timings; `ownerPath` is component and primitive names. `stacks: true` adds first-party frames to `ChangeRecord.stack` (file paths, not values) and is a dev affordance to leave off in production. +**The default is the build tier's** — `"full"` in dev builds, `"none"` in observe builds (the literal is folded per build; the observe engine ships `"none"` only). An observe build is a production artifact: it carries no user data unless a holder asks, and a holder that wants more says so — `attribution.enable({ values: "labels" })` for the interaction's control label, `"full"` for everything. Across holds the **least permissive** level wins: a diagnostics panel asking for `"full"` beside an APM adapter asking for `"none"` gets `"none"` until the adapter releases — a production holder can rely on its level regardless of who else is on the engine. A holder that names no level asks for the tier's default, so in an observe build it tightens to `"none"` beside anyone, while a single holder passing `"full"` there gets `"full"`; an explicit `"full"` never loosens what another holder demanded. The level applies from the moment it is in effect (a record built before a stricter hold was taken keeps what it carried). An observe-tier consumer that ships records off the machine should treat its level as its export contract — pass it explicitly rather than relying on the default, and never scrub after the fact. Outside the level: `ChangeOrigin.to`/`from`/`params` and `NavigationEvent.to`/`from`/`params` (`NavigationHop` too) — concrete paths and the values a route pattern bound (`/users/42`, `{ id: "42" }`), while `name` is the pattern; the verdict sentences name the navigation with them. `data.error` on the server error findings (`SSR_RENDER_ERROR_CONTAINED`, `SERVER_ERROR_SANITIZED` on either road) — the error **as thrown**, message and own properties, deliberately unsanitized: the wire got the generic message so the observer could see the real one, which means a driver's connection string or a query lands here, and an exporter treats it as it treats any captured exception. Dev-only checks may put the offending value on `data` (`PRELOAD_DESCRIPTOR_INVALID`'s `data.value`, `HEAD_TAG_INVALID`'s `data.detail`) — dev tier, never exported. Everything else is safe by construction: `RerunEvent` has names and numbers only; the runtimes' records (`"call"`, `"request"`, `"invocation"`, `"render"`, `"boundary"`, `"frame"`, `"recovery"`) never put arguments, results, thrown values, requests or responses on the record — those ride the `live` argument beside it, in-process only — and carry ids, methods, addresses, statuses, counts and timings; `ownerPath` is component and primitive names. `stacks: true` adds first-party frames to `ChangeRecord.stack` (file paths, not values) and is a dev affordance to leave off in production. `costs()` aggregates since `enable()`: `scopes` ranked by self-time with `wastedMs` (time in runs whose value didn't change — the equality cutoff absorbed them), and `writes` ranked by the total downstream re-run time each root write caused. Overlay work (optimistic-lane and held runs — `phase: "optimistic" | "held"`) is accounted separately as `overlayMs` and never blamed as waste. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index 98d8b8f82..efc13aaf8 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -610,6 +610,8 @@ async function createRequest(base, id, options, meta) { } init = prepared || init; } + // The send, as observed: the `"request"` record leaves from here — the + // final address and init, immediately before `fetch` receives them. if (IS_OBSERVE && observation) observation.request(base, init); const send = config.fetch || fetch; return send(base, init); @@ -725,8 +727,10 @@ async function initializeResponse(base, id, options, args, meta) { // arguments ride pre-encoded in the url (wire args empty) — a handler keying // state by the call (function + arguments) must still see the real ones. // Observe tier: the `"call"` record (`OBSERVE.records`, see `CallEvent`) — -// the call as the caller awaited it, request through decode; with no -// listener the dispatch runs bare, not even reading the clock. The wrapper +// the call as the caller awaited it, request through decode — and the +// `"request"` record beside it (see `CallRequestEvent`), delivered from +// `createRequest` at the send; with no listener for either the dispatch +// runs bare, not even reading the clock. The wrapper // exists in the observe and dev artifacts only: `fetchServerFunction` below // is the dispatch itself where the literal folds, so prod pays neither the // extra frame nor the promise hop. diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts index d814a9acf..bcbbf6340 100644 --- a/packages/web/src/client.ts +++ b/packages/web/src/client.ts @@ -118,14 +118,16 @@ export interface RequestEvent { export type { CookieOptions } from "./cookies.js"; // This runtime's records on `OBSERVE.records` (`"invocation"`, `"render"`, -// `"call"`, `"frame"`), and with them the `HostRecordTypes` augmentation -// that module declares: the published types resolve to this entry under -// every condition, so this re-export is what puts the augmentation in a -// consumer's program. +// `"call"`, `"request"`, `"frame"`), and with them the `HostRecordTypes` +// augmentation that module declares: the published types resolve to this +// entry under every condition, so this re-export is what puts the +// augmentation in a consumer's program. export type { CallEvent, CallListener, CallLive, + CallRequestEvent, + CallRequestListener, FrameAppliedEvent, FrameEvent, FrameListener, diff --git a/packages/web/src/index.server.ts b/packages/web/src/index.server.ts index c1644f8bd..215b6220c 100644 --- a/packages/web/src/index.server.ts +++ b/packages/web/src/index.server.ts @@ -36,6 +36,8 @@ export type { CallEvent, CallListener, CallLive, + CallRequestEvent, + CallRequestListener, FrameAppliedEvent, FrameEvent, FrameListener, diff --git a/packages/web/src/observe.ts b/packages/web/src/observe.ts index 0cd474e0e..45cbfa7ff 100644 --- a/packages/web/src/observe.ts +++ b/packages/web/src/observe.ts @@ -1,8 +1,9 @@ // This runtime's records on `OBSERVE.records` (see `Records` in // @solidjs/signals): the TYPES of every record `@solidjs/web` emits, on -// either platform, and the emitters for the client's — the `"call"` record -// (a server-function call made from the browser) and the client half of the -// `"frame"` record (a frame stream applied). The server's emitters — the +// either platform, and the emitters for the client's — the `"request"` and +// `"call"` records (a server-function call made from the browser: the +// request left; the call settled) and the client half of the `"frame"` +// record (a frame stream applied). The server's emitters — the // `"invocation"` and `"render"` records and the frame's server half — are // in server-observe.ts, which needs the server runtime; this module needs // nothing of either platform's runtime, so every entry bundles it. @@ -271,37 +272,47 @@ export interface CallEvent { } /** - * The live half of a call, for in-process consumers. `error` is the value - * as thrown to the caller — a decoded server error, or the transport's own - * failure. The bodies — `request`, and `response` as an unread clone — are - * a body viewer's (devtools' network panel), and are taken only while a - * `"call"` listener asked for them - * (`OBSERVE.records.subscribe("call", fn, { bodies: true })`): each costs - * the call a reconstruction and a transient double-buffer of the payload, - * which a consumer that reads ids, statuses and timings never pays. With - * no such listener `request` is absent and `response` is the transport's - * own object, consumed by its decode. Whether bodies are taken is read once, + * The live half of a call, for in-process consumers — ONE object across + * the call's records: the same `CallLive` is handed to the `"request"` + * listener at the send and to the `"call"` listener at settle, filled in + * as the call proceeds (`args` from the start, `request` at the send, + * `response` and `result`/`error` at settle), so an in-process consumer + * joins a call's request to its settle by identity — the way `origin` + * joins a record to the interaction's — with no id-plus-time join and no + * sequence number. `error` is the value as thrown to the caller — a + * decoded server error, or the transport's own failure. The bodies — + * `request`, and `response` as an unread clone — are a body viewer's + * (devtools' network panel), and are taken only while a listener asked for + * them (`OBSERVE.records.subscribe("call", fn, { bodies: true })`, or the + * same on `"request"` for the request alone): each costs the call a + * reconstruction and a transient double-buffer of the payload, which a + * consumer that reads ids, statuses and timings never pays. With no such + * listener `request` is absent and `response` is the transport's own + * object, consumed by its decode. Whether bodies are taken is read once, * as the call starts. */ export interface CallLive { args: unknown[]; /** - * Bodies opted in: the request as dispatched — the final url and - * `RequestInit` (the transport's headers, the `prepareRequest` hook - * applied), built into a `Request` of the listener's own at the send, so - * its headers and body are readable in full and reading them touches - * nothing the transport sent. Built WITH the body only when the body has - * a shape a second `Request` can hold without a competing consumer — - * `string`, `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a - * view of one — and WITHOUT it otherwise (a `ReadableStream` or an async - * iterable, the transport's streaming-upload contract: reconstructing - * one would consume it ahead of the send), so the request then reads as - * bodyless. Absent without the opt-in; when the call failed before the - * request was built (argument serialization threw); when the address is - * relative and there is no `location` to resolve it against (absent - * beats a URL that was never sent); and when the reconstruction itself - * failed (an init the `Request` constructor rejects but the configured - * `fetch` tolerates) — a reconstruction never fails the call. + * Bodies opted in — by a `"call"` listener or a `"request"` listener + * (`observed("call", "bodies") || observed("request", "bodies")`): the + * request as dispatched — the final url and `RequestInit` (the + * transport's headers, the `prepareRequest` hook applied), built into a + * `Request` of the listener's own at the send, so its headers and body + * are readable in full and reading them touches nothing the transport + * sent. Set before the `"request"` record is delivered, so that listener + * reads it too. Built WITH the body only when the body has a shape a + * second `Request` can hold without a competing consumer — `string`, + * `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a view of one + * — and WITHOUT it otherwise (a `ReadableStream` or an async iterable, + * the transport's streaming-upload contract: reconstructing one would + * consume it ahead of the send), so the request then reads as bodyless. + * Absent without the opt-in; when the call failed before the request was + * built (argument serialization threw); when the address is relative and + * there is no `location` to resolve it against (absent beats a URL that + * was never sent); and when the reconstruction itself failed (an init + * the `Request` constructor rejects but the configured `fetch` + * tolerates) — a reconstruction never fails the call. */ request?: Request; /** @@ -333,6 +344,58 @@ export interface CallLive { export type CallListener = (event: CallEvent, live: CallLive) => void; +// --- "request": a server-function request left, from the client --------------- + +/** + * One server-function request LEFT the browser — delivered on + * `OBSERVE.records.subscribe("request", …)` at the send: after the + * arguments were serialized and `prepareRequest` had its say, immediately + * before the transport's `fetch` is handed the request. The `"call"` + * record of the same call follows at settle; this one exists because that + * one cannot show a call that is still in flight, or one that never + * settles (a hung fetch) — a network panel's pending row. The two records + * share their `CallLive` by identity (see `CallLive`): the object handed + * here is the object handed to the `"call"` listener, so an in-process + * consumer joins them with no id-plus-time join and no sequence number. + * + * Emitted only for a request that was sent: a call that failed before its + * request was built (argument serialization threw) emits no `"request"` — + * only its `"call"` settle; a call an integration answered locally (a + * handler's `intercept`) made no request and emits neither. A deferred or + * streaming result emits `"request"` at the send and `"call"` at handoff, + * as today. Serializable; `live.request` (under `bodies`) rides beside it. + */ +export interface CallRequestEvent { + /** + * Which end recorded it. Only `"client"` exists today — the request left + * the browser. The server half (the request arrived, ahead of its + * `"invocation"`) is `"server"`, additive later, the way the `"frame"` + * record has two halves. + */ + side: "client"; + /** The function id — the same `id` the call's `"call"` and the server's `"invocation"` carry. */ + id: string; + /** The function's source name, by the same rule as `CallEvent.name`. */ + name?: string; + /** + * `performance.now()` at the send — when the request was handed to + * `fetch`, after serialization and `prepareRequest`. NOT the call's + * `CallEvent.at`, which is when the call was made: the gap between them + * is what building the request cost (an async `prepareRequest` + * included), and `CallEvent.at + durationMs` is never before this. + */ + at: number; + /** `GET` for a GET-encoded read (`GET(fn)`), `POST` otherwise — as on `CallEvent`. */ + method: "GET" | "POST"; + /** + * What the call ran for — the same object `CallEvent.origin` carries, + * read at dispatch (see `CallEvent.origin` for the rule). + */ + origin?: ChangeOrigin; +} + +export type CallRequestListener = (event: CallRequestEvent, live: CallLive) => void; + // --- "frame": a frame stream, produced (server) or applied (client) ------------ interface FrameEventBase { @@ -454,6 +517,12 @@ declare module "solid-js" { render: { event: RenderEvent; live: RenderLive }; /** Server-function calls, from the client — see `CallEvent`. */ call: { event: CallEvent; live: CallLive }; + /** + * Server-function requests left, from the client — see + * `CallRequestEvent`; the `live` is the call's own, shared with its + * `"call"` record. + */ + request: { event: CallRequestEvent; live: CallLive }; /** Frame streams, produced or applied — see `FrameEvent`. */ frame: { event: FrameEvent; live: FrameLive }; } @@ -520,6 +589,7 @@ export interface CallObservation { /** * The request is about to be sent: the address and the final * `RequestInit` — what the transport's `fetch` receives, hook applied. + * The `"request"` record is delivered from here. */ request(url: string, init: RequestInit): void; /** The response arrived (before decode); its status goes on the record. */ @@ -531,9 +601,10 @@ export interface CallObservation { /** * Opens the observation of one server-function call from the client, as * the request is about to be built; the runtime reports the request as it - * is sent, the response when it arrives and the settle when the caller - * gets its answer. `undefined` with no listener or outside observe builds - * — the runtime then does nothing extra, not even read the clock. + * is sent (the `"request"` record), the response when it arrives and the + * settle when the caller gets its answer (the `"call"` record). `undefined` + * with no listener for either record or outside observe builds — the + * runtime then does nothing extra, not even read the clock. */ export function observeCall( id: string, @@ -543,17 +614,31 @@ export function observeCall( ): CallObservation | undefined { if (!IS_OBSERVE) return undefined; const channel = records(); - if (channel === undefined || !channel.observed("call")) return undefined; + if (channel === undefined) return undefined; + // Every gate is read ONCE, here, as the call starts — which records it + // delivers and whether bodies are taken for them — so a subscription + // that arrives or leaves mid-call cannot leave a call with a `"request"` + // and no `"call"` (or the reverse), or a record with a clone and no + // request. A `"call"` listener alone, a `"request"` listener alone, or + // both: the observation exists for either. + const observedCall = channel.observed("call"); + const observedRequest = channel.observed("request"); + if (!observedCall && !observedRequest) return undefined; const at = performance.now(); - // Bodies are taken only for a listener that asked (see `CallLive`), and - // the question is asked ONCE, here: a subscription that arrives or - // leaves mid-call cannot leave the record with a clone and no request, - // or the reverse. + // Bodies are taken only for a listener that asked (see `CallLive`). The + // request is reconstructed for a `"request"` listener's opt-in as much as + // a `"call"` listener's — it is the `"request"` record's own live handle + // — while the response clone is the `"call"` record's alone. const bodies = channel.observed("call", "bodies"); + const requestBodies = bodies || channel.observed("request", "bodies"); // Provenance is read NOW, at the call site, where the handler's or the - // recompute's frame is still open; by settle it is long gone. + // recompute's frame is still open; by the send it is gone (an async + // `prepareRequest` intervenes), by settle long gone. const origin = currentOrigin(); - let request: Request | undefined; + // ONE live object for the call's records (see `CallLive`): handed to the + // `"request"` listener at the send and to the `"call"` listener at + // settle, filled in between — the identity IS the join. + const live: CallLive = { args }; let response: Response | undefined; let clone: Response | undefined; let settled = false; @@ -562,8 +647,19 @@ export function observeCall( // The send keeps its `(address, init)` shape — a configured `fetch` // does not branch on whether devtools are attached — so what the // listener gets is a reconstruction of the dispatched request, the - // listener's own to read. - if (bodies) request = reconstructRequest(url, init); + // listener's own to read. Built BEFORE the record is delivered, so + // the `"request"` listener finds it on `live`; a reconstruction that + // failed leaves `live.request` absent and the record still goes out + // — the request was sent either way. + if (requestBodies) { + const request = reconstructRequest(url, init); + if (request !== undefined) live.request = request; + } + if (!observedRequest) return; + const event: CallRequestEvent = { side: "client", id, at: performance.now(), method }; + if (name !== undefined) event.name = name; + if (origin !== undefined) event.origin = origin; + channel.emit("request", event, live); }, response(r) { response = r; @@ -586,12 +682,15 @@ export function observeCall( settle(outcome, value) { if (settled) return; settled = true; + // The `"call"` gate as it stood at the call's start: with a `"request"` + // listener alone the observation exists for that record, and the + // settle delivers nothing — not to a `"call"` listener that arrived + // mid-call either (no clone was taken for it: `bodies` was false). + if (!observedCall) return; const event: CallEvent = { id, at, durationMs: performance.now() - at, method, outcome }; if (name !== undefined) event.name = name; if (response !== undefined) event.status = response.status; if (origin !== undefined) event.origin = origin; - const live: CallLive = { args }; - if (request !== undefined) live.request = request; if (outcome === "ok") { live.result = value; if (isDeferredBody(value)) { diff --git a/packages/web/test/client-records.spec.tsx b/packages/web/test/client-records.spec.tsx index 9241e5dc7..f8f9cb7d8 100644 --- a/packages/web/test/client-records.spec.tsx +++ b/packages/web/test/client-records.spec.tsx @@ -26,7 +26,12 @@ // only for a listener that asked (`{ bodies: true }`): a plain listener // gets the transport's own response and no request; the reconstruction // and the clone never fail the call, and a deferred result's clone is -// released at settle. +// released at settle; +// - the `"request"` record — the request LEFT, delivered at the send while +// the call is still in flight — shares its `live` with the call's +// `"call"` record by identity; a call that never sent (serialization +// threw) emits none, a fetch that never settles emits it alone, and its +// own `bodies` opt-in governs `live.request`. // // The channel is the core's, reached by its registered symbol (this runtime // imports no framework): `OBSERVE.records` from `solid-js` IS what the @@ -36,7 +41,7 @@ import { resolve } from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import { OBSERVE, createMemo, createRoot, createSignal, flush } from "solid-js"; import { attribution, type InteractionEvent, type NavigationEvent } from "solid-js/attribution"; -import type { CallEvent, CallLive, FrameEvent, FrameLive } from "@solidjs/web"; +import type { CallEvent, CallLive, CallRequestEvent, FrameEvent, FrameLive } from "@solidjs/web"; import { GET, configureServerFunctionsClient, @@ -80,6 +85,42 @@ function calls(options?: { bodies?: boolean }) { return seen; } +/** + * A `"request"` listener — the request LEFT, at the send; `{ bodies: true }` + * asks for `live.request` on its own, without a `"call"` listener's opt-in. + */ +function requests(options?: { bodies?: boolean }) { + const seen: Array<{ event: CallRequestEvent; live: CallLive }> = []; + unsubscribes.push( + OBSERVE!.records.subscribe( + "request", + (event, live) => { + seen.push({ event, live }); + }, + options + ) + ); + return seen; +} + +/** + * A `fetch` the test settles by hand: the call is in flight until + * `answer(response)` or `fail(error)` — never, for a hung one. + */ +function deferredFetch() { + let answer!: (response: Response) => void; + let fail!: (error: unknown) => void; + const calls: Array<{ address: string; init: RequestInit }> = []; + vi.stubGlobal("fetch", (address: string, init: RequestInit) => { + calls.push({ address, init }); + return new Promise((resolve, reject) => { + answer = resolve; + fail = reject; + }); + }); + return { calls, answer: (r: Response) => answer(r), fail: (e: unknown) => fail(e) }; +} + /** The transport's client configuration as the suite found it. */ function resetClientConfig() { configureServerFunctionsClient({ @@ -677,6 +718,342 @@ describe("the call record's origin", () => { }); }); +// The request LEFT: the `"call"` record is delivered at settle, so a network +// panel reading it alone cannot show a call in flight or one that hung. The +// `"request"` record is delivered at the send — after serialization and +// `prepareRequest`, immediately before `fetch` — and shares the call's +// `live` with the `"call"` record by identity, so the panel's pending row +// and its settled row are one object filled in twice. +describe("the request record", () => { + test("delivered at the send, while the call is still in flight; the call record follows at settle", async () => { + const left = requests(); + const settled = calls(); + const wire = deferredFetch(); + const fn = createServerReference("requests/inflight"); + const before = performance.now(); + const pending = fn({ a: 1 }); + // The dispatch reaches the send after its own awaits (serialization, + // the hook); nothing about the response has happened yet. + await vi.waitFor(() => expect(wire.calls).toHaveLength(1)); + const afterSend = performance.now(); + + expect(left).toHaveLength(1); + expect(settled).toHaveLength(0); + const { event, live } = left[0]; + expect(event.side).toBe("client"); + expect(event.id).toBe("requests/inflight"); + expect(event.method).toBe("POST"); + expect(event.at).toBeGreaterThanOrEqual(before); + expect(event.at).toBeLessThanOrEqual(afterSend); + // Serializable — the send's facts only; the settle's (`durationMs`, + // `outcome`, `status`) belong to the "call" record. + expect(Object.keys(event).sort()).toEqual(["at", "id", "method", "side"]); + expect(JSON.parse(JSON.stringify(event))).toEqual(event); + // The live handles as they stand at the send: the arguments, nothing + // of the response yet. + expect(live.args).toEqual([{ a: 1 }]); + expect(live.response).toBeUndefined(); + expect(live.result).toBeUndefined(); + expect(live.error).toBeUndefined(); + + wire.answer(jsonResponse({ n: 1 })); + expect(await pending).toEqual({ n: 1 }); + expect(left).toHaveLength(1); + expect(settled).toHaveLength(1); + expect(settled[0].event).toMatchObject({ id: "requests/inflight", outcome: "ok", status: 200 }); + }); + + test("a hung fetch: the request record alone, no call record", async () => { + const left = requests(); + const settled = calls(); + const wire = deferredFetch(); + // Never awaited — the fetch never settles, and neither does the call. + void createServerReference("requests/hung")(); + await vi.waitFor(() => expect(wire.calls).toHaveLength(1)); + // A turn for anything that would have settled to do so. + await delay(5); + expect(left).toHaveLength(1); + expect(left[0].event.id).toBe("requests/hung"); + expect(settled).toHaveLength(0); + }); + + test("the live is one object across the call's records: the request set at the send, the response and result at settle", async () => { + const left = requests(); + const settled = calls({ bodies: true }); + const wire = deferredFetch(); + const pending = createServerReference("requests/joined")({ a: 1 }); + await vi.waitFor(() => expect(left).toHaveLength(1)); + const { live } = left[0]; + // Reconstructed BEFORE the record was delivered: the request listener + // reads it, and the transport's send is untouched by the read. + expect(live.request).toBeInstanceOf(Request); + expect(await live.request!.text()).toBe(wire.calls[0].init.body); + expect(live.response).toBeUndefined(); + + wire.answer(jsonResponse({ n: 2 })); + expect(await pending).toEqual({ n: 2 }); + expect(settled).toHaveLength(1); + // The identity IS the join — no id-plus-time match, no sequence number. + expect(settled[0].live).toBe(live); + expect(live.response!.status).toBe(200); + expect(live.response!.bodyUsed).toBe(false); + expect(live.result).toEqual({ n: 2 }); + expect(live.request).toBe(left[0].live.request); + }); + + test("bodies on the request subscription alone reconstructs live.request; a bodies-less call listener beside it gets the same", async () => { + const left = requests({ bodies: true }); + const settled = calls(); + const clone = vi.spyOn(Response.prototype, "clone"); + vi.stubGlobal("fetch", async () => jsonResponse(1)); + await createServerReference("requests/bodies")({ a: 1 }); + expect(left[0].live.request).toBeInstanceOf(Request); + expect(await left[0].live.request!.text()).toBe(JSON.stringify([{ a: 1 }])); + // One live: the call listener, which did not ask, finds the request too + // — and the response is the transport's own: the clone is the "call" + // listener's opt-in, which nobody made. + expect(settled[0].live).toBe(left[0].live); + expect(settled[0].live.request).toBe(left[0].live.request); + expect(clone).not.toHaveBeenCalled(); + expect(settled[0].live.response!.bodyUsed).toBe(true); + }); + + test("without bodies on either subscription: no request built, none on the live", async () => { + const left = requests(); + const settled = calls(); + const NativeRequest = Request; + let constructed = 0; + vi.stubGlobal( + "Request", + class extends NativeRequest { + constructor(...args: ConstructorParameters) { + constructed++; + super(...args); + } + } + ); + vi.stubGlobal("fetch", async () => jsonResponse(1)); + await createServerReference("requests/plain")({ a: 1 }); + expect(left).toHaveLength(1); + expect(left[0].live.request).toBeUndefined(); + expect(settled[0].live.request).toBeUndefined(); + expect(constructed).toBe(0); + }); + + test("a reconstruction the Request constructor refuses: the record is still delivered, the request absent", async () => { + const left = requests({ bodies: true }); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + configureServerFunctionsClient({ + prepareRequest: init => ({ + ...init, + headers: { ...(init.headers as Record), "bad header": "x" } + }), + fetch: async () => jsonResponse("tolerated") + }); + expect(await createServerReference("requests/bad-header")()).toBe("tolerated"); + expect(left).toHaveLength(1); + expect(left[0].event.id).toBe("requests/bad-header"); + expect(left[0].live.request).toBeUndefined(); + expect(error).not.toHaveBeenCalled(); + } finally { + resetClientConfig(); + } + }); + + test("`at` is the send, after prepareRequest — not the call's dispatch; the call's span covers it", async () => { + const left = requests(); + const settled = calls(); + vi.stubGlobal("fetch", async () => jsonResponse(1)); + try { + configureServerFunctionsClient({ + prepareRequest: async init => { + await delay(10); + return init; + } + }); + await createServerReference("requests/late-send")(); + const request = left[0].event; + const call = settled[0].event; + // The call was made first; the request left after the hook's wait. + expect(request.at).toBeGreaterThanOrEqual(call.at); + expect(request.at - call.at).toBeGreaterThanOrEqual(9); + // And the call's own span reaches past the send. + expect(call.at + call.durationMs).toBeGreaterThanOrEqual(request.at); + } finally { + resetClientConfig(); + } + }); + + test("the reference's source name and a GET-encoded read's method ride on the record", async () => { + const left = requests(); + vi.stubGlobal("fetch", async () => jsonResponse(1)); + await createServerReference("requests/named", "double")(); + await createServerReference("requests/anonymous")(); + await GET(createServerReference("requests/read"))("a", 2); + expect(left).toHaveLength(3); + expect(left[0].event.name).toBe("double"); + expect("name" in left[1].event).toBe(false); + expect(left[2].event).toMatchObject({ id: "requests/read", method: "GET" }); + expect(left[2].live.args).toEqual(["a", 2]); + }); + + test("a call made in a handler carries the interaction — the same origin object the call record carries", async () => { + const left = requests(); + const settled = calls(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); + attribution.enable({ log: false, hotRuns: false, hotTime: false, waterfalls: false }); + const interactions: InteractionEvent[] = []; + unsubscribes.push(OBSERVE!.records.subscribe("interaction", e => interactions.push(e))); + vi.stubGlobal("fetch", async () => jsonResponse("saved")); + const save = createServerReference("requests/save"); + const click = { type: "click", target: 'button#save "Save"' }; + const pending = OBSERVE!.attribution.withInteraction(click, () => save("draft")); + flush(); + expect(await pending).toBe("saved"); + + expect(left).toHaveLength(1); + expect(left[0].event.origin).toMatchObject({ kind: "interaction", name: "click" }); + expect(interactions).toHaveLength(1); + expect(left[0].event.origin).toBe(interactions[0].origin); + expect(left[0].event.origin).toBe(settled[0].event.origin); + expect(JSON.parse(JSON.stringify(left[0].event))).toEqual(left[0].event); + // Without an engine the field is absent, as on the call record. + attribution.disable(); + await save("again"); + expect("origin" in left[1].event).toBe(false); + }); + + test("a call that failed before its request was built emits no request record — only the call's settle", async () => { + const left = requests({ bodies: true }); + const settled = calls(); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + // No rich-args codec: a bigint argument fails serialization; nothing left. + await expect(createServerReference("requests/unbuilt")(1n)).rejects.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + expect(left).toHaveLength(0); + expect(settled).toHaveLength(1); + expect(settled[0].event.outcome).toBe("error"); + expect(settled[0].live.request).toBeUndefined(); + }); + + test("a fetch that rejected: the request left, and the call record says how it ended", async () => { + const left = requests(); + const settled = calls(); + const failure = new TypeError("network down"); + vi.stubGlobal("fetch", async () => { + throw failure; + }); + await expect(createServerReference("requests/offline")()).rejects.toBe(failure); + expect(left).toHaveLength(1); + expect(settled).toHaveLength(1); + expect(settled[0].live).toBe(left[0].live); + expect(settled[0].event.outcome).toBe("error"); + expect(settled[0].event.status).toBeUndefined(); + expect(left[0].live.error).toBe(failure); + }); + + test("a deferred result: the request at the send, the call at handoff", async () => { + const left = requests(); + const settled = calls(); + async function* produce() { + yield 1; + yield 2; + } + vi.stubGlobal( + "fetch", + async () => + new Response(serializeStream(produce(), getServerFunctionsCodec()), { + headers: { "content-type": "text/plain", [BODY_FORMAT_HEADER]: BodyFormat.Serialized } + }) + ); + const result = (await createServerReference("requests/deferred")()) as AsyncIterable; + expect(left).toHaveLength(1); + expect(settled).toHaveLength(1); + expect(settled[0].event.deferred).toBe(true); + expect(settled[0].live).toBe(left[0].live); + const values: number[] = []; + for await (const value of result) values.push(value); + expect(values).toEqual([1, 2]); + }); + + test("a call an integration answered locally made no request and emits neither record", async () => { + const left = requests(); + const settled = calls(); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + try { + configureServerFunctionsClient({ + responseHandler: { handle: () => undefined, intercept: () => "local" } as any + }); + expect(await createServerReference("requests/local")()).toBe("local"); + expect(fetch).not.toHaveBeenCalled(); + expect(left).toHaveLength(0); + expect(settled).toHaveLength(0); + } finally { + resetClientConfig(); + } + }); + + test("a request listener alone: the observation exists, the request is delivered, and the call gate is fixed at the call's start", async () => { + const left = requests(); + const wire = deferredFetch(); + const pending = createServerReference("requests/alone")(); + await vi.waitFor(() => expect(left).toHaveLength(1)); + // A "call" listener arriving mid-call hears nothing of this call: the + // gate was read once, at the start, when there was none. + const late = calls(); + wire.answer(jsonResponse("ok")); + expect(await pending).toBe("ok"); + expect(late).toHaveLength(0); + // The next call, made under both, delivers both — one live. + vi.stubGlobal("fetch", async () => jsonResponse("next")); + expect(await createServerReference("requests/both")()).toBe("next"); + expect(left).toHaveLength(2); + expect(late).toHaveLength(1); + expect(late[0].live).toBe(left[1].live); + }); + + test("a throwing request listener is reported; the call, its record and the other listeners are unaffected", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const seen: string[] = []; + unsubscribes.push( + OBSERVE!.records.subscribe("request", () => { + throw new Error("listener bug"); + }) + ); + unsubscribes.push( + OBSERVE!.records.subscribe("request", event => { + seen.push(event.id); + }) + ); + const settled = calls(); + vi.stubGlobal("fetch", async () => jsonResponse(1)); + expect(await createServerReference("requests/listener")()).toBe(1); + expect(seen).toEqual(["requests/listener"]); + expect(settled).toHaveLength(1); + expect(settled[0].event.outcome).toBe("ok"); + expect(error).toHaveBeenCalledTimes(1); + expect((error.mock.calls[0][0] as Error).message).toBe("listener bug"); + } finally { + error.mockRestore(); + } + }); + + test("without a listener for either record nothing is delivered", async () => { + const left = requests(); + const settled = calls(); + for (const off of unsubscribes.splice(0)) off(); + vi.stubGlobal("fetch", async () => jsonResponse("quiet")); + expect(await createServerReference("requests/quiet")()).toBe("quiet"); + expect(left).toHaveLength(0); + expect(settled).toHaveLength(0); + }); +}); + function frameResponse(id: string, chunks: any[]) { const body = new ReadableStream({ start(controller) { diff --git a/packages/web/test/observe.type-tests.ts b/packages/web/test/observe.type-tests.ts index 1fd596a1e..5feef6d9d 100644 --- a/packages/web/test/observe.type-tests.ts +++ b/packages/web/test/observe.type-tests.ts @@ -3,7 +3,8 @@ // declares `RecordTypes` (which solid-js augments, once, through // `@solidjs/signals`, with its `"boundary"` record) extending // `HostRecordTypes` (which this package augments, once, through `solid-js`, -// with `"invocation"`, `"call"` and `"frame"`). Beside it, `OBSERVE.server`: +// with `"invocation"`, `"call"`, `"request"` and `"frame"`). Beside it, +// `OBSERVE.server`: // the core declares it empty, solid-js augments it with `trace: // ServerTrace`, and this package's trace.ts augments `ServerTrace` with // `provide`. @@ -28,6 +29,8 @@ import { import type { CallEvent, CallLive, + CallRequestEvent, + CallRequestListener, FrameAppliedEvent, FrameEvent, FrameLive, @@ -68,6 +71,7 @@ type Declared = | "invocation" | "render" | "call" + | "request" | "frame"; const declared: Declared = "boundary" as RecordType; declared; @@ -153,9 +157,52 @@ observe.records.subscribe("call", (event, live) => { live.response satisfies Response | undefined; }); +// The client's request record: the call's request left, at the send. Its +// `live` is the call's own `CallLive` — the same object the `"call"` +// listener gets at settle — and `side` is the client's today. +observe.records.subscribe("request", (event, live) => { + event satisfies CallRequestEvent; + live satisfies CallLive; + event.side satisfies "client"; + event.id satisfies string; + event.at satisfies number; + event.method satisfies "GET" | "POST"; + event.name satisfies string | undefined; + event.origin satisfies ChangeOrigin | undefined; + live.args satisfies unknown[]; + live.request satisfies Request | undefined; + // @ts-expect-error the settle's facts are the "call" record's + event.durationMs; + // @ts-expect-error the settle's facts are the "call" record's + event.outcome; + // @ts-expect-error the settle's facts are the "call" record's + event.status; +}); +const onRequest: CallRequestListener = (event, live) => { + event satisfies CallRequestEvent; + live satisfies CallLive; +}; +observe.records.subscribe("request", onRequest); +// The bodies opt-in on a "request" subscription alone governs `live.request`. +observe.records.subscribe( + "request", + (event, live) => { + event.side satisfies "client"; + live.request satisfies Request | undefined; + }, + { bodies: true } +) satisfies () => void; +observe.records.observed("request") satisfies boolean; +observe.records.observed("request", "bodies") satisfies boolean; +declare const requestLeft: CallRequestEvent; +declare const callLive: CallLive; +observe.records.emit("request", requestLeft, callLive); +// @ts-expect-error a call (settle) record is not a request record +observe.records.emit("request", {} as CallEvent, callLive); + // The bodies opt-in: an options bag on `subscribe`, accepted for any type -// (the channel is generic; meaningful for "call" today), and the facet on -// `observed` an emitter asks with. +// (the channel is generic; meaningful for "call" and "request" today), and +// the facet on `observed` an emitter asks with. observe.records.subscribe( "call", (event, live) => { From 0c662fd9a5d70350915e163996da542f9460b7a6 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Tue, 29 Sep 2026 01:44:55 -0700 Subject: [PATCH 2/2] fix(observe): the request record fills live on every settle; read at before reconstruction; document the shared handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the "request" record (#3708). 1. `live` is filled on every settle. `settle()` used to return before assigning `result`/`error`/`response` when no "call" listener existed at the call's start, leaving a "request" listener's object without the outcome. Now `live.response` (the transport's object — no clone without a "call" bodies opt-in), `live.result` or `live.error` are assigned on every settle, once, and only the "call" EMISSION stays gated on `observedCall`. 2. Shared `live.request` body reads once. `CallLive.request` JSDoc and the docs say the `Request` is one object shared by the call's "request" and "call" records, read the body through `live.request.clone()`. The live-identity test reads through a clone and checks the "call" listener still can (`bodyUsed === false`); a negative companion pins that a direct `text()` at the send leaves `bodyUsed === true` at settle. 3. Per-call gating, per-emission delivery. Gates are read once at the call's start; each record is delivered to the listeners installed when it fires. The doc line claiming a mid-call "call" listener hears nothing was wrong whenever another "call" listener existed at start. Docs, JSDoc, the changeset and the test names/comments now state it precisely; a new test subscribes a second "call" listener mid-call beside one at start and receives the settle (and a mid-call "request" listener nothing). Implementation unchanged. 4. `at` is read first in `request()`, before the `Request` reconstruction, so reconstruction time (which varies with whether some tool asked for bodies) is not in the `at - CallEvent.at` gap the docs attribute to serialization and `prepareRequest`. Nits: "request" listeners run synchronously before the send (a slow one delays the fetch and inflates `durationMs` — hand off to a microtask); `live` is one mutable object, treat it as read-only; the mixed-package-versions note covers "request" listeners; "left" means handed to `fetch` — a synchronously throwing `fetch` still emits "request" (JSDoc, docs, test); a throwing `prepareRequest` emits no "request" and one "call" with outcome error (test). client-records.spec.tsx 50 → 55; web client suite 1073 → 1078. Production artifacts byte-identical (everything behind IS_OBSERVE); all 12 size scenarios unchanged. Co-authored-by: Claude via Cursor Co-authored-by: Cursor --- .changeset/observe-request-record.md | 6 +- documentation/solid-2.0/08-dev-diagnostics.md | 9 +- packages/web/src/observe.ts | 131 +++++++++++------ packages/web/test/client-records.spec.tsx | 133 +++++++++++++++++- 4 files changed, 222 insertions(+), 57 deletions(-) diff --git a/.changeset/observe-request-record.md b/.changeset/observe-request-record.md index 7c1a5fcbe..c9bed5290 100644 --- a/.changeset/observe-request-record.md +++ b/.changeset/observe-request-record.md @@ -4,7 +4,7 @@ New **`"request"` record** on `OBSERVE.records` (`CallRequestEvent`, `CallRequestListener`; augmenting `HostRecordTypes` through `solid-js` beside `"call"`): one per server-function request the browser **sent**, delivered at the send — after argument serialization and `prepareRequest`, immediately before the transport's `fetch` receives the request — so a network panel can show a call that is still in flight, or one that never settles, which the `"call"` record (delivered at settle) cannot. `{ side: "client", id, name?, at, method: "GET" | "POST", origin? }`: `id`, `name`, `method` and `origin` as on the call's `CallEvent`; `at` is `performance.now()` at the send (not the call's dispatch — `CallEvent.at` is when the call was made, and the gap is what building the request cost); `side` is the client's today, the server half (the request arrived, ahead of its `"invocation"`) additive later, the way `"frame"` has two halves. -- **`CallLive` is one object across a call's records** — documented and pinned: the `live` handed to the `"request"` listener is, by identity, the `live` handed to the `"call"` listener at settle, filled in as the call proceeds (`args` from the start, `request` at the send, `response` and `result`/`error` at settle). An in-process consumer joins a call's request to its settle by identity — the way `origin` joins a record to the interaction's — with no id-plus-time join and no sequence number. -- **Gating**, every gate read once at the call's start: the observation exists when either `"call"` or `"request"` is observed; `"request"` is delivered only when `observed("request")`, `"call"` only when `observed("call")` (explicit now — a `"request"` listener alone gets no settle record, not even a `"call"` listener that subscribed mid-call). `bodies` on a `"request"` subscription governs `live.request` alone: the request is reconstructed when `observed("call", "bodies") || observed("request", "bodies")`, set on `live` before the `"request"` record is delivered; the response clone stays the `"call"` listener's opt-in. -- **What emits nothing**: a call that failed before its request was built (argument serialization threw) emits no `"request"` — nothing was sent — only its `"call"` settle; a call whose fetch never settles emits `"request"` only; a call an integration answered locally (`intercept`) emits neither. A deferred/streaming result emits `"request"` at the send and `"call"` at handoff, as today. A throwing `"request"` listener is reported and cannot break the call. +- **`CallLive` is one object across a call's records** — documented and pinned: the `live` handed to the `"request"` listener is, by identity, the `live` handed to the `"call"` listener at settle, filled in as the call proceeds (`args` from the start, `request` at the send, `response` and `result`/`error` at settle — on every settle, whether or not a `"call"` record goes out, so a `"request"` listener alone reads the outcome off the object it holds). An in-process consumer joins a call's request to its settle by identity — the way `origin` joins a record to the interaction's — with no id-plus-time join and no sequence number. It is one mutable object and one `Request` shared by both records: treat it as read-only, and read the request's body through `live.request.clone()` so the other record's listener still can. +- **Gated per call, delivered per emission**: every gate is read once at the call's start — the observation exists when either `"call"` or `"request"` is observed; a `"request"` record is built only if `observed("request")` and a `"call"` record only if `observed("call")` were true then (explicit now — a `"request"` listener alone gets no settle record). Each record is delivered to the listeners installed when it fires, so a listener subscribing mid-call can receive a `"request"` and never that call's `"call"`, or a `"call"` without its `"request"`. `bodies` on a `"request"` subscription governs `live.request` alone: the request is reconstructed when `observed("call", "bodies") || observed("request", "bodies")`, set on `live` before the `"request"` record is delivered; the response clone stays the `"call"` listener's opt-in. `at` is read before the reconstruction, so its cost is not in the send's timestamp. +- **What emits nothing**: a call that failed before its request was built (argument serialization or `prepareRequest` threw) emits no `"request"` — nothing was handed to `fetch` — only its `"call"` settle; a call whose fetch never settles emits `"request"` only; a call an integration answered locally (`intercept`) emits neither. A `fetch` that throws synchronously still emits `"request"` ("left" means handed to `fetch`). A deferred/streaming result emits `"request"` at the send and `"call"` at handoff, as today. A throwing `"request"` listener is reported and cannot break the call; listeners run synchronously before the send, so a slow one delays the fetch — hand work off to a microtask. - Production artifacts are byte-identical: everything lives behind `IS_OBSERVE`. Not in the diagnostics capture's `RECORD_TYPES` (capture artifacts keep settled summaries only); no performance-tracks change. diff --git a/documentation/solid-2.0/08-dev-diagnostics.md b/documentation/solid-2.0/08-dev-diagnostics.md index bd084491a..62e2727ca 100644 --- a/documentation/solid-2.0/08-dev-diagnostics.md +++ b/documentation/solid-2.0/08-dev-diagnostics.md @@ -778,7 +778,7 @@ OBSERVE.records.subscribe("call", (event, live) => { … }, { bodies: true }); OBSERVE.records.observed("call", "bodies"); // true while a listener asked for bodies ``` -The channel is `@solidjs/signals`'s, created once per **process** and registered on `globalThis` under `Symbol.for("@solidjs/signals/observe/records")`. Two consequences an observer can rely on: it exists as soon as `import { OBSERVE } from "solid-js"` (or from the core) resolves — an APM's `init()` can subscribe before the runtimes that emit have loaded, and without importing them — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set across both copies. The channel is created by whichever copy touches the key first, and its capabilities are that copy's — the packages version together, and with two copies at different versions the behaviour falls back to the older one's (a copy from before the `bodies` option, reached first, knows no bodies set and takes bodies for every `"call"` listener). The same registration is how the wire layers emit: `@solidjs/web`'s server-function client is bundled without a framework import (a router or a non-Solid caller can use it), so it reaches the channel by the registered name rather than importing `solid-js`. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod artifacts fold the channel and every emit site out, and an emitter with no listener reads no clock. Listeners are observers: a throwing listener is reported through `console.error` and the call, the render, the stream and the other listeners are unaffected; nothing a listener does reaches the result. Delivery allocates nothing — the channel keeps one listener array per type, replaced (never mutated) on subscribe and unsubscribe, so an emit in progress finishes over the array it started with and a listener unsubscribing mid-delivery neither skips nor double-calls anyone that round. A subscription is the channel's, not any emitter's: it outlives the attribution engine's `enable()`/`disable()` cycles and is dropped only by the function `subscribe` returned. This is the seam for tooling that watches the app — APM adapters, devtools — and deliberately not a policy hook: `configureServerFunctionsServer({ wrapInvocation })` remains the single, last-writer-wins wrap around execution for code that must **change** a call, and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there. +The channel is `@solidjs/signals`'s, created once per **process** and registered on `globalThis` under `Symbol.for("@solidjs/signals/observe/records")`. Two consequences an observer can rely on: it exists as soon as `import { OBSERVE } from "solid-js"` (or from the core) resolves — an APM's `init()` can subscribe before the runtimes that emit have loaded, and without importing them — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set across both copies. The channel is created by whichever copy touches the key first, and its capabilities are that copy's — the packages version together, and with two copies at different versions the behaviour falls back to the older one's (a copy from before the `bodies` option, reached first, knows no bodies set and takes bodies for every `"call"` listener — and for every `"request"` listener, since its `observed(type, "bodies")` ignores the facet). The emitters are the runtimes', so a listener for a record the loaded runtime predates hears nothing: the channel is generic and takes a `"request"` subscription from any copy, but only a `@solidjs/web` from after the record emits one — a newer `solid-js` beside an older `@solidjs/web` gives a `"request"` listener silence, not an error. The same registration is how the wire layers emit: `@solidjs/web`'s server-function client is bundled without a framework import (a router or a non-Solid caller can use it), so it reaches the channel by the registered name rather than importing `solid-js`. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod artifacts fold the channel and every emit site out, and an emitter with no listener reads no clock. Listeners are observers: a throwing listener is reported through `console.error` and the call, the render, the stream and the other listeners are unaffected; nothing a listener does reaches the result. Delivery allocates nothing — the channel keeps one listener array per type, replaced (never mutated) on subscribe and unsubscribe, so an emit in progress finishes over the array it started with and a listener unsubscribing mid-delivery neither skips nor double-calls anyone that round. A subscription is the channel's, not any emitter's: it outlives the attribution engine's `enable()`/`disable()` cycles and is dropped only by the function `subscribe` returned. This is the seam for tooling that watches the app — APM adapters, devtools — and deliberately not a policy hook: `configureServerFunctionsServer({ wrapInvocation })` remains the single, last-writer-wins wrap around execution for code that must **change** a call, and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there. The types layer the way the packages do, each augmenting only the one beneath it: `@solidjs/signals` declares `RecordTypes`, extending `HostRecordTypes`, with the attribution engine's ten entries on it directly — the engine ships in that package, behind its own entry — `rerun`, `create`, `effect`, `flush`, `flight`, `fallback`, `interaction`, `hold`, `navigation`, `graph`; `solid-js` augments `RecordTypes` with its records (`"boundary"`, `"recovery"`); `@solidjs/web` augments `HostRecordTypes`, through `declare module "solid-js"`, with what it emits (`"invocation"`, `"render"`, `"call"`, `"request"`, `"frame"`). So `OBSERVE.records.subscribe(…)` types with the engine's records and every loaded runtime's from a single `solid-js` import, and each interface has exactly one augmenter (TypeScript merges an augmentation onto the declaration its alias resolves to; two packages augmenting one interface through different aliases would not both land). Seven runtime records so far, beside the engine's ten (described under [Run attribution](#run-attribution--why-did-this-run); none is emitted until `attribution.enable()`). `OBSERVE.records.observed(type)` is the one gate an emitter of either kind consults before building a record. @@ -832,7 +832,7 @@ const off = OBSERVE.records.subscribe("call", (event, live) => { One record per call, delivered when the caller's await settles: `durationMs` is the request built and sent, the response received and decoded (or claimed by the configured `responseHandler`) — the whole wait the caller saw — so against the server's `"invocation"` of the same `id` the difference is the wire. `method` is `"GET"` for a GET-encoded read (`GET(fn)`), `"POST"` otherwise; `status` is the response's HTTP status once one arrived and absent when the fetch itself rejected (`live.response` likewise). `name` is the function's source name from the reference's metadata (`ServerFunctionMetadata.name`): the compiled function's name, which the compiler seeds in development builds only, or an explicit label (`withMeta`, the `name` argument to `createServerReference`), which survives to production — a label for a panel, not an identity: not unique, and absent when the reference carries none. `outcome: "error"` carries the value as thrown to the caller — a decoded server error, or the transport's own failure — in `live.error`. `deferred: true` marks a streaming result (a `live()` source, a generator), timed to handoff. A call an integration answered locally (a handler's `intercept`) made no request and emits nothing. Emitted by the observe and dev artifacts of the server-function client (`@solidjs/web/server-functions/client`, the `observe`/`development` export conditions). -The live handles are a body viewer's (a devtools network panel), and are taken only for a listener that **asked for them** — `OBSERVE.records.subscribe("call", fn, { bodies: true })`: each costs the call a reconstruction and a transient double-buffer of the payload, which a consumer that reads ids, statuses and timings (an APM adapter, the performance tracks) never pays. Whether bodies are taken is read once, as the call starts (`records.observed("call", "bodies")` — and, for the request alone, `records.observed("request", "bodies")`, see the `"request"` record below), from the union of the listeners installed — so a listener that did not ask, sharing a call with one that did, receives the same `live` as it does, the clone included. The `live` is **one object per call**, across its records: the `CallLive` the `"call"` listener receives at settle is, by identity, the one the call's `"request"` listener received at the send, filled in as the call proceeded (`args` from the start, `request` at the send, `response` and `result`/`error` at settle) — so an in-process consumer joins the two the way `origin` joins a record to the interaction's, by identity, with no id-plus-time match and no sequence number. The option belongs to the listener function: it is read at the function's first subscription to the type, subscribing the same function again changes nothing (one entry per function), and either disposer removes it. With no such listener `live.request` is absent and `live.response` is the transport's own object — status and headers readable, body consumed by its decode. With the opt-in, `live.request` is the request as dispatched — the final url and `RequestInit`, transport headers and the `prepareRequest` hook applied — built into a `Request` of the listener's own at the send, so its headers and body are readable in full without touching what the transport sent. It carries the body only when the body has a shape a second `Request` can hold without a competing consumer — a `string`, `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a view of one — and is built without it otherwise (a `ReadableStream` or an async iterable, the transport's streaming-upload contract: reconstructing one would consume it ahead of the send). It is absent when the call failed before the request was built (argument serialization threw), when the address is relative and there is no `location` to resolve it against (absent beats a URL that was never sent), and when the reconstruction itself fails (an init the `Request` constructor rejects but the configured `fetch` tolerates — an invalid header name, say): nothing taken for the record may fail the call, and nothing is logged. `live.response` is then a `clone()` taken as the response arrived, before the transport's decode, so its body is whole and unread while the caller still gets its result from the original — except where the clone would be a branch nobody drains, where it is the transport's own object: an event-stream response (a `live()` source, `text/event-stream` — a connection that stays open for the page's life, whose clone would buffer every event ever sent), a deferred result (`deferred: true` — a generator's stream, served for the stream's life; its clone, taken before the result's shape was known, is cancelled at settle so its branch stops buffering), and a response the `clone()` refused (one a configured `fetch` handed over already read, claimed by the `responseHandler`). A response the `responseHandler` claimed whose result is not a deferred body — a non-live `application/x-frame-stream` frame render the frames transport claims, say — keeps its clone, so under the opt-in the clone's branch buffers that render until the listener reads it or drops the record; live frames are `text/event-stream` and are never cloned. +The live handles are a body viewer's (a devtools network panel), and are taken only for a listener that **asked for them** — `OBSERVE.records.subscribe("call", fn, { bodies: true })`: each costs the call a reconstruction and a transient double-buffer of the payload, which a consumer that reads ids, statuses and timings (an APM adapter, the performance tracks) never pays. Whether bodies are taken is read once, as the call starts (`records.observed("call", "bodies")` — and, for the request alone, `records.observed("request", "bodies")`, see the `"request"` record below), from the union of the listeners installed — so a listener that did not ask, sharing a call with one that did, receives the same `live` as it does, the clone included. The `live` is **one object per call**, across its records: the `CallLive` the `"call"` listener receives at settle is, by identity, the one the call's `"request"` listener received at the send, filled in as the call proceeded (`args` from the start, `request` at the send, `response` and `result`/`error` at settle — filled on **every** settle, whether or not a `"call"` record goes out, so a `"request"` listener holding it reads the outcome off it too) — so an in-process consumer joins the two the way `origin` joins a record to the interaction's, by identity, with no id-plus-time match and no sequence number. It is one **mutable** object shared by both records' listeners: treat it as read-only — a write shows up on the other record's listener. The same goes for `live.request`: one `Request` for the call, shared by the two records, and a body reads once — a listener that wants the body reads it through `live.request.clone()` (`live.request.clone().text()`) so the other record's listener still can; a direct `live.request.text()` leaves `bodyUsed` true for whoever reads next. The option belongs to the listener function: it is read at the function's first subscription to the type, subscribing the same function again changes nothing (one entry per function), and either disposer removes it. With no such listener `live.request` is absent and `live.response` is the transport's own object — status and headers readable, body consumed by its decode. With the opt-in, `live.request` is the request as dispatched — the final url and `RequestInit`, transport headers and the `prepareRequest` hook applied — built into a `Request` of the listener's own at the send, so its headers and body are readable in full without touching what the transport sent. It carries the body only when the body has a shape a second `Request` can hold without a competing consumer — a `string`, `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a view of one — and is built without it otherwise (a `ReadableStream` or an async iterable, the transport's streaming-upload contract: reconstructing one would consume it ahead of the send). It is absent when the call failed before the request was built (argument serialization threw), when the address is relative and there is no `location` to resolve it against (absent beats a URL that was never sent), and when the reconstruction itself fails (an init the `Request` constructor rejects but the configured `fetch` tolerates — an invalid header name, say): nothing taken for the record may fail the call, and nothing is logged. `live.response` is then a `clone()` taken as the response arrived, before the transport's decode, so its body is whole and unread while the caller still gets its result from the original — except where the clone would be a branch nobody drains, where it is the transport's own object: an event-stream response (a `live()` source, `text/event-stream` — a connection that stays open for the page's life, whose clone would buffer every event ever sent), a deferred result (`deferred: true` — a generator's stream, served for the stream's life; its clone, taken before the result's shape was known, is cancelled at settle so its branch stops buffering), and a response the `clone()` refused (one a configured `fetch` handed over already read, claimed by the `responseHandler`). A response the `responseHandler` claimed whose result is not a deferred body — a non-live `application/x-frame-stream` frame render the frames transport claims, say — keeps its clone, so under the opt-in the clone's branch buffers that render until the listener reads it or drops the record; live frames are `text/event-stream` and are never cloned. `origin` is what the call ran for, when the attribution engine is enabled and knows: the interaction whose handler made it (`{ kind: "interaction", name: "click", target, at }`), the navigation whose data needed it (`{ kind: "navigation", name: "/users/:id", …, interaction }` — a `createAsync` calling the server inside the recompute the location write caused), an effect or action frame, an async landing's recompute calling again. It is read at dispatch through `OBSERVE.attribution.currentOrigin()` and is the engine's **own** origin object — the same one `InteractionEvent.origin`, `NavigationEvent.origin` and `HoldEvent.origin`/`.interaction` carry — so an observer puts the call under the interaction's record by identity, not by a time window. A call after an `await` in a handler carries none (the escape a write there has); without an engine the field is absent. @@ -841,11 +841,12 @@ The **`"request"` record** (from `@solidjs/web`, client) is one server-function ```js const off = OBSERVE.records.subscribe("request", (event, live) => { // event: { side: "client", id, name?, at, method: "GET" | "POST", origin? } - // live: the call's own CallLive — { args, request? } now; response, result | error at settle + // live: the call's own CallLive, shared with its "call" record — { args, request? } now; + // response, result | error filled in at settle (read-only; read the body via request.clone()) }); ``` -It exists because the `"call"` record is delivered at settle, and a network panel reading it alone has nothing to show for a call still in flight, and nothing ever for one that hung: this record is the pending row. One per request **sent**, delivered from the transport's send — after the arguments were serialized and `prepareRequest` had its say, immediately before the configured `fetch` receives the final address and `RequestInit`. `id`, `name`, `method` and `origin` are the call's, by the same rules as `CallEvent`'s (`origin` is the same object — read at dispatch, where the handler's frame is still open; by the send an async `prepareRequest` may have closed it). `at` is `performance.now()` **at the send**, not at the call: `CallEvent.at` is when the call was made, and the gap between the two is what building the request cost (serialization, the hook's await); the call's span always covers it (`CallEvent.at + durationMs ≥` the request's `at`). Nothing of the settle is on it — no `durationMs`, `outcome` or `status`; those are the `"call"` record's. The join to that record is the **`live`**: the object handed here is the object handed to the `"call"` listener at settle (see above), so a panel keeps the pending row by it and fills the row in when the settle record arrives with the same object — no id-plus-time match (a function called twice in flight is two live objects), no sequence number. A `"request"` listener's own `{ bodies: true }` governs `live.request`: the request is reconstructed when either a `"call"` or a `"request"` listener asked (`observed("call", "bodies") || observed("request", "bodies")`), under the same rules as above, and is set on `live` **before** this record is delivered, so the listener reads headers and body at the send; the response clone stays the `"call"` listener's opt-in alone. A reconstruction that failed leaves `live.request` absent and the record is delivered regardless — the request was sent either way. What emits nothing: a call that failed before its request was built (argument serialization threw) sent nothing and emits no `"request"`, only its `"call"` settle with `outcome: "error"`; a call an integration answered locally (a handler's `intercept`) emits neither. A call whose fetch never settles emits `"request"` alone; a deferred or streaming result emits `"request"` at the send and `"call"` at handoff. Every gate is read once, as the call starts: the observation exists when either record is observed, `"request"` is delivered only while `observed("request")` and `"call"` only while `observed("call")` were true then — a `"call"` listener subscribing mid-call hears nothing of that call. `side` says which end recorded it: only `"client"` exists today — the server half (the request **arrived**, ahead of its `"invocation"`) is additive later, `side: "server"`, the way the `"frame"` record has two halves. A throwing listener is reported and cannot break the call. Emitted by the observe and dev artifacts of the server-function client, beside the `"call"` record; not part of the diagnostics capture's tables (`@solidjs/diagnostics` keeps settled summaries only). +It exists because the `"call"` record is delivered at settle, and a network panel reading it alone has nothing to show for a call still in flight, and nothing ever for one that hung: this record is the pending row. One per request **handed to `fetch`**, delivered from the transport's send — after the arguments were serialized and `prepareRequest` had its say, immediately before the configured `fetch` receives the final address and `RequestInit`. "Left" means handed over, not that it reached the network: a `fetch` that throws **synchronously** still has its `"request"`, and a `"call"` with `outcome: "error"` behind it. `id`, `name`, `method` and `origin` are the call's, by the same rules as `CallEvent`'s (`origin` is the same object — read at dispatch, where the handler's frame is still open; by the send an async `prepareRequest` may have closed it). `at` is `performance.now()` **at the send**, not at the call — read before the request is reconstructed for `live.request`, so that cost (which varies with whether some tool asked for bodies) is not in it: `CallEvent.at` is when the call was made, and the gap between the two is what building the request cost (serialization, the hook's await); the call's span always covers it (`CallEvent.at + durationMs ≥` the request's `at`). Nothing of the settle is on it — no `durationMs`, `outcome` or `status`; those are the `"call"` record's. The join to that record is the **`live`**: the object handed here is the object handed to the `"call"` listener at settle (see above), so a panel keeps the pending row by it and fills the row in when the settle record arrives with the same object — no id-plus-time match (a function called twice in flight is two live objects), no sequence number. The object is filled at settle whether or not a `"call"` record is delivered (`response`, `result` or `error`; `response` is then the transport's own object — no clone is taken without a `"call"` listener's `bodies`), so a consumer with a `"request"` listener alone reads the outcome off the row it holds. It is one mutable object and one `Request`, shared by the two records: treat both as read-only, and read the request's body through `live.request.clone()` so the other record's listener still can (see above). A `"request"` listener's own `{ bodies: true }` governs `live.request`: the request is reconstructed when either a `"call"` or a `"request"` listener asked (`observed("call", "bodies") || observed("request", "bodies")`), under the same rules as above, and is set on `live` **before** this record is delivered, so the listener reads headers and body at the send; the response clone stays the `"call"` listener's opt-in alone. A reconstruction that failed leaves `live.request` absent and the record is delivered regardless — the request was handed over either way. What emits nothing: a call that failed before its request was built (argument serialization threw, or `prepareRequest` did) handed nothing to `fetch` and emits no `"request"`, only its `"call"` settle with `outcome: "error"`; a call an integration answered locally (a handler's `intercept`) emits neither. A call whose fetch never settles emits `"request"` alone; a deferred or streaming result emits `"request"` at the send and `"call"` at handoff. **Gated per call, delivered per emission.** The observation is opened per call: every gate is read once, as the call starts — the observation exists when either record is observed, a `"request"` record is built only if `observed("request")` and a `"call"` record only if `observed("call")` were true then, and bodies are taken only if asked for then — so a subscription arriving or leaving mid-call cannot change what a call in flight takes. Each record is then delivered to the listeners installed the moment it fires (the channel's rule). The two together mean a listener subscribing mid-call can receive a call's `"request"` and never its `"call"` (it subscribed before the send while a `"request"` listener and no `"call"` listener was there at the start — the row looks like a hung fetch), or its `"call"` without having seen its `"request"` (it subscribed after the send while a `"call"` listener was there at the start); a consumer that must see a call whole installs both listeners before the call. Listeners run **synchronously**, on the call's path, before the send: a slow `"request"` listener delays the fetch and the time it spends is inside the call's `durationMs` — read what is needed and hand the work off to a microtask, the same guidance the engine's records give. `side` says which end recorded it: only `"client"` exists today — the server half (the request **arrived**, ahead of its `"invocation"`) is additive later, `side: "server"`, the way the `"frame"` record has two halves. A throwing listener is reported and cannot break the call. Emitted by the observe and dev artifacts of the server-function client, beside the `"call"` record; not part of the diagnostics capture's tables (`@solidjs/diagnostics` keeps settled summaries only). The **`"frame"` record** (from `@solidjs/web`, both sides) is one frame stream — a server component rendered to the frame transport ([RFC 11](11-server-components.md)) — from its `start` chunk to its `complete`, as **produced** on the server or as **applied** on the client, `side` saying which: diff --git a/packages/web/src/observe.ts b/packages/web/src/observe.ts index 45cbfa7ff..ae8f7b655 100644 --- a/packages/web/src/observe.ts +++ b/packages/web/src/observe.ts @@ -276,10 +276,14 @@ export interface CallEvent { * the call's records: the same `CallLive` is handed to the `"request"` * listener at the send and to the `"call"` listener at settle, filled in * as the call proceeds (`args` from the start, `request` at the send, - * `response` and `result`/`error` at settle), so an in-process consumer + * `response` and `result`/`error` at settle — filled on every settle, + * whether or not a `"call"` record goes out, so a `"request"` listener + * holding it reads the outcome off it too), so an in-process consumer * joins a call's request to its settle by identity — the way `origin` * joins a record to the interaction's — with no id-plus-time join and no - * sequence number. `error` is the value as thrown to the caller — a + * sequence number. One MUTABLE object shared by both records' listeners: + * treat it as read-only — a write shows up on the other record's + * listener. `error` is the value as thrown to the caller — a * decoded server error, or the transport's own failure. The bodies — * `request`, and `response` as an unread clone — are a body viewer's * (devtools' network panel), and are taken only while a listener asked for @@ -301,7 +305,12 @@ export interface CallLive { * `Request` of the listener's own at the send, so its headers and body * are readable in full and reading them touches nothing the transport * sent. Set before the `"request"` record is delivered, so that listener - * reads it too. Built WITH the body only when the body has a shape a + * reads it too — and it is ONE `Request`, shared by the call's + * `"request"` and `"call"` records: a body reads once, so a listener that + * wants it reads through `request.clone()` (`live.request.clone().text()`) + * and the other record's listener can still read it; a direct + * `live.request.text()` leaves `bodyUsed` true for whoever reads next. + * Built WITH the body only when the body has a shape a * second `Request` can hold without a competing consumer — `string`, * `URLSearchParams`, `FormData`, `Blob`, `ArrayBuffer` or a view of one * — and WITHOUT it otherwise (a `ReadableStream` or an async iterable, @@ -350,20 +359,29 @@ export type CallListener = (event: CallEvent, live: CallLive) => void; * One server-function request LEFT the browser — delivered on * `OBSERVE.records.subscribe("request", …)` at the send: after the * arguments were serialized and `prepareRequest` had its say, immediately - * before the transport's `fetch` is handed the request. The `"call"` - * record of the same call follows at settle; this one exists because that - * one cannot show a call that is still in flight, or one that never - * settles (a hung fetch) — a network panel's pending row. The two records - * share their `CallLive` by identity (see `CallLive`): the object handed - * here is the object handed to the `"call"` listener, so an in-process - * consumer joins them with no id-plus-time join and no sequence number. + * before the transport's `fetch` is handed the request. "Left" means + * HANDED TO `fetch`, not that it reached the network: a `fetch` that + * throws synchronously still has its `"request"` (and a `"call"` with + * `outcome: "error"`). The `"call"` record of the same call follows at + * settle; this one exists because that one cannot show a call that is + * still in flight, or one that never settles (a hung fetch) — a network + * panel's pending row. The two records share their `CallLive` by identity + * (see `CallLive`): the object handed here is the object handed to the + * `"call"` listener, so an in-process consumer joins them with no + * id-plus-time join and no sequence number. * - * Emitted only for a request that was sent: a call that failed before its - * request was built (argument serialization threw) emits no `"request"` — - * only its `"call"` settle; a call an integration answered locally (a - * handler's `intercept`) made no request and emits neither. A deferred or - * streaming result emits `"request"` at the send and `"call"` at handoff, - * as today. Serializable; `live.request` (under `bodies`) rides beside it. + * Emitted only for a request that was handed over: a call that failed + * before its request was built (argument serialization threw, or + * `prepareRequest` did) emits no `"request"` — only its `"call"` settle; a + * call an integration answered locally (a handler's `intercept`) made no + * request and emits neither. A deferred or streaming result emits + * `"request"` at the send and `"call"` at handoff, as today. Serializable; + * `live.request` (under `bodies`) rides beside it. + * + * Listeners run synchronously, on the call's path, BEFORE the send: a slow + * listener delays the fetch and the time it spends is inside the call's + * `durationMs`. Read what is needed and hand the work off to a microtask + * (the same guidance the engine's records give). */ export interface CallRequestEvent { /** @@ -615,12 +633,19 @@ export function observeCall( if (!IS_OBSERVE) return undefined; const channel = records(); if (channel === undefined) return undefined; - // Every gate is read ONCE, here, as the call starts — which records it - // delivers and whether bodies are taken for them — so a subscription - // that arrives or leaves mid-call cannot leave a call with a `"request"` - // and no `"call"` (or the reverse), or a record with a clone and no - // request. A `"call"` listener alone, a `"request"` listener alone, or - // both: the observation exists for either. + // The observation is opened PER CALL: every gate is read ONCE, here, as + // the call starts — which records it builds and whether bodies are taken + // for them — so a subscription that arrives or leaves mid-call cannot + // change what this call takes (a clone with no listener to read it, a + // reconstruction nobody asked for). DELIVERY is per emission: each record + // goes to the listeners installed the moment it fires (the channel's + // rule). So a listener subscribing mid-call can hear this call's + // `"call"` and not its `"request"` (it subscribed after the send, while + // a `"call"` listener was there at the start), or its `"request"` and + // never its `"call"` (it subscribed before the send, while a `"request"` + // listener and no `"call"` listener was there at the start — the row + // looks like a hung fetch). A `"call"` listener alone, a `"request"` + // listener alone, or both: the observation exists for either. const observedCall = channel.observed("call"); const observedRequest = channel.observed("request"); if (!observedCall && !observedRequest) return undefined; @@ -644,19 +669,28 @@ export function observeCall( let settled = false; return { request(url, init) { + // The send's clock, read FIRST — before the reconstruction below, so + // the record's `at` is the send and not the send plus what rebuilding + // the request cost (a cost that varies with whether some other tool + // asked for bodies): the gap `at - CallEvent.at` is what the docs + // attribute to serialization and `prepareRequest`, nothing of ours. + // Not read when nothing is subscribed to the record. + const at = observedRequest ? performance.now() : 0; // The send keeps its `(address, init)` shape — a configured `fetch` // does not branch on whether devtools are attached — so what the // listener gets is a reconstruction of the dispatched request, the // listener's own to read. Built BEFORE the record is delivered, so // the `"request"` listener finds it on `live`; a reconstruction that // failed leaves `live.request` absent and the record still goes out - // — the request was sent either way. + // — the request was handed to `fetch` either way. if (requestBodies) { const request = reconstructRequest(url, init); if (request !== undefined) live.request = request; } + // Delivery goes to the listeners installed NOW; the gate is the + // call's start's (see above). if (!observedRequest) return; - const event: CallRequestEvent = { side: "client", id, at: performance.now(), method }; + const event: CallRequestEvent = { side: "client", id, at, method }; if (name !== undefined) event.name = name; if (origin !== undefined) event.origin = origin; channel.emit("request", event, live); @@ -682,32 +716,39 @@ export function observeCall( settle(outcome, value) { if (settled) return; settled = true; - // The `"call"` gate as it stood at the call's start: with a `"request"` - // listener alone the observation exists for that record, and the - // settle delivers nothing — not to a `"call"` listener that arrived - // mid-call either (no clone was taken for it: `bodies` was false). + // The live is filled on EVERY settle, whether or not a `"call"` record + // goes out: it is the `"request"` listener's object too (see + // `CallLive`), and a panel holding the pending row by it reads the + // outcome off it when the row settles. Without a `"call"` listener + // at the call's start no clone was taken (`bodies` was false), so + // `response` is the transport's own object — nothing is taken here + // that the gates did not allow. Assigned once, here: a `"request"` + // listener never sees `response` swapped. + const deferred = outcome === "ok" && isDeferredBody(value); + if (outcome === "ok") live.result = value; + else live.error = value; + // A deferred result is a body the caller drives for the stream's life + // — the same open connection an event stream is, known only now that + // the decode handed the shape back — so the clone taken at arrival is + // released: its branch stops buffering what the caller reads, and the + // listener gets the transport's object. + if (deferred && clone !== undefined) { + cancelBody(clone); + clone = undefined; + } + if (clone !== undefined) live.response = clone; + else if (response !== undefined) live.response = response; + // The `"call"` EMISSION is gated by the call's start: with a + // `"request"` listener alone the observation exists for that record + // and no `"call"` record is built. Under the gate, delivery goes to + // the `"call"` listeners installed NOW — one that subscribed mid-call + // beside one that was there at the start hears this settle. if (!observedCall) return; const event: CallEvent = { id, at, durationMs: performance.now() - at, method, outcome }; if (name !== undefined) event.name = name; if (response !== undefined) event.status = response.status; if (origin !== undefined) event.origin = origin; - if (outcome === "ok") { - live.result = value; - if (isDeferredBody(value)) { - event.deferred = true; - // A deferred result is a body the caller drives for the stream's - // life — the same open connection an event stream is, known only - // now that the decode handed the shape back — so the clone taken - // at arrival is released: its branch stops buffering what the - // caller reads, and the listener gets the transport's object. - if (clone !== undefined) { - cancelBody(clone); - clone = undefined; - } - } - } else live.error = value; - if (clone !== undefined) live.response = clone; - else if (response !== undefined) live.response = response; + if (deferred) event.deferred = true; channel.emit("call", event, live); } }; diff --git a/packages/web/test/client-records.spec.tsx b/packages/web/test/client-records.spec.tsx index f8f9cb7d8..0f02ea156 100644 --- a/packages/web/test/client-records.spec.tsx +++ b/packages/web/test/client-records.spec.tsx @@ -785,9 +785,12 @@ describe("the request record", () => { await vi.waitFor(() => expect(left).toHaveLength(1)); const { live } = left[0]; // Reconstructed BEFORE the record was delivered: the request listener - // reads it, and the transport's send is untouched by the read. + // reads it, and the transport's send is untouched by the read. ONE + // `Request` for both records, so the body is read through a clone — + // the documented way — and stays whole for the "call" listener. expect(live.request).toBeInstanceOf(Request); - expect(await live.request!.text()).toBe(wire.calls[0].init.body); + expect(await live.request!.clone().text()).toBe(wire.calls[0].init.body); + expect(live.request!.bodyUsed).toBe(false); expect(live.response).toBeUndefined(); wire.answer(jsonResponse({ n: 2 })); @@ -799,6 +802,59 @@ describe("the request record", () => { expect(live.response!.bodyUsed).toBe(false); expect(live.result).toEqual({ n: 2 }); expect(live.request).toBe(left[0].live.request); + // The "call" listener still reads the shared request's body in full. + expect(settled[0].live.request!.bodyUsed).toBe(false); + expect(await settled[0].live.request!.text()).toBe(wire.calls[0].init.body); + }); + + test("the shared request's body reads once: read directly at the send, it is spent for the call listener", async () => { + const left = requests({ bodies: true }); + const settled = calls(); + vi.stubGlobal("fetch", async () => jsonResponse(1)); + // The documented consequence of the shared handle: a listener that + // reads `live.request.text()` rather than `live.request.clone().text()` + // leaves the other record's listener a consumed body. + unsubscribes.push( + OBSERVE!.records.subscribe("request", (_event, live) => { + void live.request!.text(); + }) + ); + await createServerReference("requests/spent")({ a: 1 }); + expect(left[0].live.request).toBe(settled[0].live.request); + expect(settled[0].live.request!.bodyUsed).toBe(true); + }); + + test("the live is filled on every settle — a request listener alone reads the outcome off it", async () => { + const left = requests(); + const wire = deferredFetch(); + const pending = createServerReference("requests/filled")({ a: 1 }); + await vi.waitFor(() => expect(left).toHaveLength(1)); + const { live } = left[0]; + expect(live.response).toBeUndefined(); + expect(live.result).toBeUndefined(); + + const answer = jsonResponse({ n: 3 }); + wire.answer(answer); + expect(await pending).toEqual({ n: 3 }); + // No "call" listener anywhere, no "call" record — and the object the + // request listener holds has the settle on it regardless: the + // transport's own response (no clone without a "call" bodies opt-in) + // and the result. + expect(live.response).toBe(answer); + expect(live.response!.bodyUsed).toBe(true); + expect(live.result).toEqual({ n: 3 }); + expect(live.error).toBeUndefined(); + + // And the error, for a call that failed. + const failure = new TypeError("network down"); + vi.stubGlobal("fetch", async () => { + throw failure; + }); + await expect(createServerReference("requests/filled-error")()).rejects.toBe(failure); + expect(left).toHaveLength(2); + expect(left[1].live.error).toBe(failure); + expect(left[1].live.result).toBeUndefined(); + expect(left[1].live.response).toBeUndefined(); }); test("bodies on the request subscription alone reconstructs live.request; a bodies-less call listener beside it gets the same", async () => { @@ -997,13 +1053,15 @@ describe("the request record", () => { } }); - test("a request listener alone: the observation exists, the request is delivered, and the call gate is fixed at the call's start", async () => { + test("a request listener alone: the observation exists, the request is delivered, and no call record is built — the gate is the call's start's", async () => { const left = requests(); const wire = deferredFetch(); const pending = createServerReference("requests/alone")(); await vi.waitFor(() => expect(left).toHaveLength(1)); - // A "call" listener arriving mid-call hears nothing of this call: the - // gate was read once, at the start, when there was none. + // The observation was opened with no "call" listener, so this call + // builds no "call" record: one arriving mid-call has nothing to hear — + // not because it arrived late, but because the gate was closed when + // the call started (the next test is the other case). const late = calls(); wire.answer(jsonResponse("ok")); expect(await pending).toBe("ok"); @@ -1016,6 +1074,71 @@ describe("the request record", () => { expect(late[0].live).toBe(left[1].live); }); + test("gated per call, delivered per emission: a listener subscribing mid-call hears the records that fire after it", async () => { + const left = requests(); + const settled = calls(); + const wire = deferredFetch(); + const pending = createServerReference("requests/mid-call")(); + await vi.waitFor(() => expect(left).toHaveLength(1)); + // The "call" gate was open at the start (`settled` was there), so the + // settle record is built — and delivered to whoever is subscribed the + // moment it fires: a second "call" listener that arrived after the + // send hears this call's settle without ever having seen its + // "request". A "request" listener arriving now hears nothing of this + // call: its record already fired. + const lateCalls = calls(); + const lateRequests = requests(); + wire.answer(jsonResponse("ok")); + expect(await pending).toBe("ok"); + expect(settled).toHaveLength(1); + expect(lateCalls).toHaveLength(1); + expect(lateCalls[0].live).toBe(left[0].live); + expect(lateCalls[0].event).toBe(settled[0].event); + expect(lateRequests).toHaveLength(0); + }); + + test("a throwing prepareRequest: nothing was handed to fetch, so no request record — one call record, outcome error", async () => { + const left = requests(); + const settled = calls(); + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const failure = new Error("no session"); + try { + configureServerFunctionsClient({ + prepareRequest: () => { + throw failure; + } + }); + await expect(createServerReference("requests/unprepared")()).rejects.toBe(failure); + expect(fetch).not.toHaveBeenCalled(); + expect(left).toHaveLength(0); + expect(settled).toHaveLength(1); + expect(settled[0].event.outcome).toBe("error"); + expect(settled[0].live.error).toBe(failure); + } finally { + resetClientConfig(); + } + }); + + test("a fetch that throws synchronously: the request was handed to fetch, so its record is delivered; the call says how it ended", async () => { + const left = requests(); + const settled = calls(); + const failure = new TypeError("refused to send"); + // Not a rejection — a throw from `fetch` itself. "Left" means handed + // to `fetch`, not that it reached the network. + vi.stubGlobal("fetch", () => { + throw failure; + }); + await expect(createServerReference("requests/sync-throw")()).rejects.toBe(failure); + expect(left).toHaveLength(1); + expect(left[0].event.id).toBe("requests/sync-throw"); + expect(settled).toHaveLength(1); + expect(settled[0].live).toBe(left[0].live); + expect(settled[0].event.outcome).toBe("error"); + expect(settled[0].event.status).toBeUndefined(); + expect(left[0].live.error).toBe(failure); + }); + test("a throwing request listener is reported; the call, its record and the other listeners are unaffected", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); try {