@znight/graphql 0.2.1 → 0.3.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.
package/src/execution.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  *
5
5
  * `GraphQLExecutor` is the internal API of the transports (`plugin.execute()`, GraphQL over HTTP
6
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).
7
+ * a stream of formatted results (or a rejection). The `*Document()` methods are what the public
8
+ * `GraphQLEngine` of the drivers calls (RFC 0009 §4.2): graphql-js-shaped, on a parsed document,
9
+ * answering `GraphQLError` instances. They share the document cache, the context, the pipelines,
10
+ * the formatting and the hook with the transport methods, whose code they leave untouched.
8
11
  *
9
12
  * Per operation, on the hot path (a cached query over a scope that already exists): one cache
10
13
  * lookup, one context object, graphql-js `execute`, and the result as graphql-js returned it when
@@ -15,12 +18,14 @@ import type { Container, CoreLogger, GraphQLOperationEvent, Hooks, ScopeContext
15
18
  import type { HttpContext } from '@znight/http'
16
19
  import type {
17
20
  DocumentNode,
21
+ ExecutionArgs,
18
22
  ExecutionResult,
19
23
  FormattedExecutionResult,
20
24
  GraphQLError,
21
25
  GraphQLFormattedError,
22
26
  GraphQLSchema,
23
27
  OperationDefinitionNode,
28
+ Source,
24
29
  ValidatedSubscriptionArgs,
25
30
  ValidationRule,
26
31
  } from 'graphql'
@@ -30,6 +35,7 @@ import type { DocumentLimits } from './limits.ts'
30
35
  import type { GraphQLOperationKind, GraphQLTransport } from './types.ts'
31
36
 
32
37
  import { createHash, randomUUID } from 'node:crypto'
38
+ import { ErrorCode, ZnightError } from '@znight/core'
33
39
  import { HttpTokens } from '@znight/http'
34
40
  import {
35
41
  AbortedGraphQLExecutionError,
@@ -40,6 +46,7 @@ import {
40
46
  Kind,
41
47
  NoSchemaIntrospectionCustomRule,
42
48
  parse,
49
+ print,
43
50
  specifiedRules,
44
51
  validate,
45
52
  validateSubscriptionArgs,
@@ -48,8 +55,9 @@ import {
48
55
  import { OperationContext } from './context.ts'
49
56
  import { GraphQLErrorCode } from './errors.ts'
50
57
  import { requestError, withCode } from './format.ts'
51
- import { checkLimits } from './limits.ts'
58
+ import { checkLimits, limitsRule } from './limits.ts'
52
59
  import { GraphQLTokens } from './tokens.ts'
60
+ import { DeferredAbort } from './transport/abort.ts'
53
61
 
54
62
  /** A GraphQL request: the body of a GraphQL-over-HTTP request, or the argument of `plugin.execute()`. */
55
63
  export interface GraphQLRequest {
@@ -87,6 +95,10 @@ export interface ExecuteOptions {
87
95
  * yet) instead of a 400: the HTTP transport then streams it (the client accepts an event stream).
88
96
  */
89
97
  readonly streamSubscriptions?: boolean
98
+ /** The driver that received the operation (`GraphQLOperationEvent.driver`). Default `'builtin'`. */
99
+ readonly driver?: string
100
+ /** The driver's context (`ExecutionArgs.contextValue`): `GraphQLContext.serverContext`. */
101
+ readonly serverContext?: unknown
90
102
  }
91
103
 
92
104
  /**
@@ -114,6 +126,28 @@ export interface GraphQLResultStream extends AsyncIterableIterator<FormattedExec
114
126
  return(): Promise<IteratorResult<FormattedExecutionResult>>
115
127
  }
116
128
 
129
+ /** The stream of results of a subscription started through the engine (`GraphQLEngine.subscribe`). */
130
+ export type EngineStream = AsyncGenerator<ExecutionResult, void, void>
131
+
132
+ /** Any result the hook reads: a transport response, or an engine one (`GraphQLError` instances). */
133
+ interface Settled {
134
+ readonly status: number
135
+ readonly result: {
136
+ readonly data?: unknown
137
+ readonly errors?: readonly {
138
+ readonly message: string
139
+ readonly path?: readonly (string | number)[] | undefined
140
+ readonly extensions?: { readonly [key: string]: unknown } | undefined
141
+ }[]
142
+ }
143
+ }
144
+
145
+ /** The answer to an engine execution: like {@link GraphQLResponse}, with a graphql-js result. */
146
+ interface EngineResponse {
147
+ readonly status: number
148
+ readonly result: ExecutionResult
149
+ }
150
+
117
151
  /** What {@link GraphQLExecutor} is built from (resolved plugin options). */
118
152
  export interface ExecutorOptions {
119
153
  readonly schema: GraphQLSchema
@@ -128,10 +162,16 @@ export interface ExecutorOptions {
128
162
  readonly documentCache: number
129
163
  /** Active subscriptions cap, `undefined`: none. */
130
164
  readonly maxActiveSubscriptions: number | undefined
165
+ /**
166
+ * `subscriptions: false`: the engine answers a subscription with a `BAD_REQUEST` error result
167
+ * (the built-in transport refuses event streams itself). Default `true`.
168
+ */
169
+ readonly subscriptions?: boolean
131
170
  }
132
171
 
133
172
  const OPERATION_HOOK = 'graphql:operation'
134
173
  const DIRECT: ExecuteOptions = Object.freeze({})
174
+ const NO_ERRORS: readonly GraphQLError[] = Object.freeze([])
135
175
  const DONE: IteratorResult<FormattedExecutionResult> = Object.freeze({ done: true, value: undefined })
136
176
 
137
177
  /** SHA-256 (hex) of a query text: `GraphQLOperationEvent.documentId`. */
@@ -255,6 +295,9 @@ interface Prepared {
255
295
  readonly operation: OperationDefinitionNode
256
296
  }
257
297
 
298
+ /** What starting a subscription gives: a stream, a rejection, or (engine) an error result. */
299
+ type Started = GraphQLResultStream | EngineStream | ExecutionResult | Rejection
300
+
258
301
  /** The GraphQL executor of a plugin. */
259
302
  export class GraphQLExecutor {
260
303
  readonly #schema: GraphQLSchema
@@ -269,10 +312,27 @@ export class GraphQLExecutor {
269
312
  readonly #hideSuggestions: boolean
270
313
  readonly #cache: DocumentCache
271
314
  readonly #maxActive: number | undefined
272
- readonly #streams = new Set<SubscriptionStream>()
273
- readonly #direct = new Set<SubscriptionStream>()
315
+ readonly #subscriptions: boolean
316
+ readonly #streams = new Set<SubscriptionStream<unknown>>()
317
+ /**
318
+ * The streams the plugin ends in `onStop`: direct subscriptions and those of the engine. The SSE
319
+ * streams of the built-in transport are ended by `HttpPlugin`'s shutdown (with a `retry` hint).
320
+ */
321
+ readonly #owned = new Set<SubscriptionStream<unknown>>()
322
+ /** The streams of the engine (drivers), a part of {@link #owned}: ended when `HttpPlugin` drains. */
323
+ readonly #engineStreams = new Set<SubscriptionStream<unknown>>()
324
+ /** The application stops (`HttpPlugin` drains, or the plugin stops): the engine refuses new subscriptions (503). */
325
+ #closing = false
274
326
  /** Subscriptions counted against `maxActive` while their source starts (not yet in `#streams`). */
275
327
  #starting = 0
328
+ /** The engine's extra rules (`GraphQLEngine.rules`): the limits, introspection. Built on first use. */
329
+ #engineRules: readonly ValidationRule[] | undefined
330
+ /** The document limits as a rule, `undefined` when every limit is off. */
331
+ readonly #limitsRule: ValidationRule | undefined
332
+ /** Documents parsed by {@link parseDocument}: within their limits, not checked again by validation. */
333
+ readonly #limited = new WeakSet<DocumentNode>()
334
+ /** Operations of the documents the engine executed, by document (the server's or the cache's). */
335
+ readonly #documents = new WeakMap<DocumentNode, CachedDocument>()
276
336
 
277
337
  constructor(options: ExecutorOptions) {
278
338
  this.#schema = options.schema
@@ -288,6 +348,26 @@ export class GraphQLExecutor {
288
348
  this.#hideSuggestions = !options.introspection
289
349
  this.#cache = new DocumentCache(options.documentCache)
290
350
  this.#maxActive = options.maxActiveSubscriptions
351
+ this.#subscriptions = options.subscriptions !== false
352
+ this.#limitsRule = limitsRule(options.limits)
353
+ }
354
+
355
+ /** The schema operations run against. */
356
+ get schema(): GraphQLSchema {
357
+ return this.#schema
358
+ }
359
+
360
+ /** The engine's own validation rules (`GraphQLEngine.rules`): the limits, and introspection when it is off. */
361
+ get engineRules(): readonly ValidationRule[] {
362
+ let rules = this.#engineRules
363
+ if (rules === undefined) {
364
+ const list: ValidationRule[] = []
365
+ if (this.#limitsRule !== undefined) list.push(this.#limitsRule)
366
+ if (this.#hideSuggestions) list.push(NoSchemaIntrospectionCustomRule)
367
+ rules = Object.freeze(list)
368
+ this.#engineRules = rules
369
+ }
370
+ return rules
291
371
  }
292
372
 
293
373
  /** Active subscriptions (every transport). */
@@ -354,22 +434,30 @@ export class GraphQLExecutor {
354
434
  const response = await this.execute(request, options)
355
435
  return response.status === 200 ? new SingleResultStream(response.result) : response
356
436
  }
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
- }
437
+ if (this.#full()) return this.#reject(overflow(prepared), request, options)
438
+ const started = await this.#start(prepared, request, options, false)
439
+ return started instanceof Rejection ? started.response() : (started as GraphQLResultStream)
440
+ }
441
+
442
+ /** Every slot of `subscriptions.maxActive` is taken (started streams and starting ones). */
443
+ #full(): boolean {
444
+ return this.#maxActive !== undefined && this.#streams.size + this.#starting >= this.#maxActive
445
+ }
446
+
447
+ /**
448
+ * Starts a subscription in the current scope (or in one of its own, open as long as the stream):
449
+ * the slot reserved, the context and the hook observer created, the source started. A rejection
450
+ * (variables) is observed and returned for the caller to answer.
451
+ */
452
+ async #start(
453
+ prepared: Prepared,
454
+ request: GraphQLRequest,
455
+ options: ExecuteOptions,
456
+ engine: boolean,
457
+ ): Promise<Started> {
370
458
  // the slot is reserved before the source starts (guards and subscribe methods may be async):
371
459
  // concurrent subscriptions see it; released once the stream is counted or did not start
372
- let reserved = limited
460
+ let reserved = this.#maxActive !== undefined
373
461
  if (reserved) this.#starting++
374
462
  const release = (): void => {
375
463
  if (reserved) {
@@ -401,13 +489,12 @@ export class GraphQLExecutor {
401
489
  const observer = this.#observer(ctx, prepared.entry.id)
402
490
  try {
403
491
  const stream = await run(() =>
404
- this.#startSubscription(prepared, request, options, ctx, observer, run, close, release),
492
+ this.#startSubscription(prepared, request, options, ctx, observer, run, close, release, engine),
405
493
  )
406
494
  release()
407
495
  if (stream instanceof Rejection) {
408
496
  close()
409
497
  observer?.end('rejected', stream.errors)
410
- return stream.response()
411
498
  }
412
499
  return stream
413
500
  } catch (error) {
@@ -418,9 +505,22 @@ export class GraphQLExecutor {
418
505
  }
419
506
  }
420
507
 
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()))
508
+ /**
509
+ * Ends every subscription the plugin owns (plugin `onStop`): direct ones and those started
510
+ * through the engine (drivers). Built-in SSE streams end with `HttpPlugin`'s shutdown.
511
+ */
512
+ async stopStreams(): Promise<void> {
513
+ this.#closing = true
514
+ await Promise.all([...this.#owned].map(stream => stream.return()))
515
+ }
516
+
517
+ /**
518
+ * Ends the subscriptions of the engine (`HttpPlugin` drains, `GraphQLPlugin.onHttpDrain()`): a
519
+ * driver serves them as streamed responses `HttpPlugin` does not close. New ones are refused (503).
520
+ */
521
+ async stopEngineStreams(): Promise<void> {
522
+ this.#closing = true
523
+ await Promise.all([...this.#engineStreams].map(stream => stream.return()))
424
524
  }
425
525
 
426
526
  /** Forgets every cached document. */
@@ -428,6 +528,311 @@ export class GraphQLExecutor {
428
528
  this.#cache.clear()
429
529
  }
430
530
 
531
+ // ------------------------------------------------------------------------------------------------
532
+ // engine (RFC 0009 §4.2): graphql-js-shaped, on parsed documents, for GraphQL servers (drivers)
533
+
534
+ /**
535
+ * `GraphQLEngine.parse`: the document of the cache for a valid text, otherwise the text parsed
536
+ * with `maxTokens` and checked against the depth, aliases and directives limits. Throws a
537
+ * `GraphQLError` (`GRAPHQL_PARSE_FAILED`, `QUERY_TOO_COMPLEX`), observed as a rejected operation.
538
+ */
539
+ parseDocument(source: string | Source, driver: string): DocumentNode {
540
+ const text = typeof source === 'string' ? source : (source as Source | undefined)?.body
541
+ if (typeof text === 'string') {
542
+ const cached = this.#cache.get(text)
543
+ if (cached !== undefined) return cached.document
544
+ }
545
+ let document: DocumentNode
546
+ try {
547
+ document = parse(source, this.#maxTokens === undefined ? undefined : { maxTokens: this.#maxTokens })
548
+ } catch (error) {
549
+ if (!(error instanceof GraphQLErrorClass)) throw error
550
+ const failed = coded(error, GraphQLErrorCode.ParseFailed)
551
+ this.#rejectedDocument([failed], text, driver)
552
+ throw failed
553
+ }
554
+ const limited = checkLimits(document, this.#limits)
555
+ if (limited !== undefined) {
556
+ this.#rejectedDocument(limited, text, driver)
557
+ throw limited[0]
558
+ }
559
+ this.#limited.add(document)
560
+ return document
561
+ }
562
+
563
+ /**
564
+ * `GraphQLEngine.validate`: `rules` (default: graphql-js `specifiedRules`) plus the engine's own
565
+ * (the limits, unless {@link parseDocument} checked them, and introspection), with
566
+ * `hideSuggestions` when introspection is off. With the default rules on the engine's schema, a
567
+ * cached document is not validated again, and a valid one enters the document cache. Errors carry
568
+ * `GRAPHQL_VALIDATION_FAILED` (or the limit's code) and are observed as a rejected operation.
569
+ */
570
+ validateDocument(
571
+ schema: GraphQLSchema,
572
+ document: DocumentNode,
573
+ rules: readonly ValidationRule[] | undefined,
574
+ driver: string,
575
+ ): readonly GraphQLError[] {
576
+ const standard = rules === undefined || isSpecifiedRules(rules)
577
+ const cacheable = standard && schema === this.#schema
578
+ const text = document.loc?.source.body
579
+ if (cacheable && text !== undefined && this.#cache.get(text)?.document === document) return NO_ERRORS
580
+ const limited = this.#limited.has(document)
581
+ let all: readonly ValidationRule[]
582
+ if (standard) {
583
+ all = limited || this.#limitsRule === undefined ? this.#rules : [this.#limitsRule, ...this.#rules]
584
+ } else {
585
+ // the server's rules (Envelop plugins add some) and the engine's, each once
586
+ const merged = new Set<ValidationRule>(limited ? rules : [...this.engineRules, ...rules])
587
+ for (const rule of this.engineRules) merged.add(rule)
588
+ if (limited && this.#limitsRule !== undefined) merged.delete(this.#limitsRule)
589
+ all = [...merged]
590
+ }
591
+ const errors = validate(schema, document, all, { hideSuggestions: this.#hideSuggestions })
592
+ if (errors.length === 0) {
593
+ if (cacheable && text !== undefined) {
594
+ const entry = new CachedDocument(text, document)
595
+ this.#cache.set(text, entry)
596
+ this.#documents.set(document, entry)
597
+ }
598
+ return NO_ERRORS
599
+ }
600
+ const failed = errors.map(error =>
601
+ coded(error, error.extensions['code'] === undefined ? GraphQLErrorCode.ValidationFailed : undefined),
602
+ )
603
+ this.#rejectedDocument(failed, text, driver)
604
+ return failed
605
+ }
606
+
607
+ /**
608
+ * `GraphQLEngine.execute`: a validated query or mutation of `args.document`, in the current
609
+ * request scope (or one of its own), with the operation's `GraphQLContext` as `contextValue`
610
+ * (`args.contextValue` becomes `serverContext`). The result is graphql-js's, its errors mapped
611
+ * and masked, as `GraphQLError` instances without `originalError`. Without `options.signal`, an
612
+ * execution that turns asynchronous follows `GraphQLContext.signal` (the HTTP request's).
613
+ */
614
+ executeDocument(args: ExecutionArgs, options: ExecuteOptions): ExecutionResult | Promise<ExecutionResult> {
615
+ const prepared = this.#prepareDocument(args)
616
+ if (prepared instanceof Rejection) return asEngineResult(this.#reject(prepared, requestOf(args), options))
617
+ if (prepared.operation.operation === 'subscription') {
618
+ return asEngineResult(
619
+ this.#reject(
620
+ new Rejection(
621
+ 400,
622
+ [requestError('Subscriptions are started with GraphQLEngine.subscribe()', GraphQLErrorCode.BadRequest)],
623
+ prepared.entry.id,
624
+ prepared.operation,
625
+ ),
626
+ requestOf(args),
627
+ options,
628
+ ),
629
+ )
630
+ }
631
+ const request: GraphQLRequest = {
632
+ query: prepared.entry.query,
633
+ operationName: args.operationName,
634
+ variables: args.variableValues,
635
+ }
636
+ let abort: DeferredAbort | undefined
637
+ let effective = options
638
+ if (options.signal === undefined) {
639
+ // like the HTTP transport: the request's signal is only followed once the execution is pending
640
+ abort = new DeferredAbort()
641
+ effective = { ...options, abortSignal: abort.asSignal() }
642
+ }
643
+ const scope = this.#container.currentScope()
644
+ const ctx = this.#context(prepared, request, effective, scope)
645
+ const run = (): EngineResponse | Promise<EngineResponse> =>
646
+ this.#runEngine(prepared, request, effective, ctx, args.rootValue)
647
+ const hooks = this.#hooks
648
+ const response =
649
+ !hooks.hasWrappers(OPERATION_HOOK) && !hooks.hasListeners(OPERATION_HOOK)
650
+ ? this.#inScope(scope, ctx, run)
651
+ : this.#inScope(scope, ctx, () => this.#observed(ctx, prepared.entry.id, run))
652
+ if (!isPromise(response)) return response.result
653
+ abort?.follow(() => ctx.signal)
654
+ return response.then(resultOf)
655
+ }
656
+
657
+ /**
658
+ * `GraphQLEngine.subscribe`: starts a validated subscription, a stream of graphql-js results (its
659
+ * loaders renewed per event), or a single error result (rejected, past `maxActive`, a source that
660
+ * could not start). A query or a mutation is executed: its single result. The stream ends with
661
+ * `return()`, `options.signal` (default: the HTTP request's), its source, or the plugin's stop.
662
+ */
663
+ async subscribeDocument(args: ExecutionArgs, options: ExecuteOptions): Promise<EngineStream | ExecutionResult> {
664
+ const prepared = this.#prepareDocument(args)
665
+ if (prepared instanceof Rejection) return asEngineResult(this.#reject(prepared, requestOf(args), options))
666
+ if (prepared.operation.operation !== 'subscription') return this.executeDocument(args, options)
667
+ if (!this.#subscriptions) {
668
+ return asEngineResult(
669
+ this.#reject(
670
+ new Rejection(
671
+ 400,
672
+ [requestError('Subscriptions are disabled on this server', GraphQLErrorCode.BadRequest)],
673
+ prepared.entry.id,
674
+ prepared.operation,
675
+ ),
676
+ requestOf(args),
677
+ options,
678
+ ),
679
+ )
680
+ }
681
+ if (this.#full() || this.#closing) {
682
+ return asEngineResult(this.#reject(unavailable(prepared, this.#closing), requestOf(args), options))
683
+ }
684
+ const request: GraphQLRequest = {
685
+ query: prepared.entry.query,
686
+ operationName: args.operationName,
687
+ variables: args.variableValues,
688
+ }
689
+ let effective = options
690
+ if (options.signal === undefined) {
691
+ const http = this.#container.currentScope()?.get(HttpTokens.Context) as HttpContext | undefined
692
+ if (http !== undefined) effective = { ...options, signal: http.signal }
693
+ }
694
+ const started = await this.#start(prepared, request, effective, true)
695
+ if (started instanceof Rejection) return { errors: started.errors.map(fromFormatted) }
696
+ return started as EngineStream | ExecutionResult
697
+ }
698
+
699
+ /** The document of `args` and its operation, or a rejection (operation selection). */
700
+ #prepareDocument(args: ExecutionArgs): Prepared | Rejection {
701
+ if (args?.schema !== this.#schema) {
702
+ throw new ZnightError(
703
+ ErrorCode.InvalidGraphQLSetup,
704
+ "GraphQLEngine: the operation targets another schema than the engine's (ExecutionArgs.schema)",
705
+ { hints: ["Give the GraphQL server engine.schema: its resolvers are the engine's pipelines"] },
706
+ )
707
+ }
708
+ const document = args.document
709
+ let entry = this.#documents.get(document)
710
+ if (entry === undefined) {
711
+ const text = document.loc?.source.body
712
+ const cached = text === undefined ? undefined : this.#cache.get(text)
713
+ entry =
714
+ cached !== undefined && cached.document === document
715
+ ? cached
716
+ : new CachedDocument(text ?? print(document), document)
717
+ this.#documents.set(document, entry)
718
+ }
719
+ const operationName = args.operationName
720
+ let operation: OperationDefinitionNode | undefined
721
+ if (operationName === undefined || operationName === null) {
722
+ operation = entry.single
723
+ if (operation === undefined) {
724
+ return new Rejection(
725
+ 400,
726
+ [
727
+ requestError(
728
+ 'Must provide operation name if query contains multiple operations.',
729
+ GraphQLErrorCode.BadRequest,
730
+ ),
731
+ ],
732
+ entry.id,
733
+ )
734
+ }
735
+ } else {
736
+ operation = entry.named?.get(operationName)
737
+ if (operation === undefined) {
738
+ return new Rejection(
739
+ 400,
740
+ [requestError(`Unknown operation named "${operationName}".`, GraphQLErrorCode.BadRequest)],
741
+ entry.id,
742
+ )
743
+ }
744
+ }
745
+ return { entry, operation }
746
+ }
747
+
748
+ #runEngine(
749
+ prepared: Prepared,
750
+ request: GraphQLRequest,
751
+ options: ExecuteOptions,
752
+ ctx: OperationContext,
753
+ rootValue: unknown,
754
+ ): EngineResponse | Promise<EngineResponse> {
755
+ let result: ExecutionResult | Promise<ExecutionResult>
756
+ try {
757
+ result = execute({
758
+ schema: this.#schema,
759
+ document: prepared.entry.document,
760
+ rootValue,
761
+ operationName: request.operationName,
762
+ variableValues: request.variables,
763
+ contextValue: ctx,
764
+ abortSignal: options.abortSignal ?? options.signal,
765
+ hideSuggestions: this.#hideSuggestions,
766
+ })
767
+ } catch (error) {
768
+ return Promise.reject(abortReason(error, options))
769
+ }
770
+ if (isPromise(result)) {
771
+ return result.then(
772
+ settled => this.#respondEngine(settled, ctx),
773
+ (error: unknown) => Promise.reject(abortReason(error, options)),
774
+ )
775
+ }
776
+ return this.#respondEngine(result, ctx)
777
+ }
778
+
779
+ /** {@link #respond} for the engine: the errors as `GraphQLError` instances. */
780
+ #respondEngine(result: ExecutionResult, ctx: OperationContext): EngineResponse {
781
+ const errors = result.errors
782
+ if (errors === undefined) return { status: 200, result }
783
+ if (!('data' in result)) {
784
+ // no data: graphql-js refused the variables before executing
785
+ return {
786
+ status: 400,
787
+ result: {
788
+ errors: errors.map(error =>
789
+ coded(error, error.extensions['code'] === undefined ? GraphQLErrorCode.BadUserInput : undefined),
790
+ ),
791
+ },
792
+ }
793
+ }
794
+ return {
795
+ status: 200,
796
+ result: {
797
+ data: result.data,
798
+ errors: asGraphQLErrors(errors, this.#formatter.format(errors, ctx)),
799
+ ...(result.extensions === undefined ? {} : { extensions: result.extensions }),
800
+ },
801
+ }
802
+ }
803
+
804
+ /** A document rejected by {@link parseDocument} or {@link validateDocument}, for the hook. */
805
+ #rejectedDocument(errors: readonly GraphQLError[], text: string | undefined, driver: string): void {
806
+ const hooks = this.#hooks
807
+ const wrapped = hooks.hasWrappers(OPERATION_HOOK)
808
+ if (!wrapped && !hooks.hasListeners(OPERATION_HOOK)) return
809
+ const event: GraphQLOperationEvent = {
810
+ operationType: undefined,
811
+ operationName: undefined,
812
+ documentId: text === undefined ? undefined : documentId(text),
813
+ transport: 'http',
814
+ driver,
815
+ requestId: this.#scopedRequestId(),
816
+ context: undefined,
817
+ startTime: performance.now(),
818
+ outcome: 'rejected',
819
+ errors: summaries(errors),
820
+ }
821
+ if (!wrapped) {
822
+ hooks.emit(OPERATION_HOOK, event)
823
+ return
824
+ }
825
+ // parse and validate are synchronous: wrappers see the rejection, they cannot delay it
826
+ hooks
827
+ .run(OPERATION_HOOK, event, () => Promise.resolve())
828
+ .then(
829
+ () => {
830
+ if (hooks.hasListeners(OPERATION_HOOK)) hooks.emit(OPERATION_HOOK, event)
831
+ },
832
+ (error: unknown) => this.#runtimeLogError('A "graphql:operation" hook wrapper failed', error),
833
+ )
834
+ }
835
+
431
836
  // ------------------------------------------------------------------------------------------------
432
837
  // preparation
433
838
 
@@ -623,6 +1028,21 @@ export class GraphQLExecutor {
623
1028
  }
624
1029
  }
625
1030
 
1031
+ /** {@link formatEvent} for the engine: the errors formatted, then given back as `GraphQLError` instances. */
1032
+ engineEvent(result: ExecutionResult, ctx: OperationContext): ExecutionResult {
1033
+ const errors = result.errors
1034
+ if (errors === undefined) return result
1035
+ return {
1036
+ ...('data' in result ? { data: result.data } : {}),
1037
+ errors: asGraphQLErrors(errors, this.#formatter.format(errors, ctx)),
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Starts the source of a subscription. `engine`: the results are graphql-js results
1043
+ * ({@link engineEvent}) and a source that could not start is an error result instead of a stream
1044
+ * of one result.
1045
+ */
626
1046
  #startSubscription(
627
1047
  prepared: Prepared,
628
1048
  request: GraphQLRequest,
@@ -632,7 +1052,8 @@ export class GraphQLExecutor {
632
1052
  run: <R>(fn: () => R) => R,
633
1053
  close: () => void,
634
1054
  release: () => void,
635
- ): Promise<GraphQLResultStream | Rejection> | GraphQLResultStream | Rejection {
1055
+ engine: boolean,
1056
+ ): Promise<Started> | Started {
636
1057
  const validated = validateSubscriptionArgs({
637
1058
  schema: this.#schema,
638
1059
  document: prepared.entry.document,
@@ -645,17 +1066,23 @@ export class GraphQLExecutor {
645
1066
  if (!('schema' in validated)) {
646
1067
  return new Rejection(400, validated.map(variableError), prepared.entry.id, prepared.operation)
647
1068
  }
648
- const start = (source: AsyncIterable<unknown> | ExecutionResult): GraphQLResultStream => {
1069
+ const start = (source: AsyncIterable<unknown> | ExecutionResult): Started => {
649
1070
  // the reservation becomes the stream (counted in #streams), or ends with the failed start
650
1071
  release()
651
1072
  if (!isAsyncIterable(source)) {
652
1073
  // the subscription could not start (guard, resolver error): one error result
1074
+ if (engine) {
1075
+ const result = this.engineEvent(source, ctx)
1076
+ close()
1077
+ observer?.end('error', result.errors)
1078
+ return result
1079
+ }
653
1080
  const result = this.formatEvent(source, ctx)
654
1081
  close()
655
1082
  observer?.end('error', result.errors)
656
1083
  return new SingleResultStream(result)
657
1084
  }
658
- const stream = new SubscriptionStream({
1085
+ const stream = new SubscriptionStream<unknown>({
659
1086
  executor: this,
660
1087
  source: source[Symbol.asyncIterator](),
661
1088
  validated,
@@ -663,16 +1090,23 @@ export class GraphQLExecutor {
663
1090
  run,
664
1091
  signal: options.signal,
665
1092
  field: responseKey(prepared.operation),
1093
+ engine,
666
1094
  onEnd: (outcome, errors) => {
667
1095
  this.#streams.delete(stream)
668
- this.#direct.delete(stream)
1096
+ this.#owned.delete(stream)
1097
+ if (engine) this.#engineStreams.delete(stream)
669
1098
  close()
670
1099
  observer?.end(outcome, errors)
671
1100
  },
672
1101
  })
673
1102
  this.#streams.add(stream)
674
- if (ctx.transport === 'direct') this.#direct.add(stream)
675
- return stream
1103
+ if (engine || ctx.transport === 'direct') this.#owned.add(stream)
1104
+ if (engine) {
1105
+ this.#engineStreams.add(stream)
1106
+ // stopped while its source started: ended at once
1107
+ if (this.#closing) void stream.return()
1108
+ }
1109
+ return stream as GraphQLResultStream | EngineStream
676
1110
  }
677
1111
  const source = createSourceEventStream(validated)
678
1112
  return isPromise(source) ? source.then(start) : start(source)
@@ -696,6 +1130,7 @@ export class GraphQLExecutor {
696
1130
  rejection.operationName ?? (typeof request?.operationName === 'string' ? request.operationName : undefined),
697
1131
  documentId: rejection.documentId,
698
1132
  transport: options.transport ?? 'direct',
1133
+ driver: options.driver ?? 'builtin',
699
1134
  requestId: options.requestId ?? options.http?.requestId ?? this.#scopedRequestId(),
700
1135
  context: undefined,
701
1136
  startTime: performance.now(),
@@ -715,14 +1150,10 @@ export class GraphQLExecutor {
715
1150
  }
716
1151
 
717
1152
  /** 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> {
1153
+ #observed<T extends Settled>(ctx: OperationContext, id: string, run: () => T | Promise<T>): T | Promise<T> {
723
1154
  const hooks = this.#hooks
724
1155
  const event = operationEvent(ctx, id)
725
- const done = (response: GraphQLResponse): GraphQLResponse => {
1156
+ const done = (response: T): T => {
726
1157
  event.outcome = outcomeOf(response)
727
1158
  if (response.result.errors !== undefined) event.errors = summaries(response.result.errors)
728
1159
  return response
@@ -748,7 +1179,7 @@ export class GraphQLExecutor {
748
1179
  )
749
1180
  }
750
1181
  // listeners only: no promise added to a synchronous execution
751
- let response: GraphQLResponse | Promise<GraphQLResponse>
1182
+ let response: T | Promise<T>
752
1183
  try {
753
1184
  response = run()
754
1185
  } catch (error) {
@@ -818,10 +1249,99 @@ export class GraphQLExecutor {
818
1249
  interface Observer {
819
1250
  end(
820
1251
  outcome: NonNullable<GraphQLOperationEvent['outcome']>,
821
- errors: readonly GraphQLFormattedError[] | undefined,
1252
+ errors: NonNullable<Settled['result']['errors']> | undefined,
822
1253
  ): void
823
1254
  }
824
1255
 
1256
+ /** The 503 of a subscription past `subscriptions.maxActive`. */
1257
+ function overflow(prepared: Prepared): Rejection {
1258
+ return new Rejection(
1259
+ 503,
1260
+ [requestError('Too many active subscriptions, retry later', GraphQLErrorCode.ServiceUnavailable)],
1261
+ prepared.entry.id,
1262
+ prepared.operation,
1263
+ )
1264
+ }
1265
+
1266
+ /** The 503 of an engine subscription past `subscriptions.maxActive`, or while the application stops. */
1267
+ function unavailable(prepared: Prepared, closing: boolean): Rejection {
1268
+ if (!closing) return overflow(prepared)
1269
+ return new Rejection(
1270
+ 503,
1271
+ [requestError('The server is shutting down, retry later', GraphQLErrorCode.ServiceUnavailable)],
1272
+ prepared.entry.id,
1273
+ prepared.operation,
1274
+ )
1275
+ }
1276
+
1277
+ /** Whether `rules` are graphql-js `specifiedRules` (the same list, or a copy of it). */
1278
+ function isSpecifiedRules(rules: readonly ValidationRule[]): boolean {
1279
+ if (rules === specifiedRules) return true
1280
+ if (rules.length !== specifiedRules.length) return false
1281
+ for (let index = 0; index < rules.length; index++) if (rules[index] !== specifiedRules[index]) return false
1282
+ return true
1283
+ }
1284
+
1285
+ /** The request of engine arguments, for the hook of a rejection (its operation name). */
1286
+ function requestOf(args: ExecutionArgs): GraphQLRequest {
1287
+ return { query: '', operationName: args.operationName }
1288
+ }
1289
+
1290
+ function resultOf(response: EngineResponse): ExecutionResult {
1291
+ return response.result
1292
+ }
1293
+
1294
+ /** `error` with `extensions.code` set to `code` (as is without code), without its original error. */
1295
+ function coded(error: GraphQLError, code: string | undefined): GraphQLError {
1296
+ if (code === undefined) return error
1297
+ return new GraphQLErrorClass(error.message, {
1298
+ nodes: error.nodes,
1299
+ source: error.source,
1300
+ positions: error.positions,
1301
+ path: error.path,
1302
+ extensions: { ...error.extensions, code },
1303
+ })
1304
+ }
1305
+
1306
+ /**
1307
+ * The formatted errors of an execution as `GraphQLError` instances (RFC 0009 §4.2, rule 5): the
1308
+ * formatted message and extensions, the locations and path of the error they come from, no
1309
+ * `originalError` (servers such as Apollo Server wrap anything that is not an `Error`).
1310
+ */
1311
+ function asGraphQLErrors(
1312
+ originals: readonly GraphQLError[],
1313
+ formatted: readonly GraphQLFormattedError[],
1314
+ ): GraphQLError[] {
1315
+ const errors: GraphQLError[] = new Array(formatted.length)
1316
+ for (let index = 0; index < formatted.length; index++) {
1317
+ const original = originals[index] as GraphQLError
1318
+ const shown = formatted[index] as GraphQLFormattedError
1319
+ errors[index] = new GraphQLErrorClass(shown.message, {
1320
+ nodes: original.nodes,
1321
+ source: original.source,
1322
+ positions: original.positions,
1323
+ path: original.path,
1324
+ extensions: shown.extensions,
1325
+ })
1326
+ }
1327
+ return errors
1328
+ }
1329
+
1330
+ /** A request error (formatted, without location) as a `GraphQLError`. */
1331
+ function fromFormatted(error: GraphQLFormattedError): GraphQLError {
1332
+ return new GraphQLErrorClass(error.message, { path: error.path, extensions: error.extensions })
1333
+ }
1334
+
1335
+ /** A rejection answered to the engine: a result of `GraphQLError`s. */
1336
+ function asEngineResult(
1337
+ response: GraphQLResponse | Promise<GraphQLResponse>,
1338
+ ): ExecutionResult | Promise<ExecutionResult> {
1339
+ const settle = (settled: GraphQLResponse): ExecutionResult => ({
1340
+ errors: (settled.result.errors ?? []).map(fromFormatted),
1341
+ })
1342
+ return isPromise(response) ? response.then(settle) : settle(response)
1343
+ }
1344
+
825
1345
  function noop(): void {}
826
1346
 
827
1347
  function badRequest(message: string): Rejection {
@@ -845,20 +1365,21 @@ function operationEvent(ctx: OperationContext, id: string): GraphQLOperationEven
845
1365
  operationName: ctx.operationName,
846
1366
  documentId: id,
847
1367
  transport: ctx.transport,
1368
+ driver: ctx.driver,
848
1369
  requestId: ctx.requestId,
849
1370
  context: ctx,
850
1371
  startTime: performance.now(),
851
1372
  }
852
1373
  }
853
1374
 
854
- function outcomeOf(response: GraphQLResponse): NonNullable<GraphQLOperationEvent['outcome']> {
1375
+ function outcomeOf(response: Settled): NonNullable<GraphQLOperationEvent['outcome']> {
855
1376
  if (response.status !== 200) return 'rejected'
856
1377
  const result = response.result
857
1378
  if (result.errors === undefined || result.errors.length === 0) return 'success'
858
1379
  return result.data === null || result.data === undefined ? 'error' : 'partial'
859
1380
  }
860
1381
 
861
- function summaries(errors: readonly GraphQLFormattedError[]): NonNullable<GraphQLOperationEvent['errors']> {
1382
+ function summaries(errors: NonNullable<Settled['result']['errors']>): NonNullable<GraphQLOperationEvent['errors']> {
862
1383
  return errors.map(error => {
863
1384
  const code = error.extensions?.['code']
864
1385
  return {
@@ -925,15 +1446,19 @@ interface SubscriptionStreamInit {
925
1446
  readonly run: <R>(fn: () => R) => R
926
1447
  readonly signal: AbortSignal | undefined
927
1448
  readonly field: string | undefined
1449
+ /** Results as graphql-js results with `GraphQLError` instances (the engine), or formatted. */
1450
+ readonly engine: boolean
928
1451
  readonly onEnd: (
929
1452
  outcome: NonNullable<GraphQLOperationEvent['outcome']>,
930
- errors: readonly GraphQLFormattedError[] | undefined,
1453
+ errors: NonNullable<Settled['result']['errors']> | undefined,
931
1454
  ) => void
932
1455
  }
933
1456
 
934
1457
  /**
935
1458
  * The results of a subscription: each source event executed with `executeSubscriptionEvent`, a
936
- * fresh per-execution state (loaders) and the operation's `GraphQLContext`.
1459
+ * fresh per-execution state (loaders) and the operation's `GraphQLContext`. `R` is
1460
+ * `FormattedExecutionResult` for the transports (`GraphQLResultStream`), `ExecutionResult` for the
1461
+ * engine (`EngineStream`).
937
1462
  *
938
1463
  * The source is started at once: its first `next()` is requested when the stream is created, so
939
1464
  * that a resolver written as an async generator (`for await (const x of pubsub.subscribe(...))`)
@@ -941,7 +1466,7 @@ interface SubscriptionStreamInit {
941
1466
  * reads (an SSE body pulled on demand). Only that first event is read ahead; the next ones are read
942
1467
  * when the consumer asks (backpressure).
943
1468
  */
944
- class SubscriptionStream implements GraphQLResultStream {
1469
+ class SubscriptionStream<R> implements AsyncIterableIterator<R> {
945
1470
  readonly #init: SubscriptionStreamInit
946
1471
  readonly #onAbort: (() => void) | undefined
947
1472
  /** The first `next()` of the source, requested at creation and consumed by the first read. */
@@ -968,8 +1493,8 @@ class SubscriptionStream implements GraphQLResultStream {
968
1493
  if (signal?.aborted === true) void this.return()
969
1494
  }
970
1495
 
971
- next(): Promise<IteratorResult<FormattedExecutionResult>> {
972
- if (this.#ended) return Promise.resolve(DONE)
1496
+ next(): Promise<IteratorResult<R, void>> {
1497
+ if (this.#ended) return Promise.resolve(DONE as IteratorResult<R, void>)
973
1498
  return this.#init.run(() => this.#pull())
974
1499
  }
975
1500
 
@@ -983,7 +1508,15 @@ class SubscriptionStream implements GraphQLResultStream {
983
1508
  }
984
1509
  }
985
1510
 
986
- async #pull(): Promise<IteratorResult<FormattedExecutionResult>> {
1511
+ /** A result of the subscription, formatted for its consumer. */
1512
+ #format(result: ExecutionResult): R {
1513
+ const init = this.#init
1514
+ return (
1515
+ init.engine ? init.executor.engineEvent(result, init.ctx) : init.executor.formatEvent(result, init.ctx)
1516
+ ) as R
1517
+ }
1518
+
1519
+ async #pull(): Promise<IteratorResult<R, void>> {
987
1520
  const init = this.#init
988
1521
  let step: IteratorResult<unknown>
989
1522
  try {
@@ -991,35 +1524,32 @@ class SubscriptionStream implements GraphQLResultStream {
991
1524
  this.#first = undefined
992
1525
  step = await (first ?? this.#read())
993
1526
  } catch (error) {
994
- if (this.#ended) return DONE
1527
+ if (this.#ended) return DONE as IteratorResult<R, void>
995
1528
  // 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)
1529
+ const result = this.#format({
1530
+ errors: [
1531
+ new GraphQLErrorClass(errorMessage(error), {
1532
+ originalError: asError(error),
1533
+ path: init.field === undefined ? undefined : [init.field],
1534
+ }),
1535
+ ],
1536
+ })
1537
+ this.#end('error', (result as Settled['result']).errors)
1008
1538
  return { done: false, value: result }
1009
1539
  }
1010
1540
  if (step.done === true || this.#ended) {
1011
1541
  this.#end(this.#partial ? 'partial' : 'success', undefined)
1012
- return DONE
1542
+ return DONE as IteratorResult<R, void>
1013
1543
  }
1014
1544
  init.ctx.resetLoaders()
1015
1545
  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
1546
+ const result = this.#format(isPromise(executed) ? await executed : executed)
1547
+ if ((result as Settled['result']).errors !== undefined) this.#partial = true
1018
1548
  return { done: false, value: result }
1019
1549
  }
1020
1550
 
1021
- async return(): Promise<IteratorResult<FormattedExecutionResult>> {
1022
- if (this.#ended) return DONE
1551
+ async return(): Promise<IteratorResult<R, void>> {
1552
+ if (this.#ended) return DONE as IteratorResult<R, void>
1023
1553
  this.#first = undefined
1024
1554
  // ends ctx.signal: a source following it (MemoryPubSub, a generator iterating it) ends at once
1025
1555
  this.#end(this.#partial ? 'partial' : 'success', undefined)
@@ -1033,20 +1563,25 @@ class SubscriptionStream implements GraphQLResultStream {
1033
1563
  } catch {
1034
1564
  // the source is gone either way
1035
1565
  }
1036
- return DONE
1566
+ return DONE as IteratorResult<R, void>
1037
1567
  }
1038
1568
 
1039
- throw(): Promise<IteratorResult<FormattedExecutionResult>> {
1569
+ throw(): Promise<IteratorResult<R, void>> {
1040
1570
  return this.return()
1041
1571
  }
1042
1572
 
1043
- [Symbol.asyncIterator](): GraphQLResultStream {
1573
+ [Symbol.asyncIterator](): this {
1044
1574
  return this
1045
1575
  }
1046
1576
 
1577
+ /** `await using`: ends the stream (an `AsyncGenerator` is disposable). */
1578
+ async [Symbol.asyncDispose](): Promise<void> {
1579
+ await this.return()
1580
+ }
1581
+
1047
1582
  #end(
1048
1583
  outcome: NonNullable<GraphQLOperationEvent['outcome']>,
1049
- errors: readonly GraphQLFormattedError[] | undefined,
1584
+ errors: NonNullable<Settled['result']['errors']> | undefined,
1050
1585
  ): void {
1051
1586
  if (this.#ended) return
1052
1587
  this.#ended = true