Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/server-boundary-records.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@solidjs/signals": patch
"solid-js": patch
"@solidjs/web": patch
---

Server boundary records on `OBSERVE.server.records` (observe/dev tiers)

- New `"boundary"` record: one per `<Loading>` boundary that waited during a server render, delivered when it settles — `{ id, at, durationMs, heldMs, passes, outcome: "settled" | "fallback" | "client" | "error", streamed, revealGroup?, ownerPath? }`, with the thrown error beside it. `id` pairs it with `SSR_RENDER_ERROR_CONTAINED`; `passes` counts render passes (a sequential chain reads as `3+`); under a `<Reveal>` group the record waits for the group's swap so `heldMs` measures how long finished content was held for its siblings. No clock is read without a listener, and the emitter folds out of prod.
- The server records channel is `OBSERVE.server.records.subscribe(type, listener)` — the server twin of `OBSERVE.attribution.subscribe(type, …)`. `OBSERVE.server.invocations` (unreleased) is renamed onto it: `subscribe("invocation", …)`. The `InvocationChannel` type is gone; `ServerRecords` is the channel's interface.
- Types now layer one augmenter per interface: `@solidjs/signals` declares `ServerObserve` empty; `solid-js` augments it with `records: ServerRecords` and `trace: ServerTrace`, declaring both; `@solidjs/web` augments those two through `"solid-js"`. `TraceSlot` in `@solidjs/web` is now an alias of `solid-js`'s `ServerTrace`. (Two augmentations of one re-exported interface through different module aliases merge order-dependently in TypeScript — one set was silently lost.)
- `@solidjs/signals` exports `ownerPath(subject)` — the root-first component-label walk its diagnostics already make — so a record's `ownerPath` and the finding it pairs with come from the one walk.
7 changes: 6 additions & 1 deletion documentation/plans/server-dev-build-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,12 @@ type/tooling reuse, with unused members no-op.
> `solid-js`, and populated (object at load, type by `declare module
"solid-js"` augmentation) by `@solidjs/web`'s server entries. First channel:
> `OBSERVE.server.invocations` (`subscribe("invocation", (event, live) =>
…)`), emitted from both server-function legs. Observe-tier only: web now
…)`), emitted from both server-function legs. _(Since C3, 2026-09-14: the
> channel is `OBSERVE.server.records`, generic over record type — the
> `"boundary"` record from solid-js's server entry joined the `"invocation"`
> one — and the objects and the `records`/`trace` members are solid-js's,
> with web augmenting solid-js's `ServerRecords`/`ServerTrace`; see
> `sentry-integration-plan.md` C3.)_ Observe-tier only: web now
> ships `dist/server.observe.js`, `server-functions/dist/server.observe.js`
> and `frames/dist/server.observe.js` under the `observe` condition (the P0
> plumbing, third flavour), and every emit site folds out of prod behind
Expand Down
25 changes: 20 additions & 5 deletions documentation/solid-2.0/08-dev-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -599,22 +599,37 @@ Each `DiagnosticEvent` has:

`OBSERVE` is one object per process, shared by every package that reads it, so it is also where the **server** runtime publishes what it has to observe. `OBSERVE.server` is an empty slot on the object `@solidjs/signals` ships; `solid-js`'s **server entry** fills it the moment it evaluates, with containers it registers once per process on `globalThis` (under `Symbol.for("solid-js/observe/server")`). Two consequences an observer can rely on: the slots exist as soon as `import { OBSERVE } from "solid-js"` resolves on the server — an APM's `init()` can subscribe or install a provider before `@solidjs/web` (which emits into them) has loaded, and without importing it — and a host that bundles the runtime into its server build and instruments through a `--import`ed module still finds one listener set and one provider across both copies. Same tiers as the rest of `OBSERVE`: present in dev and observe builds, absent in prod — the prod server artifacts fold the surface and every emit site out, so an observer that finds `OBSERVE === undefined` has nothing to subscribe to. On the client `OBSERVE.server` stays empty.

The type is an augmentable interface — `ServerObserve`, declared empty in `solid-js` and filled in by `@solidjs/web` through `declare module "solid-js"` — so `OBSERVE.server.invocations` types without `@solidjs/web` having to be the place `OBSERVE` is imported from. Observers import `OBSERVE` from `solid-js`, as on the client.
The types layer the way the packages do, each layer augmenting only the one beneath it: `ServerObserve` is declared empty in `@solidjs/signals`; `solid-js` augments it with its two members, `records: ServerRecords` and `trace: ServerTrace`, declaring both interfaces itself; `@solidjs/web` augments those two through `declare module "solid-js"` with what it emits (the `"invocation"` record) and what it defines (the trace provider). So `OBSERVE.server.records.subscribe(…)` types with both packages' records without `@solidjs/web` having to be the place `OBSERVE` is imported from. Observers import `OBSERVE` from `solid-js`, as on the client.

Two members so far. The first is the **server-function invocation channel**:
Two members. The first is the **records channel**, `OBSERVE.server.records` — the server twin of `OBSERVE.attribution.subscribe(type, …)`: a record is a completed, serializable summary of one thing the server did, delivered synchronously the moment it is complete, with the live handles an in-process observer may want passed beside it rather than on it. Subscribe by record type; the types available are whatever the loaded runtimes declared. Two so far.

The **`"boundary"` record** (from `solid-js`) is one `<Loading>` boundary that **waited** during a server render:

```js
import { OBSERVE } from "solid-js";

