@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/src/index.ts CHANGED
@@ -7,6 +7,13 @@
7
7
 
8
8
  export type { StandardSchemaV1 } from '@znight/http'
9
9
  export type { Float, ID, Int, JSONValue, ResolverOptions } from './decorators.ts'
10
+ export type {
11
+ GraphQLDriver,
12
+ GraphQLDriverContext,
13
+ GraphQLDriverRoute,
14
+ GraphQLDriverRoutesContext,
15
+ } from './driver.ts'
16
+ export type { EngineExecuteOptions, GraphQLEngine, GraphQLEngineSettings } from './engine.ts'
10
17
  export type { GraphQLRequest, GraphQLResultStream } from './execution.ts'
11
18
  export type {
12
19
  AnyPubSub,
package/src/limits.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * written, and, when the document spreads fragments, as each operation executes them (a fragment
6
6
  * spread several times counts each time). `maxTokens` is enforced by `parse()` itself.
7
7
  */
8
- import type { DirectiveNode, DocumentNode, FragmentDefinitionNode, SelectionSetNode } from 'graphql'
8
+ import type { DirectiveNode, DocumentNode, FragmentDefinitionNode, SelectionSetNode, ValidationRule } from 'graphql'
9
9
 
10
10
  import { GraphQLError, Kind } from 'graphql'
11
11
 
@@ -76,6 +76,27 @@ export function checkLimits(document: DocumentNode, limits: DocumentLimits): Gra
76
76
  return errors
77
77
  }
78
78
 
