@znight/graphql 0.0.0 → 0.2.1
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/LICENSE +21 -0
- package/README.md +1346 -0
- package/dist/compiler.d.mts +114 -0
- package/dist/compiler.mjs +4599 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/index.d.mts +583 -0
- package/dist/index.mjs +134 -0
- package/dist/index.mjs.map +1 -0
- package/dist/names-BlzDM5cF.mjs +111 -0
- package/dist/names-BlzDM5cF.mjs.map +1 -0
- package/dist/plugin-CR_wVfRU.mjs +3445 -0
- package/dist/plugin-CR_wVfRU.mjs.map +1 -0
- package/dist/testing.d.mts +101 -0
- package/dist/testing.mjs +306 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-DQ713UgI.d.mts +266 -0
- package/package.json +97 -1
- package/src/compiler/analyze.ts +727 -0
- package/src/compiler/builder.ts +1561 -0
- package/src/compiler/checks.ts +105 -0
- package/src/compiler/conformance.ts +1068 -0
- package/src/compiler/constants.ts +80 -0
- package/src/compiler/data.ts +83 -0
- package/src/compiler/defaults.ts +92 -0
- package/src/compiler/discriminators.ts +53 -0
- package/src/compiler/docs.ts +55 -0
- package/src/compiler/generate.ts +419 -0
- package/src/compiler/graphql-js.ts +42 -0
- package/src/compiler/ids.ts +128 -0
- package/src/compiler/index.ts +98 -0
- package/src/compiler/locate.ts +82 -0
- package/src/compiler/model.ts +137 -0
- package/src/compiler/options.ts +85 -0
- package/src/compiler/scalars.ts +138 -0
- package/src/compiler/schema-first.ts +595 -0
- package/src/compiler/sdl-source.ts +529 -0
- package/src/compiler/sdl-view.ts +61 -0
- package/src/compiler/suggest.ts +39 -0
- package/src/context.ts +181 -0
- package/src/decisions.ts +169 -0
- package/src/decorators.ts +202 -0
- package/src/errors.ts +89 -0
- package/src/execution.ts +1064 -0
- package/src/format.ts +285 -0
- package/src/index.ts +89 -0
- package/src/limits.ts +177 -0
- package/src/loaders.ts +149 -0
- package/src/memory-pubsub.ts +133 -0
- package/src/metadata.ts +222 -0
- package/src/names.ts +47 -0
- package/src/options.ts +331 -0
- package/src/pipeline.ts +338 -0
- package/src/plugin.ts +579 -0
- package/src/pubsub.ts +36 -0
- package/src/results.ts +206 -0
- package/src/scalars.ts +138 -0
- package/src/schema-hash.ts +18 -0
- package/src/schema.ts +635 -0
- package/src/sdl.ts +530 -0
- package/src/testing.ts +454 -0
- package/src/tokens.ts +22 -0
- package/src/transport/abort.ts +80 -0
- package/src/transport/accept.ts +155 -0
- package/src/transport/graphiql.ts +106 -0
- package/src/transport/http.ts +373 -0
- package/src/transport/sse.ts +45 -0
- package/src/type-ref.ts +89 -0
- package/src/types.ts +225 -0
- package/src/validate-args.ts +48 -0
package/src/execution.ts
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution (RFC 0005 §8.4, §8.7–§8.10): the document cache, request checks, operation selection,
|
|
3
|
+
* graphql-js `execute` and subscriptions, error formatting and the `graphql:operation` hook.
|
|
4
|
+
*
|
|
5
|
+
* `GraphQLExecutor` is the internal API of the transports (`plugin.execute()`, GraphQL over HTTP
|
|
6
|
+
* and SSE): `execute()` answers a request with a status and a formatted result, `subscribe()` with
|
|
7
|
+
* a stream of formatted results (or a rejection).
|
|
8
|
+
*
|
|
9
|
+
* Per operation, on the hot path (a cached query over a scope that already exists): one cache
|
|
10
|
+
* lookup, one context object, graphql-js `execute`, and the result as graphql-js returned it when
|
|
11
|
+
* it has no error. Synchronous when every resolver is; nothing is awaited that is not a promise.
|
|
12
|
+
* The hook event is built only when the hook has wrappers or listeners.
|
|
13
|
+
*/
|
|
14
|
+
import type { Container, CoreLogger, GraphQLOperationEvent, Hooks, ScopeContext } from '@znight/core'
|
|
15
|
+
import type { HttpContext } from '@znight/http'
|
|
16
|
+
import type {
|
|
17
|
+
DocumentNode,
|
|
18
|
+
ExecutionResult,
|
|
19
|
+
FormattedExecutionResult,
|
|
20
|
+
GraphQLError,
|
|
21
|
+
GraphQLFormattedError,
|
|
22
|
+
GraphQLSchema,
|
|
23
|
+
OperationDefinitionNode,
|
|
24
|
+
ValidatedSubscriptionArgs,
|
|
25
|
+
ValidationRule,
|
|
26
|
+
} from 'graphql'
|
|
27
|
+
import type { ContextRuntime } from './context.ts'
|
|
28
|
+
import type { ErrorFormatter } from './format.ts'
|
|
29
|
+
import type { DocumentLimits } from './limits.ts'
|
|
30
|
+
import type { GraphQLOperationKind, GraphQLTransport } from './types.ts'
|
|
31
|
+
|
|
32
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
33
|
+
import { HttpTokens } from '@znight/http'
|
|
34
|
+
import {
|
|
35
|
+
AbortedGraphQLExecutionError,
|
|
36
|
+
createSourceEventStream,
|
|
37
|
+
execute,
|
|
38
|
+
executeSubscriptionEvent,
|
|
39
|
+
GraphQLError as GraphQLErrorClass,
|
|
40
|
+
Kind,
|
|
41
|
+
NoSchemaIntrospectionCustomRule,
|
|
42
|
+
parse,
|
|
43
|
+
specifiedRules,
|
|
44
|
+
validate,
|
|
45
|
+
validateSubscriptionArgs,
|
|
46
|
+
} from 'graphql'
|
|
47
|
+
|
|
48
|
+
import { OperationContext } from './context.ts'
|
|
49
|
+
import { GraphQLErrorCode } from './errors.ts'
|
|
50
|
+
import { requestError, withCode } from './format.ts'
|
|
51
|
+
import { checkLimits } from './limits.ts'
|
|
52
|
+
import { GraphQLTokens } from './tokens.ts'
|
|
53
|
+
|
|
54
|
+
/** A GraphQL request: the body of a GraphQL-over-HTTP request, or the argument of `plugin.execute()`. */
|
|
55
|
+
export interface GraphQLRequest {
|
|
56
|
+
readonly query: string
|
|
57
|
+
readonly operationName?: string | null | undefined
|
|
58
|
+
readonly variables?: Readonly<Record<string, unknown>> | null | undefined
|
|
59
|
+
/** Request extensions (ignored by the executor; available to transports). */
|
|
60
|
+
readonly extensions?: Readonly<Record<string, unknown>> | null | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** How a request reached the executor. */
|
|
64
|
+
export interface ExecuteOptions {
|
|
65
|
+
/** Default `'direct'`. */
|
|
66
|
+
readonly transport?: GraphQLTransport
|
|
67
|
+
/** The HTTP request carrying the operation (GraphQL over HTTP and SSE). */
|
|
68
|
+
readonly http?: HttpContext
|
|
69
|
+
/**
|
|
70
|
+
* Cancels the operation (passed to graphql-js `abortSignal`, and `GraphQLContext.signal`). Only
|
|
71
|
+
* passed when given: graphql-js then adds an abort listener per execution. Over HTTP, the
|
|
72
|
+
* transport passes `ctx.signal`.
|
|
73
|
+
*/
|
|
74
|
+
readonly signal?: AbortSignal
|
|
75
|
+
/**
|
|
76
|
+
* Passed to graphql-js as `abortSignal` instead of {@link signal} (which stays the signal of
|
|
77
|
+
* `GraphQLContext.signal`). The HTTP transport passes a stand-in that reads `ctx.signal` only when
|
|
78
|
+
* an execution turns asynchronous (see `transport/abort.ts`).
|
|
79
|
+
*/
|
|
80
|
+
readonly abortSignal?: AbortSignal
|
|
81
|
+
/** Default: the HTTP request id, the request id of the enclosing scope, or a UUID (on first read). */
|
|
82
|
+
readonly requestId?: string
|
|
83
|
+
/** GET: queries only, other operations are rejected with 405 and `allow: POST`. */
|
|
84
|
+
readonly queriesOnly?: boolean
|
|
85
|
+
/**
|
|
86
|
+
* {@link GraphQLExecutor.execute} answers a subscription with {@link SUBSCRIBE} (nothing observed
|
|
87
|
+
* yet) instead of a 400: the HTTP transport then streams it (the client accepts an event stream).
|
|
88
|
+
*/
|
|
89
|
+
readonly streamSubscriptions?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The answer of {@link GraphQLExecutor.execute} to a subscription with
|
|
94
|
+
* {@link ExecuteOptions.streamSubscriptions}: start it with {@link GraphQLExecutor.subscribe}.
|
|
95
|
+
*/
|
|
96
|
+
export const SUBSCRIBE: GraphQLResponse = Object.freeze({ status: 0, result: Object.freeze({}) })
|
|
97
|
+
|
|
98
|
+
/** The answer to a request: an HTTP-like status and the formatted result. */
|
|
99
|
+
export interface GraphQLResponse {
|
|
100
|
+
/**
|
|
101
|
+
* 200: executed (the result may hold field errors); 400: request error (parse, validation,
|
|
102
|
+
* variables, limits, operation selection, subscription without stream); 405: not a query with
|
|
103
|
+
* `queriesOnly`; 503: too many active subscriptions.
|
|
104
|
+
*/
|
|
105
|
+
readonly status: number
|
|
106
|
+
readonly result: FormattedExecutionResult
|
|
107
|
+
/** Headers of the HTTP response (`allow` with a 405). */
|
|
108
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The results of a subscription (one per event), or the single result of a query sent as a stream. */
|
|
112
|
+
export interface GraphQLResultStream extends AsyncIterableIterator<FormattedExecutionResult> {
|
|
113
|
+
/** Ends the stream: the source event stream is returned (unsubscribed). */
|
|
114
|
+
return(): Promise<IteratorResult<FormattedExecutionResult>>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What {@link GraphQLExecutor} is built from (resolved plugin options). */
|
|
118
|
+
export interface ExecutorOptions {
|
|
119
|
+
readonly schema: GraphQLSchema
|
|
120
|
+
readonly container: Container
|
|
121
|
+
readonly hooks: Hooks
|
|
122
|
+
readonly logger: CoreLogger
|
|
123
|
+
readonly formatter: ErrorFormatter
|
|
124
|
+
readonly runtime: ContextRuntime
|
|
125
|
+
readonly limits: DocumentLimits & { readonly maxTokens: number | undefined }
|
|
126
|
+
readonly introspection: boolean
|
|
127
|
+
/** Entries of the document cache, 0 disables it. */
|
|
128
|
+
readonly documentCache: number
|
|
129
|
+
/** Active subscriptions cap, `undefined`: none. */
|
|
130
|
+
readonly maxActiveSubscriptions: number | undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const OPERATION_HOOK = 'graphql:operation'
|
|
134
|
+
const DIRECT: ExecuteOptions = Object.freeze({})
|
|
135
|
+
const DONE: IteratorResult<FormattedExecutionResult> = Object.freeze({ done: true, value: undefined })
|
|
136
|
+
|
|
137
|
+
/** SHA-256 (hex) of a query text: `GraphQLOperationEvent.documentId`. */
|
|
138
|
+
function documentId(query: string): string {
|
|
139
|
+
return createHash('sha256').update(query, 'utf8').digest('hex')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A parsed and validated document, kept by the document cache. */
|
|
143
|
+
class CachedDocument {
|
|
144
|
+
readonly query: string
|
|
145
|
+
readonly document: DocumentNode
|
|
146
|
+
/** The operation of a single-operation document. */
|
|
147
|
+
readonly single: OperationDefinitionNode | undefined
|
|
148
|
+
/** Operations by name (documents with several operations). */
|
|
149
|
+
readonly named: ReadonlyMap<string, OperationDefinitionNode> | undefined
|
|
150
|
+
#id: string | undefined
|
|
151
|
+
|
|
152
|
+
constructor(query: string, document: DocumentNode) {
|
|
153
|
+
this.query = query
|
|
154
|
+
this.document = document
|
|
155
|
+
let single: OperationDefinitionNode | undefined
|
|
156
|
+
let named: Map<string, OperationDefinitionNode> | undefined
|
|
157
|
+
let count = 0
|
|
158
|
+
for (const definition of document.definitions) {
|
|
159
|
+
if (definition.kind !== Kind.OPERATION_DEFINITION) continue
|
|
160
|
+
count++
|
|
161
|
+
single = definition
|
|
162
|
+
if (definition.name !== undefined) {
|
|
163
|
+
named ??= new Map()
|
|
164
|
+
named.set(definition.name.value, definition)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.single = count === 1 ? single : undefined
|
|
168
|
+
this.named = named
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** SHA-256 of the query, computed on first read (only the hook needs it). */
|
|
172
|
+
get id(): string {
|
|
173
|
+
this.#id ??= documentId(this.query)
|
|
174
|
+
return this.#id
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A bounded LRU of documents by exact query text. */
|
|
179
|
+
class DocumentCache {
|
|
180
|
+
readonly #max: number
|
|
181
|
+
readonly #entries = new Map<string, CachedDocument>()
|
|
182
|
+
#last: string | undefined
|
|
183
|
+
|
|
184
|
+
constructor(max: number) {
|
|
185
|
+
this.#max = max
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
get size(): number {
|
|
189
|
+
return this.#entries.size
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
get(query: string): CachedDocument | undefined {
|
|
193
|
+
const entry = this.#entries.get(query)
|
|
194
|
+
// refresh the recency (a re-insertion), except for the most recent entry: the hot loop of one query
|
|
195
|
+
if (entry !== undefined && this.#last !== query) {
|
|
196
|
+
this.#entries.delete(query)
|
|
197
|
+
this.#entries.set(query, entry)
|
|
198
|
+
this.#last = query
|
|
199
|
+
}
|
|
200
|
+
return entry
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
set(query: string, entry: CachedDocument): void {
|
|
204
|
+
if (this.#max === 0) return
|
|
205
|
+
if (this.#entries.size >= this.#max) {
|
|
206
|
+
const oldest = this.#entries.keys().next()
|
|
207
|
+
if (oldest.done !== true) this.#entries.delete(oldest.value)
|
|
208
|
+
}
|
|
209
|
+
this.#entries.set(query, entry)
|
|
210
|
+
this.#last = query
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
clear(): void {
|
|
214
|
+
this.#entries.clear()
|
|
215
|
+
this.#last = undefined
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A request refused before execution. */
|
|
220
|
+
class Rejection {
|
|
221
|
+
readonly status: number
|
|
222
|
+
readonly errors: GraphQLFormattedError[]
|
|
223
|
+
readonly operationType: GraphQLOperationKind | undefined
|
|
224
|
+
readonly operationName: string | undefined
|
|
225
|
+
readonly documentId: string | undefined
|
|
226
|
+
readonly headers: Readonly<Record<string, string>> | undefined
|
|
227
|
+
|
|
228
|
+
constructor(
|
|
229
|
+
status: number,
|
|
230
|
+
errors: GraphQLFormattedError[],
|
|
231
|
+
documentId: string | undefined,
|
|
232
|
+
operation?: OperationDefinitionNode,
|
|
233
|
+
headers?: Readonly<Record<string, string>>,
|
|
234
|
+
) {
|
|
235
|
+
this.status = status
|
|
236
|
+
this.errors = errors
|
|
237
|
+
this.documentId = documentId
|
|
238
|
+
this.operationType = operation?.operation
|
|
239
|
+
this.operationName = operation?.name?.value
|
|
240
|
+
this.headers = headers
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
response(): GraphQLResponse {
|
|
244
|
+
return {
|
|
245
|
+
status: this.status,
|
|
246
|
+
result: { errors: this.errors },
|
|
247
|
+
...(this.headers === undefined ? {} : { headers: this.headers }),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** A request ready to execute. */
|
|
253
|
+
interface Prepared {
|
|
254
|
+
readonly entry: CachedDocument
|
|
255
|
+
readonly operation: OperationDefinitionNode
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The GraphQL executor of a plugin. */
|
|
259
|
+
export class GraphQLExecutor {
|
|
260
|
+
readonly #schema: GraphQLSchema
|
|
261
|
+
readonly #container: Container
|
|
262
|
+
readonly #hooks: Hooks
|
|
263
|
+
readonly #logger: CoreLogger
|
|
264
|
+
readonly #formatter: ErrorFormatter
|
|
265
|
+
readonly #runtime: ContextRuntime
|
|
266
|
+
readonly #rules: readonly ValidationRule[]
|
|
267
|
+
readonly #limits: DocumentLimits
|
|
268
|
+
readonly #maxTokens: number | undefined
|
|
269
|
+
readonly #hideSuggestions: boolean
|
|
270
|
+
readonly #cache: DocumentCache
|
|
271
|
+
readonly #maxActive: number | undefined
|
|
272
|
+
readonly #streams = new Set<SubscriptionStream>()
|
|
273
|
+
readonly #direct = new Set<SubscriptionStream>()
|
|
274
|
+
/** Subscriptions counted against `maxActive` while their source starts (not yet in `#streams`). */
|
|
275
|
+
#starting = 0
|
|
276
|
+
|
|
277
|
+
constructor(options: ExecutorOptions) {
|
|
278
|
+
this.#schema = options.schema
|
|
279
|
+
this.#container = options.container
|
|
280
|
+
this.#hooks = options.hooks
|
|
281
|
+
this.#logger = options.logger
|
|
282
|
+
this.#formatter = options.formatter
|
|
283
|
+
this.#runtime = options.runtime
|
|
284
|
+
this.#rules = options.introspection ? specifiedRules : [...specifiedRules, NoSchemaIntrospectionCustomRule]
|
|
285
|
+
this.#limits = options.limits
|
|
286
|
+
this.#maxTokens = options.limits.maxTokens
|
|
287
|
+
// without introspection, suggestions would leak field and type names
|
|
288
|
+
this.#hideSuggestions = !options.introspection
|
|
289
|
+
this.#cache = new DocumentCache(options.documentCache)
|
|
290
|
+
this.#maxActive = options.maxActiveSubscriptions
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Active subscriptions (every transport). */
|
|
294
|
+
get activeSubscriptions(): number {
|
|
295
|
+
return this.#streams.size
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Documents in the cache. */
|
|
299
|
+
get cachedDocuments(): number {
|
|
300
|
+
return this.#cache.size
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Executes a query or a mutation. Resolves (or returns, when every resolver is synchronous) a
|
|
305
|
+
* response; rejects only when `options.signal` aborts the execution (with the signal's reason).
|
|
306
|
+
* A subscription is a 400: it needs {@link subscribe} ({@link SUBSCRIBE} with
|
|
307
|
+
* `streamSubscriptions`).
|
|
308
|
+
*/
|
|
309
|
+
execute(request: GraphQLRequest, options: ExecuteOptions = DIRECT): GraphQLResponse | Promise<GraphQLResponse> {
|
|
310
|
+
const prepared = this.#prepare(request, options)
|
|
311
|
+
if (prepared instanceof Rejection) return this.#reject(prepared, request, options)
|
|
312
|
+
if (prepared.operation.operation === 'subscription') {
|
|
313
|
+
if (options.streamSubscriptions === true) return SUBSCRIBE
|
|
314
|
+
return this.#reject(
|
|
315
|
+
new Rejection(
|
|
316
|
+
400,
|
|
317
|
+
[
|
|
318
|
+
requestError(
|
|
319
|
+
'Subscriptions are delivered as a stream: send the request with accept: text/event-stream (GraphQL over SSE), or call GraphQLPlugin.subscribe()',
|
|
320
|
+
GraphQLErrorCode.BadRequest,
|
|
321
|
+
),
|
|
322
|
+
],
|
|
323
|
+
prepared.entry.id,
|
|
324
|
+
prepared.operation,
|
|
325
|
+
),
|
|
326
|
+
request,
|
|
327
|
+
options,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
const scope = this.#container.currentScope()
|
|
331
|
+
const ctx = this.#context(prepared, request, options, scope)
|
|
332
|
+
const hooks = this.#hooks
|
|
333
|
+
if (!hooks.hasWrappers(OPERATION_HOOK) && !hooks.hasListeners(OPERATION_HOOK)) {
|
|
334
|
+
return this.#inScope(scope, ctx, () => this.#run(prepared, request, options, ctx))
|
|
335
|
+
}
|
|
336
|
+
return this.#inScope(scope, ctx, () =>
|
|
337
|
+
this.#observed(ctx, prepared.entry.id, () => this.#run(prepared, request, options, ctx)),
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Subscribes: a stream of results, one per source event, or a rejection (status 400, 405 or 503).
|
|
343
|
+
* A query or a mutation gives a stream of its single result. The stream ends when the source
|
|
344
|
+
* ends, when `options.signal` aborts, or with `return()`; a source failure ends it with a final
|
|
345
|
+
* error result (`SUBSCRIPTION_OVERFLOW` for a subscriber that fell behind `MemoryPubSub`).
|
|
346
|
+
*/
|
|
347
|
+
async subscribe(
|
|
348
|
+
request: GraphQLRequest,
|
|
349
|
+
options: ExecuteOptions = DIRECT,
|
|
350
|
+
): Promise<GraphQLResponse | GraphQLResultStream> {
|
|
351
|
+
const prepared = this.#prepare(request, options)
|
|
352
|
+
if (prepared instanceof Rejection) return this.#reject(prepared, request, options)
|
|
353
|
+
if (prepared.operation.operation !== 'subscription') {
|
|
354
|
+
const response = await this.execute(request, options)
|
|
355
|
+
return response.status === 200 ? new SingleResultStream(response.result) : response
|
|
356
|
+
}
|
|
357
|
+
const limited = this.#maxActive !== undefined
|
|
358
|
+
if (limited && this.#streams.size + this.#starting >= (this.#maxActive as number)) {
|
|
359
|
+
return this.#reject(
|
|
360
|
+
new Rejection(
|
|
361
|
+
503,
|
|
362
|
+
[requestError('Too many active subscriptions, retry later', GraphQLErrorCode.ServiceUnavailable)],
|
|
363
|
+
prepared.entry.id,
|
|
364
|
+
prepared.operation,
|
|
365
|
+
),
|
|
366
|
+
request,
|
|
367
|
+
options,
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
// the slot is reserved before the source starts (guards and subscribe methods may be async):
|
|
371
|
+
// concurrent subscriptions see it; released once the stream is counted or did not start
|
|
372
|
+
let reserved = limited
|
|
373
|
+
if (reserved) this.#starting++
|
|
374
|
+
const release = (): void => {
|
|
375
|
+
if (reserved) {
|
|
376
|
+
reserved = false
|
|
377
|
+
this.#starting--
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const outer = this.#container.currentScope()
|
|
381
|
+
const ctx = this.#context(prepared, request, options, outer)
|
|
382
|
+
// ctx.signal also aborts when the stream ends (return() included, on every transport): the
|
|
383
|
+
// source, started before the first read (SubscriptionStream), then lets go of its PubSub topic
|
|
384
|
+
const ended = new AbortController()
|
|
385
|
+
ctx.endsWith(ended.signal)
|
|
386
|
+
let run: <R>(fn: () => R) => R
|
|
387
|
+
let close: () => void
|
|
388
|
+
if (outer === undefined) {
|
|
389
|
+
// a direct subscription: its own scope, open as long as the stream
|
|
390
|
+
const opened = this.#container.openScope([[GraphQLTokens.Context, ctx]])
|
|
391
|
+
run = fn => opened.run(fn)
|
|
392
|
+
close = () => {
|
|
393
|
+
ended.abort()
|
|
394
|
+
opened.close()?.catch(error => this.#runtimeLogError('Failed to close the scope of a subscription', error))
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
outer.set(GraphQLTokens.Context, ctx)
|
|
398
|
+
run = fn => fn()
|
|
399
|
+
close = () => ended.abort()
|
|
400
|
+
}
|
|
401
|
+
const observer = this.#observer(ctx, prepared.entry.id)
|
|
402
|
+
try {
|
|
403
|
+
const stream = await run(() =>
|
|
404
|
+
this.#startSubscription(prepared, request, options, ctx, observer, run, close, release),
|
|
405
|
+
)
|
|
406
|
+
release()
|
|
407
|
+
if (stream instanceof Rejection) {
|
|
408
|
+
close()
|
|
409
|
+
observer?.end('rejected', stream.errors)
|
|
410
|
+
return stream.response()
|
|
411
|
+
}
|
|
412
|
+
return stream
|
|
413
|
+
} catch (error) {
|
|
414
|
+
release()
|
|
415
|
+
close()
|
|
416
|
+
observer?.end('error', undefined)
|
|
417
|
+
throw error
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Ends every subscription started with `transport: 'direct'` (plugin `onStop`). */
|
|
422
|
+
async stopDirect(): Promise<void> {
|
|
423
|
+
await Promise.all([...this.#direct].map(stream => stream.return()))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Forgets every cached document. */
|
|
427
|
+
clear(): void {
|
|
428
|
+
this.#cache.clear()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ------------------------------------------------------------------------------------------------
|
|
432
|
+
// preparation
|
|
433
|
+
|
|
434
|
+
/** Request checks, the document (cached, or parsed, limited and validated), the operation. */
|
|
435
|
+
#prepare(request: GraphQLRequest, options: ExecuteOptions): Prepared | Rejection {
|
|
436
|
+
const query: unknown = request?.query
|
|
437
|
+
if (typeof query !== 'string' || query === '') {
|
|
438
|
+
return badRequest(typeof query === 'string' ? 'The query is empty' : 'Missing query (a string)')
|
|
439
|
+
}
|
|
440
|
+
const operationName: unknown = request.operationName
|
|
441
|
+
if (operationName !== undefined && operationName !== null && typeof operationName !== 'string') {
|
|
442
|
+
return badRequest('operationName must be a string')
|
|
443
|
+
}
|
|
444
|
+
const variables: unknown = request.variables
|
|
445
|
+
if (variables !== undefined && variables !== null && (typeof variables !== 'object' || Array.isArray(variables))) {
|
|
446
|
+
return badRequest('variables must be an object')
|
|
447
|
+
}
|
|
448
|
+
const entry = this.#cache.get(query) ?? this.#load(query)
|
|
449
|
+
if (entry instanceof Rejection) return entry
|
|
450
|
+
let operation: OperationDefinitionNode | undefined
|
|
451
|
+
if (operationName === undefined || operationName === null) {
|
|
452
|
+
operation = entry.single
|
|
453
|
+
if (operation === undefined) {
|
|
454
|
+
return new Rejection(
|
|
455
|
+
400,
|
|
456
|
+
[
|
|
457
|
+
requestError(
|
|
458
|
+
'Must provide operation name if query contains multiple operations.',
|
|
459
|
+
GraphQLErrorCode.BadRequest,
|
|
460
|
+
),
|
|
461
|
+
],
|
|
462
|
+
entry.id,
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
operation = entry.named?.get(operationName)
|
|
467
|
+
if (operation === undefined) {
|
|
468
|
+
return new Rejection(
|
|
469
|
+
400,
|
|
470
|
+
[requestError(`Unknown operation named "${operationName}".`, GraphQLErrorCode.BadRequest)],
|
|
471
|
+
entry.id,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (options.queriesOnly === true && operation.operation !== 'query') {
|
|
476
|
+
return new Rejection(
|
|
477
|
+
405,
|
|
478
|
+
[requestError(`A ${operation.operation} cannot be sent with GET: use POST`, GraphQLErrorCode.BadRequest)],
|
|
479
|
+
entry.id,
|
|
480
|
+
operation,
|
|
481
|
+
{ allow: 'POST' },
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
return { entry, operation }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Parses, limits and validates a document; caches it when valid (rejected documents are not cached). */
|
|
488
|
+
#load(query: string): CachedDocument | Rejection {
|
|
489
|
+
let document: DocumentNode
|
|
490
|
+
try {
|
|
491
|
+
document = parse(query, this.#maxTokens === undefined ? undefined : { maxTokens: this.#maxTokens })
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (!(error instanceof GraphQLErrorClass)) throw error
|
|
494
|
+
return new Rejection(400, [withCode(error, GraphQLErrorCode.ParseFailed)], documentId(query))
|
|
495
|
+
}
|
|
496
|
+
const limited = checkLimits(document, this.#limits)
|
|
497
|
+
if (limited !== undefined) {
|
|
498
|
+
return new Rejection(
|
|
499
|
+
400,
|
|
500
|
+
limited.map(error => withCode(error, undefined)),
|
|
501
|
+
documentId(query),
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
const errors = validate(this.#schema, document, this.#rules, { hideSuggestions: this.#hideSuggestions })
|
|
505
|
+
if (errors.length > 0) {
|
|
506
|
+
return new Rejection(
|
|
507
|
+
400,
|
|
508
|
+
errors.map(error => withCode(error, GraphQLErrorCode.ValidationFailed)),
|
|
509
|
+
documentId(query),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
const entry = new CachedDocument(query, document)
|
|
513
|
+
this.#cache.set(query, entry)
|
|
514
|
+
return entry
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#context(
|
|
518
|
+
prepared: Prepared,
|
|
519
|
+
request: GraphQLRequest,
|
|
520
|
+
options: ExecuteOptions,
|
|
521
|
+
scope: ScopeContext | undefined,
|
|
522
|
+
): OperationContext {
|
|
523
|
+
return new OperationContext(this.#runtime, prepared.entry.document, prepared.operation, request, options, scope)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ------------------------------------------------------------------------------------------------
|
|
527
|
+
// execution
|
|
528
|
+
|
|
529
|
+
/** Runs `fn` in the current scope (the GraphQL context set in it) or in a new one, closed after. */
|
|
530
|
+
#inScope<R>(scope: ScopeContext | undefined, ctx: OperationContext, fn: () => R | Promise<R>): R | Promise<R> {
|
|
531
|
+
if (scope !== undefined) {
|
|
532
|
+
scope.set(GraphQLTokens.Context, ctx)
|
|
533
|
+
return fn()
|
|
534
|
+
}
|
|
535
|
+
const opened = this.#container.openScope([[GraphQLTokens.Context, ctx]])
|
|
536
|
+
let result: R | Promise<R>
|
|
537
|
+
try {
|
|
538
|
+
result = opened.run(fn)
|
|
539
|
+
} catch (error) {
|
|
540
|
+
const closing = opened.close()
|
|
541
|
+
if (closing === undefined) throw error
|
|
542
|
+
return closing.then(
|
|
543
|
+
() => Promise.reject(error),
|
|
544
|
+
() => Promise.reject(error),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
if (isPromise(result)) {
|
|
548
|
+
return result.then(
|
|
549
|
+
value => {
|
|
550
|
+
const closing = opened.close()
|
|
551
|
+
return closing === undefined ? value : closing.then(() => value)
|
|
552
|
+
},
|
|
553
|
+
(error: unknown) => {
|
|
554
|
+
const closing = opened.close()
|
|
555
|
+
if (closing === undefined) throw error
|
|
556
|
+
return closing.then(
|
|
557
|
+
() => Promise.reject(error),
|
|
558
|
+
() => Promise.reject(error),
|
|
559
|
+
)
|
|
560
|
+
},
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
// request-scoped providers created by a synchronous execution: disposed before answering
|
|
564
|
+
const closing = opened.close()
|
|
565
|
+
return closing === undefined ? result : closing.then(() => result)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#run(
|
|
569
|
+
prepared: Prepared,
|
|
570
|
+
request: GraphQLRequest,
|
|
571
|
+
options: ExecuteOptions,
|
|
572
|
+
ctx: OperationContext,
|
|
573
|
+
): GraphQLResponse | Promise<GraphQLResponse> {
|
|
574
|
+
let result: ExecutionResult | Promise<ExecutionResult>
|
|
575
|
+
try {
|
|
576
|
+
result = execute({
|
|
577
|
+
schema: this.#schema,
|
|
578
|
+
document: prepared.entry.document,
|
|
579
|
+
operationName: request.operationName,
|
|
580
|
+
variableValues: request.variables,
|
|
581
|
+
contextValue: ctx,
|
|
582
|
+
// undefined: no abort listener in graphql-js (one per execution otherwise)
|
|
583
|
+
abortSignal: options.abortSignal ?? options.signal,
|
|
584
|
+
hideSuggestions: this.#hideSuggestions,
|
|
585
|
+
})
|
|
586
|
+
} catch (error) {
|
|
587
|
+
return Promise.reject(abortReason(error, options))
|
|
588
|
+
}
|
|
589
|
+
if (isPromise(result)) {
|
|
590
|
+
return result.then(
|
|
591
|
+
settled => this.#respond(settled, ctx),
|
|
592
|
+
(error: unknown) => Promise.reject(abortReason(error, options)),
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
return this.#respond(result, ctx)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The response of an execution result: as is without errors, formatted errors otherwise. */
|
|
599
|
+
#respond(result: ExecutionResult, ctx: OperationContext): GraphQLResponse {
|
|
600
|
+
const errors = result.errors
|
|
601
|
+
if (errors === undefined) return { status: 200, result: result as FormattedExecutionResult }
|
|
602
|
+
if (!('data' in result)) {
|
|
603
|
+
// no data: graphql-js refused the variables before executing
|
|
604
|
+
return { status: 400, result: { errors: errors.map(variableError) } }
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
status: 200,
|
|
608
|
+
result: {
|
|
609
|
+
data: result.data,
|
|
610
|
+
errors: this.#formatter.format(errors, ctx),
|
|
611
|
+
...(result.extensions === undefined ? {} : { extensions: result.extensions }),
|
|
612
|
+
},
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Formats a subscription result: an event, a source that could not start or failed (field errors). */
|
|
617
|
+
formatEvent(result: ExecutionResult, ctx: OperationContext): FormattedExecutionResult {
|
|
618
|
+
const errors = result.errors
|
|
619
|
+
if (errors === undefined) return result as FormattedExecutionResult
|
|
620
|
+
return {
|
|
621
|
+
...('data' in result ? { data: result.data } : {}),
|
|
622
|
+
errors: this.#formatter.format(errors, ctx),
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
#startSubscription(
|
|
627
|
+
prepared: Prepared,
|
|
628
|
+
request: GraphQLRequest,
|
|
629
|
+
options: ExecuteOptions,
|
|
630
|
+
ctx: OperationContext,
|
|
631
|
+
observer: Observer | undefined,
|
|
632
|
+
run: <R>(fn: () => R) => R,
|
|
633
|
+
close: () => void,
|
|
634
|
+
release: () => void,
|
|
635
|
+
): Promise<GraphQLResultStream | Rejection> | GraphQLResultStream | Rejection {
|
|
636
|
+
const validated = validateSubscriptionArgs({
|
|
637
|
+
schema: this.#schema,
|
|
638
|
+
document: prepared.entry.document,
|
|
639
|
+
operationName: request.operationName,
|
|
640
|
+
variableValues: request.variables,
|
|
641
|
+
contextValue: ctx,
|
|
642
|
+
abortSignal: options.signal,
|
|
643
|
+
hideSuggestions: this.#hideSuggestions,
|
|
644
|
+
})
|
|
645
|
+
if (!('schema' in validated)) {
|
|
646
|
+
return new Rejection(400, validated.map(variableError), prepared.entry.id, prepared.operation)
|
|
647
|
+
}
|
|
648
|
+
const start = (source: AsyncIterable<unknown> | ExecutionResult): GraphQLResultStream => {
|
|
649
|
+
// the reservation becomes the stream (counted in #streams), or ends with the failed start
|
|
650
|
+
release()
|
|
651
|
+
if (!isAsyncIterable(source)) {
|
|
652
|
+
// the subscription could not start (guard, resolver error): one error result
|
|
653
|
+
const result = this.formatEvent(source, ctx)
|
|
654
|
+
close()
|
|
655
|
+
observer?.end('error', result.errors)
|
|
656
|
+
return new SingleResultStream(result)
|
|
657
|
+
}
|
|
658
|
+
const stream = new SubscriptionStream({
|
|
659
|
+
executor: this,
|
|
660
|
+
source: source[Symbol.asyncIterator](),
|
|
661
|
+
validated,
|
|
662
|
+
ctx,
|
|
663
|
+
run,
|
|
664
|
+
signal: options.signal,
|
|
665
|
+
field: responseKey(prepared.operation),
|
|
666
|
+
onEnd: (outcome, errors) => {
|
|
667
|
+
this.#streams.delete(stream)
|
|
668
|
+
this.#direct.delete(stream)
|
|
669
|
+
close()
|
|
670
|
+
observer?.end(outcome, errors)
|
|
671
|
+
},
|
|
672
|
+
})
|
|
673
|
+
this.#streams.add(stream)
|
|
674
|
+
if (ctx.transport === 'direct') this.#direct.add(stream)
|
|
675
|
+
return stream
|
|
676
|
+
}
|
|
677
|
+
const source = createSourceEventStream(validated)
|
|
678
|
+
return isPromise(source) ? source.then(start) : start(source)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ------------------------------------------------------------------------------------------------
|
|
682
|
+
// hook
|
|
683
|
+
|
|
684
|
+
/** A rejection as a response, observed by the hook (outcome `rejected`). */
|
|
685
|
+
#reject(
|
|
686
|
+
rejection: Rejection,
|
|
687
|
+
request: GraphQLRequest,
|
|
688
|
+
options: ExecuteOptions,
|
|
689
|
+
): GraphQLResponse | Promise<GraphQLResponse> {
|
|
690
|
+
const hooks = this.#hooks
|
|
691
|
+
const wrapped = hooks.hasWrappers(OPERATION_HOOK)
|
|
692
|
+
if (!wrapped && !hooks.hasListeners(OPERATION_HOOK)) return rejection.response()
|
|
693
|
+
const event: GraphQLOperationEvent = {
|
|
694
|
+
operationType: rejection.operationType,
|
|
695
|
+
operationName:
|
|
696
|
+
rejection.operationName ?? (typeof request?.operationName === 'string' ? request.operationName : undefined),
|
|
697
|
+
documentId: rejection.documentId,
|
|
698
|
+
transport: options.transport ?? 'direct',
|
|
699
|
+
requestId: options.requestId ?? options.http?.requestId ?? this.#scopedRequestId(),
|
|
700
|
+
context: undefined,
|
|
701
|
+
startTime: performance.now(),
|
|
702
|
+
outcome: 'rejected',
|
|
703
|
+
errors: summaries(rejection.errors),
|
|
704
|
+
}
|
|
705
|
+
if (!wrapped) {
|
|
706
|
+
hooks.emit(OPERATION_HOOK, event)
|
|
707
|
+
return rejection.response()
|
|
708
|
+
}
|
|
709
|
+
return hooks
|
|
710
|
+
.run(OPERATION_HOOK, event, () => Promise.resolve(rejection.response()))
|
|
711
|
+
.then(response => {
|
|
712
|
+
if (hooks.hasListeners(OPERATION_HOOK)) hooks.emit(OPERATION_HOOK, event)
|
|
713
|
+
return response
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** An executed query or mutation, observed by the hook. */
|
|
718
|
+
#observed(
|
|
719
|
+
ctx: OperationContext,
|
|
720
|
+
id: string,
|
|
721
|
+
run: () => GraphQLResponse | Promise<GraphQLResponse>,
|
|
722
|
+
): GraphQLResponse | Promise<GraphQLResponse> {
|
|
723
|
+
const hooks = this.#hooks
|
|
724
|
+
const event = operationEvent(ctx, id)
|
|
725
|
+
const done = (response: GraphQLResponse): GraphQLResponse => {
|
|
726
|
+
event.outcome = outcomeOf(response)
|
|
727
|
+
if (response.result.errors !== undefined) event.errors = summaries(response.result.errors)
|
|
728
|
+
return response
|
|
729
|
+
}
|
|
730
|
+
const failed = (error: unknown): never => {
|
|
731
|
+
event.outcome = 'error'
|
|
732
|
+
throw error
|
|
733
|
+
}
|
|
734
|
+
const emit = (): void => {
|
|
735
|
+
if (hooks.hasListeners(OPERATION_HOOK)) hooks.emit(OPERATION_HOOK, event)
|
|
736
|
+
}
|
|
737
|
+
if (hooks.hasWrappers(OPERATION_HOOK)) {
|
|
738
|
+
const promise = hooks.run(OPERATION_HOOK, event, () => Promise.resolve(run()).then(done, failed))
|
|
739
|
+
return promise.then(
|
|
740
|
+
response => {
|
|
741
|
+
emit()
|
|
742
|
+
return response
|
|
743
|
+
},
|
|
744
|
+
(error: unknown) => {
|
|
745
|
+
emit()
|
|
746
|
+
throw error
|
|
747
|
+
},
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
// listeners only: no promise added to a synchronous execution
|
|
751
|
+
let response: GraphQLResponse | Promise<GraphQLResponse>
|
|
752
|
+
try {
|
|
753
|
+
response = run()
|
|
754
|
+
} catch (error) {
|
|
755
|
+
event.outcome = 'error'
|
|
756
|
+
emit()
|
|
757
|
+
throw error
|
|
758
|
+
}
|
|
759
|
+
if (!isPromise(response)) {
|
|
760
|
+
done(response)
|
|
761
|
+
emit()
|
|
762
|
+
return response
|
|
763
|
+
}
|
|
764
|
+
return response.then(
|
|
765
|
+
settled => {
|
|
766
|
+
done(settled)
|
|
767
|
+
emit()
|
|
768
|
+
return settled
|
|
769
|
+
},
|
|
770
|
+
(error: unknown) => {
|
|
771
|
+
event.outcome = 'error'
|
|
772
|
+
emit()
|
|
773
|
+
throw error
|
|
774
|
+
},
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** The hook of a subscription, for its whole life (`end()` settles it), or `undefined` without wrappers nor listeners. */
|
|
779
|
+
#observer(ctx: OperationContext, id: string): Observer | undefined {
|
|
780
|
+
const hooks = this.#hooks
|
|
781
|
+
const wrapped = hooks.hasWrappers(OPERATION_HOOK)
|
|
782
|
+
if (!wrapped && !hooks.hasListeners(OPERATION_HOOK)) return undefined
|
|
783
|
+
const event = operationEvent(ctx, id)
|
|
784
|
+
let resolve: (() => void) | undefined
|
|
785
|
+
if (wrapped) {
|
|
786
|
+
const ended = new Promise<void>(settle => {
|
|
787
|
+
resolve = settle
|
|
788
|
+
})
|
|
789
|
+
hooks
|
|
790
|
+
.run(OPERATION_HOOK, event, () => ended)
|
|
791
|
+
.catch(error => this.#runtimeLogError('A "graphql:operation" hook wrapper failed', error))
|
|
792
|
+
}
|
|
793
|
+
let ended = false
|
|
794
|
+
return {
|
|
795
|
+
end: (outcome, errors) => {
|
|
796
|
+
if (ended) return
|
|
797
|
+
ended = true
|
|
798
|
+
event.outcome = outcome
|
|
799
|
+
if (errors !== undefined && errors.length > 0) event.errors = summaries(errors)
|
|
800
|
+
resolve?.()
|
|
801
|
+
if (hooks.hasListeners(OPERATION_HOOK)) hooks.emit(OPERATION_HOOK, event)
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The request id of the enclosing scope (a controller calling GraphQL), or a new one. */
|
|
807
|
+
#scopedRequestId(): string {
|
|
808
|
+
const id = this.#container.currentScope()?.get(HttpTokens.RequestId)
|
|
809
|
+
return typeof id === 'string' ? id : randomUUID()
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#runtimeLogError(message: string, error: unknown): void {
|
|
813
|
+
this.#logger.error(`GraphQLPlugin: ${message}`, { err: error })
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Settles the hook event of a subscription. */
|
|
818
|
+
interface Observer {
|
|
819
|
+
end(
|
|
820
|
+
outcome: NonNullable<GraphQLOperationEvent['outcome']>,
|
|
821
|
+
errors: readonly GraphQLFormattedError[] | undefined,
|
|
822
|
+
): void
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function noop(): void {}
|
|
826
|
+
|
|
827
|
+
function badRequest(message: string): Rejection {
|
|
828
|
+
return new Rejection(400, [requestError(message, GraphQLErrorCode.BadRequest)], undefined)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function variableError(error: GraphQLError): GraphQLFormattedError {
|
|
832
|
+
return withCode(error, error.extensions['code'] === undefined ? GraphQLErrorCode.BadUserInput : undefined)
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** graphql-js rejects with `AbortedGraphQLExecutionError`: callers get the reason of their signal. */
|
|
836
|
+
function abortReason(error: unknown, options: ExecuteOptions): unknown {
|
|
837
|
+
const signal = options.abortSignal ?? options.signal
|
|
838
|
+
if (error instanceof AbortedGraphQLExecutionError && signal?.aborted === true) return signal.reason
|
|
839
|
+
return error
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function operationEvent(ctx: OperationContext, id: string): GraphQLOperationEvent {
|
|
843
|
+
return {
|
|
844
|
+
operationType: ctx.operationType,
|
|
845
|
+
operationName: ctx.operationName,
|
|
846
|
+
documentId: id,
|
|
847
|
+
transport: ctx.transport,
|
|
848
|
+
requestId: ctx.requestId,
|
|
849
|
+
context: ctx,
|
|
850
|
+
startTime: performance.now(),
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function outcomeOf(response: GraphQLResponse): NonNullable<GraphQLOperationEvent['outcome']> {
|
|
855
|
+
if (response.status !== 200) return 'rejected'
|
|
856
|
+
const result = response.result
|
|
857
|
+
if (result.errors === undefined || result.errors.length === 0) return 'success'
|
|
858
|
+
return result.data === null || result.data === undefined ? 'error' : 'partial'
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function summaries(errors: readonly GraphQLFormattedError[]): NonNullable<GraphQLOperationEvent['errors']> {
|
|
862
|
+
return errors.map(error => {
|
|
863
|
+
const code = error.extensions?.['code']
|
|
864
|
+
return {
|
|
865
|
+
message: error.message,
|
|
866
|
+
code: typeof code === 'string' ? code : undefined,
|
|
867
|
+
...(error.path === undefined ? {} : { path: error.path }),
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** The response key of the root field of a subscription (its single selection). */
|
|
873
|
+
function responseKey(operation: OperationDefinitionNode): string | undefined {
|
|
874
|
+
const selection = operation.selectionSet.selections[0]
|
|
875
|
+
return selection?.kind === Kind.FIELD ? (selection.alias ?? selection.name).value : undefined
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
|
879
|
+
return typeof (value as { then?: unknown } | null)?.then === 'function'
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
|
883
|
+
return (
|
|
884
|
+
value !== null &&
|
|
885
|
+
typeof value === 'object' &&
|
|
886
|
+
typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
|
|
887
|
+
)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** A stream of one result (a query sent as a stream, a rejection for `plugin.subscribe()`). */
|
|
891
|
+
export function singleResult(result: FormattedExecutionResult): GraphQLResultStream {
|
|
892
|
+
return new SingleResultStream(result)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** The stream of a query or a mutation sent as a stream (SSE): its single result. */
|
|
896
|
+
class SingleResultStream implements GraphQLResultStream {
|
|
897
|
+
#result: FormattedExecutionResult | undefined
|
|
898
|
+
|
|
899
|
+
constructor(result: FormattedExecutionResult) {
|
|
900
|
+
this.#result = result
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
next(): Promise<IteratorResult<FormattedExecutionResult>> {
|
|
904
|
+
const result = this.#result
|
|
905
|
+
if (result === undefined) return Promise.resolve(DONE)
|
|
906
|
+
this.#result = undefined
|
|
907
|
+
return Promise.resolve({ done: false, value: result })
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return(): Promise<IteratorResult<FormattedExecutionResult>> {
|
|
911
|
+
this.#result = undefined
|
|
912
|
+
return Promise.resolve(DONE)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
[Symbol.asyncIterator](): GraphQLResultStream {
|
|
916
|
+
return this
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
interface SubscriptionStreamInit {
|
|
921
|
+
readonly executor: GraphQLExecutor
|
|
922
|
+
readonly source: AsyncIterator<unknown>
|
|
923
|
+
readonly validated: ValidatedSubscriptionArgs
|
|
924
|
+
readonly ctx: OperationContext
|
|
925
|
+
readonly run: <R>(fn: () => R) => R
|
|
926
|
+
readonly signal: AbortSignal | undefined
|
|
927
|
+
readonly field: string | undefined
|
|
928
|
+
readonly onEnd: (
|
|
929
|
+
outcome: NonNullable<GraphQLOperationEvent['outcome']>,
|
|
930
|
+
errors: readonly GraphQLFormattedError[] | undefined,
|
|
931
|
+
) => void
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The results of a subscription: each source event executed with `executeSubscriptionEvent`, a
|
|
936
|
+
* fresh per-execution state (loaders) and the operation's `GraphQLContext`.
|
|
937
|
+
*
|
|
938
|
+
* The source is started at once: its first `next()` is requested when the stream is created, so
|
|
939
|
+
* that a resolver written as an async generator (`for await (const x of pubsub.subscribe(...))`)
|
|
940
|
+
* has subscribed when `subscribe()` resolves, whatever the transport and however late its consumer
|
|
941
|
+
* reads (an SSE body pulled on demand). Only that first event is read ahead; the next ones are read
|
|
942
|
+
* when the consumer asks (backpressure).
|
|
943
|
+
*/
|
|
944
|
+
class SubscriptionStream implements GraphQLResultStream {
|
|
945
|
+
readonly #init: SubscriptionStreamInit
|
|
946
|
+
readonly #onAbort: (() => void) | undefined
|
|
947
|
+
/** The first `next()` of the source, requested at creation and consumed by the first read. */
|
|
948
|
+
#first: Promise<IteratorResult<unknown>> | undefined
|
|
949
|
+
/** `next()` calls of the source still pending. */
|
|
950
|
+
#reading = 0
|
|
951
|
+
#ended = false
|
|
952
|
+
#partial = false
|
|
953
|
+
|
|
954
|
+
constructor(init: SubscriptionStreamInit) {
|
|
955
|
+
this.#init = init
|
|
956
|
+
const signal = init.signal
|
|
957
|
+
if (signal !== undefined) {
|
|
958
|
+
this.#onAbort = () => {
|
|
959
|
+
void this.return()
|
|
960
|
+
}
|
|
961
|
+
signal.addEventListener('abort', this.#onAbort, { once: true })
|
|
962
|
+
}
|
|
963
|
+
const first = init.run(() => this.#read())
|
|
964
|
+
// handled by the first read; ignored when the stream ends before it
|
|
965
|
+
first.catch(noop)
|
|
966
|
+
this.#first = first
|
|
967
|
+
// the client may have left while the operation was starting (async guards): end at once
|
|
968
|
+
if (signal?.aborted === true) void this.return()
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
next(): Promise<IteratorResult<FormattedExecutionResult>> {
|
|
972
|
+
if (this.#ended) return Promise.resolve(DONE)
|
|
973
|
+
return this.#init.run(() => this.#pull())
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** The next event of the source. */
|
|
977
|
+
async #read(): Promise<IteratorResult<unknown>> {
|
|
978
|
+
this.#reading++
|
|
979
|
+
try {
|
|
980
|
+
return await this.#init.source.next()
|
|
981
|
+
} finally {
|
|
982
|
+
this.#reading--
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async #pull(): Promise<IteratorResult<FormattedExecutionResult>> {
|
|
987
|
+
const init = this.#init
|
|
988
|
+
let step: IteratorResult<unknown>
|
|
989
|
+
try {
|
|
990
|
+
const first = this.#first
|
|
991
|
+
this.#first = undefined
|
|
992
|
+
step = await (first ?? this.#read())
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (this.#ended) return DONE
|
|
995
|
+
// the source failed (MemoryPubSub overflow...): a final error result, then the end
|
|
996
|
+
const result = init.executor.formatEvent(
|
|
997
|
+
{
|
|
998
|
+
errors: [
|
|
999
|
+
new GraphQLErrorClass(errorMessage(error), {
|
|
1000
|
+
originalError: asError(error),
|
|
1001
|
+
path: init.field === undefined ? undefined : [init.field],
|
|
1002
|
+
}),
|
|
1003
|
+
],
|
|
1004
|
+
},
|
|
1005
|
+
init.ctx,
|
|
1006
|
+
)
|
|
1007
|
+
this.#end('error', result.errors)
|
|
1008
|
+
return { done: false, value: result }
|
|
1009
|
+
}
|
|
1010
|
+
if (step.done === true || this.#ended) {
|
|
1011
|
+
this.#end(this.#partial ? 'partial' : 'success', undefined)
|
|
1012
|
+
return DONE
|
|
1013
|
+
}
|
|
1014
|
+
init.ctx.resetLoaders()
|
|
1015
|
+
const executed = executeSubscriptionEvent({ ...init.validated, rootValue: step.value })
|
|
1016
|
+
const result = init.executor.formatEvent(isPromise(executed) ? await executed : executed, init.ctx)
|
|
1017
|
+
if (result.errors !== undefined) this.#partial = true
|
|
1018
|
+
return { done: false, value: result }
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async return(): Promise<IteratorResult<FormattedExecutionResult>> {
|
|
1022
|
+
if (this.#ended) return DONE
|
|
1023
|
+
this.#first = undefined
|
|
1024
|
+
// ends ctx.signal: a source following it (MemoryPubSub, a generator iterating it) ends at once
|
|
1025
|
+
this.#end(this.#partial ? 'partial' : 'success', undefined)
|
|
1026
|
+
const reading = this.#reading > 0
|
|
1027
|
+
try {
|
|
1028
|
+
const returned = Promise.resolve(this.#init.source.return?.())
|
|
1029
|
+
// an async generator runs return() only once its pending next() settles, which a source
|
|
1030
|
+
// ignoring ctx.signal may never do: do not wait for it then
|
|
1031
|
+
if (reading) returned.catch(noop)
|
|
1032
|
+
else await returned
|
|
1033
|
+
} catch {
|
|
1034
|
+
// the source is gone either way
|
|
1035
|
+
}
|
|
1036
|
+
return DONE
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
throw(): Promise<IteratorResult<FormattedExecutionResult>> {
|
|
1040
|
+
return this.return()
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
[Symbol.asyncIterator](): GraphQLResultStream {
|
|
1044
|
+
return this
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
#end(
|
|
1048
|
+
outcome: NonNullable<GraphQLOperationEvent['outcome']>,
|
|
1049
|
+
errors: readonly GraphQLFormattedError[] | undefined,
|
|
1050
|
+
): void {
|
|
1051
|
+
if (this.#ended) return
|
|
1052
|
+
this.#ended = true
|
|
1053
|
+
if (this.#onAbort !== undefined) this.#init.signal?.removeEventListener('abort', this.#onAbort)
|
|
1054
|
+
this.#init.onEnd(outcome, errors)
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function asError(error: unknown): Error {
|
|
1059
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function errorMessage(error: unknown): string {
|
|
1063
|
+
return error instanceof Error ? error.message : String(error)
|
|
1064
|
+
}
|