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
17 changes: 17 additions & 0 deletions .changeset/fix-get-grant-lifetime-and-read-folding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@solidjs/web": patch
---

Two server-function grant fixes (#3129, #3128). A `GET()` declaration now
dies with the binding it was made about: `registerServerFunction` revokes
the id's declared method when it rebinds the id to a different function,
so a mutation registered onto a once-declared id (an id collision, a
module re-evaluated in a live process after an edit dropped the wrapper)
no longer inherits GET dispatch and the origin-gate exemption — a function
that still declares GET re-runs `GET()` right after re-registering, which
re-arms the grant exactly when it is still meant. And the single-flight
request header is now honored on POST only, the server half of the
client's own rule: folding on a GET would put a second body — an envelope
carrying data computed from that caller's request — at a cacheable url
under whatever public Cache-Control the author wrote, with nothing naming
the variance, one curl away from a shared-cache poisoning.
26 changes: 26 additions & 0 deletions examples/effect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,32 @@ pending propagates to `<Loading>`, failures propagate to `<Errored>`, and
`latest`/`isPending` give stale-while-revalidate. That surface happens to line up with Effect's
execution model almost one-to-one.

## Why Solid specifically

The comparison that matters isn't Solid 1.x — it's that these seams don't exist in other
frameworks:

- **A place to put interruption.** Solid consumes AsyncIterables as first-class computation
values, and a superseded flight's iterator gets `it.return()`. That protocol hook is the entire
cancellation bridge (`return()` → `Fiber.interrupt`). React's `use()` and query libraries can
render promise states, and Svelte's async `$derived` covers loading/error propagation — but
both simply _drop_ a stale promise. There is no lifecycle moment that says "this producer is
now unwanted," so Effect's structured interruption has nothing to attach to short of hand-wired
`AbortController`s. Wasted retries, open resources, and server load are invisible and
unrecoverable by design.
- **A transaction to put steps in.** `action` runs a generator as a transaction with atomic
per-step commits, and `createOptimistic` writes revert on failure. That is the half of a saga
Effect cannot provide: Effect can compensate the server, but it cannot roll back your UI.
React's `useOptimistic` is per-hook state, not a multi-step transaction; Svelte has no
counterpart. Without it, "cancel mid-checkout" means hand-written undo logic no matter how good
the effect system is.
- **Matching execution models.** Solid flights and Effect fibers are both structured and
pull-based — they start, supersede, and dispose on the same schedule, and both suspend on
generator yields. That's why the integration is protocol-level (async iteration on the read
path, `yield*` delegation on the action path) rather than a binding library: neither side wraps
or schedules the other. Frameworks whose unit of work is "re-render the component" need the
atom/registry layer precisely because their lifecycle and Effect's don't line up anywhere.

## Read path — typeahead (`runEffect`)