79
+ /**
80
+ * The limits as a graphql-js validation rule (`GraphQLEngine.rules`, RFC 0009), for GraphQL servers
81
+ * that parse and validate documents themselves: the same check as {@link checkLimits}, run once on
82
+ * the document node. `undefined` when every limit is disabled.
83
+ */
84
+ export function limitsRule(limits: DocumentLimits): ValidationRule | undefined {
85
+ if (limits.maxDepth === undefined && limits.maxAliases === undefined && limits.maxDirectives === undefined) {
86
+ return undefined
87
+ }
88
+ const rule: ValidationRule = context => ({
89
+ Document(node) {
90
+ const errors = checkLimits(node, limits)
91
+ if (errors !== undefined) for (const error of errors) context.reportError(error)
92
+ // the whole document is checked at once: nothing to visit below it
93
+ return false
94
+ },
95
+ })
96
+ Object.defineProperty(rule, 'name', { value: 'ZnightDocumentLimitsRule' })
97
+ return rule
98
+ }
99
+
79
100
  interface Counter {
80
101
  aliases: number
81
102
  directives: number
package/src/metadata.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import type { Token } from '@znight/core'
13
13
  import type { HttpRouteGroupDefinition } from '@znight/http'
14
14
  import type { GraphQLResolveInfo, GraphQLScalarType } from 'graphql'
15
- import type { GraphQLArgs, GraphQLContext, ScalarDefinition } from './types.ts'
15
+ import type { GraphQLArgs, GraphQLContext, ResolverContext, ScalarDefinition } from './types.ts'
16
16
 
17
17
  /** Format version of `metadata.extensions.graphql` (another version is ZNIGHT066 at boot). */
18
18
  export const GRAPHQL_METADATA_VERSION = 1
@@ -201,18 +201,34 @@ export interface GraphQLResolverBinding {
201
201
  readonly meta: Readonly<Record<string, unknown>>
202
202
  /** Informational: loader provider tokens the method injects. */
203
203
  readonly loaders?: readonly Token[]
204
+ /**
205
+ * The method has custom parameters (`createParamDecorator`, RFC 0010): the pipeline passes the
206
+ * field's `ResolverContext` to `invoke` (`f`). Absent otherwise.
207
+ */
208
+ readonly context?: true
209
+ /**
210
+ * Provider classes resolving custom parameters (`ParamResolver`), in order of first use: the
211
+ * pipeline resolves them in the operation scope and passes the instances to `invoke` (`x`).
212
+ * Absent when there is none.
213
+ */
214
+ readonly paramResolvers?: readonly Token[]
204
215
  /**
205
216
  * Calls the method: maps the graphql-js arguments to the method parameters (§7.3). Synchronous
206
- * unless an argument is validated with a Standard Schema.
217
+ * unless an argument is validated with a Standard Schema or a custom parameter resolver returns a
218
+ * promise. `field` and `resolvers` are passed only to methods with custom parameters (declared as
219
+ * a method so that a generated invoke requiring them is accepted).
207
220
  */
208
- readonly invoke: (
221
+ invoke(
209
222
  // biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the concrete resolver class
210
223
  resolver: any,
211
224
  parent: unknown,
212
225
  args: GraphQLArgs,
213
226
  context: GraphQLContext,
214
227
  info: GraphQLResolveInfo,
215
- ) => unknown
228
+ field?: ResolverContext,
229
+ // biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the tuple of resolver classes
230
+ resolvers?: any,
231
+ ): unknown
216
232
  }
217
233
 
218
234
  declare module '@znight/core' {
package/src/options.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Options of `GraphQLPlugin` (RFC 0005 §5.11): their types, defaults and validation (ZNIGHT066).
3
3
  */
4
4
  import type { ByteSize, GuardRef, RouteMetaEntry } from '@znight/http'
5
+ import type { GraphQLDriver } from './driver.ts'
5
6
  import type { SubscribeOptions } from './pubsub.ts'
6
7
  import type {
7
8
  GraphQLErrorMapper,
@@ -70,9 +71,12 @@ export interface GraphQLSubscriptionOptions {
70
71
  readonly maxActive?: number
71
72
  /** Events buffered per subscriber by the default `MemoryPubSub`. Default 100. */
72
73
  readonly bufferSize?: number
73
- /** SSE heartbeat (ms), `false` for none. Default: the `HttpPlugin` `sse` option. */
74
+ /**
75
+ * SSE heartbeat (ms), `false` for none. Default: the `HttpPlugin` `sse` option. Built-in transport
76
+ * only: with a `driver`, its own keep-alive options apply (ZNIGHT066).
77
+ */
74
78
  readonly heartbeat?: number | false
75
- /** SSE maximum duration (ms). Default: none. */
79
+ /** SSE maximum duration (ms). Default: none. Built-in transport only (ZNIGHT066 with a `driver`). */
76
80
  readonly maxDuration?: number
77
81
  }
78
82
 
@@ -95,12 +99,15 @@ export interface GraphQLPluginOptions {
95
99
  readonly exposeErrors?: boolean
96
100
  /** Introspection; off, suggestions are hidden too. Default `NODE_ENV !== 'production'`. */
97
101
  readonly introspection?: boolean
98
- /** GraphiQL on `GET path` with `accept: text/html`. Default `NODE_ENV === 'development'`. */
102
+ /**
103
+ * GraphiQL on `GET path` with `accept: text/html`; with a `driver`, the driver's IDE. Default
104
+ * `NODE_ENV === 'development'`.
105
+ */
99
106
  readonly graphiql?: boolean
100
107
  readonly limits?: GraphQLLimits
101
108
  /** Distinct documents kept parsed and validated (LRU); 0 disables the cache. Default 1 000. */
102
109
  readonly documentCache?: number
103
- /** `false` disables subscriptions over SSE. */
110
+ /** `false` disables subscriptions over SSE (with a `driver`, subscriptions altogether). */
104
111
  readonly subscriptions?: GraphQLSubscriptionOptions | false
105
112
  /** The `PubSub` provided to the container. Default: a `MemoryPubSub` (closed with the application). */
106
113
  readonly pubsub?: AnyPubSub
@@ -109,6 +116,11 @@ export interface GraphQLPluginOptions {
109
116
  * `'error'` (default) fails the boot, `'warn'` logs.
110
117
  */
111
118
  readonly enforcement?: 'error' | 'warn'
119
+ /**
120
+ * The GraphQL server serving the schema (RFC 0009): a driver of a subpath (`yogaDriver()`) or a
121
+ * third party's. Default: the built-in GraphQL-over-HTTP and SSE transport, the fastest.
122
+ */
123
+ readonly driver?: GraphQLDriver
112
124
  }
113
125
 
114
126
  /** Options with their defaults, validated. */
@@ -139,6 +151,8 @@ export interface ResolvedGraphQLOptions {
139
151
  readonly bufferSize: number
140
152
  readonly pubsub: AnyPubSub | undefined
141
153
  readonly enforcement: 'error' | 'warn'
154
+ /** `undefined`: the built-in transport. */
155
+ readonly driver: GraphQLDriver | undefined
142
156
  }
143
157
 
144
158
  const KEYS: ReadonlySet<string> = new Set([
@@ -155,6 +169,7 @@ const KEYS: ReadonlySet<string> = new Set([
155
169
  'subscriptions',
156
170
  'pubsub',
157
171
  'enforcement',
172
+ 'driver',
158
173
  ])
159
174
  const PATH = /^\/[A-Za-z0-9\-._~!$&'()*+,;=:@/]*$/
160
175
  const LIMIT_DEFAULTS = { maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 } as const
@@ -307,6 +322,19 @@ export function resolveGraphQLOptions(
307
322
  if (enforcement !== 'error' && enforcement !== 'warn')
308
323
  throw invalidOption('enforcement', "'error' or 'warn'", enforcement)
309
324
 
325
+ const driver = resolveDriver(options['driver'])
326
+ if (driver !== undefined && subscriptions !== false) {
327
+ for (const key of ['heartbeat', 'maxDuration'] as const) {
328
+ if (subscriptions[key] !== undefined) {
329
+ throw new ZnightError(
330
+ ErrorCode.InvalidGraphQLSetup,
331
+ `GraphQLPlugin: subscriptions.${key} configures the built-in SSE transport, not the ${driver.name} driver`,
332
+ { hints: [`Remove subscriptions.${key}: the ${driver.name} driver's own keep-alive options apply`] },
333
+ )
334
+ }
335
+ }
336
+ }
337
+
310
338
  return {
311
339
  path,
312
340
  http,
@@ -327,5 +355,26 @@ export function resolveGraphQLOptions(
327
355
  bufferSize,
328
356
  pubsub: pubsub as AnyPubSub | undefined,
329
357
  enforcement,
358
+ driver,
359
+ }
360
+ }
361
+
362
+ /** The `driver` option: an object with a `name` (not `builtin`) and `start()`, `routes()` and `stop()` functions when present. */
363
+ function resolveDriver(value: unknown): GraphQLDriver | undefined {
364
+ if (value === undefined) return undefined
365
+ const expected = 'a GraphQL driver ({ name, start(), routes?(), stop?() }, from yogaDriver() for instance)'
366
+ if (value === null || typeof value !== 'object') throw invalidOption('driver', expected, value)
367
+ const driver = value as Record<string, unknown>
368
+ const name = driver['name']
369
+ if (typeof name !== 'string' || name === '') throw invalidOption('driver', `${expected} with a name`, value)
370
+ if (name === 'builtin') {
371
+ throw invalidOption('driver.name', "a name other than 'builtin' (reserved for the built-in transport)", name)
372
+ }
373
+ if (typeof driver['start'] !== 'function') throw invalidOption('driver', `${expected} with a start() method`, value)
374
+ for (const method of ['routes', 'stop']) {
375
+ if (driver[method] !== undefined && typeof driver[method] !== 'function') {
376
+ throw invalidOption(`driver.${method}`, 'a function', driver[method])
377
+ }
330
378
  }
379
+ return value as GraphQLDriver
331
380
  }
package/src/pipeline.ts CHANGED
@@ -45,6 +45,8 @@ export interface PipelineOptions {
45
45
  readonly guard: (token: Token, coordinate: string) => Resolvable<GraphQLGuard>
46
46
  /** The resolver provider of a binding (memoised by the caller). */
47
47
  readonly resolver: (binding: GraphQLResolverBinding, coordinate: string) => Resolvable<object>
48
+ /** The provider of a custom parameter resolver class (RFC 0010; memoised by the caller). */
49
+ readonly paramResolver?: (token: Token, coordinate: string) => Resolvable<object>
48
50
  /** Input object types by name (null handling of nested input values). */
49
51
  readonly inputs: ReadonlyMap<string, GraphQLInputTypeDefinition>
50
52
  /** The {@link Recover} function of a field that carries results (RFC 0008), `undefined` for the others. */
@@ -52,7 +54,15 @@ export interface PipelineOptions {
52
54
  }
53
55
 
54
56
  /** The generated invoker of a binding. */
55
- type Invoke = GraphQLResolverBinding['invoke']
57
+ type Invoke = (
58
+ resolver: object,
59
+ parent: unknown,
60
+ args: GraphQLArgs,
61
+ context: GraphQLContext,
62
+ info: GraphQLResolveInfo,
63
+ field?: ResolverContext,
64
+ resolvers?: readonly object[],
65
+ ) => unknown
56
66
 
57
67
  /** Mutates a coerced argument or input value in place: `null` → `undefined` where flagged. */
58
68
  type Normalize = (value: unknown) => void
@@ -78,7 +88,6 @@ function fieldFunctions(
78
88
  parent: GraphQLObjectTypeDefinition,
79
89
  ): FieldFunctions {
80
90
  const container = options.container
81
- const invoke: Invoke = binding.invoke
82
91
  const target = options.resolver(binding, coordinate)
83
92
  const guards: Resolvable<GraphQLGuard>[] = [
84
93
  ...(globalGuardsApply(binding) ? options.globalGuards : []),
@@ -86,10 +95,16 @@ function fieldFunctions(
86
95
  ]
87
96
  const normalize = field.args === undefined ? undefined : normalizers.args(field.args)
88
97
  const recover = options.results?.(field, coordinate)
98
+ const custom = binding.context === true
89
99
  const route =
90
- guards.length === 0 && recover === undefined ? undefined : Object.freeze({ meta: binding.meta, coordinate })
100
+ guards.length === 0 && recover === undefined && !custom
101
+ ? undefined
102
+ : Object.freeze({ meta: binding.meta, coordinate })
91
103
  const parentType = parent.name
92
104
  const fieldName = field.name
105
+ const invoke: Invoke = custom
106
+ ? customInvoke(options, binding, coordinate, route as ResolverContext['route'], parentType, fieldName)
107
+ : (binding.invoke as Invoke)
93
108
 
94
109
  let resolve: GraphQLFieldResolver<unknown, unknown>
95
110
  if (guards.length === 0) {
@@ -120,15 +135,21 @@ function fieldFunctions(
120
135
  resolve = (p, a, c, i) => {
121
136
  const operation = c as OperationContext
122
137
  const args = a as GraphQLArgs
123
- const allowed = runGuards(
124
- container,
125
- guards,
126
- new FieldContext(route as ResolverContext['route'], operation, parentType, fieldName, args, p, i),
138
+ const fieldContext = new FieldContext(
139
+ route as ResolverContext['route'],
140
+ operation,
141
+ parentType,
142
+ fieldName,
143
+ args,
144
+ p,
145
+ i,
127
146
  )
128
- // §8.5: guards (they see the coerced arguments), then null handling, then invoke
147
+ const allowed = runGuards(container, guards, fieldContext)
148
+ // §8.5: guards (they see the coerced arguments), then null handling, then invoke (custom
149
+ // parameters share the guards' ResolverContext)
129
150
  const call = (): unknown => {
130
151
  if (normalize !== undefined) normalize(args)
131
- return invoke(resolveIn(container, target), p, args, operation, i)
152
+ return invoke(resolveIn(container, target), p, args, operation, i, fieldContext)
132
153
  }
133
154
  if (allowed === true) return call()
134
155
  if (allowed === false) return forbidden()
@@ -150,6 +171,48 @@ function identity(payload: unknown): unknown {
150
171
  return payload
151
172
  }
152
173
 
174
+ /**
175
+ * The invoke of a method with custom parameters (RFC 0010): the generated `invoke` receives the
176
+ * field's `ResolverContext` (the guards' one when the field has guards, else a new one) and the
177
+ * instances of its parameter resolvers: one frozen array resolved at boot when they are all
178
+ * singletons, otherwise resolved in the operation scope on every call.
179
+ */
180
+ function customInvoke(
181
+ options: PipelineOptions,
182
+ binding: GraphQLResolverBinding,
183
+ coordinate: string,
184
+ route: ResolverContext['route'],
185
+ parentType: string,
186
+ fieldName: string,
187
+ ): Invoke {
188
+ const generated = binding.invoke as Invoke
189
+ const container = options.container
190
+ const tokens = binding.paramResolvers ?? []
191
+ const resolvers = tokens.map(token => {
192
+ if (options.paramResolver === undefined) throw new TypeError(`No parameter resolver for ${coordinate}`)
193
+ return options.paramResolver(token, coordinate)
194
+ })
195
+ const context = (p: unknown, a: GraphQLArgs, c: GraphQLContext, i: GraphQLResolveInfo, f?: ResolverContext) =>
196
+ f ?? new FieldContext(route, c as OperationContext, parentType, fieldName, a, p, i)
197
+ if (resolvers.length === 0) {
198
+ return (r, p, a, c, i, f) => generated(r, p, a, c, i, context(p, a, c, i, f))
199
+ }
200
+ if (resolvers.every(resolver => resolver.token === undefined)) {
201
+ const instances = Object.freeze(resolvers.map(resolver => resolveIn(container, resolver)))
202
+ return (r, p, a, c, i, f) => generated(r, p, a, c, i, context(p, a, c, i, f), instances)
203
+ }
204
+ return (r, p, a, c, i, f) =>
205
+ generated(
206
+ r,
207
+ p,
208
+ a,
209
+ c,
210
+ i,
211
+ context(p, a, c, i, f),
212
+ resolvers.map(resolver => resolveIn(container, resolver)),
213
+ )
214
+ }
215
+
153
216
  /**
154
217
  * The resolve function of a field that carries results (RFC 0008): errors of the whole pipeline
155
218
  * (guards, null handling, `invoke`), thrown or rejected, go through `recover`. Synchronous results
package/src/plugin.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * `GraphQLPlugin` (RFC 0005 §5.11, §8.2): builds the schema from `metadata.extensions.graphql`,
3
3
  * provides `GraphQLContext`, `PubSub` and itself to the container, executes operations
4
- * (`execute()`, `subscribe()`) and contributes the GraphQL endpoint to `HttpPlugin`.
4
+ * (`execute()`, `subscribe()`, the `engine` of drivers, RFC 0009) and contributes the routes of its
5
+ * driver (the built-in GraphQL-over-HTTP endpoint by default) to `HttpPlugin`.
5
6
  */
6
7
  import type {
7
8
  Container,
@@ -15,9 +16,11 @@ import type {
15
16
  Token,
16
17
  ZnightMetadata,
17
18
  } from '@znight/core'
18
- import type { PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute, SseOptions } from '@znight/http'
19
+ import type { HttpPlugin, PluginHttpRoutes, PluginHttpRoutesContext, PluginRoute, SseOptions } from '@znight/http'
19
20
  import type { FormattedExecutionResult, GraphQLSchema } from 'graphql'
20
21
  import type { CheckedField } from './decisions.ts'
22
+ import type { GraphQLDriver, GraphQLDriverRoute } from './driver.ts'
23
+ import type { GraphQLEngine } from './engine.ts'
21
24
  import type { ExecuteOptions, GraphQLRequest, GraphQLResultStream } from './execution.ts'
22
25
  import type { Resolvable } from './format.ts'
23
26
  import type { GraphQLInputTypeDefinition, GraphQLMetadata, GraphQLResolverBinding } from './metadata.ts'
@@ -28,6 +31,7 @@ import { displayName, ErrorCode, resolveOptions, ZnightError } from '@znight/cor
28
31
  import { printSchema } from 'graphql'
29
32
 
30
33
  import { checkFieldDecisions } from './decisions.ts'
34
+ import { Engine } from './engine.ts'
31
35
  import { GraphQLExecutor, singleResult } from './execution.ts'
32
36
  import { ErrorFormatter } from './format.ts'
33
37
  import { LoaderFactory } from './loaders.ts'
@@ -38,7 +42,8 @@ import { PubSub } from './pubsub.ts'
38
42
  import { checkResultTypes, declaredTypes, ResultMappers } from './results.ts'
39
43
  import { buildGraphQLSchema, checkGraphQLSection, graphqlSection } from './schema.ts'
40
44
  import { GraphQLTokens } from './tokens.ts'
41
- import { checkSseOptions, HttpTransport } from './transport/http.ts'
45
+ import { BUILTIN_DRIVER, BuiltinDriver } from './transport/builtin.ts'
46
+ import { checkSseOptions } from './transport/http.ts'
42
47
 
43
48
  /** Options of {@link GraphQLPlugin.execute} and {@link GraphQLPlugin.subscribe}. */
44
49
  export interface DirectExecuteOptions {
@@ -53,6 +58,7 @@ interface Runtime {
53
58
  readonly schema: GraphQLSchema
54
59
  readonly section: GraphQLMetadata
55
60
  readonly executor: GraphQLExecutor
61
+ readonly engine: GraphQLEngine
56
62
  }
57
63
 
58
64
  let readExecutor: (plugin: GraphQLPlugin) => GraphQLExecutor
@@ -101,6 +107,10 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
101
107
  #runtime: Runtime | undefined
102
108
  #stop: AbortController | undefined
103
109
  #endpoint: string | undefined
110
+ /** The driver serving the schema: the `driver` option, or the built-in transport. */
111
+ #driver: GraphQLDriver | undefined
112
+ /** The driver was started (`onBootstrap`): `onStop` stops it. */
113
+ #started = false
104
114
  #active = false
105
115
 
106
116
  private constructor(options: PluginOptions<GraphQLPluginOptions>) {
@@ -164,6 +174,8 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
164
174
  this.#runtime = undefined
165
175
  this.#ownPubSub = undefined
166
176
  this.#endpoint = undefined
177
+ this.#driver = undefined
178
+ this.#started = false
167
179
  this.#container = ctx.container
168
180
  this.#metadata = ctx.metadata
169
181
  this.#logger = ctx.logger
@@ -183,42 +195,58 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
183
195
  }
184
196
 
185
197
  /**
186
- * The `PluginHttpRoutes` contract of `@znight/http`: the GraphQL endpoint (`GET` and `POST` on
187
- * `path`). `[]` with `http: false`.
198
+ * The `PluginHttpRoutes` contract of `@znight/http`: the routes of the driver, with the `http`
199
+ * option's meta and guards, and its body limit on the routes that are not `GET`. For the built-in
200
+ * transport, the GraphQL endpoint (`GET` and `POST` on `path`). `[]` with `http: false`.
188
201
  */
189
202
  httpRoutes(context: PluginHttpRoutesContext): readonly PluginRoute[] {
190
203
  this.#container ??= context.container
191
204
  const options = this.#resolve()
192
205
  const http = options.http
193
206
  if (http === false) return []
207
+ const driver = this.#driverOf(options)
208
+ // a server-mounted driver attaches itself to the adapter in start()
209
+ if (driver.routes === undefined) return []
194
210
  const endpoint = endpointPath(context.prefix, options.path)
195
- const transport = new HttpTransport({
196
- // called on the first request: the routes are collected before or after this plugin's onBootstrap
197
- executor: () => this.#ready().executor,
198
- endpoint,
199
- get: http.get,
200
- graphiql: options.graphiql,
201
- sse: sseOptions(options),
202
- })
211
+ const routes = driver.routes({ path: options.path, endpoint })
212
+ if (!Array.isArray(routes))
213
+ throw invalidOption(`routes() of the ${driver.name} driver`, 'an array of routes', routes)
203
214
  this.#endpoint = endpoint
204
- const common = {
205
- name: 'handle',
206
- path: options.path,
207
- ...(http.meta.length === 0 ? {} : { meta: http.meta }),
208
- ...(http.guards.length === 0 ? {} : { guards: http.guards }),
209
- }
210
- const routes: PluginRoute[] = [{ ...common, method: 'POST', handler: transport.post, bodyLimit: http.bodyLimit }]
211
- // GET serves queries (`http.get`) and GraphiQL; without either, the router answers 405
212
- if (http.get || options.graphiql) routes.unshift({ ...common, method: 'GET', handler: transport.get })
213
- return routes
215
+ return routes.map(route => mount(route, http, driver))
214
216
  }
215
217
 
216
- onBootstrap(ctx: PluginContext): void {
218
+ onBootstrap(ctx: PluginContext): void | Promise<void> {
217
219
  this.#container = ctx.container
218
220
  const options = this.#resolve()
219
221
  if (typeof this.#options === 'function') this.#checkHttp(ctx, options)
220
- this.#checkSse(options)
221
- this.#runtime = this.#build(ctx, options)
222
+ if (options.driver === undefined) this.#checkSse(options)
223
+ const runtime = this.#build(ctx, options)
224
+ this.#runtime = runtime
225
+ const driver = this.#driverOf(options)
226
+ // the built-in transport reads the executor on its first request: nothing to start
227
+ if (options.driver === undefined) return
228
+ let started: void | Promise<void>
229
+ try {
230
+ started = driver.start(runtime.engine, {
231
+ logger: ctx.logger,
232
+ container: ctx.container,
233
+ http: ctx.getPlugin<HttpPlugin>('http'),
234
+ })
235
+ } catch (error) {
236
+ throw driverFailed(driver, error)
237
+ }
238
+ if (!isPromise(started)) {
239
+ this.#started = true
240
+ return
241
+ }
242
+ return started.then(
243
+ () => {
244
+ this.#started = true
245
+ },
246
+ (error: unknown) => {
247
+ throw driverFailed(driver, error)
248
+ },
249
+ )
222
250
  }
223
251
 
224
252
  onStart(ctx: PluginContext): void {
@@ -230,12 +258,45 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
230
258
  else ctx.logger.info('GraphQL endpoint ready', { path: options.path, ...details })
231
259
  }
232
260
 
261
+ /**
262
+ * The `PluginHttpRoutes` contract: `HttpPlugin` starts draining, whatever the plugin order. The
263
+ * subscriptions of the engine end (a route-mounted driver serves them as streamed responses
264
+ * `HttpPlugin` does not close), and new ones are refused (503); queries and mutations in flight
265
+ * are served. Built-in SSE streams are closed by `HttpPlugin` itself.
266
+ */
267
+ onHttpDrain(): Promise<void> | undefined {
268
+ return this.#runtime?.executor.stopEngineStreams()
269
+ }
270
+
233
271
  async onStop(): Promise<void> {
234
272
  this.#stop?.abort(new Error('The application is stopping'))
235
- await this.#runtime?.executor.stopDirect()
273
+ // direct subscriptions and the driver's (engine) ones; built-in SSE streams end with HttpPlugin
274
+ await this.#runtime?.executor.stopStreams()
275
+ // a route-mounted driver serves requests until HttpPlugin drained them: it stops in onDispose
276
+ if (this.#driver?.routes === undefined) await this.#stopDriver()
277
+ }
278
+
279
+ onDispose(): void | Promise<void> {
280
+ if (!this.#started) {
281
+ this.#release()
282
+ return
283
+ }
284
+ // after HttpPlugin drained (every onStop ran), or disposed without having started (onStop did not run)
285
+ this.#stop?.abort(new Error('The application is stopping'))
286
+ return Promise.resolve(this.#runtime?.executor.stopStreams())
287
+ .then(() => this.#stopDriver())
288
+ .finally(() => this.#release())
236
289
  }
237
290
 
238
- onDispose(): void {
291
+ /** Stops the driver started in onBootstrap (once). */
292
+ #stopDriver(): void | Promise<void> {
293
+ const driver = this.#driver
294
+ if (!this.#started) return
295
+ this.#started = false
296
+ return driver?.stop?.()
297
+ }
298
+
299
+ #release(): void {
239
300
  this.#active = false
240
301
  this.#ownPubSub?.close()
241
302
  this.#ownPubSub = undefined
@@ -250,6 +311,15 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
250
311
  return this.#ready().schema
251
312
  }
252
313
 
314
+ /**
315
+ * The GraphQL engine (after bootstrap, RFC 0009): graphql-js-shaped `parse`, `validate`, `execute`
316
+ * and `subscribe` running this plugin's schema with its guards, loaders, request scope, error
317
+ * formatting and hook. What drivers receive in `start()`; any GraphQL server can call it.
318
+ */
319
+ get engine(): GraphQLEngine {
320
+ return this.#ready().engine
321
+ }
322
+
253
323
  /** The SDL of the schema (graphql-js `printSchema`). */
254
324
  printSchema(): string {
255
325
  return printSchema(this.#ready().schema)
@@ -320,6 +390,25 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
320
390
  return this.#resolved
321
391
  }
322
392
 
393
+ /** The driver of the application: the `driver` option, or the built-in transport. */
394
+ #driverOf(options: ResolvedGraphQLOptions): GraphQLDriver {
395
+ let driver = this.#driver
396
+ if (driver === undefined) {
397
+ const http = options.http
398
+ driver =
399
+ options.driver ??
400
+ new BuiltinDriver({
401
+ // called on the first request: the routes are collected before or after this plugin's onBootstrap
402
+ executor: () => this.#ready().executor,
403
+ get: http !== false && http.get,
404
+ graphiql: options.graphiql,
405
+ sse: sseOptions(options),
406
+ })
407
+ this.#driver = driver
408
+ }
409
+ return driver
410
+ }
411
+
323
412
  /** The SSE options are those of `HttpResponse.sse()` (heartbeat and maxDuration from 1 000 ms): checked at boot. */
324
413
  #checkSse(options: ResolvedGraphQLOptions): void {
325
414
  const sse = sseOptions(options)
@@ -329,6 +418,14 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
329
418
  }
330
419
 
331
420
  #checkHttp(ctx: PluginContext, options: ResolvedGraphQLOptions): void {
421
+ const driver = options.driver
422
+ if (options.http === false && driver?.routes !== undefined) {
423
+ throw new ZnightError(
424
+ ErrorCode.InvalidGraphQLSetup,
425
+ `GraphQLPlugin: the ${driver.name} driver serves routes, which http: false turns off`,
426
+ { hints: ['Remove http: false: the driver serves the endpoint through HttpPlugin'] },
427
+ )
428
+ }
332
429
  if (options.http !== false && ctx.getPlugin('http') === undefined) {
333
430
  throw new ZnightError(
334
431
  ErrorCode.InvalidGraphQLSetup,
@@ -396,6 +493,8 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
396
493
  guard: (token, coordinate) => providers.token<GraphQLGuard>(token, `guard of ${coordinate}`, 'canResolve'),
397
494
  resolver: (binding, coordinate) =>
398
495
  providers.token<object>(binding.token, `resolver of ${coordinate}`, binding.method),
496
+ paramResolver: (token, coordinate) =>
497
+ providers.token<object>(token, `parameter resolver of ${coordinate}`, 'resolve'),
399
498
  inputs,
400
499
  results: (field, coordinate) => results.recover(field, coordinate),
401
500
  }),
@@ -423,13 +522,29 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
423
522
  introspection: options.introspection,
424
523
  documentCache: options.documentCache,
425
524
  maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
525
+ subscriptions: options.subscriptions !== false,
426
526
  })
527
+ const http = ctx.getPlugin<HttpPlugin>('http')
528
+ const engine = new Engine(
529
+ executor,
530
+ {
531
+ path:
532
+ this.#endpoint ?? endpointPath(options.http === false || http === undefined ? '' : http.prefix, options.path),
533
+ introspection: options.introspection,
534
+ ide: options.graphiql,
535
+ exposeErrors: options.exposeErrors,
536
+ maxTokens: options.limits.maxTokens,
537
+ subscriptions: options.subscriptions === false ? false : { maxActive: options.subscriptions.maxActive },
538
+ http: options.http === false ? false : { get: options.http.get, bodyLimit: options.http.bodyLimit },
539
+ },
540
+ options.driver?.name ?? BUILTIN_DRIVER,
541
+ )
427
542
  ctx.logger.debug('GraphQL schema built', {
428
543
  types: types.length,
429
544
  schemaHash: section.schemaHash,
430
545
  ...(results.fields === 0 ? {} : { resultFields: results.fields }),
431
546
  })
432
- return { schema, section, executor }
547
+ return { schema, section, executor, engine }
433
548
  }
434
549
  }
435
550
 
@@ -444,6 +559,34 @@ function direct(options: DirectExecuteOptions | undefined): ExecuteOptions {
444
559
 
445
560
  const DIRECT: ExecuteOptions = Object.freeze({ transport: 'direct' })
446
561
 
562
+ /** A route of the driver as a plugin route: the `http` option's meta and guards, its body limit unless `GET`. */
563
+ function mount(
564
+ route: GraphQLDriverRoute,
565
+ http: Exclude<ResolvedGraphQLOptions['http'], false>,
566
+ driver: GraphQLDriver,
567
+ ): PluginRoute {
568
+ if (route === null || typeof route !== 'object' || typeof route.handler !== 'function') {
569
+ throw invalidOption(`routes() of the ${driver.name} driver`, 'routes { method, path, name, handler }', route)
570
+ }
571
+ return {
572
+ method: route.method,
573
+ path: route.path,
574
+ name: route.name,
575
+ handler: route.handler,
576
+ ...(http.meta.length === 0 ? {} : { meta: http.meta }),
577
+ ...(http.guards.length === 0 ? {} : { guards: http.guards }),
578
+ ...(route.method === 'GET' || route.method === 'HEAD' ? {} : { bodyLimit: http.bodyLimit }),
579
+ }
580
+ }
581
+
582
+ /** ZNIGHT066 for a driver whose `start()` failed, the original error as `cause`. */
583
+ function driverFailed(driver: GraphQLDriver, error: unknown): ZnightError {
584
+ return new ZnightError(ErrorCode.InvalidGraphQLSetup, `GraphQLPlugin: the ${driver.name} driver failed to start`, {
585
+ cause: error,
586
+ hints: ['See the cause: the driver could not create its server'],
587
+ })
588
+ }
589
+
447
590
  /** `prefix` (`''`, `/api`) joined with `path` (`/graphql`), without trailing slash: `/api/graphql`. */
448
591
  function endpointPath(prefix: string, path: string): string {
449
592
  const joined = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+|\/+$/g, '')}`