@znight/graphql 0.0.0 → 0.2.0

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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1063 -0
  3. package/dist/compiler.d.mts +93 -0
  4. package/dist/compiler.mjs +2832 -0
  5. package/dist/compiler.mjs.map +1 -0
  6. package/dist/index.d.mts +542 -0
  7. package/dist/index.mjs +128 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/names-BlzDM5cF.mjs +111 -0
  10. package/dist/names-BlzDM5cF.mjs.map +1 -0
  11. package/dist/plugin-BdBxr5NQ.mjs +3222 -0
  12. package/dist/plugin-BdBxr5NQ.mjs.map +1 -0
  13. package/dist/testing.d.mts +101 -0
  14. package/dist/testing.mjs +306 -0
  15. package/dist/testing.mjs.map +1 -0
  16. package/dist/types-CgbV9LjD.d.mts +234 -0
  17. package/package.json +97 -1
  18. package/src/compiler/analyze.ts +608 -0
  19. package/src/compiler/builder.ts +1640 -0
  20. package/src/compiler/constants.ts +74 -0
  21. package/src/compiler/data.ts +73 -0
  22. package/src/compiler/defaults.ts +92 -0
  23. package/src/compiler/discriminators.ts +53 -0
  24. package/src/compiler/docs.ts +55 -0
  25. package/src/compiler/generate.ts +405 -0
  26. package/src/compiler/ids.ts +128 -0
  27. package/src/compiler/index.ts +93 -0
  28. package/src/compiler/locate.ts +44 -0
  29. package/src/compiler/model.ts +122 -0
  30. package/src/compiler/options.ts +54 -0
  31. package/src/compiler/scalars.ts +138 -0
  32. package/src/compiler/sdl-view.ts +59 -0
  33. package/src/context.ts +181 -0
  34. package/src/decisions.ts +169 -0
  35. package/src/decorators.ts +196 -0
  36. package/src/errors.ts +89 -0
  37. package/src/execution.ts +1064 -0
  38. package/src/format.ts +285 -0
  39. package/src/index.ts +84 -0
  40. package/src/limits.ts +177 -0
  41. package/src/loaders.ts +149 -0
  42. package/src/memory-pubsub.ts +133 -0
  43. package/src/metadata.ts +195 -0
  44. package/src/names.ts +47 -0
  45. package/src/options.ts +309 -0
  46. package/src/pipeline.ts +277 -0
  47. package/src/plugin.ts +557 -0
  48. package/src/pubsub.ts +36 -0
  49. package/src/scalars.ts +131 -0
  50. package/src/schema-hash.ts +18 -0
  51. package/src/schema.ts +557 -0
  52. package/src/sdl.ts +515 -0
  53. package/src/testing.ts +454 -0
  54. package/src/tokens.ts +22 -0
  55. package/src/transport/abort.ts +80 -0
  56. package/src/transport/accept.ts +155 -0
  57. package/src/transport/graphiql.ts +106 -0
  58. package/src/transport/http.ts +373 -0
  59. package/src/transport/sse.ts +45 -0
  60. package/src/type-ref.ts +89 -0
  61. package/src/types.ts +190 -0
  62. package/src/validate-args.ts +48 -0
@@ -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
+ }