```tsx
Expand Down
31 changes: 28 additions & 3 deletions packages/web/server-functions/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,17 @@ export function registerServerFunction<T extends any[], R>(

export function registerServerFunction(id, callback) {
provideRPC();
// A `GET()` declaration is made ABOUT a function, not about an id — it
// grants GET dispatch and the origin-gate exemption (#3114), and both
// must die with the binding they were granted to. Rebinding the id to a
// different function (an id collision between integrations, a module
// re-evaluated in a live process after an edit dropped the wrapper)
// otherwise leaves the grant governing a function that never signed it:
// a mutation reachable over GET, from any origin, with ambient cookies
// (#3129). A function that still declares GET re-runs `GET()` right
// after re-registering — module order guarantees it — so the grant
// re-arms itself exactly when it is still meant.
if (REGISTRATIONS.get(id) !== callback) METHODS.delete(id);
REGISTRATIONS.set(id, callback);
return callback;
} /**
Expand Down Expand Up @@ -909,6 +920,11 @@ export function GET<A extends readonly any[], R>(
* is executable from any origin with the user's ambient cookies — it must
* be a safe read in the RFC 9110 §9.2.1 sense.
*
* The declaration is about the FUNCTION, not the id: registering a
* different function under the same id revokes it (#3129), and the new
* function's own `GET()` — which module order runs right after the
* re-registration — is what re-grants it.
*
* Wrap the reference at its declaration; the compiler round-trips the call
* in both builds:
*
Expand Down Expand Up @@ -1756,8 +1772,7 @@ export function serializeResponseStream(value, codecOptions, signal) {
// first would park the next pull on a resolver nobody will ever
// call, stranding the codec's pump. `finished` is re-read after
// the wait for the same reason from the other direction.
next: () =>
finished || wantsMore() ? Promise.resolve(step()) : awaitDemand().then(step)
next: () => (finished || wantsMore() ? Promise.resolve(step()) : awaitDemand().then(step))
};
}
};
Expand Down Expand Up @@ -2409,7 +2424,17 @@ export async function handleServerFunctionRequest(request, options = {}) {
// advertised sources run: a client that never subscribed a source's
// consumer never pays for its collection, and an id naming no registered
// hook simply does not fold.
const flightHeader = scripted ? request.headers.get(SINGLE_FLIGHT_HEADER) : null;
//
// POST only — the server half of the client's own rule (client.ts: reads
// "stay plain — folding per-request flight data into them would defeat
// caching"). A GET is a cacheable URL, and folding on it would put two
// bodies at one cache key — the plain value and an envelope carrying
// data the hook computed from THAT caller's request — with nothing
// naming the variance, under whatever public Cache-Control the author
// wrote (#3128). The shipped client never sends the header on a read;
// honoring it from anyone else hands a curl one shared-cache poisoning.
const flightHeader =
scripted && method === "POST" ? request.headers.get(SINGLE_FLIGHT_HEADER) : null;
const flightHooks = flightHeader
? flightHeader.split(",").flatMap(source => {
const hook = source === "true" ? flightHook : flightSources.get(source);
Expand Down
70 changes: 70 additions & 0 deletions packages/web/test/server/server-functions-http-hygiene.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,76 @@ describe("server-function method allowlist (#3069)", () => {
expect(calls).toBe(2);
});

it("a GET declaration dies with the binding it was made about (#3129)", async () => {
// The declaration grants two things — GET dispatch and the origin-gate
// exemption (#3114) — and both were granted to a FUNCTION. Rebinding
// the id (a collision between integrations, a module re-evaluated in a
// live process after an edit dropped the wrapper) must revoke them:
// otherwise the function now answering to the id inherits a grant it
// never signed, and a mutation becomes reachable over GET, from any
// origin, with the user's ambient cookies.
const read = vi.fn(async () => "READ");
declareGET("hygiene-rebind", read);
const before = await handleServerFunctionRequest(readRequest("hygiene-rebind", "GET"), {
provideEvent
});
expect(before.status).toBe(200);
expect(read).toHaveBeenCalledTimes(1);

// the id changes hands
const mutation = vi.fn(async () => "MUTATED");
registerServerFunction("hygiene-rebind", mutation);

// both grants are gone, in gate order: a bare GET — the exact request
// the stale grant used to answer — now meets the re-armed origin gate
// (403), and a same-origin GET gets past it only to find the method
// allowlist no longer advertising the reads (405)
const bare = await handleServerFunctionRequest(readRequest("hygiene-rebind", "GET"), {
provideEvent
});
expect(bare.status).toBe(403);
const sameOrigin = await handleServerFunctionRequest(
readRequest("hygiene-rebind", "GET", { "Sec-Fetch-Site": "same-origin" }),
{ provideEvent }
);
expect(sameOrigin.status).toBe(405);
expect(sameOrigin.headers.get("Allow")).toBe("POST");
expect(mutation).not.toHaveBeenCalled();

// the default transport is untouched: the new function dispatches
// over gated POST like any undeclared function
const post = await handleServerFunctionRequest(postRequest("hygiene-rebind"), {
provideEvent
});
expect(post.status).toBe(200);
expect(mutation).toHaveBeenCalledTimes(1);
});

it("re-registering the same function keeps its declaration; a redeclaring rebind re-grants", async () => {
// Same identity, same grant: registering the callback the declaration
// was made about is not a change of hands (integrations re-running
// their registration path must not silently lose GET).
const read = async () => "READ";
declareGET("hygiene-rebind-same", read);
registerServerFunction("hygiene-rebind-same", read);
const kept = await handleServerFunctionRequest(readRequest("hygiene-rebind-same", "GET"), {
provideEvent
});
expect(kept.status).toBe(200);

// The re-evaluated-module path: registration and declaration travel
// together in module order, so a function that still wraps GET()
// re-grants itself immediately after the rebind revokes.
declareGET("hygiene-rebind-redeclare", async () => "v1");
declareGET("hygiene-rebind-redeclare", async () => "v2");
const redeclared = await handleServerFunctionRequest(
readRequest("hygiene-rebind-redeclare", "GET"),
{ provideEvent }
);
expect(redeclared.status).toBe(200);
expect(await redeclared.text()).toContain("v2");
});

it("matches the method exactly: a lowercased `post` is not POST", async () => {
// The comparison is `===` against the uppercase token, and the platform
// `Request` constructor normalizes the six well-known methods, so this
Expand Down
36 changes: 36 additions & 0 deletions packages/web/test/server/server-functions-single-flight.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
GET as serverGET,
SINGLE_FLIGHT_HEADER,
configureServerFunctionsServer,
createServerReference as createServerSideReference,
decodeResponse,
handleServerFunctionRequest,
registerFlightDataSource,
registerServerFunction,
registerServerReference,
subscribeFlightData
} from "@solidjs/web/server-functions/server";
import type {
Expand Down Expand Up @@ -80,6 +83,39 @@ describe("single-flight server bridge (built server bundle)", () => {
expect(seen.event.request).toBe(seen.outcome.request);
});

it("never folds on a GET: a read's body cannot be reshaped by a request header (#3128)", async () => {
// A GET is a cacheable url, and a shared cache stores one body per key.
// Folding on it would put a second body at that key — an envelope
// carrying data the hook computed from THAT caller's request — under
// whatever public Cache-Control the author wrote, with nothing naming
// the variance. The shipped client already refuses to send the header
// on a read (client.ts: reads "stay plain"); this is the server half
// of the same rule, so a curl carrying the header cannot poison the
// key for everyone behind the cache.
serverGET(
createServerSideReference(
registerServerReference("sf-bridge-read-0", async () => "PUBLIC MENU")
)
);
const collector = vi.fn(() => ({ "/account": { seenBy: "session=CALLER" } }));

const response = await handleServerFunctionRequest(
new Request("http://localhost/_server/data/sf-bridge-read-0", {
method: "GET",
headers: { [SINGLE_FLIGHT_HEADER]: "true" }
}),
{ collectFlightData: collector }
);

expect(response.status).toBe(200);
// no fold: the plain value is the ONLY body this url ever answers,
// whatever headers arrive with the call — and the hook never ran, so
// a read costs no collection work either
expect(response.headers.get(SINGLE_FLIGHT_HEADER)).toBeNull();
expect(await decodeResponse(response)).toBe("PUBLIC MENU");
expect(collector).not.toHaveBeenCalled();
});

it("registers the hook through configureServerFunctionsServer", async () => {
registerServerFunction("sf-bridge-config-0", async () => "value");
// the config option's type surfaces through the copied .d.ts chain
Expand Down