effect 4.0.3 → 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -0
- package/CLAUDE.md +24 -0
- package/ai-docs/package.json +2 -2
- package/ai-docs/src/52_graphql/10_basics.ts +111 -0
- package/ai-docs/src/52_graphql/20_testing.ts +45 -0
- package/ai-docs/src/52_graphql/30_batching.ts +116 -0
- package/ai-docs/src/52_graphql/40_aliased-batching.ts +122 -0
- package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
- package/ai-docs/src/52_graphql/index.md +6 -0
- package/dist/Context.d.ts.map +1 -1
- package/dist/Context.js +9 -0
- package/dist/Context.js.map +1 -1
- package/dist/RequestResolver.d.ts.map +1 -1
- package/dist/RequestResolver.js +4 -10
- package/dist/RequestResolver.js.map +1 -1
- package/dist/Scheduler.d.ts +9 -0
- package/dist/Scheduler.d.ts.map +1 -1
- package/dist/Scheduler.js +9 -0
- package/dist/Scheduler.js.map +1 -1
- package/dist/Schema.d.ts +8 -0
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +7 -0
- package/dist/Schema.js.map +1 -1
- package/dist/SchemaGetter.d.ts +4 -0
- package/dist/SchemaGetter.d.ts.map +1 -1
- package/dist/SchemaGetter.js +52 -33
- package/dist/SchemaGetter.js.map +1 -1
- package/dist/Sink.d.ts.map +1 -1
- package/dist/Sink.js +38 -17
- package/dist/Sink.js.map +1 -1
- package/dist/Stream.d.ts +11 -0
- package/dist/Stream.d.ts.map +1 -1
- package/dist/Stream.js +16 -1
- package/dist/Stream.js.map +1 -1
- package/dist/Utils.js +8 -3
- package/dist/Utils.js.map +1 -1
- package/dist/ai/AnthropicStructuredOutput.js +0 -2
- package/dist/ai/AnthropicStructuredOutput.js.map +1 -1
- package/dist/ai/Chat.d.ts +2 -2
- package/dist/ai/Chat.d.ts.map +1 -1
- package/dist/ai/LanguageModel.d.ts +38 -7
- package/dist/ai/LanguageModel.d.ts.map +1 -1
- package/dist/ai/LanguageModel.js +25 -7
- package/dist/ai/LanguageModel.js.map +1 -1
- package/dist/ai/McpServer.d.ts +55 -0
- package/dist/ai/McpServer.d.ts.map +1 -1
- package/dist/ai/McpServer.js +132 -22
- package/dist/ai/McpServer.js.map +1 -1
- package/dist/ai/Prompt.d.ts.map +1 -1
- package/dist/ai/Prompt.js +28 -5
- package/dist/ai/Prompt.js.map +1 -1
- package/dist/ai/Response.d.ts +4 -3
- package/dist/ai/Response.d.ts.map +1 -1
- package/dist/ai/Response.js.map +1 -1
- package/dist/cluster/ClusterWorkflowEngine.d.ts.map +1 -1
- package/dist/cluster/ClusterWorkflowEngine.js +46 -8
- package/dist/cluster/ClusterWorkflowEngine.js.map +1 -1
- package/dist/cluster/Sharding.d.ts.map +1 -1
- package/dist/cluster/Sharding.js +6 -4
- package/dist/cluster/Sharding.js.map +1 -1
- package/dist/encoding/Sse.d.ts +6 -0
- package/dist/encoding/Sse.d.ts.map +1 -1
- package/dist/encoding/Sse.js +62 -70
- package/dist/encoding/Sse.js.map +1 -1
- package/dist/graphql/GraphQL.d.ts +349 -0
- package/dist/graphql/GraphQL.d.ts.map +1 -0
- package/dist/graphql/GraphQL.js +207 -0
- package/dist/graphql/GraphQL.js.map +1 -0
- package/dist/graphql/GraphQLClient.d.ts +562 -0
- package/dist/graphql/GraphQLClient.d.ts.map +1 -0
- package/dist/graphql/GraphQLClient.js +498 -0
- package/dist/graphql/GraphQLClient.js.map +1 -0
- package/dist/graphql/GraphQLClientError.d.ts +445 -0
- package/dist/graphql/GraphQLClientError.d.ts.map +1 -0
- package/dist/graphql/GraphQLClientError.js +479 -0
- package/dist/graphql/GraphQLClientError.js.map +1 -0
- package/dist/graphql/GraphQLGroup.d.ts +118 -0
- package/dist/graphql/GraphQLGroup.d.ts.map +1 -0
- package/dist/graphql/GraphQLGroup.js +109 -0
- package/dist/graphql/GraphQLGroup.js.map +1 -0
- package/dist/graphql/GraphQLMiddleware.d.ts +249 -0
- package/dist/graphql/GraphQLMiddleware.d.ts.map +1 -0
- package/dist/graphql/GraphQLMiddleware.js +130 -0
- package/dist/graphql/GraphQLMiddleware.js.map +1 -0
- package/dist/graphql/GraphQLProtocol.d.ts +731 -0
- package/dist/graphql/GraphQLProtocol.d.ts.map +1 -0
- package/dist/graphql/GraphQLProtocol.js +719 -0
- package/dist/graphql/GraphQLProtocol.js.map +1 -0
- package/dist/graphql/GraphQLResolver.d.ts +453 -0
- package/dist/graphql/GraphQLResolver.d.ts.map +1 -0
- package/dist/graphql/GraphQLResolver.js +552 -0
- package/dist/graphql/GraphQLResolver.js.map +1 -0
- package/dist/graphql/index.d.ts +40 -0
- package/dist/graphql/index.d.ts.map +1 -0
- package/dist/graphql/index.js +41 -0
- package/dist/graphql/index.js.map +1 -0
- package/dist/http/HttpClient.d.ts +15 -0
- package/dist/http/HttpClient.d.ts.map +1 -1
- package/dist/http/HttpClient.js +24 -10
- package/dist/http/HttpClient.js.map +1 -1
- package/dist/http/HttpEffect.d.ts.map +1 -1
- package/dist/http/HttpEffect.js +14 -7
- package/dist/http/HttpEffect.js.map +1 -1
- package/dist/http/HttpMiddleware.d.ts.map +1 -1
- package/dist/http/HttpMiddleware.js +31 -32
- package/dist/http/HttpMiddleware.js.map +1 -1
- package/dist/http/HttpRouter.d.ts +35 -0
- package/dist/http/HttpRouter.d.ts.map +1 -1
- package/dist/http/HttpRouter.js +35 -0
- package/dist/http/HttpRouter.js.map +1 -1
- package/dist/http/HttpServerResponse.d.ts +8 -6
- package/dist/http/HttpServerResponse.d.ts.map +1 -1
- package/dist/http/HttpServerResponse.js +27 -16
- package/dist/http/HttpServerResponse.js.map +1 -1
- package/dist/http/Multipart.d.ts.map +1 -1
- package/dist/http/Multipart.js +15 -1
- package/dist/http/Multipart.js.map +1 -1
- package/dist/http-api/HttpApiScalar.d.ts +5 -0
- package/dist/http-api/HttpApiScalar.d.ts.map +1 -1
- package/dist/http-api/HttpApiScalar.js.map +1 -1
- package/dist/internal/dateTime.js +40 -35
- package/dist/internal/dateTime.js.map +1 -1
- package/dist/internal/effect.js +8 -3
- package/dist/internal/effect.js.map +1 -1
- package/dist/internal/graphql.d.ts +2 -0
- package/dist/internal/graphql.d.ts.map +1 -0
- package/dist/internal/graphql.js +195 -0
- package/dist/internal/graphql.js.map +1 -0
- package/dist/internal/graphqlHeaders.d.ts +4 -0
- package/dist/internal/graphqlHeaders.d.ts.map +1 -0
- package/dist/internal/graphqlHeaders.js +11 -0
- package/dist/internal/graphqlHeaders.js.map +1 -0
- package/dist/internal/request.js +20 -1
- package/dist/internal/request.js.map +1 -1
- package/dist/internal/schema/toCodeDocument.js +4 -1
- package/dist/internal/schema/toCodeDocument.js.map +1 -1
- package/dist/internal/schema/toJsonSchemaDocument.js +29 -5
- package/dist/internal/schema/toJsonSchemaDocument.js.map +1 -1
- package/dist/internal/version.js +1 -1
- package/dist/net/Host.d.ts +49 -0
- package/dist/net/Host.d.ts.map +1 -1
- package/dist/net/Host.js +55 -0
- package/dist/net/Host.js.map +1 -1
- package/dist/net/NetAddress.d.ts +8 -0
- package/dist/net/NetAddress.d.ts.map +1 -1
- package/dist/net/NetAddress.js +8 -0
- package/dist/net/NetAddress.js.map +1 -1
- package/dist/rpc/RpcServer.d.ts.map +1 -1
- package/dist/rpc/RpcServer.js +36 -9
- package/dist/rpc/RpcServer.js.map +1 -1
- package/dist/socket/Socket.d.ts +6 -0
- package/dist/socket/Socket.d.ts.map +1 -1
- package/dist/socket/Socket.js.map +1 -1
- package/dist/workflow/DurableDeferred.d.ts.map +1 -1
- package/dist/workflow/DurableDeferred.js +32 -3
- package/dist/workflow/DurableDeferred.js.map +1 -1
- package/dist/workflow/WorkflowEngine.d.ts +24 -2
- package/dist/workflow/WorkflowEngine.d.ts.map +1 -1
- package/dist/workflow/WorkflowEngine.js +11 -2
- package/dist/workflow/WorkflowEngine.js.map +1 -1
- package/package.json +6 -3
- package/src/Context.ts +9 -0
- package/src/RequestResolver.ts +4 -10
- package/src/Scheduler.ts +9 -0
- package/src/Schema.ts +8 -0
- package/src/SchemaGetter.ts +47 -35
- package/src/Sink.ts +45 -18
- package/src/Stream.ts +33 -4
- package/src/Utils.ts +8 -5
- package/src/ai/AnthropicStructuredOutput.ts +2 -2
- package/src/ai/Chat.ts +2 -2
- package/src/ai/LanguageModel.ts +96 -24
- package/src/ai/McpServer.ts +244 -90
- package/src/ai/Prompt.ts +24 -10
- package/src/ai/Response.ts +6 -3
- package/src/cluster/ClusterWorkflowEngine.ts +51 -12
- package/src/cluster/Sharding.ts +12 -9
- package/src/encoding/Sse.ts +69 -79
- package/src/graphql/GraphQL.ts +494 -0
- package/src/graphql/GraphQLClient.ts +912 -0
- package/src/graphql/GraphQLClientError.ts +542 -0
- package/src/graphql/GraphQLGroup.ts +167 -0
- package/src/graphql/GraphQLMiddleware.ts +253 -0
- package/src/graphql/GraphQLProtocol.ts +1286 -0
- package/src/graphql/GraphQLResolver.ts +1010 -0
- package/src/graphql/index.ts +48 -0
- package/src/http/HttpClient.ts +54 -10
- package/src/http/HttpEffect.ts +11 -5
- package/src/http/HttpMiddleware.ts +33 -34
- package/src/http/HttpRouter.ts +35 -0
- package/src/http/HttpServerResponse.ts +28 -16
- package/src/http/Multipart.ts +17 -1
- package/src/http-api/HttpApiScalar.ts +5 -0
- package/src/internal/dateTime.ts +13 -10
- package/src/internal/effect.ts +8 -3
- package/src/internal/graphql.ts +263 -0
- package/src/internal/graphqlHeaders.ts +11 -0
- package/src/internal/request.ts +31 -3
- package/src/internal/schema/toCodeDocument.ts +7 -1
- package/src/internal/schema/toJsonSchemaDocument.ts +34 -2
- package/src/internal/version.ts +1 -1
- package/src/net/Host.ts +72 -0
- package/src/net/NetAddress.ts +9 -0
- package/src/rpc/RpcServer.ts +55 -15
- package/src/socket/Socket.ts +6 -0
- package/src/workflow/DurableDeferred.ts +43 -5
- package/src/workflow/WorkflowEngine.ts +48 -4
|
@@ -0,0 +1,1286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transports for `GraphQLClient`. They accept encoded requests and return raw
|
|
3
|
+
* response bodies with available HTTP status and headers, without schema
|
|
4
|
+
* decoding or middleware.
|
|
5
|
+
*
|
|
6
|
+
* - {@link layerHttp} uses HTTP POST (or GET for queries) and graphql-sse
|
|
7
|
+
* subscriptions, with an optional graphql-ws subscription transport.
|
|
8
|
+
* - {@link layerWebSocket} multiplexes all operations over graphql-ws.
|
|
9
|
+
*
|
|
10
|
+
* {@link makeHttp} and {@link makeWebSocket} construct the same transports as effects.
|
|
11
|
+
*
|
|
12
|
+
* @stability experimental
|
|
13
|
+
* @since 4.0.0
|
|
14
|
+
*/
|
|
15
|
+
import * as Cause from "../Cause.ts"
|
|
16
|
+
import * as Clock from "../Clock.ts"
|
|
17
|
+
import * as Context from "../Context.ts"
|
|
18
|
+
import * as Deferred from "../Deferred.ts"
|
|
19
|
+
import * as Duration from "../Duration.ts"
|
|
20
|
+
import * as Effect from "../Effect.ts"
|
|
21
|
+
import * as Sse from "../encoding/Sse.ts"
|
|
22
|
+
import * as Fiber from "../Fiber.ts"
|
|
23
|
+
import * as Headers from "../http/Headers.ts"
|
|
24
|
+
import * as HttpClient from "../http/HttpClient.ts"
|
|
25
|
+
import * as HttpClientError from "../http/HttpClientError.ts"
|
|
26
|
+
import * as HttpClientRequest from "../http/HttpClientRequest.ts"
|
|
27
|
+
import * as HttpClientResponse from "../http/HttpClientResponse.ts"
|
|
28
|
+
import * as UrlParams from "../http/UrlParams.ts"
|
|
29
|
+
import * as internalGraphQL from "../internal/graphql.ts"
|
|
30
|
+
import { HeadersSchema } from "../internal/graphqlHeaders.ts"
|
|
31
|
+
import * as Layer from "../Layer.ts"
|
|
32
|
+
import * as Queue from "../Queue.ts"
|
|
33
|
+
import * as Result from "../Result.ts"
|
|
34
|
+
import * as Schema from "../Schema.ts"
|
|
35
|
+
import type * as Scope from "../Scope.ts"
|
|
36
|
+
import * as Socket from "../socket/Socket.ts"
|
|
37
|
+
import * as Stream from "../Stream.ts"
|
|
38
|
+
import type { Kind } from "./GraphQL.ts"
|
|
39
|
+
import { GraphQLError, TransportError } from "./GraphQLClientError.ts"
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The request a transport sends. Middleware sees and may rewrite it before it
|
|
43
|
+
* reaches the transport.
|
|
44
|
+
*
|
|
45
|
+
* **Details**
|
|
46
|
+
*
|
|
47
|
+
* `kind` is the operation type, which the HTTP transport uses to send queries
|
|
48
|
+
* as `GET` when `queryMethod: "GET"` is set. `variables` are already encoded
|
|
49
|
+
* through the operation's variables Schema.
|
|
50
|
+
*
|
|
51
|
+
* `headers` and `urlParams` are applied by the HTTP transport, to queries,
|
|
52
|
+
* mutations and graphql-sse subscriptions alike. A graphql-ws transport
|
|
53
|
+
* ignores both, because graphql-ws has no per-operation headers or URL.
|
|
54
|
+
* Request headers are normalized, immutable `Headers.Headers` values.
|
|
55
|
+
*
|
|
56
|
+
* @stability experimental
|
|
57
|
+
* @category models
|
|
58
|
+
* @since 4.0.0
|
|
59
|
+
*/
|
|
60
|
+
export interface GraphQLRequest {
|
|
61
|
+
readonly kind: Kind
|
|
62
|
+
readonly query: string
|
|
63
|
+
readonly operationName: string
|
|
64
|
+
readonly variables: unknown
|
|
65
|
+
readonly extensions?: Record<string, unknown> | undefined
|
|
66
|
+
readonly headers: Headers.Headers
|
|
67
|
+
readonly urlParams: UrlParams.UrlParams
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A raw GraphQL result before schema decoding. Middleware receive it from
|
|
72
|
+
* `next` and can read response `extensions`, such as rate-limit costs.
|
|
73
|
+
*
|
|
74
|
+
* **Details**
|
|
75
|
+
*
|
|
76
|
+
* `status` and `headers` are transport metadata, not part of the GraphQL
|
|
77
|
+
* response body: the client decodes the body without them and then copies
|
|
78
|
+
* what the transport reported. Middleware may read or rewrite them, and a
|
|
79
|
+
* failed call's `ResponseError` carries the final values. Headers use
|
|
80
|
+
* `Headers.Headers` at runtime and encode as a plain string record.
|
|
81
|
+
*
|
|
82
|
+
* @stability experimental
|
|
83
|
+
* @category schemas
|
|
84
|
+
* @since 4.0.0
|
|
85
|
+
*/
|
|
86
|
+
export const ExecutionResult = Schema.Struct({
|
|
87
|
+
data: Schema.optional(Schema.Unknown),
|
|
88
|
+
errors: Schema.optional(Schema.Array(GraphQLError)),
|
|
89
|
+
extensions: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
|
|
90
|
+
status: Schema.optional(Schema.Int),
|
|
91
|
+
headers: Schema.optional(HeadersSchema)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The decoded type of {@link ExecutionResult}.
|
|
96
|
+
*
|
|
97
|
+
* @stability experimental
|
|
98
|
+
* @category models
|
|
99
|
+
* @since 4.0.0
|
|
100
|
+
*/
|
|
101
|
+
export type ExecutionResult = typeof ExecutionResult.Type
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The transport service. `execute` answers a query or mutation with the raw
|
|
105
|
+
* JSON body, and `subscribe` turns a subscription into a stream of raw events.
|
|
106
|
+
* Both fail only with `TransportError`; GraphQL `errors` are left in the body
|
|
107
|
+
* for the client to interpret.
|
|
108
|
+
*
|
|
109
|
+
* **Details**
|
|
110
|
+
*
|
|
111
|
+
* Each answer is `{ body, status?, headers? }`. The HTTP transport sets
|
|
112
|
+
* `headers` on every answer, including graphql-sse events, and `status` on
|
|
113
|
+
* all but graphql-sse events. Headers are `Headers.Headers` values.
|
|
114
|
+
* graphql-ws leaves both unset.
|
|
115
|
+
*
|
|
116
|
+
* **Example** (A scripted transport for tests)
|
|
117
|
+
*
|
|
118
|
+
* ```ts import.meta.vitest
|
|
119
|
+
* import { Effect, Layer, Schema, Stream } from "effect"
|
|
120
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
121
|
+
*
|
|
122
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
123
|
+
* document: "query Viewer{viewer{login}}",
|
|
124
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
125
|
+
* })
|
|
126
|
+
*
|
|
127
|
+
* const Scripted = Layer.succeed(GraphQLProtocol.GraphQLProtocol, {
|
|
128
|
+
* execute: () => Effect.succeed({ body: { data: { viewer: { login: "tim" } } } }),
|
|
129
|
+
* subscribe: () => Stream.empty
|
|
130
|
+
* })
|
|
131
|
+
*
|
|
132
|
+
* const program = Effect.gen(function*() {
|
|
133
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
134
|
+
* return yield* client.Viewer()
|
|
135
|
+
* })
|
|
136
|
+
*
|
|
137
|
+
* await Effect.runPromise(program.pipe(Effect.provide(Scripted))) // => { viewer: { login: "tim" } }
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @stability experimental
|
|
141
|
+
* @category services
|
|
142
|
+
* @since 4.0.0
|
|
143
|
+
*/
|
|
144
|
+
export class GraphQLProtocol extends Context.Service<GraphQLProtocol, {
|
|
145
|
+
readonly execute: (request: GraphQLRequest) => Effect.Effect<{
|
|
146
|
+
readonly body: unknown
|
|
147
|
+
readonly status?: number | undefined
|
|
148
|
+
readonly headers?: Headers.Headers | undefined
|
|
149
|
+
}, TransportError>
|
|
150
|
+
readonly subscribe: (request: GraphQLRequest) => Stream.Stream<{
|
|
151
|
+
readonly body: unknown
|
|
152
|
+
readonly status?: number | undefined
|
|
153
|
+
readonly headers?: Headers.Headers | undefined
|
|
154
|
+
}, TransportError>
|
|
155
|
+
}>()("effect/graphql/GraphQLProtocol") {}
|
|
156
|
+
|
|
157
|
+
const graphqlResponseContentType = "application/graphql-response+json"
|
|
158
|
+
const jsonContentType = "application/json"
|
|
159
|
+
const eventStreamContentType = "text/event-stream"
|
|
160
|
+
|
|
161
|
+
const isObject = (u: unknown): u is Record<string, unknown> => typeof u === "object" && u !== null && !Array.isArray(u)
|
|
162
|
+
|
|
163
|
+
// A gateway's `{ "errors": "Throttled" }` is not a GraphQL response, so its
|
|
164
|
+
// status and Retry-After still apply.
|
|
165
|
+
const isGraphQLBody = (body: unknown): boolean =>
|
|
166
|
+
isObject(body) && ("data" in body || "errors" in body) &&
|
|
167
|
+
(!("errors" in body) || Array.isArray(body.errors)) &&
|
|
168
|
+
(!("data" in body) || body.data === null || isObject(body.data))
|
|
169
|
+
|
|
170
|
+
// The JSON body of a POST and the payload of a graphql-ws `subscribe`.
|
|
171
|
+
// `extensions` is dropped from the JSON when unset.
|
|
172
|
+
const requestBody = (request: GraphQLRequest) => ({
|
|
173
|
+
query: request.query,
|
|
174
|
+
operationName: request.operationName,
|
|
175
|
+
variables: request.variables,
|
|
176
|
+
extensions: request.extensions
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const postRequest = (url: string, request: GraphQLRequest, accept: string) =>
|
|
180
|
+
HttpClientRequest.post(url).pipe(
|
|
181
|
+
HttpClientRequest.setHeaders(request.headers),
|
|
182
|
+
HttpClientRequest.setHeader("accept", accept),
|
|
183
|
+
HttpClientRequest.appendUrlParams(request.urlParams),
|
|
184
|
+
HttpClientRequest.bodyJsonUnsafe(requestBody(request))
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const graphqlGetParams = new Set(["query", "operationName", "variables", "extensions"])
|
|
188
|
+
|
|
189
|
+
// Remove all protocol params from the configured URL and caller params before
|
|
190
|
+
// adding the request's values, including when optional fields are unset. Split
|
|
191
|
+
// the URL without resolving it so relative URLs remain supported.
|
|
192
|
+
const getRequest = (url: string, request: GraphQLRequest, accept: string) => {
|
|
193
|
+
const hashIndex = url.indexOf("#")
|
|
194
|
+
const hash = hashIndex === -1 ? "" : url.slice(hashIndex)
|
|
195
|
+
const withoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex)
|
|
196
|
+
const queryIndex = withoutHash.indexOf("?")
|
|
197
|
+
const baseUrl = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex)
|
|
198
|
+
const urlParams = [
|
|
199
|
+
...UrlParams.fromInput(new URLSearchParams(queryIndex === -1 ? "" : withoutHash.slice(queryIndex + 1))),
|
|
200
|
+
...request.urlParams
|
|
201
|
+
].filter(([key]) => !graphqlGetParams.has(key))
|
|
202
|
+
const params: Record<string, string> = { query: request.query, operationName: request.operationName }
|
|
203
|
+
if (request.variables !== undefined) params.variables = JSON.stringify(request.variables)
|
|
204
|
+
if (request.extensions !== undefined) params.extensions = JSON.stringify(request.extensions)
|
|
205
|
+
return HttpClientRequest.get(baseUrl + hash).pipe(
|
|
206
|
+
HttpClientRequest.setHeaders(request.headers),
|
|
207
|
+
HttpClientRequest.setHeader("accept", accept),
|
|
208
|
+
HttpClientRequest.appendUrlParams(urlParams),
|
|
209
|
+
HttpClientRequest.setUrlParams(params)
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Reads the clock when the error arrives, so an HTTP-date `Retry-After` is
|
|
214
|
+
// measured from when the response was received.
|
|
215
|
+
const fromHttpClientError =
|
|
216
|
+
(clock: Clock.Clock) =>
|
|
217
|
+
<A>(effect: Effect.Effect<A, TransportError | HttpClientError.HttpClientError>): Effect.Effect<A, TransportError> =>
|
|
218
|
+
Effect.catch(effect, (error) =>
|
|
219
|
+
Effect.fail(
|
|
220
|
+
HttpClientError.isHttpClientError(error)
|
|
221
|
+
? TransportError.fromHttpClientError(error, { now: clock.currentTimeMillisUnsafe() })
|
|
222
|
+
: error
|
|
223
|
+
))
|
|
224
|
+
|
|
225
|
+
// Media types are case-insensitive.
|
|
226
|
+
const contentTypeOf = (response: HttpClientResponse.HttpClientResponse): string =>
|
|
227
|
+
(response.headers["content-type"] ?? "").toLowerCase()
|
|
228
|
+
|
|
229
|
+
const readResponse = (
|
|
230
|
+
response: HttpClientResponse.HttpClientResponse
|
|
231
|
+
): Effect.Effect<unknown, TransportError | HttpClientError.HttpClientError> => {
|
|
232
|
+
const contentType = contentTypeOf(response)
|
|
233
|
+
// The GraphQL-over-HTTP media type is authoritative, whatever the status.
|
|
234
|
+
if (contentType.includes(graphqlResponseContentType)) {
|
|
235
|
+
return response.json
|
|
236
|
+
}
|
|
237
|
+
// Legacy servers answer with application/json; only a body with the
|
|
238
|
+
// GraphQL keys counts as a GraphQL response.
|
|
239
|
+
if (contentType.includes(jsonContentType)) {
|
|
240
|
+
return Effect.flatMap(response.json, (body) =>
|
|
241
|
+
isGraphQLBody(body)
|
|
242
|
+
? Effect.succeed(body)
|
|
243
|
+
: Effect.as(HttpClientResponse.filterStatusOk(response), body))
|
|
244
|
+
}
|
|
245
|
+
return Effect.flatMap(
|
|
246
|
+
HttpClientResponse.filterStatusOk(response),
|
|
247
|
+
() =>
|
|
248
|
+
Effect.fail(
|
|
249
|
+
new TransportError({
|
|
250
|
+
description: `Expected a GraphQL response, received ${
|
|
251
|
+
contentType === "" ? "no Content-Type" : `Content-Type ${contentType}`
|
|
252
|
+
}`,
|
|
253
|
+
status: response.status,
|
|
254
|
+
headers: response.headers
|
|
255
|
+
})
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const readAnswer = (response: HttpClientResponse.HttpClientResponse) =>
|
|
261
|
+
Effect.map(readResponse(response), (body) => ({ body, status: response.status, headers: response.headers }))
|
|
262
|
+
|
|
263
|
+
const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause)
|
|
264
|
+
|
|
265
|
+
// The graphql-sse distinct-mode events of a `text/event-stream` body. The
|
|
266
|
+
// stream ends on a `complete` event, with or without a `data:` line. A body
|
|
267
|
+
// that ends without one means the connection was lost, so it fails with a
|
|
268
|
+
// retryable `TransportError`. A `retry:` line is kept and reported as
|
|
269
|
+
// `retryAfter` if the body fails or ends early.
|
|
270
|
+
const sseEvents = (
|
|
271
|
+
response: HttpClientResponse.HttpClientResponse
|
|
272
|
+
): Stream.Stream<{ readonly body: unknown; readonly headers: Headers.Headers }, TransportError> =>
|
|
273
|
+
Stream.suspend(() => {
|
|
274
|
+
const headers = response.headers
|
|
275
|
+
let retryAfter: Duration.Duration | undefined
|
|
276
|
+
let complete = false
|
|
277
|
+
let events: Array<Sse.Event> = []
|
|
278
|
+
const parser = Sse.makeParser((event) => {
|
|
279
|
+
if (event._tag === "Retry") {
|
|
280
|
+
retryAfter = event.duration
|
|
281
|
+
} else {
|
|
282
|
+
events.push(event)
|
|
283
|
+
}
|
|
284
|
+
}, { dispatchEmptyNamedEvents: true })
|
|
285
|
+
return response.stream.pipe(
|
|
286
|
+
Stream.decodeText(),
|
|
287
|
+
Stream.mapError((cause) =>
|
|
288
|
+
new TransportError({ description: `The graphql-sse stream failed: ${cause.message}`, retryAfter, cause })
|
|
289
|
+
),
|
|
290
|
+
Stream.mapEffect((text) => {
|
|
291
|
+
const error = parser.feed(text)
|
|
292
|
+
if (error !== undefined) {
|
|
293
|
+
return Effect.fail(new TransportError({ description: error.message, cause: error }))
|
|
294
|
+
}
|
|
295
|
+
const batch = events
|
|
296
|
+
events = []
|
|
297
|
+
return Effect.succeed(batch)
|
|
298
|
+
}),
|
|
299
|
+
Stream.flattenIterable,
|
|
300
|
+
Stream.takeWhile((event) => {
|
|
301
|
+
if (event.event === "complete") complete = true
|
|
302
|
+
return !complete
|
|
303
|
+
}),
|
|
304
|
+
Stream.filter((event) => event.event === "next"),
|
|
305
|
+
Stream.mapEffect((event) =>
|
|
306
|
+
Effect.try({
|
|
307
|
+
try: () => ({ body: JSON.parse(event.data) as unknown, headers }),
|
|
308
|
+
catch: (cause) =>
|
|
309
|
+
new TransportError({ description: `Invalid graphql-sse next event: ${errorMessage(cause)}`, cause })
|
|
310
|
+
})
|
|
311
|
+
),
|
|
312
|
+
Stream.concat(Stream.suspend(() =>
|
|
313
|
+
complete ? Stream.empty : Stream.fail(
|
|
314
|
+
new TransportError({ description: "The graphql-sse stream ended without a complete event", retryAfter })
|
|
315
|
+
)
|
|
316
|
+
))
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
interface HttpOptions<R = never> {
|
|
321
|
+
readonly url: string
|
|
322
|
+
readonly queryMethod?: "GET" | "POST" | undefined
|
|
323
|
+
readonly subscriptions?: {
|
|
324
|
+
readonly webSocket: WebSocketOptions<R>
|
|
325
|
+
} | undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const makeHttpWith = <R>(options: HttpOptions<R>): Effect.Effect<
|
|
329
|
+
GraphQLProtocol["Service"],
|
|
330
|
+
never,
|
|
331
|
+
HttpClient.HttpClient | Socket.WebSocketConstructor | Scope.Scope | R
|
|
332
|
+
> => {
|
|
333
|
+
const http = Clock.clockWith((clock) =>
|
|
334
|
+
HttpClient.HttpClient.useSync((client) => {
|
|
335
|
+
const execute = (request: GraphQLRequest) =>
|
|
336
|
+
Effect.flatMap(Effect.service(internalGraphQL.ForcePost), (forcePost) => {
|
|
337
|
+
const toRequest = options.queryMethod === "GET" && request.kind === "query" && !forcePost
|
|
338
|
+
? getRequest
|
|
339
|
+
: postRequest
|
|
340
|
+
return client.execute(
|
|
341
|
+
toRequest(options.url, request, `${graphqlResponseContentType}, ${jsonContentType}`)
|
|
342
|
+
).pipe(
|
|
343
|
+
Effect.flatMap(readAnswer),
|
|
344
|
+
fromHttpClientError(clock)
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
const subscribe = (request: GraphQLRequest) =>
|
|
348
|
+
Stream.unwrap(Effect.suspend(() =>
|
|
349
|
+
client.execute(postRequest(options.url, request, eventStreamContentType)).pipe(
|
|
350
|
+
Effect.flatMap((response) => {
|
|
351
|
+
const contentType = contentTypeOf(response)
|
|
352
|
+
if (contentType.includes(eventStreamContentType) && response.status >= 200 && response.status < 300) {
|
|
353
|
+
return Effect.succeed(sseEvents(response))
|
|
354
|
+
}
|
|
355
|
+
// A server that rejects the subscription answers with a single
|
|
356
|
+
// GraphQL response, which the client reads like a query's.
|
|
357
|
+
return Effect.map(readAnswer(response), Stream.succeed)
|
|
358
|
+
}),
|
|
359
|
+
fromHttpClientError(clock)
|
|
360
|
+
)
|
|
361
|
+
))
|
|
362
|
+
return GraphQLProtocol.of({ execute, subscribe })
|
|
363
|
+
})
|
|
364
|
+
)
|
|
365
|
+
const webSocket = options.subscriptions?.webSocket
|
|
366
|
+
return webSocket === undefined
|
|
367
|
+
? http
|
|
368
|
+
: Effect.zipWith(
|
|
369
|
+
http,
|
|
370
|
+
makeWebSocket(webSocket),
|
|
371
|
+
(http, ws) => GraphQLProtocol.of({ execute: http.execute, subscribe: ws.subscribe })
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Builds the HTTP transport as an effect, for composing transports by hand.
|
|
377
|
+
* Most code uses {@link layerHttp}.
|
|
378
|
+
*
|
|
379
|
+
* **Details**
|
|
380
|
+
*
|
|
381
|
+
* Takes the same options as {@link layerHttp}, including `queryMethod`.
|
|
382
|
+
* Without `subscriptions`, subscriptions use graphql-sse distinct mode on
|
|
383
|
+
* `url`. With `subscriptions.webSocket`, they go over graphql-ws instead, and
|
|
384
|
+
* the effect also builds the graphql-ws transport, so it needs a
|
|
385
|
+
* `Socket.WebSocketConstructor` and a `Scope`.
|
|
386
|
+
*
|
|
387
|
+
* @stability experimental
|
|
388
|
+
* @category constructors
|
|
389
|
+
* @since 4.0.0
|
|
390
|
+
*/
|
|
391
|
+
export const makeHttp: {
|
|
392
|
+
/**
|
|
393
|
+
* Builds the HTTP transport as an effect, for composing transports by hand.
|
|
394
|
+
* Most code uses {@link layerHttp}.
|
|
395
|
+
*
|
|
396
|
+
* **Details**
|
|
397
|
+
*
|
|
398
|
+
* Takes the same options as {@link layerHttp}, including `queryMethod`.
|
|
399
|
+
* Without `subscriptions`, subscriptions use graphql-sse distinct mode on
|
|
400
|
+
* `url`. With `subscriptions.webSocket`, they go over graphql-ws instead, and
|
|
401
|
+
* the effect also builds the graphql-ws transport, so it needs a
|
|
402
|
+
* `Socket.WebSocketConstructor` and a `Scope`.
|
|
403
|
+
*
|
|
404
|
+
* @stability experimental
|
|
405
|
+
* @category constructors
|
|
406
|
+
* @since 4.0.0
|
|
407
|
+
*/
|
|
408
|
+
<R = never>(
|
|
409
|
+
options: {
|
|
410
|
+
readonly url: string
|
|
411
|
+
readonly queryMethod?: "GET" | "POST" | undefined
|
|
412
|
+
readonly subscriptions: {
|
|
413
|
+
readonly webSocket: {
|
|
414
|
+
readonly url: string
|
|
415
|
+
readonly headers?: Headers.Input | undefined
|
|
416
|
+
readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R> | undefined
|
|
417
|
+
readonly connectionAckTimeout?: Duration.Input | undefined
|
|
418
|
+
readonly keepAlive?: Duration.Input | false | undefined
|
|
419
|
+
readonly idleTimeout?: Duration.Input | undefined
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
): Effect.Effect<
|
|
424
|
+
GraphQLProtocol["Service"],
|
|
425
|
+
never,
|
|
426
|
+
HttpClient.HttpClient | Socket.WebSocketConstructor | Scope.Scope | R
|
|
427
|
+
>
|
|
428
|
+
/**
|
|
429
|
+
* Builds the HTTP transport as an effect, for composing transports by hand.
|
|
430
|
+
* Most code uses {@link layerHttp}.
|
|
431
|
+
*
|
|
432
|
+
* **Details**
|
|
433
|
+
*
|
|
434
|
+
* Takes the same options as {@link layerHttp}, including `queryMethod`.
|
|
435
|
+
* Without `subscriptions`, subscriptions use graphql-sse distinct mode on
|
|
436
|
+
* `url`. With `subscriptions.webSocket`, they go over graphql-ws instead, and
|
|
437
|
+
* the effect also builds the graphql-ws transport, so it needs a
|
|
438
|
+
* `Socket.WebSocketConstructor` and a `Scope`.
|
|
439
|
+
*
|
|
440
|
+
* @stability experimental
|
|
441
|
+
* @category constructors
|
|
442
|
+
* @since 4.0.0
|
|
443
|
+
*/
|
|
444
|
+
(
|
|
445
|
+
options: {
|
|
446
|
+
readonly url: string
|
|
447
|
+
readonly queryMethod?: "GET" | "POST" | undefined
|
|
448
|
+
readonly subscriptions?: undefined
|
|
449
|
+
}
|
|
450
|
+
): Effect.Effect<
|
|
451
|
+
GraphQLProtocol["Service"],
|
|
452
|
+
never,
|
|
453
|
+
HttpClient.HttpClient
|
|
454
|
+
>
|
|
455
|
+
} = makeHttpWith
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The HTTP transport: queries and mutations are sent as `POST` with a JSON
|
|
459
|
+
* body to `url`, using the `HttpClient` in context. Subscriptions use
|
|
460
|
+
* graphql-sse distinct mode on the same URL, or graphql-ws when
|
|
461
|
+
* `subscriptions.webSocket` is set.
|
|
462
|
+
*
|
|
463
|
+
* **Details**
|
|
464
|
+
*
|
|
465
|
+
* With `queryMethod: "GET"` (default `"POST"`), queries are sent as `GET`
|
|
466
|
+
* with no body: `query` and `operationName` become URL params, plus
|
|
467
|
+
* `variables` and `extensions` as JSON when set. Mutations are always
|
|
468
|
+
* `POST`, and so are `GraphQLResolver.aliased` batches. A long query is not
|
|
469
|
+
* retried as `POST`.
|
|
470
|
+
*
|
|
471
|
+
* A request's `headers` and `urlParams`, from the call options or from
|
|
472
|
+
* middleware, are applied to every HTTP request. A query string already on
|
|
473
|
+
* `url` is kept. On `GET`, the request's `urlParams` come before the GraphQL
|
|
474
|
+
* params, which replace any caller param with the same name.
|
|
475
|
+
*
|
|
476
|
+
* Every response's HTTP `status` is passed to the client as
|
|
477
|
+
* `ExecutionResult.status`, and a call that fails with GraphQL `errors`
|
|
478
|
+
* reports it as `ResponseError.status`. Response headers are passed the
|
|
479
|
+
* same way, as `ExecutionResult.headers` and `ResponseError.headers`.
|
|
480
|
+
*
|
|
481
|
+
* A response is a GraphQL response when its `Content-Type` is
|
|
482
|
+
* `application/graphql-response+json`, whatever the status, or when it is
|
|
483
|
+
* `application/json` and the body has a list of `errors` or a `data` object
|
|
484
|
+
* (or `null`). Media types match case-insensitively. Anything else
|
|
485
|
+
* is a `TransportError` with the response `status` and `headers`, and a
|
|
486
|
+
* `Retry-After` header (whole seconds or an HTTP date, measured from when the
|
|
487
|
+
* response arrived) becomes `retryAfter`.
|
|
488
|
+
*
|
|
489
|
+
* A subscription is a `POST` with `Accept: text/event-stream`. Each `next`
|
|
490
|
+
* event is emitted with the response headers but no status, and the stream
|
|
491
|
+
* ends on a `complete` event, with or without a `data:` line. A body that
|
|
492
|
+
* fails, or ends without a `complete` event, is a retryable `TransportError`
|
|
493
|
+
* with a server `retry:` line as its `retryAfter`, so the client's
|
|
494
|
+
* `subscriptionRetry` resubscribes. A non-stream answer, such as a `400` with
|
|
495
|
+
* `errors`, is read like a query's response. Interrupting the stream aborts
|
|
496
|
+
* the request.
|
|
497
|
+
*
|
|
498
|
+
* With `subscriptions: { webSocket }`, subscriptions go over graphql-ws as in
|
|
499
|
+
* {@link layerWebSocket}, and the layer needs a `Socket.WebSocketConstructor`.
|
|
500
|
+
*
|
|
501
|
+
* **Example** (Building a client over HTTP)
|
|
502
|
+
*
|
|
503
|
+
* ```ts import.meta.vitest
|
|
504
|
+
* import { Effect, Layer, Schema } from "effect"
|
|
505
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
506
|
+
* import { HttpClient, HttpClientResponse } from "effect/http"
|
|
507
|
+
*
|
|
508
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
509
|
+
* document: "query Viewer{viewer{login}}",
|
|
510
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
511
|
+
* })
|
|
512
|
+
*
|
|
513
|
+
* // A stand-in for a real HttpClient layer such as FetchHttpClient.layer
|
|
514
|
+
* const FakeServer = Layer.succeed(
|
|
515
|
+
* HttpClient.HttpClient,
|
|
516
|
+
* HttpClient.make((request) =>
|
|
517
|
+
* Effect.succeed(HttpClientResponse.fromWeb(
|
|
518
|
+
* request,
|
|
519
|
+
* new Response(JSON.stringify({ data: { viewer: { login: "tim" } } }), {
|
|
520
|
+
* headers: { "content-type": "application/graphql-response+json" }
|
|
521
|
+
* })
|
|
522
|
+
* ))
|
|
523
|
+
* )
|
|
524
|
+
* )
|
|
525
|
+
*
|
|
526
|
+
* const program = Effect.gen(function*() {
|
|
527
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
528
|
+
* return yield* client.Viewer()
|
|
529
|
+
* }).pipe(
|
|
530
|
+
* Effect.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })),
|
|
531
|
+
* Effect.provide(FakeServer)
|
|
532
|
+
* )
|
|
533
|
+
*
|
|
534
|
+
* await Effect.runPromise(program) // => { viewer: { login: "tim" } }
|
|
535
|
+
* ```
|
|
536
|
+
*
|
|
537
|
+
* **Example** (Queries over GET with a per-call URL param)
|
|
538
|
+
*
|
|
539
|
+
* ```ts import.meta.vitest
|
|
540
|
+
* import { Effect, Layer, Schema } from "effect"
|
|
541
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
542
|
+
* import { HttpClient, HttpClientResponse } from "effect/http"
|
|
543
|
+
*
|
|
544
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
545
|
+
* document: "query Viewer{viewer{login}}",
|
|
546
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
547
|
+
* })
|
|
548
|
+
*
|
|
549
|
+
* // A stand-in server that answers with the method and the `preview` param
|
|
550
|
+
* const FakeServer = Layer.succeed(
|
|
551
|
+
* HttpClient.HttpClient,
|
|
552
|
+
* HttpClient.make((request, url) =>
|
|
553
|
+
* Effect.succeed(HttpClientResponse.fromWeb(
|
|
554
|
+
* request,
|
|
555
|
+
* new Response(
|
|
556
|
+
* JSON.stringify({ data: { viewer: { login: `${request.method} ${url.searchParams.get("preview")}` } } }),
|
|
557
|
+
* { headers: { "content-type": "application/graphql-response+json" } }
|
|
558
|
+
* )
|
|
559
|
+
* ))
|
|
560
|
+
* )
|
|
561
|
+
* )
|
|
562
|
+
*
|
|
563
|
+
* const program = Effect.gen(function*() {
|
|
564
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
565
|
+
* return yield* client.Viewer(undefined, { urlParams: { preview: "draft-token" } })
|
|
566
|
+
* }).pipe(
|
|
567
|
+
* Effect.provide(GraphQLProtocol.layerHttp({ url: "https://cms.example.com/graphql", queryMethod: "GET" })),
|
|
568
|
+
* Effect.provide(FakeServer)
|
|
569
|
+
* )
|
|
570
|
+
*
|
|
571
|
+
* await Effect.runPromise(program) // => { viewer: { login: "GET draft-token" } }
|
|
572
|
+
* ```
|
|
573
|
+
*
|
|
574
|
+
* **Example** (Subscriptions over graphql-ws, everything else over POST)
|
|
575
|
+
*
|
|
576
|
+
* ```ts import.meta.vitest
|
|
577
|
+
* import { Effect } from "effect"
|
|
578
|
+
* import { GraphQLProtocol } from "effect/graphql"
|
|
579
|
+
*
|
|
580
|
+
* const token = Effect.succeed("secret")
|
|
581
|
+
*
|
|
582
|
+
* // Provide an HttpClient and a Socket.WebSocketConstructor, such as
|
|
583
|
+
* // FetchHttpClient.layer and NodeSocket.layerWebSocketConstructor
|
|
584
|
+
* export const Protocol = GraphQLProtocol.layerHttp({
|
|
585
|
+
* url: "https://example.com/graphql",
|
|
586
|
+
* subscriptions: {
|
|
587
|
+
* webSocket: {
|
|
588
|
+
* url: "wss://example.com/graphql",
|
|
589
|
+
* connectionParams: Effect.map(token, (token) => ({ authorization: `Bearer ${token}` }))
|
|
590
|
+
* }
|
|
591
|
+
* }
|
|
592
|
+
* })
|
|
593
|
+
* ```
|
|
594
|
+
*
|
|
595
|
+
* @stability experimental
|
|
596
|
+
* @category layers
|
|
597
|
+
* @since 4.0.0
|
|
598
|
+
*/
|
|
599
|
+
export const layerHttp: {
|
|
600
|
+
/**
|
|
601
|
+
* The HTTP transport: queries and mutations are sent as `POST` with a JSON
|
|
602
|
+
* body to `url`, using the `HttpClient` in context. Subscriptions use
|
|
603
|
+
* graphql-sse distinct mode on the same URL, or graphql-ws when
|
|
604
|
+
* `subscriptions.webSocket` is set.
|
|
605
|
+
*
|
|
606
|
+
* **Details**
|
|
607
|
+
*
|
|
608
|
+
* With `queryMethod: "GET"` (default `"POST"`), queries are sent as `GET`
|
|
609
|
+
* with no body: `query` and `operationName` become URL params, plus
|
|
610
|
+
* `variables` and `extensions` as JSON when set. Mutations are always
|
|
611
|
+
* `POST`, and so are `GraphQLResolver.aliased` batches. A long query is not
|
|
612
|
+
* retried as `POST`.
|
|
613
|
+
*
|
|
614
|
+
* A request's `headers` and `urlParams`, from the call options or from
|
|
615
|
+
* middleware, are applied to every HTTP request. A query string already on
|
|
616
|
+
* `url` is kept. On `GET`, the request's `urlParams` come before the GraphQL
|
|
617
|
+
* params, which replace any caller param with the same name.
|
|
618
|
+
*
|
|
619
|
+
* Every response's HTTP `status` is passed to the client as
|
|
620
|
+
* `ExecutionResult.status`, and a call that fails with GraphQL `errors`
|
|
621
|
+
* reports it as `ResponseError.status`. Response headers are passed the
|
|
622
|
+
* same way, as `ExecutionResult.headers` and `ResponseError.headers`.
|
|
623
|
+
*
|
|
624
|
+
* A response is a GraphQL response when its `Content-Type` is
|
|
625
|
+
* `application/graphql-response+json`, whatever the status, or when it is
|
|
626
|
+
* `application/json` and the body has a list of `errors` or a `data` object
|
|
627
|
+
* (or `null`). Media types match case-insensitively. Anything else
|
|
628
|
+
* is a `TransportError` with the response `status` and `headers`, and a
|
|
629
|
+
* `Retry-After` header (whole seconds or an HTTP date, measured from when the
|
|
630
|
+
* response arrived) becomes `retryAfter`.
|
|
631
|
+
*
|
|
632
|
+
* A subscription is a `POST` with `Accept: text/event-stream`. Each `next`
|
|
633
|
+
* event is emitted with the response headers but no status, and the stream
|
|
634
|
+
* ends on a `complete` event, with or without a `data:` line. A body that
|
|
635
|
+
* fails, or ends without a `complete` event, is a retryable `TransportError`
|
|
636
|
+
* with a server `retry:` line as its `retryAfter`, so the client's
|
|
637
|
+
* `subscriptionRetry` resubscribes. A non-stream answer, such as a `400` with
|
|
638
|
+
* `errors`, is read like a query's response. Interrupting the stream aborts
|
|
639
|
+
* the request.
|
|
640
|
+
*
|
|
641
|
+
* With `subscriptions: { webSocket }`, subscriptions go over graphql-ws as in
|
|
642
|
+
* {@link layerWebSocket}, and the layer needs a `Socket.WebSocketConstructor`.
|
|
643
|
+
*
|
|
644
|
+
* **Example** (Building a client over HTTP)
|
|
645
|
+
*
|
|
646
|
+
* ```ts import.meta.vitest
|
|
647
|
+
* import { Effect, Layer, Schema } from "effect"
|
|
648
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
649
|
+
* import { HttpClient, HttpClientResponse } from "effect/http"
|
|
650
|
+
*
|
|
651
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
652
|
+
* document: "query Viewer{viewer{login}}",
|
|
653
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
654
|
+
* })
|
|
655
|
+
*
|
|
656
|
+
* // A stand-in for a real HttpClient layer such as FetchHttpClient.layer
|
|
657
|
+
* const FakeServer = Layer.succeed(
|
|
658
|
+
* HttpClient.HttpClient,
|
|
659
|
+
* HttpClient.make((request) =>
|
|
660
|
+
* Effect.succeed(HttpClientResponse.fromWeb(
|
|
661
|
+
* request,
|
|
662
|
+
* new Response(JSON.stringify({ data: { viewer: { login: "tim" } } }), {
|
|
663
|
+
* headers: { "content-type": "application/graphql-response+json" }
|
|
664
|
+
* })
|
|
665
|
+
* ))
|
|
666
|
+
* )
|
|
667
|
+
* )
|
|
668
|
+
*
|
|
669
|
+
* const program = Effect.gen(function*() {
|
|
670
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
671
|
+
* return yield* client.Viewer()
|
|
672
|
+
* }).pipe(
|
|
673
|
+
* Effect.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })),
|
|
674
|
+
* Effect.provide(FakeServer)
|
|
675
|
+
* )
|
|
676
|
+
*
|
|
677
|
+
* await Effect.runPromise(program) // => { viewer: { login: "tim" } }
|
|
678
|
+
* ```
|
|
679
|
+
*
|
|
680
|
+
* **Example** (Queries over GET with a per-call URL param)
|
|
681
|
+
*
|
|
682
|
+
* ```ts import.meta.vitest
|
|
683
|
+
* import { Effect, Layer, Schema } from "effect"
|
|
684
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
685
|
+
* import { HttpClient, HttpClientResponse } from "effect/http"
|
|
686
|
+
*
|
|
687
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
688
|
+
* document: "query Viewer{viewer{login}}",
|
|
689
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
690
|
+
* })
|
|
691
|
+
*
|
|
692
|
+
* // A stand-in server that answers with the method and the `preview` param
|
|
693
|
+
* const FakeServer = Layer.succeed(
|
|
694
|
+
* HttpClient.HttpClient,
|
|
695
|
+
* HttpClient.make((request, url) =>
|
|
696
|
+
* Effect.succeed(HttpClientResponse.fromWeb(
|
|
697
|
+
* request,
|
|
698
|
+
* new Response(
|
|
699
|
+
* JSON.stringify({ data: { viewer: { login: `${request.method} ${url.searchParams.get("preview")}` } } }),
|
|
700
|
+
* { headers: { "content-type": "application/graphql-response+json" } }
|
|
701
|
+
* )
|
|
702
|
+
* ))
|
|
703
|
+
* )
|
|
704
|
+
* )
|
|
705
|
+
*
|
|
706
|
+
* const program = Effect.gen(function*() {
|
|
707
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
708
|
+
* return yield* client.Viewer(undefined, { urlParams: { preview: "draft-token" } })
|
|
709
|
+
* }).pipe(
|
|
710
|
+
* Effect.provide(GraphQLProtocol.layerHttp({ url: "https://cms.example.com/graphql", queryMethod: "GET" })),
|
|
711
|
+
* Effect.provide(FakeServer)
|
|
712
|
+
* )
|
|
713
|
+
*
|
|
714
|
+
* await Effect.runPromise(program) // => { viewer: { login: "GET draft-token" } }
|
|
715
|
+
* ```
|
|
716
|
+
*
|
|
717
|
+
* **Example** (Subscriptions over graphql-ws, everything else over POST)
|
|
718
|
+
*
|
|
719
|
+
* ```ts import.meta.vitest
|
|
720
|
+
* import { Effect } from "effect"
|
|
721
|
+
* import { GraphQLProtocol } from "effect/graphql"
|
|
722
|
+
*
|
|
723
|
+
* const token = Effect.succeed("secret")
|
|
724
|
+
*
|
|
725
|
+
* // Provide an HttpClient and a Socket.WebSocketConstructor, such as
|
|
726
|
+
* // FetchHttpClient.layer and NodeSocket.layerWebSocketConstructor
|
|
727
|
+
* export const Protocol = GraphQLProtocol.layerHttp({
|
|
728
|
+
* url: "https://example.com/graphql",
|
|
729
|
+
* subscriptions: {
|
|
730
|
+
* webSocket: {
|
|
731
|
+
* url: "wss://example.com/graphql",
|
|
732
|
+
* connectionParams: Effect.map(token, (token) => ({ authorization: `Bearer ${token}` }))
|
|
733
|
+
* }
|
|
734
|
+
* }
|
|
735
|
+
* })
|
|
736
|
+
* ```
|
|
737
|
+
*
|
|
738
|
+
* @stability experimental
|
|
739
|
+
* @category layers
|
|
740
|
+
* @since 4.0.0
|
|
741
|
+
*/
|
|
742
|
+
<R = never>(
|
|
743
|
+
options: {
|
|
744
|
+
readonly url: string
|
|
745
|
+
readonly queryMethod?: "GET" | "POST" | undefined
|
|
746
|
+
readonly subscriptions: {
|
|
747
|
+
readonly webSocket: {
|
|
748
|
+
readonly url: string
|
|
749
|
+
readonly headers?: Headers.Input | undefined
|
|
750
|
+
readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R> | undefined
|
|
751
|
+
readonly connectionAckTimeout?: Duration.Input | undefined
|
|
752
|
+
readonly keepAlive?: Duration.Input | false | undefined
|
|
753
|
+
readonly idleTimeout?: Duration.Input | undefined
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
): Layer.Layer<GraphQLProtocol, never, HttpClient.HttpClient | Socket.WebSocketConstructor | R>
|
|
758
|
+
/**
|
|
759
|
+
* The HTTP transport: queries and mutations are sent as `POST` with a JSON
|
|
760
|
+
* body to `url`, using the `HttpClient` in context. Subscriptions use
|
|
761
|
+
* graphql-sse distinct mode on the same URL, or graphql-ws when
|
|
762
|
+
* `subscriptions.webSocket` is set.
|
|
763
|
+
*
|
|
764
|
+
* **Details**
|
|
765
|
+
*
|
|
766
|
+
* With `queryMethod: "GET"` (default `"POST"`), queries are sent as `GET`
|
|
767
|
+
* with no body: `query` and `operationName` become URL params, plus
|
|
768
|
+
* `variables` and `extensions` as JSON when set. Mutations are always
|
|
769
|
+
* `POST`, and so are `GraphQLResolver.aliased` batches. A long query is not
|
|
770
|
+
* retried as `POST`.
|
|
771
|
+
*
|
|
772
|
+
* A request's `headers` and `urlParams`, from the call options or from
|
|
773
|
+
* middleware, are applied to every HTTP request. A query string already on
|
|
774
|
+
* `url` is kept. On `GET`, the request's `urlParams` come before the GraphQL
|
|
775
|
+
* params, which replace any caller param with the same name.
|
|
776
|
+
*
|
|
777
|
+
* Every response's HTTP `status` is passed to the client as
|
|
778
|
+
* `ExecutionResult.status`, and a call that fails with GraphQL `errors`
|
|
779
|
+
* reports it as `ResponseError.status`. Response headers are passed the
|
|
780
|
+
* same way, as `ExecutionResult.headers` and `ResponseError.headers`.
|
|
781
|
+
*
|
|
782
|
+
* A response is a GraphQL response when its `Content-Type` is
|
|
783
|
+
* `application/graphql-response+json`, whatever the status, or when it is
|
|
784
|
+
* `application/json` and the body has a list of `errors` or a `data` object
|
|
785
|
+
* (or `null`). Media types match case-insensitively. Anything else
|
|
786
|
+
* is a `TransportError` with the response `status` and `headers`, and a
|
|
787
|
+
* `Retry-After` header (whole seconds or an HTTP date, measured from when the
|
|
788
|
+
* response arrived) becomes `retryAfter`.
|
|
789
|
+
*
|
|
790
|
+
* A subscription is a `POST` with `Accept: text/event-stream`. Each `next`
|
|
791
|
+
* event is emitted with the response headers but no status, and the stream
|
|
792
|
+
* ends on a `complete` event, with or without a `data:` line. A body that
|
|
793
|
+
* fails, or ends without a `complete` event, is a retryable `TransportError`
|
|
794
|
+
* with a server `retry:` line as its `retryAfter`, so the client's
|
|
795
|
+
* `subscriptionRetry` resubscribes. A non-stream answer, such as a `400` with
|
|
796
|
+
* `errors`, is read like a query's response. Interrupting the stream aborts
|
|
797
|
+
* the request.
|
|
798
|
+
*
|
|
799
|
+
* With `subscriptions: { webSocket }`, subscriptions go over graphql-ws as in
|
|
800
|
+
* {@link layerWebSocket}, and the layer needs a `Socket.WebSocketConstructor`.
|
|
801
|
+
*
|
|
802
|
+
* **Example** (Building a client over HTTP)
|
|
803
|
+
*
|
|
804
|
+
* ```ts import.meta.vitest
|
|
805
|
+
* import { Effect, Layer, Schema } from "effect"
|
|
806
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
807
|
+
* import { HttpClient, HttpClientResponse } from "effect/http"
|
|
808
|
+
*
|
|
809
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
810
|
+
* document: "query Viewer{viewer{login}}",
|
|
811
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
812
|
+
* })
|
|
813
|
+
*
|
|
814
|
+
* // A stand-in for a real HttpClient layer such as FetchHttpClient.layer
|
|
815
|
+
* const FakeServer = Layer.succeed(
|
|
816
|
+
* HttpClient.HttpClient,
|
|
817
|
+
* HttpClient.make((request) =>
|
|
818
|
+
* Effect.succeed(HttpClientResponse.fromWeb(
|
|
819
|
+
* request,
|
|
820
|
+
* new Response(JSON.stringify({ data: { viewer: { login: "tim" } } }), {
|
|
821
|
+
* headers: { "content-type": "application/graphql-response+json" }
|
|
822
|
+
* })
|
|
823
|
+
* ))
|
|
824
|
+
* )
|
|
825
|
+
* )
|
|
826
|
+
*
|
|
827
|
+
* const program = Effect.gen(function*() {
|
|
828
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
829
|
+
* return yield* client.Viewer()
|
|
830
|
+
* }).pipe(
|
|
831
|
+
* Effect.provide(GraphQLProtocol.layerHttp({ url: "https://api.github.com/graphql" })),
|
|
832
|
+
* Effect.provide(FakeServer)
|
|
833
|
+
* )
|
|
834
|
+
*
|
|
835
|
+
* await Effect.runPromise(program) // => { viewer: { login: "tim" } }
|
|
836
|
+
* ```
|
|
837
|
+
*
|
|
838
|
+
* **Example** (Queries over GET with a per-call URL param)
|
|
839
|
+
*
|
|
840
|
+
* ```ts import.meta.vitest
|
|
841
|
+
* import { Effect, Layer, Schema } from "effect"
|
|
842
|
+
* import { GraphQL, GraphQLClient, GraphQLGroup, GraphQLProtocol } from "effect/graphql"
|
|
843
|
+
* import { HttpClient, HttpClientResponse } from "effect/http"
|
|
844
|
+
*
|
|
845
|
+
* const Viewer = GraphQL.query("Viewer", {
|
|
846
|
+
* document: "query Viewer{viewer{login}}",
|
|
847
|
+
* result: Schema.Struct({ viewer: Schema.Struct({ login: Schema.String }) })
|
|
848
|
+
* })
|
|
849
|
+
*
|
|
850
|
+
* // A stand-in server that answers with the method and the `preview` param
|
|
851
|
+
* const FakeServer = Layer.succeed(
|
|
852
|
+
* HttpClient.HttpClient,
|
|
853
|
+
* HttpClient.make((request, url) =>
|
|
854
|
+
* Effect.succeed(HttpClientResponse.fromWeb(
|
|
855
|
+
* request,
|
|
856
|
+
* new Response(
|
|
857
|
+
* JSON.stringify({ data: { viewer: { login: `${request.method} ${url.searchParams.get("preview")}` } } }),
|
|
858
|
+
* { headers: { "content-type": "application/graphql-response+json" } }
|
|
859
|
+
* )
|
|
860
|
+
* ))
|
|
861
|
+
* )
|
|
862
|
+
* )
|
|
863
|
+
*
|
|
864
|
+
* const program = Effect.gen(function*() {
|
|
865
|
+
* const client = yield* GraphQLClient.make(GraphQLGroup.make(Viewer))
|
|
866
|
+
* return yield* client.Viewer(undefined, { urlParams: { preview: "draft-token" } })
|
|
867
|
+
* }).pipe(
|
|
868
|
+
* Effect.provide(GraphQLProtocol.layerHttp({ url: "https://cms.example.com/graphql", queryMethod: "GET" })),
|
|
869
|
+
* Effect.provide(FakeServer)
|
|
870
|
+
* )
|
|
871
|
+
*
|
|
872
|
+
* await Effect.runPromise(program) // => { viewer: { login: "GET draft-token" } }
|
|
873
|
+
* ```
|
|
874
|
+
*
|
|
875
|
+
* **Example** (Subscriptions over graphql-ws, everything else over POST)
|
|
876
|
+
*
|
|
877
|
+
* ```ts import.meta.vitest
|
|
878
|
+
* import { Effect } from "effect"
|
|
879
|
+
* import { GraphQLProtocol } from "effect/graphql"
|
|
880
|
+
*
|
|
881
|
+
* const token = Effect.succeed("secret")
|
|
882
|
+
*
|
|
883
|
+
* // Provide an HttpClient and a Socket.WebSocketConstructor, such as
|
|
884
|
+
* // FetchHttpClient.layer and NodeSocket.layerWebSocketConstructor
|
|
885
|
+
* export const Protocol = GraphQLProtocol.layerHttp({
|
|
886
|
+
* url: "https://example.com/graphql",
|
|
887
|
+
* subscriptions: {
|
|
888
|
+
* webSocket: {
|
|
889
|
+
* url: "wss://example.com/graphql",
|
|
890
|
+
* connectionParams: Effect.map(token, (token) => ({ authorization: `Bearer ${token}` }))
|
|
891
|
+
* }
|
|
892
|
+
* }
|
|
893
|
+
* })
|
|
894
|
+
* ```
|
|
895
|
+
*
|
|
896
|
+
* @stability experimental
|
|
897
|
+
* @category layers
|
|
898
|
+
* @since 4.0.0
|
|
899
|
+
*/
|
|
900
|
+
(
|
|
901
|
+
options: {
|
|
902
|
+
readonly url: string
|
|
903
|
+
readonly queryMethod?: "GET" | "POST" | undefined
|
|
904
|
+
readonly subscriptions?: undefined
|
|
905
|
+
}
|
|
906
|
+
): Layer.Layer<
|
|
907
|
+
GraphQLProtocol,
|
|
908
|
+
never,
|
|
909
|
+
HttpClient.HttpClient
|
|
910
|
+
>
|
|
911
|
+
} = <R>(
|
|
912
|
+
options: HttpOptions<R>
|
|
913
|
+
): Layer.Layer<GraphQLProtocol, never, HttpClient.HttpClient | Socket.WebSocketConstructor | R> =>
|
|
914
|
+
Layer.effect(GraphQLProtocol, makeHttpWith(options))
|
|
915
|
+
|
|
916
|
+
interface WebSocketOptions<R = never> {
|
|
917
|
+
readonly url: string
|
|
918
|
+
readonly headers?: Headers.Input | undefined
|
|
919
|
+
readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R> | undefined
|
|
920
|
+
readonly connectionAckTimeout?: Duration.Input | undefined
|
|
921
|
+
readonly keepAlive?: Duration.Input | false | undefined
|
|
922
|
+
readonly idleTimeout?: Duration.Input | undefined
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
interface WsMessage {
|
|
926
|
+
readonly type: string
|
|
927
|
+
readonly id?: string | undefined
|
|
928
|
+
readonly payload?: unknown
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// One WebSocket connection. `error` is set once it is lost, and the operations
|
|
932
|
+
// attached to it fail with it.
|
|
933
|
+
interface Connection {
|
|
934
|
+
readonly ack: Deferred.Deferred<void, TransportError>
|
|
935
|
+
error: TransportError | undefined
|
|
936
|
+
receivedFrame: boolean
|
|
937
|
+
fiber: Fiber.Fiber<void> | undefined
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
interface Operation {
|
|
941
|
+
readonly queue: Queue.Queue<{ readonly body: unknown }, TransportError | Cause.Done>
|
|
942
|
+
readonly connection: Connection
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const textDecoder = new TextDecoder()
|
|
946
|
+
|
|
947
|
+
// As in graphql-ws's `validateMessage`, `error` carries at least one entry with
|
|
948
|
+
// a `message`.
|
|
949
|
+
const isFormattedErrors = (u: unknown): boolean =>
|
|
950
|
+
Array.isArray(u) && u.length > 0 && u.every((error) => isObject(error) && "message" in error)
|
|
951
|
+
|
|
952
|
+
const connectionError = (cause: Cause.Cause<unknown>): TransportError => {
|
|
953
|
+
const found = Cause.findError(cause)
|
|
954
|
+
if (Result.isFailure(found)) {
|
|
955
|
+
return new TransportError({ description: "The graphql-ws connection was closed" })
|
|
956
|
+
}
|
|
957
|
+
const error = found.success
|
|
958
|
+
if (error instanceof TransportError) {
|
|
959
|
+
return error
|
|
960
|
+
}
|
|
961
|
+
if (Socket.isSocketError(error) && error.reason._tag === "SocketCloseError") {
|
|
962
|
+
return new TransportError({
|
|
963
|
+
description: `The graphql-ws connection closed with ${error.reason.message}`,
|
|
964
|
+
closeCode: error.reason.code,
|
|
965
|
+
cause: error
|
|
966
|
+
})
|
|
967
|
+
}
|
|
968
|
+
return new TransportError({ description: `The graphql-ws connection failed: ${errorMessage(error)}`, cause: error })
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Builds the graphql-ws transport as an effect, for composing transports by
|
|
973
|
+
* hand. Most code uses {@link layerWebSocket}, which describes the options.
|
|
974
|
+
*
|
|
975
|
+
* @stability experimental
|
|
976
|
+
* @category constructors
|
|
977
|
+
* @since 4.0.0
|
|
978
|
+
*/
|
|
979
|
+
export const makeWebSocket = <R = never>(
|
|
980
|
+
options: {
|
|
981
|
+
readonly url: string
|
|
982
|
+
readonly headers?: Headers.Input | undefined
|
|
983
|
+
readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R> | undefined
|
|
984
|
+
readonly connectionAckTimeout?: Duration.Input | undefined
|
|
985
|
+
readonly keepAlive?: Duration.Input | false | undefined
|
|
986
|
+
readonly idleTimeout?: Duration.Input | undefined
|
|
987
|
+
}
|
|
988
|
+
): Effect.Effect<GraphQLProtocol["Service"], never, Socket.WebSocketConstructor | Scope.Scope | R> =>
|
|
989
|
+
Effect.gen(function*() {
|
|
990
|
+
const socket = yield* Socket.makeWebSocket(options.url, {
|
|
991
|
+
protocols: "graphql-transport-ws",
|
|
992
|
+
headers: options.headers === undefined ? undefined : Headers.fromInput(options.headers)
|
|
993
|
+
})
|
|
994
|
+
const writer = yield* socket.writer
|
|
995
|
+
const scope = yield* Effect.scope
|
|
996
|
+
const context = yield* Effect.context<R>()
|
|
997
|
+
const connectionParams = options.connectionParams === undefined
|
|
998
|
+
? Effect.succeed(undefined)
|
|
999
|
+
: options.connectionParams.pipe(
|
|
1000
|
+
Effect.provideContext(context),
|
|
1001
|
+
Effect.mapError((cause) =>
|
|
1002
|
+
new TransportError({ description: `graphql-ws connectionParams failed: ${errorMessage(cause)}`, cause })
|
|
1003
|
+
)
|
|
1004
|
+
)
|
|
1005
|
+
const ackTimeout = options.connectionAckTimeout ?? Duration.seconds(10)
|
|
1006
|
+
const keepAlive = options.keepAlive ?? Duration.seconds(10)
|
|
1007
|
+
const idleTimeout = Duration.fromInputUnsafe(options.idleTimeout ?? 0)
|
|
1008
|
+
|
|
1009
|
+
const operations = new Map<string, Operation>()
|
|
1010
|
+
let connection: Connection | undefined
|
|
1011
|
+
let nextId = 0
|
|
1012
|
+
let active = 0
|
|
1013
|
+
let idleGeneration = 0
|
|
1014
|
+
|
|
1015
|
+
const send = (message: WsMessage) =>
|
|
1016
|
+
writer.write(JSON.stringify(message)).pipe(
|
|
1017
|
+
Effect.mapError((cause) => connectionError(Cause.fail(cause)))
|
|
1018
|
+
)
|
|
1019
|
+
|
|
1020
|
+
const lose = (conn: Connection, error: TransportError) => {
|
|
1021
|
+
if (conn.error !== undefined) return
|
|
1022
|
+
conn.error = error
|
|
1023
|
+
if (connection === conn) connection = undefined
|
|
1024
|
+
Deferred.doneUnsafe(conn.ack, Effect.fail(error))
|
|
1025
|
+
for (const [id, operation] of operations) {
|
|
1026
|
+
if (operation.connection !== conn) continue
|
|
1027
|
+
operations.delete(id)
|
|
1028
|
+
Queue.failCauseUnsafe(operation.queue, Cause.fail(error))
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// An invalid server message closes the connection with 4400, which is
|
|
1033
|
+
// fatal for every operation on it.
|
|
1034
|
+
const invalid = (reason: string, cause?: unknown) =>
|
|
1035
|
+
Effect.andThen(
|
|
1036
|
+
Effect.ignore(writer.write(new Socket.CloseEvent(4400, "Invalid message"))),
|
|
1037
|
+
Effect.fail(
|
|
1038
|
+
new TransportError({ description: `Invalid graphql-ws message: ${reason}`, closeCode: 4400, cause })
|
|
1039
|
+
)
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
const handle = (conn: Connection, frame: string | Uint8Array): Effect.Effect<void, TransportError> => {
|
|
1043
|
+
// Any frame answers a keep-alive ping; an invalid one drops the connection anyway.
|
|
1044
|
+
conn.receivedFrame = true
|
|
1045
|
+
let message: WsMessage
|
|
1046
|
+
try {
|
|
1047
|
+
message = JSON.parse(typeof frame === "string" ? frame : textDecoder.decode(frame))
|
|
1048
|
+
} catch (cause) {
|
|
1049
|
+
return invalid(errorMessage(cause), cause)
|
|
1050
|
+
}
|
|
1051
|
+
if (!isObject(message)) {
|
|
1052
|
+
return invalid("expected an object")
|
|
1053
|
+
}
|
|
1054
|
+
switch (message.type) {
|
|
1055
|
+
case "connection_ack":
|
|
1056
|
+
case "ping":
|
|
1057
|
+
case "pong": {
|
|
1058
|
+
if (message.payload != null && !isObject(message.payload)) {
|
|
1059
|
+
return invalid(`${message.type} with a payload that is not an object`)
|
|
1060
|
+
}
|
|
1061
|
+
if (message.type === "connection_ack") {
|
|
1062
|
+
Deferred.doneUnsafe(conn.ack, Effect.void)
|
|
1063
|
+
}
|
|
1064
|
+
return message.type === "ping" ? send({ type: "pong" }) : Effect.void
|
|
1065
|
+
}
|
|
1066
|
+
case "next":
|
|
1067
|
+
case "error":
|
|
1068
|
+
case "complete": {
|
|
1069
|
+
const id = message.id
|
|
1070
|
+
if (typeof id !== "string" || id === "") {
|
|
1071
|
+
return invalid(`${message.type} without an id`)
|
|
1072
|
+
}
|
|
1073
|
+
if (message.type === "next" && !isObject(message.payload)) {
|
|
1074
|
+
return invalid("next with a payload that is not an object")
|
|
1075
|
+
}
|
|
1076
|
+
if (message.type === "error" && !isFormattedErrors(message.payload)) {
|
|
1077
|
+
return invalid("error with a payload that is not a list of GraphQL errors")
|
|
1078
|
+
}
|
|
1079
|
+
const operation = operations.get(id)
|
|
1080
|
+
// Unknown ids, including operations this client already completed, are dropped.
|
|
1081
|
+
if (operation === undefined || operation.connection !== conn) return Effect.void
|
|
1082
|
+
if (message.type === "next") {
|
|
1083
|
+
Queue.offerUnsafe(operation.queue, { body: message.payload })
|
|
1084
|
+
return Effect.void
|
|
1085
|
+
}
|
|
1086
|
+
operations.delete(id)
|
|
1087
|
+
if (message.type === "error") {
|
|
1088
|
+
// The server rejected the operation; the client fails it with a ResponseError.
|
|
1089
|
+
Queue.offerUnsafe(operation.queue, { body: { errors: message.payload } })
|
|
1090
|
+
}
|
|
1091
|
+
Queue.endUnsafe(operation.queue)
|
|
1092
|
+
return Effect.void
|
|
1093
|
+
}
|
|
1094
|
+
default: {
|
|
1095
|
+
return invalid(`unknown type ${JSON.stringify(message.type)}`)
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const pinger = Effect.fnUntraced(function*(conn: Connection) {
|
|
1101
|
+
if (keepAlive === false) return yield* Effect.never
|
|
1102
|
+
while (true) {
|
|
1103
|
+
yield* Effect.sleep(keepAlive)
|
|
1104
|
+
if (!conn.receivedFrame) {
|
|
1105
|
+
return yield* Effect.fail(
|
|
1106
|
+
new TransportError({ description: "The graphql-ws server did not answer the keep-alive ping" })
|
|
1107
|
+
)
|
|
1108
|
+
}
|
|
1109
|
+
conn.receivedFrame = false
|
|
1110
|
+
yield* send({ type: "ping" })
|
|
1111
|
+
}
|
|
1112
|
+
})
|
|
1113
|
+
|
|
1114
|
+
const run = Effect.fnUntraced(
|
|
1115
|
+
function*(conn: Connection) {
|
|
1116
|
+
const { pull } = yield* socket.reader
|
|
1117
|
+
const init = Effect.flatMap(connectionParams, (payload) => send({ type: "connection_init", payload }))
|
|
1118
|
+
const read = Effect.forever(
|
|
1119
|
+
Effect.flatMap(pull, (frames) => Effect.forEach(frames, (frame) => handle(conn, frame), { discard: true }))
|
|
1120
|
+
)
|
|
1121
|
+
const acknowledged = Deferred.await(conn.ack).pipe(
|
|
1122
|
+
Effect.timeoutOrElse({
|
|
1123
|
+
duration: ackTimeout,
|
|
1124
|
+
orElse: () =>
|
|
1125
|
+
Effect.fail(new TransportError({ description: "The graphql-ws server did not send connection_ack" }))
|
|
1126
|
+
})
|
|
1127
|
+
)
|
|
1128
|
+
// Reading starts with `connectionParams`, so a close while it runs still
|
|
1129
|
+
// fails the waiting operations. The ack timeout starts once
|
|
1130
|
+
// `connection_init` is sent.
|
|
1131
|
+
yield* Effect.all([read, init.pipe(Effect.andThen(acknowledged), Effect.andThen(pinger(conn)))], {
|
|
1132
|
+
concurrency: 2,
|
|
1133
|
+
discard: true
|
|
1134
|
+
})
|
|
1135
|
+
},
|
|
1136
|
+
// Runs before the socket closes, so no write waits on a dead socket.
|
|
1137
|
+
(effect, conn) => Effect.onError(effect, (cause) => Effect.sync(() => lose(conn, connectionError(cause)))),
|
|
1138
|
+
(effect) => Effect.ignore(Effect.scoped(effect))
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
const close = (conn: Connection) => {
|
|
1142
|
+
lose(conn, new TransportError({ description: "The graphql-ws connection was closed" }))
|
|
1143
|
+
return conn.fiber === undefined ? Effect.void : Fiber.interrupt(conn.fiber)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const acquireConnection = Effect.suspend(() => {
|
|
1147
|
+
if (connection !== undefined) return Effect.succeed(connection)
|
|
1148
|
+
const conn: Connection = {
|
|
1149
|
+
ack: Deferred.makeUnsafe(),
|
|
1150
|
+
error: undefined,
|
|
1151
|
+
receivedFrame: true,
|
|
1152
|
+
fiber: undefined
|
|
1153
|
+
}
|
|
1154
|
+
connection = conn
|
|
1155
|
+
return Effect.forkIn(run(conn), scope).pipe(
|
|
1156
|
+
Effect.map((fiber) => {
|
|
1157
|
+
conn.fiber = fiber
|
|
1158
|
+
return conn
|
|
1159
|
+
})
|
|
1160
|
+
)
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
const register = Effect.suspend(() => {
|
|
1164
|
+
// A connection forked into a closed scope is interrupted before it
|
|
1165
|
+
// starts, so new operations fail instead of waiting for an ack.
|
|
1166
|
+
if (scope.state._tag === "Closed") {
|
|
1167
|
+
return Effect.fail(
|
|
1168
|
+
new TransportError({ description: "The graphql-ws transport's scope is closed", fatal: true })
|
|
1169
|
+
)
|
|
1170
|
+
}
|
|
1171
|
+
active++
|
|
1172
|
+
idleGeneration++
|
|
1173
|
+
return Effect.map(acquireConnection, (conn) => [String(++nextId), conn] as const)
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
const closeIfIdle = Effect.suspend(() => {
|
|
1177
|
+
if (--active > 0 || connection === undefined) return Effect.void
|
|
1178
|
+
const conn = connection
|
|
1179
|
+
if (Duration.isZero(idleTimeout)) return close(conn)
|
|
1180
|
+
const generation = ++idleGeneration
|
|
1181
|
+
const closeIfStillIdle = Effect.suspend(() =>
|
|
1182
|
+
generation === idleGeneration && connection === conn ? close(conn) : Effect.void
|
|
1183
|
+
)
|
|
1184
|
+
return Effect.asVoid(Effect.forkIn(Effect.delay(closeIfStillIdle, idleTimeout), scope))
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
const release = (id: string, conn: Connection) =>
|
|
1188
|
+
Effect.suspend(() =>
|
|
1189
|
+
Effect.andThen(
|
|
1190
|
+
// Only a subscribed operation the server has not completed is still in `operations`.
|
|
1191
|
+
operations.delete(id) && conn.error === undefined
|
|
1192
|
+
? Effect.ignore(send({ id, type: "complete" }))
|
|
1193
|
+
: Effect.void,
|
|
1194
|
+
closeIfIdle
|
|
1195
|
+
)
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
const operation = (request: GraphQLRequest): Stream.Stream<{ readonly body: unknown }, TransportError> =>
|
|
1199
|
+
Stream.unwrap(Effect.gen(function*() {
|
|
1200
|
+
const [id, conn] = yield* Effect.acquireRelease(register, ([id, conn]) => release(id, conn))
|
|
1201
|
+
yield* Deferred.await(conn.ack)
|
|
1202
|
+
if (conn.error !== undefined) {
|
|
1203
|
+
return yield* Effect.fail(conn.error)
|
|
1204
|
+
}
|
|
1205
|
+
const queue = yield* Queue.unbounded<{ readonly body: unknown }, TransportError | Cause.Done>()
|
|
1206
|
+
operations.set(id, { queue, connection: conn })
|
|
1207
|
+
// Per-call `request.headers` and `request.urlParams` are ignored:
|
|
1208
|
+
// graphql-ws has no per-operation headers or URL.
|
|
1209
|
+
yield* send({ id, type: "subscribe", payload: requestBody(request) })
|
|
1210
|
+
return Stream.fromQueue(queue)
|
|
1211
|
+
}))
|
|
1212
|
+
|
|
1213
|
+
const execute = (request: GraphQLRequest): Effect.Effect<{ readonly body: unknown }, TransportError> =>
|
|
1214
|
+
Effect.flatMap(Stream.runHead(operation(request)), (result) =>
|
|
1215
|
+
result._tag === "Some"
|
|
1216
|
+
? Effect.succeed(result.value)
|
|
1217
|
+
: Effect.fail(new TransportError({ description: "The graphql-ws operation completed without a result" })))
|
|
1218
|
+
|
|
1219
|
+
return GraphQLProtocol.of({ execute, subscribe: operation })
|
|
1220
|
+
})
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* The graphql-ws transport: every operation goes over one WebSocket using the
|
|
1224
|
+
* `graphql-transport-ws` subprotocol, with the `Socket.WebSocketConstructor`
|
|
1225
|
+
* in context.
|
|
1226
|
+
*
|
|
1227
|
+
* **Details**
|
|
1228
|
+
*
|
|
1229
|
+
* The socket connects lazily and multiplexes operations by id. It closes after
|
|
1230
|
+
* `idleTimeout` (default 0) with no active operations, or with code `1000`
|
|
1231
|
+
* when the layer's scope closes.
|
|
1232
|
+
*
|
|
1233
|
+
* - Operations wait for `connection_ack`. `connectionAckTimeout` (default
|
|
1234
|
+
* 10 seconds) starts after sending `connection_init`.
|
|
1235
|
+
* - `connectionParams` runs on every connection, allowing token refresh.
|
|
1236
|
+
* Failure or a socket close during evaluation is a `TransportError`.
|
|
1237
|
+
* - Handshake `headers` accept `Headers.Input` and are normalized before
|
|
1238
|
+
* reaching the socket. They require a Node or Bun WebSocket constructor;
|
|
1239
|
+
* browsers cannot set them. Per-operation `headers` and `urlParams` are ignored.
|
|
1240
|
+
* - Results carry no HTTP `status`.
|
|
1241
|
+
* - `keepAlive` (default 10 seconds, or `false` to disable) sends `ping`.
|
|
1242
|
+
* No server frame in the following interval causes a retryable failure.
|
|
1243
|
+
* Server `ping` receives `pong`.
|
|
1244
|
+
* - Lost connections fail every active operation with a `TransportError`
|
|
1245
|
+
* carrying the close code. See `GraphQLClientError.isFatalCloseCode` for
|
|
1246
|
+
* non-retryable codes. The transport never replays operations; the client
|
|
1247
|
+
* retries subscriptions and the next operation reconnects.
|
|
1248
|
+
* - Once the layer's scope has closed, every new operation fails at once
|
|
1249
|
+
* with a non-retryable `TransportError` marked `fatal`.
|
|
1250
|
+
* - Messages follow graphql-ws validation. Invalid messages close the socket
|
|
1251
|
+
* with fatal code `4400`; valid messages for unknown ids are ignored.
|
|
1252
|
+
* - Server `error` messages become `ResponseError` values. Interrupting a
|
|
1253
|
+
* subscription sends `complete`.
|
|
1254
|
+
*
|
|
1255
|
+
* **Example** (A client over graphql-ws)
|
|
1256
|
+
*
|
|
1257
|
+
* ```ts import.meta.vitest
|
|
1258
|
+
* import { Effect, Layer } from "effect"
|
|
1259
|
+
* import { GraphQLProtocol } from "effect/graphql"
|
|
1260
|
+
* import type { Socket } from "effect/socket"
|
|
1261
|
+
*
|
|
1262
|
+
* // Provide a Socket.WebSocketConstructor, such as
|
|
1263
|
+
* // Socket.layerWebSocketConstructorGlobal in a browser
|
|
1264
|
+
* export const Protocol: Layer.Layer<GraphQLProtocol.GraphQLProtocol, never, Socket.WebSocketConstructor> =
|
|
1265
|
+
* GraphQLProtocol.layerWebSocket({
|
|
1266
|
+
* url: "wss://example.com/graphql",
|
|
1267
|
+
* connectionParams: Effect.succeed({ authorization: "Bearer secret" }),
|
|
1268
|
+
* idleTimeout: "30 seconds"
|
|
1269
|
+
* })
|
|
1270
|
+
* ```
|
|
1271
|
+
*
|
|
1272
|
+
* @stability experimental
|
|
1273
|
+
* @category layers
|
|
1274
|
+
* @since 4.0.0
|
|
1275
|
+
*/
|
|
1276
|
+
export const layerWebSocket = <R = never>(
|
|
1277
|
+
options: {
|
|
1278
|
+
readonly url: string
|
|
1279
|
+
readonly headers?: Headers.Input | undefined
|
|
1280
|
+
readonly connectionParams?: Effect.Effect<Record<string, unknown> | undefined, unknown, R> | undefined
|
|
1281
|
+
readonly connectionAckTimeout?: Duration.Input | undefined
|
|
1282
|
+
readonly keepAlive?: Duration.Input | false | undefined
|
|
1283
|
+
readonly idleTimeout?: Duration.Input | undefined
|
|
1284
|
+
}
|
|
1285
|
+
): Layer.Layer<GraphQLProtocol, never, Socket.WebSocketConstructor | R> =>
|
|
1286
|
+
Layer.effect(GraphQLProtocol, makeWebSocket(options))
|