GraphQLProtocol
Transports for GraphQLClient. They accept encoded requests and return raw
response bodies with available HTTP status and headers, without schema
decoding or middleware.
- layerHttp uses HTTP POST (or GET for queries) and graphql-sse subscriptions, with an optional graphql-ws subscription transport.
- layerWebSocket multiplexes all operations over graphql-ws.
makeHttp and makeWebSocket construct the same transports as effects.
Constructors
Builds the HTTP transport as an effect, for composing transports by hand. Most code uses layerHttp.
Details
Takes the same options as layerHttp, including queryMethod.
Without subscriptions, subscriptions use graphql-sse distinct mode on
url. With subscriptions.webSocket, they go over graphql-ws instead, and
the effect also builds the graphql-ws transport, so it needs a
Socket.WebSocketConstructor and a Scope.
Signature
declare const makeHttp: { <R = never>(options: { readonly queryMethod?: "GET" | "POST"; readonly subscriptions: { readonly webSocket: { readonly connectionAckTimeout?: Duration.Input; readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R>; readonly headers?: Headers.Input; readonly idleTimeout?: Duration.Input; readonly keepAlive?: Duration.Input | false; readonly url: string; }; }; readonly url: string; }): Effect<{ readonly execute: (request: GraphQLRequest) => Effect.Effect<{ readonly body: unknown; readonly headers?: Headers.Headers; readonly status?: number; }, TransportError>; readonly subscribe: (request: GraphQLRequest) => Stream.Stream<{ readonly body: unknown; readonly headers?: Headers.Headers; readonly status?: number; }, TransportError>; }, never, Scope | HttpClient | WebSocketConstructor | R>; (options: { readonly queryMethod?: "GET" | "POST"; readonly subscriptions?: undefined; readonly url: string; }): Effect<{ readonly execute: (request: GraphQLRequest) => Effect.Effect<{ readonly body: unknown; readonly headers?: Headers.Headers; readonly status?: number; }, TransportError>; readonly subscribe: (request: GraphQLRequest) => Stream.Stream<{ readonly body: unknown; readonly headers?: Headers.Headers; readonly status?: number; }, TransportError>; }, never, HttpClient>;}Builds the graphql-ws transport as an effect, for composing transports by hand. Most code uses layerWebSocket, which describes the options.
Signature
declare function makeWebSocket<R = never>(options: { readonly connectionAckTimeout?: Input; readonly connectionParams?: Effect<Record<string, unknown> | undefined, unknown, R>; readonly headers?: Input; readonly idleTimeout?: Input; readonly keepAlive?: false | Input; readonly url: string;}): Effect<{ readonly execute: (request: GraphQLRequest) => Effect<{ readonly body: unknown; readonly headers?: Headers; readonly status?: number; }, TransportError>; readonly subscribe: (request: GraphQLRequest) => Stream<{ readonly body: unknown; readonly headers?: Headers; readonly status?: number; }, TransportError>;}, never, Scope | WebSocketConstructor | R>Layers
The HTTP transport: queries and mutations are sent as POST with a JSON
body to url, using the HttpClient in context. Subscriptions use
graphql-sse distinct mode on the same URL, or graphql-ws when
subscriptions.webSocket is set.
Details
With queryMethod: "GET" (default "POST"), queries are sent as GET
with no body: query and operationName become URL params, plus
variables and extensions as JSON when set. Mutations are always
POST, and so are GraphQLResolver.aliased batches. A long query is not
retried as POST.
A request's headers and urlParams, from the call options or from
middleware, are applied to every HTTP request. A query string already on
url is kept. On GET, the request's urlParams come before the GraphQL
params, which replace any caller param with the same name.
Every response's HTTP status is passed to the client as
ExecutionResult.status, and a call that fails with GraphQL errors
reports it as ResponseError.status. Response headers are passed the
same way, as ExecutionResult.headers and ResponseError.headers.
A response is a GraphQL response when its Content-Type is
application/graphql-response+json, whatever the status, or when it is
application/json and the body has a list of errors or a data object
(or null). Media types match case-insensitively. Anything else
is a TransportError with the response status and headers, and a
Retry-After header (whole seconds or an HTTP date, measured from when the
response arrived) becomes retryAfter.
A subscription is a POST with Accept: text/event-stream. Each next
event is emitted with the response headers but no status, and the stream
ends on a complete event, with or without a data: line. A body that
fails, or ends without a complete event, is a retryable TransportError
with a server retry: line as its retryAfter, so the client's
subscriptionRetry resubscribes. A non-stream answer, such as a 400 with
errors, is read like a query's response. Interrupting the stream aborts
the request.
With subscriptions: { webSocket }, subscriptions go over graphql-ws as in
layerWebSocket, and the layer needs a Socket.WebSocketConstructor.
Signature
declare const layerHttp: { <R = never>(options: { readonly queryMethod?: "GET" | "POST"; readonly subscriptions: { readonly webSocket: { readonly connectionAckTimeout?: Duration.Input; readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R>; readonly headers?: Headers.Input; readonly idleTimeout?: Duration.Input; readonly keepAlive?: Duration.Input | false; readonly url: string; }; }; readonly url: string; }): Layer<GraphQLProtocol, never, HttpClient | WebSocketConstructor | R>; (options: { readonly queryMethod?: "GET" | "POST"; readonly subscriptions?: undefined; readonly url: string; }): Layer<GraphQLProtocol, never, HttpClient>;}Example Building a client over HTTP
import { Effect, Layer, Schema } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"import { HttpClient, HttpClientResponse } from "effect/http"
const Viewer = GraphQL.query("Viewer", { document: "query Viewer{viewer{login}}", result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })})
// A stand-in for a real HttpClient layer such as FetchHttpClient.layerconst FakeServer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb( request, new Response(JSON.stringify({ data: { viewer: { login: "tim" } } }), { headers: { "content-type": "application/graphql-response+json" } }) )) ))
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer)) return yield* client.Viewer()}).pipe( Effect.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })), Effect.provide(FakeServer))
await Effect.runPromise(program) // => { viewer: { login: "tim" } }Example Queries over GET with a per-call URL param
import { Effect, Layer, Schema } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"import { HttpClient, HttpClientResponse } from "effect/http"
const Viewer = GraphQL.query("Viewer", { document: "query Viewer{viewer{login}}", result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })})
// A stand-in server that answers with the method and the `preview` paramconst FakeServer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request, url) => Effect.succeed(HttpClientResponse.fromWeb( request, new Response( JSON.stringify({ data: { viewer: { login: `${request.method} ${url.searchParams.get("preview")}` } } }), { headers: { "content-type": "application/graphql-response+json" } } ) )) ))
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer)) return yield* client.Viewer(undefined, { urlParams: { preview: "draft-token" } })}).pipe( Effect.provide(GraphQLProtocol.layerHttp({ url: "https://cms.example.com/graphql", queryMethod: "GET" })), Effect.provide(FakeServer))
await Effect.runPromise(program) // => { viewer: { login: "GET draft-token" } }Example Subscriptions over graphql-ws, everything else over POST
import { Effect } from "effect"import { GraphQLProtocol } from "effect/graphql"
const token = Effect.succeed("secret")
// Provide an HttpClient and a Socket.WebSocketConstructor, such as// FetchHttpClient.layer and NodeSocket.layerWebSocketConstructorexport const Protocol = GraphQLProtocol.layerHttp({ url: "https://example.com/graphql", subscriptions: { webSocket: { url: "wss://example.com/graphql", connectionParams: Effect.map(token, (token) => ({ authorization: `Bearer ${token}` })) } }})The graphql-ws transport: every operation goes over one WebSocket using the
graphql-transport-ws subprotocol, with the Socket.WebSocketConstructor
in context.
Details
The socket connects lazily and multiplexes operations by id. It closes after
idleTimeout (default 0) with no active operations, or with code 1000
when the layer's scope closes.
- Operations wait for
connection_ack.connectionAckTimeout(default 10 seconds) starts after sendingconnection_init. connectionParamsruns on every connection, allowing token refresh. Failure or a socket close during evaluation is aTransportError.- Handshake
headersacceptHeaders.Inputand are normalized before reaching the socket. They require a Node or Bun WebSocket constructor; browsers cannot set them. Per-operationheadersandurlParamsare ignored. - Results carry no HTTP
status. keepAlive(default 10 seconds, orfalseto disable) sendsping. No server frame in the following interval causes a retryable failure. Serverpingreceivespong.- Lost connections fail every active operation with a
TransportErrorcarrying the close code. SeeGraphQLClientError.isFatalCloseCodefor non-retryable codes. The transport never replays operations; the client retries subscriptions and the next operation reconnects. - Once the layer's scope has closed, every new operation fails at once
with a non-retryable
TransportErrormarkedfatal. - Messages follow graphql-ws validation. Invalid messages close the socket
with fatal code
4400; valid messages for unknown ids are ignored. - Server
errormessages becomeResponseErrorvalues. Interrupting a subscription sendscomplete.
Signature
declare function layerWebSocket<R = never>(options: { readonly connectionAckTimeout?: Input; readonly connectionParams?: Effect<Record<string, unknown> | undefined, unknown, R>; readonly headers?: Input; readonly idleTimeout?: Input; readonly keepAlive?: false | Input; readonly url: string;}): Layer<GraphQLProtocol, never, WebSocketConstructor | R>Example A client over graphql-ws
import { Effect, Layer } from "effect"import { GraphQLProtocol } from "effect/graphql"import type { Socket } from "effect/socket"
// Provide a Socket.WebSocketConstructor, such as// Socket.layerWebSocketConstructorGlobal in a browserexport const Protocol: Layer.Layer<GraphQLProtocol.GraphQLProtocol, never, Socket.WebSocketConstructor> = GraphQLProtocol.layerWebSocket({ url: "wss://example.com/graphql", connectionParams: Effect.succeed({ authorization: "Bearer secret" }), idleTimeout: "30 seconds" })Models
ExecutionResult
typeThe decoded type of ExecutionResult.
Signature
type ExecutionResult = typeof ExecutionResult.TypeGraphQLRequest
interfaceThe request a transport sends. Middleware sees and may rewrite it before it reaches the transport.
Details
kind is the operation type, which the HTTP transport uses to send queries
as GET when queryMethod: "GET" is set. variables are already encoded
through the operation's variables Schema.
headers and urlParams are applied by the HTTP transport, to queries,
mutations and graphql-sse subscriptions alike. A graphql-ws transport
ignores both, because graphql-ws has no per-operation headers or URL.
Request headers are normalized, immutable Headers.Headers values.
Signature
interface GraphQLRequest { readonly extensions?: Record<string, unknown>; readonly headers: Headers; readonly kind: Kind; readonly operationName: string; readonly query: string; readonly urlParams: UrlParams; readonly variables: unknown;}Schemas
A raw GraphQL result before schema decoding. Middleware receive it from
next and can read response extensions, such as rate-limit costs.
Details
status and headers are transport metadata, not part of the GraphQL
response body: the client decodes the body without them and then copies
what the transport reported. Middleware may read or rewrite them, and a
failed call's ResponseError carries the final values. Headers use
Headers.Headers at runtime and encode as a plain string record.
Signature
declare const ExecutionResult: Struct<{ readonly data: optional<Unknown>; readonly errors: optional<$Array<StructWithRest<Struct<{ readonly extensions: optional<$Record<String, Codec<Json, Json, never, never>>>; readonly locations: optional<$Array<Struct<{ readonly column: ...; readonly line: ...; }>>>; readonly message: String; readonly path: optional<$Array<Union<readonly [..., ...]>>>; }>, readonly [$Record<String, Codec<Json, Json, never, never>>]>>>; readonly extensions: optional<$Record<String, Codec<Json, Json, never, never>>>; readonly headers: optional<decodeTo<declare<Headers, Headers>, $Record<String, String>, never, never>>; readonly status: optional<Int>;}>Services
The transport service. execute answers a query or mutation with the raw
JSON body, and subscribe turns a subscription into a stream of raw events.
Both fail only with TransportError; GraphQL errors are left in the body
for the client to interpret.
Details
Each answer is { body, status?, headers? }. The HTTP transport sets
headers on every answer, including graphql-sse events, and status on
all but graphql-sse events. Headers are Headers.Headers values.
graphql-ws leaves both unset.
Signature
declare class GraphQLProtocol extends Shape<"effect/graphql/GraphQLProtocol", { readonly execute: (request: GraphQLRequest) => Effect<{ readonly body: unknown; readonly headers?: Headers; readonly status?: number; }, TransportError>; readonly subscribe: (request: GraphQLRequest) => Stream<{ readonly body: unknown; readonly headers?: Headers; readonly status?: number; }, TransportError>;}, this> { constructor(_: never);}Example A scripted transport for tests
import { Effect, Layer, Schema, Stream } from "effect"import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
const Viewer = GraphQL.query("Viewer", { document: "query Viewer{viewer{login}}", result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })})
const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, { execute: () => Effect.succeed({ body: { data: { viewer: { login: "tim" } } } }), subscribe: () => Stream.empty})
const program = Effect.gen(function*() { const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer)) return yield* client.Viewer()})
await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => { viewer: { login: "tim" } }