npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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:

  1. operationMetadataMiddleware()
  2. urlMiddleware()
  3. headersMiddleware()
  4. authMiddleware()
  5. persistedQueryMiddleware()
  6. uploadMiddleware()
  7. responseCacheMiddleware() or dedupeMiddleware()
  8. retryMiddleware()
  9. graphqlErrorMiddleware(), timingMiddleware(), loggerMiddleware()
  10. 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: true is set).
  • The request carries uploadables -- file uploads always bypass batching.
  • A custom shouldBatch predicate returns false.

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 fetch runtimes
  • worker/edge runtimes that provide fetch

Guidance:

  • inject runtime-specific fetch implementations into httpExecutor() 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() and RelayNetworkError for transport/error-policy handling
  • use classifyErrorDisposition() when callers need an explicit refresh vs retry vs fail decision

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 correlation
  • timingMiddleware() for fetch timing
  • subscribeTimingMiddleware() for subscription setup and first-value timing
  • loggerMiddleware() / subscribeLoggerMiddleware() for structured logs
  • classifyErrorDisposition() 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/auth
  • relay-runtime-network/errors
  • relay-runtime-network/ssr
  • relay-runtime-network/executors
  • relay-runtime-network/executors/http
  • relay-runtime-network/executors/ws
  • relay-runtime-network/executors/local-graph
  • relay-runtime-network/executors/replay
  • relay-runtime-network/executors/sse
  • relay-runtime-network/executors/test
  • relay-runtime-network/testing

Subscribe transport guidance

Two subscribe transport helpers are available today:

  • graphqlWSSubscribeExecutor() for GraphQL-over-WebSocket style transports
  • graphqlSSESubscribeExecutor() for GraphQL-over-SSE style transports

Recommended subscribe middleware order:

  1. subscribeOperationMetadataMiddleware()
  2. subscribeHeadersMiddleware() and/or subscribeExtensionsMiddleware()
  3. auth-related transport shaping in the executor factory if still needed
  4. subscribeRetryMiddleware()
  5. subscribeTimingMiddleware()
  6. subscribeLoggerMiddleware()
  7. 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 subscribeRetryMiddleware and 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 error callback.

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:

  • RelayNetworkError
  • classifyError()
  • classifyErrorDisposition()
  • createRelayObservable()
  • createHydratedReplayExecutor()
  • createMemorySessionHintStore()
  • createReplayCollector()
  • fromAsyncIterable()
  • isAsyncIterable()
  • isRelayObservableLike()
  • isSessionHintRefreshDue()
  • createRefreshCoordinator()
  • createReplayRecord()
  • hydrateReplayRecords()
  • toAsyncIterable()
  • serializeReplayRecords()