relay-runtime-network
v0.1.0
Published
Framework-agnostic middleware-driven network toolkit for relay-runtime
Readme
relay-runtime-network
Framework-agnostic, middleware-driven network tooling for relay-runtime.
The default HTTP executor also understands multipart/mixed GraphQL responses
and merges incremental payload chunks into a final Relay-compatible payload.
Table of Contents
- Scope
- Included today
- Example
- Recommended HTTP middleware order
- Batch middleware
- Status
- Runtime guidance
- Local graph recipe
- SSR request-scoped recipe
- Auth and error composition guidance
- Diagnostics cookbook
- Testing utilities
- Subpath exports
- Subscribe transport guidance
- Subscription error handling
- Incremental payload stream
- Stream normalization utilities
- Replay capture
- Migration notes
- Operational helpers
Scope
This package builds Relay fetch pipelines from:
- classic curried middleware
- a terminal executor
- typed request/response context
It is designed to stay runtime-level rather than UI-framework-level.
Included today
composeMiddlewares()createFetchPipeline()createHydratedReplayExecutor()createRelayObservable()createReplayCollector()createReplayRecord()createSubscribePipeline()createRelayRuntimeNetwork()classifyErrorDisposition()fromAsyncIterable()graphqlSSESubscribeExecutor()graphqlWSSubscribeExecutor()httpExecutor()isAsyncIterable()isRelayObservableLike()localGraphExecutor()replayExecutor()testExecutor()urlMiddleware()headersMiddleware()operationMetadataMiddleware()authMiddleware()batchMiddleware()dedupeMiddleware()graphqlErrorMiddleware()loggerMiddleware()progressMiddleware()responseCacheMiddleware()retryMiddleware()subscribeExtensionsMiddleware()subscribeHeadersMiddleware()subscribeLoggerMiddleware()subscribeOperationMetadataMiddleware()subscribeRetryMiddleware()subscribeTimingMiddleware()timingMiddleware()toAsyncIterable()uploadMiddleware()persistedQueryMiddleware()
Example
import {
authMiddleware,
createFetchPipeline,
headersMiddleware,
httpExecutor,
retryMiddleware,
urlMiddleware,
} from "relay-runtime-network";
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [
urlMiddleware({ url: "/graphql" }),
authMiddleware({
getAccessToken: () => localStorage.getItem("access_token"),
strategy: "bearer-access-token",
}),
headersMiddleware({
headers: {
"X-Client": "relay-runtime-network",
},
}),
retryMiddleware({
maxAttempts: 3,
backoff: {
baseDelayMs: 200,
kind: "exponential",
},
}),
],
});Recommended HTTP middleware order
For most HTTP stacks, the default order should be:
operationMetadataMiddleware()urlMiddleware()headersMiddleware()authMiddleware()persistedQueryMiddleware()uploadMiddleware()responseCacheMiddleware()ordedupeMiddleware()retryMiddleware()graphqlErrorMiddleware(),timingMiddleware(),loggerMiddleware()- terminal executor
That ordering keeps request shaping early, policy middleware in the middle, and observability middleware around the final execution path.
Batch middleware
batchMiddleware() coalesces overlapping query executions into a single HTTP
request using the ordered-array protocol.
Wire format
The middleware collects queued operations into a JSON array of operation objects
and sends them as a single POST request:
[
{ "operationName": "ViewerQuery", "query": "query ViewerQuery { ... }", "variables": { "id": "1" } },
{ "operationName": "FeedQuery", "query": "query FeedQuery { ... }", "variables": {} }
]When an operation uses persisted queries (params.id instead of params.text),
the payload includes id instead of query.
The server must return a JSON array of responses in the same order as the request array. Each element is a standard GraphQL response object:
[
{ "data": { "viewer": { "id": "1" } } },
{ "data": { "feed": [] } }
]The middleware splits the response array and resolves each queued caller with its positionally matched entry.
When batching triggers
Batching only occurs when multiple requests overlap within the batch window
(batchWindowMs, default 0). A single request that arrives with no peers in
the queue is dispatched normally as a one-element batch. Requests that arrive
after the window closes form a new, separate batch.
maxBatchSize can force an early flush before the window expires.
Default skip conditions
The middleware passes requests through to the next layer unbatched when:
- The executor is not HTTP (
executor.kind !== "http") -- non-HTTP executors such as local-graph or custom transports are never batched. - The executor does not advertise
supportsBatching. - The operation is a mutation (unless
allowMutations: trueis set). - The request carries uploadables -- file uploads always bypass batching.
- A custom
shouldBatchpredicate returnsfalse.
Configuration
batchMiddleware({
batchUrl: "/graphql/batch", // static URL, dynamic function, or omit to use the first request's URL
batchWindowMs: 10, // coalescing window in milliseconds (default 0)
maxBatchSize: 20, // flush early when this many requests queue up
allowMutations: false, // opt in to batching mutations
shouldBatch: (context) => true, // custom predicate (overrides default query-only filter)
protocol: "ordered-array", // only supported value today
})Status
Initial implementation is in place and validated with package-local checks and tests.
Runtime guidance
This package is intended to work across:
- browsers
- Node 18+
- Bun
- Deno-style
fetchruntimes - worker/edge runtimes that provide
fetch
Guidance:
- inject runtime-specific
fetchimplementations intohttpExecutor()rather than relying on global mutation - keep auth, headers, and tracing request-scoped in SSR or multi-tenant flows
- avoid Node-only assumptions in shared middleware
- prefer executor factories and middleware factories over global singleton state
Edge / worker runtime recipe
In worker-style runtimes, keep the network fully request-scoped and pass the
runtime fetch through explicitly.
import {
createFetchPipeline,
headersMiddleware,
httpExecutor,
operationMetadataMiddleware,
urlMiddleware,
} from "relay-runtime-network";
export const createWorkerFetch = (request: Request, runtimeFetch: typeof fetch) => {
return createFetchPipeline({
executor: httpExecutor({ fetch: runtimeFetch }),
middlewares: [
operationMetadataMiddleware(),
urlMiddleware({ url: new URL("/graphql", request.url).toString() }),
headersMiddleware({
headers: {
cookie: request.headers.get("cookie") ?? "",
},
}),
],
});
};Prefer this style over global monkey-patching. It keeps worker, Bun, Node, and browser behavior aligned.
Local graph recipe
localGraphExecutor() is the escape hatch for in-process GraphQL execution,
Storybook-style previews, test harnesses, and embedded graph engines.
import {
createFetchPipeline,
dedupeMiddleware,
graphqlErrorMiddleware,
localGraphExecutor,
loggerMiddleware,
timingMiddleware,
} from "relay-runtime-network";
const fetchGraphQL = createFetchPipeline({
executor: localGraphExecutor({
kind: "local-schema",
execute: async (context) => {
return runLocalGraphOperation({
operationName: context.operation.name,
query: context.operation.text,
variables: context.variables,
});
},
}),
middlewares: [
dedupeMiddleware(),
graphqlErrorMiddleware(),
timingMiddleware(),
loggerMiddleware(),
],
});Use this path when the execution target is not HTTP at all. Avoid HTTP-specific middleware unless it is deliberately configured to no-op.
SSR request-scoped recipe
For SSR, build the network per request and keep auth, cookies, tracing, and replay capture request-scoped.
import {
authMiddleware,
createFetchPipeline,
createReplayCollector,
headersMiddleware,
httpExecutor,
operationMetadataMiddleware,
retryMiddleware,
urlMiddleware,
} from "relay-runtime-network";
export const createRequestScopedFetch = (request: Request) => {
const replayCollector = createReplayCollector({
meta: { source: "ssr" },
});
return {
replayCollector,
fetchGraphQL: createFetchPipeline({
executor: httpExecutor({ fetch: globalThis.fetch }),
middlewares: [
operationMetadataMiddleware(),
urlMiddleware({ url: new URL("/graphql", request.url).toString() }),
headersMiddleware({
headers: {
cookie: request.headers.get("cookie") ?? "",
},
}),
authMiddleware({
getAccessToken: () => readRequestScopedAccessToken(request),
strategy: "bearer-access-token",
}),
retryMiddleware({
maxAttempts: 2,
backoff: { kind: "exponential", baseDelayMs: 150 },
}),
replayCollector.middleware,
],
}),
};
};Do not rely on shared mutable auth state in SSR.
HTTP-only cookie forwarding for SSR
When the GraphQL API uses HTTP-only cookies for authentication, the server must forward cookies from the incoming HTTP request to the upstream GraphQL endpoint. The browser manages these cookies automatically on the client, but during SSR there is no browser — you must thread them through explicitly.
Server side — forward incoming cookies via headersMiddleware:
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [
urlMiddleware({ url: new URL("/graphql", request.url).toString() }),
headersMiddleware({
headers: {
cookie: request.headers.get("cookie") ?? "",
},
}),
authMiddleware({
strategy: "cookie-session-only",
credentials: "include",
}),
],
});The cookie-session-only strategy skips the Authorization header entirely — authentication is carried solely by the forwarded cookie. If the token expires mid-request, authMiddleware's reactive refresh (Phase 4) catches 401/403 responses and retries automatically.
Client side — let the browser handle cookies:
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [
urlMiddleware({ url: "/graphql", credentials: "include" }),
authMiddleware({
strategy: "cookie-session-only",
credentials: "include",
}),
],
});With credentials: "include", the browser automatically includes HTTP-only cookies in every request. No manual cookie handling, no session hint serialization between server and client, no token exposure to JavaScript.
Proactive refresh (optional) — avoid the single extra round-trip on first expired token:
authMiddleware({
strategy: "access-token-with-refresh-cookie",
credentials: "include",
sessionHintStore: createMemorySessionHintStore(),
refresh: {
execute: async () => {
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
const { accessToken, expiresIn } = await res.json();
return {
accessToken,
sessionHint: {
issuedAt: Date.now(),
expiresAt: Date.now() + expiresIn * 1000,
refreshAt: Date.now() + (expiresIn - 60) * 1000,
},
};
},
},
});Session hints store non-secret timing metadata (when to refresh), not the tokens themselves. The refresh token stays in the HTTP-only cookie and is never accessible to JavaScript.
String SSR with cookie forwarding
The batch (string) SSR model renders the full page in one pass and serializes the entire store:
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { installRelaySSR, serializeRelayStore } from "lit-relay/ssr/server";
// 1. Create a per-request environment with forwarded cookies
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [
urlMiddleware({ url: new URL("/graphql", request.url).toString() }),
headersMiddleware({ headers: { cookie: request.headers.get("cookie") ?? "" } }),
authMiddleware({ strategy: "cookie-session-only", credentials: "include" }),
],
});
const environment = new Environment({
network: Network.create(fetchGraphQL),
store: new Store(new RecordSource()),
});
// 2. Install and render
installRelaySSR(environment);
const html = render(appTemplate);
// 3. Serialize the full store in one shot
const storeJson = serializeRelayStore(environment);
res.send(`${html}<script type="application/relay-store">${storeJson}</script>`);On the client, hydrateRelayStore(fetchGraphQL) parses the single script tag and creates a pre-populated environment.
Streaming SSR with cookie forwarding
Streaming SSR sends the initial HTML immediately and flushes store chunks as deferred queries resolve:
import { installRelaySSR, serializeRelayStore } from "lit-relay/ssr/server";
import { createStreamingSSRPipeline } from "lit-relay/ssr";
// 1. Same per-request environment with forwarded cookies (see above)
installRelaySSR(environment);
// 2. Render and send initial HTML
for (const chunk of render(appTemplate)) res.write(chunk);
res.write(`<script type="application/relay-store">${serializeRelayStore(environment)}</script>`);
// 3. Stream deferred data as it arrives
const pipeline = createStreamingSSRPipeline(environment, {
operations: [
{ query: FeedQueryNode, variables: { userId: "1" } },
{ query: RecommendationsQueryNode, variables: {} },
],
timeout: 10_000,
});
for await (const scriptTag of pipeline) res.write(scriptTag);
res.end("</body></html>");On the client, hydrateRelayStore handles the initial batch, and createStreamingStoreHydrator(environment).connect() picks up the incremental chunks via MutationObserver.
Auth and error composition guidance
Recommended production pattern:
- use
authMiddleware()to inject access tokens or request credentials - use
createRefreshCoordinator()and session-hint helpers for refresh policy - use
graphqlErrorMiddleware()to observe partial-success GraphQL errors - use
classifyError()andRelayNetworkErrorfor transport/error-policy handling - use
classifyErrorDisposition()when callers need an explicitrefreshvsretryvsfaildecision
Example:
import {
authMiddleware,
classifyError,
createMemorySessionHintStore,
createRefreshCoordinator,
graphqlErrorMiddleware,
RelayNetworkError,
} from "relay-runtime-network";
const sessionHintStore = createMemorySessionHintStore();
const refreshCoordinator = createRefreshCoordinator();
const middlewares = [
authMiddleware({
getAccessToken: () => accessTokenStore.current(),
refresh: {
execute: async () => refreshCoordinator.run(() => refreshAccessToken()),
},
sessionHintStore,
strategy: "bearer-access-token",
}),
graphqlErrorMiddleware({
onErrors: (errors, context, output) => {
const classification = classifyError(
new RelayNetworkError(`GraphQL errors for ${context.operation.name}`, {
category: "graphql",
graphqlErrors: [...errors],
operationKind: context.operation.kind,
operationName: context.operation.name,
status: output.status,
}),
);
reportGraphQLErrors(classification);
},
}),
];Use GraphQL-error observation for partial success and reserve thrown transport errors for true network/policy failure paths.
When the caller needs a stable policy decision instead of a raw classification,
use classifyErrorDisposition():
import { classifyErrorDisposition } from "relay-runtime-network";
const disposition = classifyErrorDisposition(error, { output });
if (disposition.action === "refresh") {
await refreshSession();
}
if (disposition.action === "retry") {
scheduleRetry();
}Diagnostics cookbook
For production diagnostics, a practical default is:
operationMetadataMiddleware()for request correlationtimingMiddleware()for fetch timingsubscribeTimingMiddleware()for subscription setup and first-value timingloggerMiddleware()/subscribeLoggerMiddleware()for structured logsclassifyErrorDisposition()for retry vs refresh vs fail policy reporting
Fetch example:
import {
classifyErrorDisposition,
loggerMiddleware,
operationMetadataMiddleware,
timingMiddleware,
} from "relay-runtime-network";
const middlewares = [
operationMetadataMiddleware(),
timingMiddleware({
onTiming: (timing) => diagnostics.captureFetchTiming(timing),
}),
loggerMiddleware({
logger: (entry) => diagnostics.captureFetchLog(entry),
logHeaders: true,
}),
];
const disposition = classifyErrorDisposition(error, { context, output });
diagnostics.capturePolicyDecision(disposition);Subscribe example:
import {
subscribeLoggerMiddleware,
subscribeOperationMetadataMiddleware,
subscribeTimingMiddleware,
} from "relay-runtime-network";
const middlewares = [
subscribeOperationMetadataMiddleware(),
subscribeTimingMiddleware({
onTiming: (timing) => diagnostics.captureSubscribeTiming(timing),
}),
subscribeLoggerMiddleware({
logger: (entry) => diagnostics.captureSubscribeLog(entry),
}),
];Testing utilities
Public test helpers are available from relay-runtime-network/testing.
Included helpers:
createMockRelayFetch()createTestFetchContext()createTestFetchExecutor()createTestSubscribeContext()createTestSubscribeExecutor()
Subpath exports
Available stable subpaths:
relay-runtime-network/authrelay-runtime-network/errorsrelay-runtime-network/ssrrelay-runtime-network/executorsrelay-runtime-network/executors/httprelay-runtime-network/executors/wsrelay-runtime-network/executors/local-graphrelay-runtime-network/executors/replayrelay-runtime-network/executors/sserelay-runtime-network/executors/testrelay-runtime-network/testing
Subscribe transport guidance
Two subscribe transport helpers are available today:
graphqlWSSubscribeExecutor()for GraphQL-over-WebSocket style transportsgraphqlSSESubscribeExecutor()for GraphQL-over-SSE style transports
Recommended subscribe middleware order:
subscribeOperationMetadataMiddleware()subscribeHeadersMiddleware()and/orsubscribeExtensionsMiddleware()- auth-related transport shaping in the executor factory if still needed
subscribeRetryMiddleware()subscribeTimingMiddleware()subscribeLoggerMiddleware()- terminal subscribe executor
Use subscribeHeadersMiddleware() for header-aware transports such as SSE and
subscribeExtensionsMiddleware() for protocol extensions or connection params
that should flow into WS/SSE subscribe executors.
Add subscribeTimingMiddleware() when you need setup timing plus first-value
and completion/error timing for Observable- or AsyncIterable-style subscribe
results.
WebSocket auth and connection parameters
For WebSocket-style clients, keep auth request-scoped and resolve protocol extensions from the subscribe context.
import {
createSubscribePipeline,
graphqlWSSubscribeExecutor,
subscribeLoggerMiddleware,
subscribeOperationMetadataMiddleware,
subscribeRetryMiddleware,
} from "relay-runtime-network";
const subscribe = createSubscribePipeline({
executor: graphqlWSSubscribeExecutor({
extensions: (context) => ({
requestId: context.meta.get("request.id"),
token: requestScopedAccessToken(),
}),
subscribe: (payload) => wsClient.subscribe(payload),
}),
middlewares: [
subscribeOperationMetadataMiddleware(),
subscribeRetryMiddleware({
maxAttempts: 3,
backoff: { kind: "exponential", baseDelayMs: 250 },
}),
subscribeLoggerMiddleware(),
],
});SSE auth and headers
For SSE-style clients, prefer request-scoped headers and URL resolvers.
import {
createSubscribePipeline,
graphqlSSESubscribeExecutor,
subscribeLoggerMiddleware,
subscribeOperationMetadataMiddleware,
subscribeRetryMiddleware,
} from "relay-runtime-network";
const subscribe = createSubscribePipeline({
executor: graphqlSSESubscribeExecutor({
headers: (context) => ({
Authorization: `Bearer ${requestScopedAccessToken()}`,
"X-Request-Id": String(context.meta.get("request.id") ?? ""),
}),
subscribe: (request) => sseClient.subscribe(request),
url: "/graphql/stream",
}),
middlewares: [
subscribeOperationMetadataMiddleware(),
subscribeRetryMiddleware({
maxAttempts: 2,
backoff: { kind: "constant", baseDelayMs: 500 },
}),
subscribeLoggerMiddleware(),
],
});Middleware-managed transport shaping can also be used directly:
import {
createSubscribePipeline,
graphqlSSESubscribeExecutor,
subscribeExtensionsMiddleware,
subscribeHeadersMiddleware,
subscribeOperationMetadataMiddleware,
} from "relay-runtime-network";
const subscribe = createSubscribePipeline({
executor: graphqlSSESubscribeExecutor({
subscribe: (request) => sseClient.subscribe(request),
url: "/graphql/stream",
}),
middlewares: [
subscribeOperationMetadataMiddleware(),
subscribeHeadersMiddleware({
headers: {
Authorization: `Bearer ${requestScopedAccessToken()}`,
},
}),
subscribeExtensionsMiddleware({
extensions: (context) => ({
requestId: context.meta.get("request.id"),
}),
}),
],
});Keep auth and connection configuration request-scoped. Do not rely on shared global mutable auth state for subscription setup in SSR or multi-tenant flows.
Subscription error handling
Subscription errors differ from fetch errors in timing and recoverability:
- Setup errors occur when the transport connection fails (WebSocket connect timeout, SSE endpoint unreachable). These are caught by
subscribeRetryMiddlewareand retried with backoff. - Stream errors occur after a successful connection when the server sends an error frame or the transport drops. These propagate to the subscription observable's
errorcallback.
subscribeRetryMiddleware wraps exhausted errors in RelayNetworkError via classifyError(), giving them the same structured categorisation as fetch errors (network, timeout, abort, etc.):
createSubscribePipeline({
executor: graphqlWSSubscribeExecutor({ url: "ws://localhost/graphql" }),
middlewares: [
subscribeRetryMiddleware({
maxAttempts: 3,
backoff: { kind: "exponential", baseDelayMs: 500 },
shouldRetry: (context, error, nextAttempt) => {
// Only retry network/timeout errors, not auth failures
return !(error instanceof RelayNetworkError) ||
error.category !== "abort";
},
afterFinalFailure: (context, error) => {
// error is a RelayNetworkError with .category, .canRetry, .retryAttempted
console.error(`Subscription ${context.operation.name} failed:`, error.category);
},
}),
],
});For GraphQL-level errors within subscription payloads, handle them in the subscription observable's next callback by inspecting payload.errors.
Incremental payload stream
Fetch executions expose a first-class async stream at
context.incremental.stream.
They also expose a multicast Observable-style view at
context.incremental.observable.
Use it when payload observers are not enough and middleware needs one stable
AsyncIterable of multipart payload observations.
const incrementalObserverMiddleware = (next) => async (context) => {
const consumePayloads = (async () => {
for await (const payload of context.incremental.stream) {
console.log(payload.phase, payload.chunkIndex);
}
})();
const output = await next(context);
await consumePayloads;
return output;
};Non-incremental executions simply complete the stream without payloads.
Observable-style consumers can subscribe directly:
const subscription = context.incremental.observable.subscribe({
next(payload) {
console.log(payload.phase, payload.chunkIndex);
},
});
subscription.unsubscribe();Stream normalization utilities
When callers need to normalize Promise, Observable-like, AsyncIterable, or single-value results into one stream shape, use the public helpers:
createRelayObservable()fromAsyncIterable()toAsyncIterable()isRelayObservableLike()isAsyncIterable()
Example:
import {
createRelayObservable,
fromAsyncIterable,
toAsyncIterable,
} from "relay-runtime-network";
const observable = createRelayObservable<number>((observer) => {
observer.next(1);
observer.next(2);
observer.complete();
return undefined;
});
for await (const value of toAsyncIterable<number>(observable)) {
console.log(value);
}
const asObservable = fromAsyncIterable(
(async function* () {
yield 3;
yield 4;
})(),
);Replay capture
createReplayRecord() captures a completed fetch execution into a serializable
record that can later be passed to replayExecutor().
For multi-request SSR capture, createReplayCollector() provides middleware,
record accumulation, and deterministic serialization. createHydratedReplayExecutor()
can then bootstrap a replay-backed client from the serialized hydration payload.
import {
createFetchPipeline,
createHydratedReplayExecutor,
createReplayCollector,
createReplayRecord,
httpExecutor,
urlMiddleware,
} from "relay-runtime-network";
const collector = createReplayCollector();
const fetchGraphQL = createFetchPipeline({
executor: httpExecutor(),
middlewares: [collector.middleware, urlMiddleware({ url: "/graphql" })],
});
await fetchGraphQL(params, variables, cacheConfig);
const record = createReplayRecord(context, context.result);
// Later, hydrate a deterministic client/network instance:
const replayFetch = createFetchPipeline({
executor: createHydratedReplayExecutor({
source: collector.serialize(),
}),
});Migration notes
This package is a conceptual migration path from react-relay-network-modern,
not a drop-in clone.
Key differences:
- classic curried middleware is preserved, but the terminal transport is an explicit executor
- the package is runtime-first rather than React-first
- local graph, replay, test, HTTP, WebSocket, and SSE execution are all treated as first-class transport shapes
- request/response state is exposed through typed fetch/subscribe contexts
- SSR and hydration are modeled through replay helpers rather than framework coupling
Migration rule of thumb:
- old request-shaping middleware maps to fetch middleware here
- old transport-specific behavior usually belongs in an executor factory
- app-specific globals should become request-scoped middleware/executor inputs
Operational helpers
Also exported from the package root:
RelayNetworkErrorclassifyError()classifyErrorDisposition()createRelayObservable()createHydratedReplayExecutor()createMemorySessionHintStore()createReplayCollector()fromAsyncIterable()isAsyncIterable()isRelayObservableLike()isSessionHintRefreshDue()createRefreshCoordinator()createReplayRecord()hydrateReplayRecords()toAsyncIterable()serializeReplayRecords()
