RedwoodSDK integration
RedwoodSDK is one of two built-in framework presets, alongside React Router 7. It is an adapter, not the foundation — the core compiler and runtime have no dependency on it. The package is decoupled from rwsdk itself, the same way @gleanql/vite is decoupled from Vite. It matches the framework's shapes structurally (a RequestInfo), so it tests in isolation and pins no framework version.
RSC vs. isomorphic. RedwoodSDK is the RSC preset (server/client split; the graph snapshot rides the flight stream). The react-router preset proves the binding is pluggable with an isomorphic, non-RSC host — see examples/remix-real and @gleanql/vite.
What an adapter answers
Any framework adapter has to answer four questions. This package answers them:
| Question | How |
|---|---|
| Which operation drives this entrypoint? | resolveOperationName / an explicit name passed to preload |
| How do we read params/search/request/env? | buildRouteContext(requestInfo, { context }) |
| How do we preload + seed? | runRoute into a fresh per-request cache |
| How do we expose the graph & hydrate? | bound graph on ctx + serializeGraph/hydrateGraph |
Setup
Create one integration with the compiled operations (generated into @gleanql/client), the schema, and a transport adapter. context contributes auth/locale/env. clientSafeContext is the allow-list of context keys safe to serialize — secrets stay server-side.
const integration = createGraphIntegration({
schema, operations, adapter,
context: ({ request }) => ({ locale: localeFor(request), accessToken: env.TOKEN }),
clientSafeContext: ["locale"], // accessToken is NOT serialized
unexpectedMissingField: "warn", // hybrid mode
fetchMissing, // optional: batched lazy/patch fetcher
});Per request
Preload does the per-request work:
- picks the operation
- computes variables from the
RequestInfo - executes via the adapter
- seeds a fresh cache
- attaches
{ runtime, graph, roots, variables }torequestInfo.ctx
Concurrent requests are isolated in separate caches.
await integration.preload(requestInfo, "ProductRoute");
const graph = integration.getGraph(requestInfo);
// Pages/components read normally — cache hits, no GraphQL in sight:
const product = graph.product({ handle: params.handle });
product.title; product.featuredImage?.url; product.priceRange.minVariantPrice.amount;You may prefer a module-level import over reading ctx — an app-owned module (say ~/graph) re-exporting a scoped accessor. In that case, back the integration with a GraphScope and wrap rendering in integration.runInScope(requestInfo, render).
Serialize & hydrate
Graph values are proxies, not JSON — so serializeGraph serializes the cache, not the values. The hydration script escapes its payload (<, >, &, U+2028/U+2029) so it cannot break out of the <script> element. On the client, the runtime is rebuilt from the snapshot and the graph re-bound. Warm reads hit; missing fields fetch through the client adapter.
// Server (in the Document):
const payload = serializeGraph(integration.getActive(requestInfo)!, { clientSafeContext: ["locale"] });
head += renderGraphHydrationScript(payload, { nonce });
// Client:
const { graph } = hydrateGraph(readGraphHydrationPayload()!, { schema, adapter });Boundary rules
- Graph values are serializable as handles + cache records, never as live proxies.
- Only
clientSafeContextkeys cross to the client; tokens/secrets are dropped. - Client components can trigger runtime missing-field fetches through the client adapter.
- Two hydration models ship: the simple SSR
<script>model and the RSC flight model (snapshot as a client-component prop, folded into a long-lived runtime). See @gleanql/client.
Mutations
The integration also exposes the write side per request. getMutator(requestInfo) returns the glean.mutate.* namespace — one callable per compiled mutation operation. invalidate(requestInfo, value) drops a record so the next read re-fetches. Results normalize into the per-request cache, so a mutation is immediately visible through the already-rendered graph.
const result = await integration.getMutator(requestInfo).ProductUpdate(
{ id, title: "Renamed" },
{ optimistic: (tx) => tx.set(productRef, "title", "Renamed") },
);Client islands & refetch (mixing client + RSC)
RSC renders the page server-side, and a "use client" island can refetch live — with no hydration boilerplate. The plugin generates the client glue too: a @gleanql/client/client module. It exposes useGlean(), the hydrated graph that re-renders on cache change. It also exposes refresh(operationName?), which re-runs the page's compiled operation over the wire. The app imports them; nothing else is needed.
// a "use client" island — the only graph code the app writes
import { useGlean, refresh } from "@gleanql/client/client";
const glean = useGlean(); // hydrated; re-renders on cache change
const product = glean.product({ handle }); // warm read from the hydrated cache
// <button onClick={() => refresh()}> → /graphql → re-seed → cache notifies → re-renderrefresh(operationName?) re-runs the entire compiled operation for the current page, or the named one. It bypasses cache-first and re-seeds. It is a whole-operation refetch, not a field-level one. The normalized cache then reconciles by entity identity, so only changed fields actually re-render. The network request still fetches the whole operation. To refetch a smaller slice today, pass a smaller operation name.
Under the hood the snapshot rides the RSC flight stream, not a <script> global. @gleanql/vite auto-injects a <GraphHydrate /> server component around each route component, via the preset's transformRoute hook. That component comes from the generated @gleanql/client/server — a thin shim over createGraphServer — and receives this request's serialized payload. On every render the client side folds the payload into one long-lived browser runtime (absorbHydrationPayload → runtime.absorbRecords), so the cache accumulates across navigations. The runtime points at the configured endpoint (default /graphql) and wires useSyncExternalStore to cache.subscribe. It builds on the client-safe entrypoints @gleanql/client/runtime + @gleanql/client/operations. No request-scoped accessor means no server-only rwsdk/worker in the client bundle. There is zero app glue: worker and page files are untouched. And there is no inline state <script>, so it sidesteps CSP.
A mutation island — writes update in place
The write side is a client island too, with the same zero graph glue. The generated @gleanql/client/client also exports useMutation (gqty-style). The selector (m, vars) => … is compile-time only. It defines the operation, rooted at the Mutation type, and types vars/data. It never runs — the build injects the compiled op name into the call site. Calling rename(vars) runs that op. The mutation returns the entity (__typename + id), so the result normalizes in place into the same cache the page hydrated. Any island reading that record through useGlean() updates with no reload.
// a "use client" mutation island — the only graph code the app writes
import { useGlean, useMutation } from "@gleanql/client/client";
const glean = useGlean(); // hydrated; re-renders fine-grained
const product = glean?.product({ handle });
const title = product?.title ?? initialTitle; // reads the record the mutation writes
const [rename, { isLoading, error }] = useMutation(
(m, vars) => m.setProductTitle(vars).title, // compile-time selector → kind:"mutation" op; never runs
);
// <button onClick={() => rename({ id, title })}> → /graphql → returns {__typename,id,title}
// → normalized in place → only THIS record's readers re-render → heading updates, no reloadThe hook runs the same engine as the server-side runMutation. optimistic / update / invalidate are available through the hook's options, and userErrors surface on the returned state. See examples/rwsdk-real's RenameTitle.tsx.
The real app — zero glue (@gleanql/vite)
examples/rwsdk-real/ is a genuine RedwoodSDK app — React 19 RSC on workerd — that boots: pnpm --filter @example/rwsdk-real dev. It commits no graph glue at all. The app is a schema, routes/components, a transport, and one line in vite.config.mts:
// vite.config.mts
import { defineConfig } from "vite";
import { glean } from "@gleanql/vite";
export default defineConfig({
plugins: [
glean({ schema: "schema.graphql" }), // routes auto-discovered
cloudflare(),
redwood(),
],
});On startup, before the directive scan, the plugin does four things:
- provisions the
@gleanql/clientruntime - runs
@gleanql/codegenfrom the schema - compiles the route files with
@gleanql/compiler - emits a real
@gleanql/clientpackage intonode_modules, whosepackage.jsonexportsdeclare the generated types
App code therefore imports by package name — no tsconfig paths, no alias:
import { glean } from "@gleanql/client";
import type { Product } from "@gleanql/client/schema";Two routes compile to two operations: a list /collections/:handle and a detail /products/:handle. Components live in separate files, and the compiler follows the imports. The app is verified end-to-end on real workerd, including client hydration in the browser.
In-CI worker (no workerd)
examples/storefront/rwsdk-app/ is a RedwoodSDK-shaped worker that runs in the test suite. It takes defineApp/route/Document from a local shim, since real rwsdk/worker needs workerd. worker.fetch(request) returns an HTML Response with the rendered page plus the hydration payload. It gives CI coverage of the integration without the workerd toolchain.
Status
Reads and writes are complete end-to-end:
examples/storefront/rwsdk.test.tsdrives the real compiler output forProductRoute.tsxthrough the adapter: request → preload → proxy reads → serialize → hydrate.packages/rwsdk/test/integration.test.tscovers the mutation + optimistic + invalidation flow.rwsdk-app/worker.test.tsruns the whole thing as afetchhandler.
RSC-native serialization ships too: the snapshot rides the flight stream and folds into a long-lived runtime. It is verified end-to-end on real workerd in examples/rwsdk-real.
Back to Overview · the runtime that powers this: @gleanql/client.