const off = OBSERVE.server.invocations.subscribe("invocation", (event, live) => {
const off = OBSERVE.server.records.subscribe("boundary", (event, live) => {
// event: { id, at, durationMs, heldMs, passes,
// outcome: "settled" | "fallback" | "client" | "error",
// streamed, revealGroup?, ownerPath? }
// live: { error? }
});
```

A boundary whose content rendered on its first pass emits nothing — there was no wait to attribute, the same rule as the client's `hold` records. For one that waited: `id` is the boundary's hydration id, the id the `SSR_RENDER_ERROR_CONTAINED` finding names in `data.boundary`, so a record and a finding pair by it; `at` is `performance.now()` at discovery; `durationMs` runs discovery → settle — to the content being complete, or to the decision that the server will not produce it; `passes` counts render passes over the content (the discovery pass plus one per wait — `2` is one round of async, more is a sequential chain, a read that depended on the answer to the previous one); `streamed` says whether the outcome reached the client after the shell had flushed (the user saw the fallback, then the swap) or in time to inline into it. `outcome` is how it ended: `"settled"` — rendered on the server and swapped in; `"fallback"` — the renderer had no stream to settle into (`renderToString`), the fallback shipped final and the client renders the content; `"client"` — the content is client-only (`ssrSource: "client"`); `"error"` — the content threw, `live.error` is the value as thrown, and the paired finding says where it went. Under a `<Reveal>` group the record names the group and waits for the group's reveal, so `heldMs` is real: how long the finished content sat behind its siblings (`order="together"`, a sequential tail) — `0` for every boundary whose swap was issued as it settled. `ownerPath` locates the boundary by component, the same labels the diagnostics carry. The cost is paid only with a listener installed: a boundary with none takes no clock readings.

The **`"invocation"` record** (from `@solidjs/web`) is one server-function execution:

```js
const off = OBSERVE.server.records.subscribe("invocation", (event, live) => {
// event: { id, direct, at, durationMs, outcome: "ok" | "error", deferred? }
// live: { event: RequestEvent, request?, args, result? | error? }
});
```

One record per call, delivered when the call **settles** — synchronously for a synchronous direct call, at resolution for a promise. `id` is the function's registered id; `direct` says whether this was an in-process SSR call (`true`, no `request`) or HTTP dispatch (`false`, `request` is the `Request` the handler dispatched). `outcome: "error"` carries the value **as thrown** in `live.error` — the sanitized `Error` the wire gets in production is the client's view, not the observer's. `deferred: true` marks a result the caller drives after the record (a stream or async generator): `durationMs` then measures to the handoff, not to the last chunk. Listeners are observers: a throwing listener is reported through `console.error` and the call and the other listeners are unaffected; nothing a listener does reaches the result. The record is the settled, serializable summary; the second argument holds the live handles beside it for observers that want them.
One record per call, delivered when the call **settles** — synchronously for a synchronous direct call, at resolution for a promise. `id` is the function's registered id; `direct` says whether this was an in-process SSR call (`true`, no `request`) or HTTP dispatch (`false`, `request` is the `Request` the handler dispatched). `outcome: "error"` carries the value **as thrown** in `live.error` — the sanitized `Error` the wire gets in production is the client's view, not the observer's. `deferred: true` marks a result the caller drives after the record (a stream or async generator): `durationMs` then measures to the handoff, not to the last chunk.

This is the seam for tooling that watches the server — 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 (guards, error mapping), and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there.
Listeners on the channel are observers: a throwing listener is reported through `console.error` and the render or call and the other listeners are unaffected; nothing a listener does reaches the result. This is the seam for tooling that watches the server — 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 (guards, error mapping), and an observer that installed itself there would either displace the host's policy or be displaced by it. Subscribe here, wrap there.

The second member is the **trace-provider slot**, `OBSERVE.server.trace`:

Expand Down
2 changes: 1 addition & 1 deletion documentation/solid-2.0/10-server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ The handler resolves the function id, decodes arguments, runs the function under

Inside a function body, `getRequestEvent()` (from `@solidjs/web`) reads the current event and `getServerFunctionInvocation()` reads the in-flight call’s id — usable for keying caches or logs. (Renamed from `getServerFunctionMeta` to keep clear of `getServerFunctionMetadata(fn)`, which reads a reference’s _static declaration_ metadata; the invocation accessor describes the call currently executing.) In-process SSR calls run the original function directly (no HTTP loopback) under a derived event marked `serverOnly`.

**Observing invocations (dev and observe builds).** Both legs — HTTP dispatch and direct SSR calls — report each execution on `OBSERVE.server.invocations`, the server half of the `OBSERVE` object described in [RFC 08](08-dev-diagnostics.md#observeserver--the-server-runtimes-observe-surface): `subscribe("invocation", (event, live) => …)` delivers one `{ id, direct, at, durationMs, outcome, deferred? }` record per call when it settles, with the request event, `request` (HTTP only), `args`, and the result or the error **as thrown** beside it. The measured execution is the `wrapInvocation`-wrapped run, so what the observer times is what the request spent on the call, policy included; a stream or generator result is flagged `deferred` and timed to handoff. This is the client’s `observeServerFunctionCalls` mirrored on the server: observers, any number, none able to change the call. Prod server artifacts carry neither the channel nor the emit sites. A listener that wants the trace a call belongs to reads `getTraceContext()` — in scope during delivery — and a function body forwards it downstream the same way (`getTraceContext()?.entries.traceparent`); the handler’s responses carry it back to the caller as `Server-Timing` (see [RFC 12](12-ssr-http.md#the-trace-the-request-belongs-to-gettracecontext)).
**Observing invocations (dev and observe builds).** Both legs — HTTP dispatch and direct SSR calls — report each execution as the `"invocation"` record on `OBSERVE.server.records`, the server records channel described in [RFC 08](08-dev-diagnostics.md#observeserver--the-server-runtimes-observe-surface): `subscribe("invocation", (event, live) => …)` delivers one `{ id, direct, at, durationMs, outcome, deferred? }` record per call when it settles, with the request event, `request` (HTTP only), `args`, and the result or the error **as thrown** beside it. The measured execution is the `wrapInvocation`-wrapped run, so what the observer times is what the request spent on the call, policy included; a stream or generator result is flagged `deferred` and timed to handoff. This is the client’s `observeServerFunctionCalls` mirrored on the server: observers, any number, none able to change the call. Prod server artifacts carry neither the channel nor the emit sites. A listener that wants the trace a call belongs to reads `getTraceContext()` — in scope during delivery — and a function body forwards it downstream the same way (`getTraceContext()?.entries.traceparent`); the handler’s responses carry it back to the caller as `Server-Timing` (see [RFC 12](12-ssr-http.md#the-trace-the-request-belongs-to-gettracecontext)).

`registerServerFunction(id, fn)` / `getServerFunction(id)` remain exported for integrations building custom dispatch or introspection. Registry _mutation_ as a userland extension pattern is rejected (see Alternatives).

Expand Down
24 changes: 18 additions & 6 deletions packages/signals/src/core/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,20 @@ export interface AttributionSlot {
/**
* The server runtime's observe surface — the one place a server-side
* consumer (an APM adapter's `init()`) installs on, beside `diagnostics`.
* Declared EMPTY here and typed by the runtime that owns the facts:
* `@solidjs/web`'s server entries augment this interface with their
* members (the server-function invocation channel, the trace-provider
* slot), so the core never learns those shapes and the consumer still finds
* everything on the one `OBSERVE`.
* Declared EMPTY here and typed by the runtime that owns the surface:
* `solid-js`'s server entry augments this interface with its members — the
* records channel (`ServerRecords`) and the trace-provider slot
* (`ServerTrace`), each an interface of its own that `@solidjs/web`'s
* server entries fill in further — so the core never learns those shapes
* and the consumer still finds everything on the one `OBSERVE`.
*
* One augmenter per interface, by design: TypeScript merges an
* augmentation into a re-exported interface by following the alias, and
* two augmentations reaching the same interface through DIFFERENT aliases
* (`"@solidjs/signals"` from solid-js, `"solid-js"` from web) merge
* order-dependently — one set is lost. So each layer augments only the
* layer beneath it, through one module name, and declares the interfaces
* the layer above fills in.
*
* The OBJECTS behind those members are not the core's either: the core has
* one artifact per tier for both platforms, and the client would carry
Expand Down Expand Up @@ -315,7 +324,10 @@ export const OBSERVE: Observe = __OBSERVE__
? {
diagnostics,
attribution: attributionSlot,
server: {},
// Replaced by solid-js's server entry (see `ServerObserve`); on the
// client the slot stays this placeholder. The cast: the interface is
// empty HERE and gains its members by augmentation downstream.
server: {} as ServerObserve,
subjectOf(event) {
return eventSubjects.get(event);
},
Expand Down
1 change: 1 addition & 0 deletions packages/signals/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export type {
export {
DEV,
OBSERVE,
ownerPath,
type AttributionSlot,
type Dev,
type Observe,
Expand Down
3 changes: 2 additions & 1 deletion packages/signals/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export {
clearSnapshots,
enforceLoadingBoundary,
enableExternalSource,
resetErrorHalt
resetErrorHalt,
ownerPath
} from "./core/index.js";
import { DEV as _DEV, OBSERVE as _OBSERVE, type Dev, type Observe } from "./core/index.js";
/**
Expand Down
14 changes: 14 additions & 0 deletions packages/solid/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ export type {
DiagnosticSeverity,
DiagnosticSubject
} from "@solidjs/signals";
// The server observe surface's types — the members solid-js declares onto
// `OBSERVE.server` (`records`, `trace`) and the record it emits — and with
// them the `ServerObserve` augmentation that module declares: the published
// types resolve to THIS entry under every condition, so this re-export is
// what puts the surface in an observer's program (and what `@solidjs/web`
// augments further, through `"solid-js"`). Type-only — the module's runtime
// never enters the client build.
export type {
BoundaryEvent,
BoundaryLive,
BoundaryListener,
ServerRecords,
ServerTrace
} from "./server/observe.js";
export type {
Acknowledgement,
AttributionRecords,
Expand Down
Loading
Loading