@znight/graphql 0.2.2 → 0.4.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/README.md +262 -17
- package/dist/compiler.d.mts +21 -1
- package/dist/compiler.mjs +116 -15
- package/dist/compiler.mjs.map +1 -1
- package/dist/{types-DQ713UgI.d.mts → driver-DgbpFj4-.d.mts} +193 -9
- package/dist/http-BWcayUtH.mjs +2165 -0
- package/dist/http-BWcayUtH.mjs.map +1 -0
- package/dist/index.d.mts +52 -51
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/names-BlzDM5cF.mjs.map +1 -1
- package/dist/{plugin-CR_wVfRU.mjs → plugin-BssB7wtn.mjs} +242 -1778
- package/dist/plugin-BssB7wtn.mjs.map +1 -0
- package/dist/testing.d.mts +40 -1
- package/dist/testing.mjs +649 -4
- package/dist/testing.mjs.map +1 -1
- package/dist/yoga.d.mts +64 -0
- package/dist/yoga.mjs +379 -0
- package/dist/yoga.mjs.map +1 -0
- package/package.json +19 -10
- package/src/compiler/analyze.ts +27 -0
- package/src/compiler/data.ts +20 -1
- package/src/compiler/generate.ts +137 -19
- package/src/conformance.ts +700 -0
- package/src/context.ts +13 -0
- package/src/driver.ts +63 -0
- package/src/engine.ts +146 -0
- package/src/execution.ts +602 -67
- package/src/index.ts +7 -0
- package/src/limits.ts +22 -1
- package/src/metadata.ts +20 -4
- package/src/options.ts +53 -4
- package/src/pipeline.ts +72 -9
- package/src/plugin.ts +172 -29
- package/src/testing.ts +3 -0
- package/src/transport/builtin.ts +57 -0
- package/src/transport/fetch.ts +57 -0
- package/src/transport/http.ts +12 -9
- package/src/types.ts +22 -4
- package/src/yoga.ts +471 -0
- package/dist/plugin-CR_wVfRU.mjs.map +0 -1
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 #
|
|
273
|
-
readonly #
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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):
|
|
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.#
|
|
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.#
|
|
675
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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<
|
|
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
|
-
|
|
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 =
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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 =
|
|
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<
|
|
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<
|
|
1569
|
+
throw(): Promise<IteratorResult<R, void>> {
|
|
1040
1570
|
return this.return()
|
|
1041
1571
|
}
|
|
1042
1572
|
|
|
1043
|
-
[Symbol.asyncIterator]():
|
|
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:
|
|
1584
|
+
errors: NonNullable<Settled['result']['errors']> | undefined,
|
|
1050
1585
|
): void {
|
|
1051
1586
|
if (this.#ended) return
|
|
1052
1587
|
this.#ended = true
|