From 0b9e8afa2e3ec07633a5d2eb70d2b6d1b46e8bdf Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sun, 30 Aug 2026 23:42:14 -0700 Subject: [PATCH 1/2] Make the Effect example README argue Solid's uniqueness, not 1.x's shortcomings. Adds a "Why Solid specifically" section: iterator close as the only protocol-level interruption hook among frameworks, action transactions as the UI half of a saga Effect can't provide, and matching pull-based execution models as the reason no binding library is needed. Co-authored-by: Cursor --- examples/effect/README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/examples/effect/README.md b/examples/effect/README.md index c5e562389..8de2ef679 100644 --- a/examples/effect/README.md +++ b/examples/effect/README.md @@ -18,6 +18,32 @@ pending propagates to ``, failures propagate to ``, 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 From bcdf79c8a1044b68ca89b3e803baf772e3e3cce4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 01:33:21 -0700 Subject: [PATCH 2/2] fix(web): revoke GET grants on rebind and never fold single-flight into reads Co-authored-by: Cursor --- ...fix-get-grant-lifetime-and-read-folding.md | 17 +++++ packages/web/server-functions/src/server.ts | 31 +++++++- .../server-functions-http-hygiene.spec.tsx | 70 +++++++++++++++++++ .../server-functions-single-flight.spec.tsx | 36 ++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-get-grant-lifetime-and-read-folding.md diff --git a/.changeset/fix-get-grant-lifetime-and-read-folding.md b/.changeset/fix-get-grant-lifetime-and-read-folding.md new file mode 100644 index 000000000..679fa720e --- /dev/null +++ b/.changeset/fix-get-grant-lifetime-and-read-folding.md @@ -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. diff --git a/packages/web/server-functions/src/server.ts b/packages/web/server-functions/src/server.ts index 2df8a501a..69d5ebc39 100644 --- a/packages/web/server-functions/src/server.ts +++ b/packages/web/server-functions/src/server.ts @@ -694,6 +694,17 @@ export function registerServerFunction( 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; } /** @@ -909,6 +920,11 @@ export function GET( * 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: * @@ -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)) }; } }; @@ -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); diff --git a/packages/web/test/server/server-functions-http-hygiene.spec.tsx b/packages/web/test/server/server-functions-http-hygiene.spec.tsx index 9d959a490..474e3d81a 100644 --- a/packages/web/test/server/server-functions-http-hygiene.spec.tsx +++ b/packages/web/test/server/server-functions-http-hygiene.spec.tsx @@ -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 diff --git a/packages/web/test/server/server-functions-single-flight.spec.tsx b/packages/web/test/server/server-functions-single-flight.spec.tsx index f3e51088b..7f77d4aef 100644 --- a/packages/web/test/server/server-functions-single-flight.spec.tsx +++ b/packages/web/test/server/server-functions-single-flight.spec.tsx @@ -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 { @@ -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