@znight/graphql 0.2.2 → 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/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/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/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,
@@ -423,13 +520,29 @@ export class GraphQLPlugin implements Plugin, PluginHttpRoutes {
423
520
  introspection: options.introspection,
424
521
  documentCache: options.documentCache,
425
522
  maxActiveSubscriptions: options.subscriptions === false ? undefined : options.subscriptions.maxActive,
523
+ subscriptions: options.subscriptions !== false,
426
524
  })
525
+ const http = ctx.getPlugin<HttpPlugin>('http')
526
+ const engine = new Engine(
527
+ executor,
528
+ {
529
+ path:
530
+ this.#endpoint ?? endpointPath(options.http === false || http === undefined ? '' : http.prefix, options.path),
531
+ introspection: options.introspection,
532
+ ide: options.graphiql,
533
+ exposeErrors: options.exposeErrors,
534
+ maxTokens: options.limits.maxTokens,
535
+ subscriptions: options.subscriptions === false ? false : { maxActive: options.subscriptions.maxActive },
536
+ http: options.http === false ? false : { get: options.http.get, bodyLimit: options.http.bodyLimit },
537
+ },
538
+ options.driver?.name ?? BUILTIN_DRIVER,
539
+ )
427
540
  ctx.logger.debug('GraphQL schema built', {
428
541
  types: types.length,
429
542
  schemaHash: section.schemaHash,
430
543
  ...(results.fields === 0 ? {} : { resultFields: results.fields }),
431
544
  })
432
- return { schema, section, executor }
545
+ return { schema, section, executor, engine }
433
546
  }
434
547
  }
435
548
 
@@ -444,6 +557,34 @@ function direct(options: DirectExecuteOptions | undefined): ExecuteOptions {
444
557
 
445
558
  const DIRECT: ExecuteOptions = Object.freeze({ transport: 'direct' })
446
559
 
560
+ /** A route of the driver as a plugin route: the `http` option's meta and guards, its body limit unless `GET`. */
561
+ function mount(
562
+ route: GraphQLDriverRoute,
563
+ http: Exclude<ResolvedGraphQLOptions['http'], false>,
564
+ driver: GraphQLDriver,
565
+ ): PluginRoute {
566
+ if (route === null || typeof route !== 'object' || typeof route.handler !== 'function') {
567
+ throw invalidOption(`routes() of the ${driver.name} driver`, 'routes { method, path, name, handler }', route)
568
+ }
569
+ return {
570
+ method: route.method,
571
+ path: route.path,
572
+ name: route.name,
573
+ handler: route.handler,
574
+ ...(http.meta.length === 0 ? {} : { meta: http.meta }),
575
+ ...(http.guards.length === 0 ? {} : { guards: http.guards }),
576
+ ...(route.method === 'GET' || route.method === 'HEAD' ? {} : { bodyLimit: http.bodyLimit }),
577
+ }
578
+ }
579
+
580
+ /** ZNIGHT066 for a driver whose `start()` failed, the original error as `cause`. */
581
+ function driverFailed(driver: GraphQLDriver, error: unknown): ZnightError {
582
+ return new ZnightError(ErrorCode.InvalidGraphQLSetup, `GraphQLPlugin: the ${driver.name} driver failed to start`, {
583
+ cause: error,
584
+ hints: ['See the cause: the driver could not create its server'],
585
+ })
586
+ }
587
+
447
588
  /** `prefix` (`''`, `/api`) joined with `path` (`/graphql`), without trailing slash: `/api/graphql`. */
448
589
  function endpointPath(prefix: string, path: string): string {
449
590
  const joined = `${prefix.replace(/\/+$/, '')}/${path.replace(/^\/+|\/+$/g, '')}`
package/src/testing.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * `GraphQLPlugin.execute()`;
7
7
  * - `subscribeOperation()` streams an operation over GraphQL over SSE in memory, or through
8
8
  * `GraphQLPlugin.subscribe()` without HTTP transport;
9
+ * - `describeDriverConformance()` describes the conformance scenarios of a GraphQL driver (RFC 0009);
9
10
  * - `fakeLoader()` builds a `Loader<L>` whose `load` / `loadMany` are spies of the running test
10
11
  * framework (`bun:test`, Vitest, Jest, or the built-in spy of `@znight/testing`).
11
12
  *
@@ -23,6 +24,8 @@ import { resolveMockFn } from '@znight/testing'
23
24
 
24
25
  import { endpointOf, executorOf, type GraphQLPlugin } from './plugin.ts'
25
26
 
27
+ export { type ConformanceTestApi, type DriverConformanceOptions, describeDriverConformance } from './conformance.ts'
28
+
26
29
  /** An operation for {@link executeOperation} and {@link subscribeOperation}. */
27
30
  export interface OperationRequest {
28
31
  readonly query: string
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The built-in transport as a driver (RFC 0009 §4.11): GraphQL over HTTP and SSE, the default when
3
+ * the `driver` option is absent. It keeps its privileged access to the internal executor (statuses,
4
+ * formatted results, SSE sessions, the memoised negotiation): its handlers are the ones of
5
+ * `HttpTransport`, unchanged, and it never goes through the public engine.
6
+ */
7
+ import type { SseOptions } from '@znight/http'
8
+ import type { GraphQLDriver, GraphQLDriverRoute, GraphQLDriverRoutesContext } from '../driver.ts'
9
+ import type { GraphQLExecutor } from '../execution.ts'
10
+
11
+ import { HttpTransport } from './http.ts'
12
+
13
+ /** What the built-in driver is built from (resolved plugin options). */
14
+ export interface BuiltinDriverOptions {
15
+ /** The executor of the plugin, read on the first request (routes may be collected before the bootstrap). */
16
+ readonly executor: () => GraphQLExecutor
17
+ /** Queries over `GET`. */
18
+ readonly get: boolean
19
+ readonly graphiql: boolean
20
+ /** `HttpResponse.sse()` options of GraphQL over SSE; `false`: no event streams. */
21
+ readonly sse: SseOptions | false
22
+ }
23
+
24
+ /** The name of the built-in transport (`GraphQLOperationEvent.driver`), reserved. */
25
+ export const BUILTIN_DRIVER = 'builtin'
26
+
27
+ /** The built-in GraphQL-over-HTTP and SSE endpoint. */
28
+ export class BuiltinDriver implements GraphQLDriver {
29
+ readonly name: string = BUILTIN_DRIVER
30
+ readonly #options: BuiltinDriverOptions
31
+
32
+ constructor(options: BuiltinDriverOptions) {
33
+ this.#options = options
34
+ }
35
+
36
+ /** `POST` on the path, and `GET` when queries over `GET` or GraphiQL are on (the router answers 405 otherwise). */
37
+ routes(context: GraphQLDriverRoutesContext): readonly GraphQLDriverRoute[] {
38
+ const options = this.#options
39
+ const transport = new HttpTransport({
40
+ executor: options.executor,
41
+ endpoint: context.endpoint,
42
+ get: options.get,
43
+ graphiql: options.graphiql,
44
+ sse: options.sse,
45
+ })
46
+ const routes: GraphQLDriverRoute[] = [
47
+ { method: 'POST', path: context.path, name: 'handle', handler: transport.post },
48
+ ]
49
+ if (options.get || options.graphiql) {
50
+ routes.unshift({ method: 'GET', path: context.path, name: 'handle', handler: transport.get })
51
+ }
52
+ return routes
53
+ }
54
+
55
+ /** Nothing to start: the handlers read the executor on the first request. */
56
+ start(): void {}
57
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The Fetch bridge of route-mounted drivers (RFC 0009 §4.4, Q10): the Web `Request` a Fetch server
3
+ * (GraphQL Yoga) handles, built from the `HttpContext` of the route. Internal to `@znight/graphql`
4
+ * (a candidate for `@znight/http` once a second user appears).
5
+ *
6
+ * - On `honoAdapter()` the native request already is a Web `Request`: it is handed over as is when
7
+ * its body was not read and cannot exceed the route's body limit (a `GET`, or a declared
8
+ * `content-length` within the limit), so nothing is copied.
9
+ * - Otherwise (Node-style adapters, chunked bodies) a `Request` is built from the method, the URL,
10
+ * the headers and the body read by `ctx.rawBody()`: the route's body limit then answers with
11
+ * `HttpPlugin`'s 413 on every adapter, before the server sees the request. GraphQL servers buffer
12
+ * the body to parse it anyway, so reading it first costs no extra copy of note.
13
+ */
14
+ import type { HttpContext } from '@znight/http'
15
+
16
+ /** The Web `Request` of the route's request, read under `limit` bytes. */
17
+ export function fetchRequest(ctx: HttpContext, limit: number): Request | Promise<Request> {
18
+ const native = ctx.request
19
+ const method = ctx.method
20
+ const bodyless = method === 'GET' || method === 'HEAD'
21
+ if (native instanceof Request && !native.bodyUsed) {
22
+ if (bodyless) return native
23
+ const length = ctx.headers['content-length']
24
+ if (length !== undefined && ctx.headers['transfer-encoding'] === undefined) {
25
+ // past the limit, ctx.rawBody() below rejects with HttpPlugin's own 413
26
+ const declared = Number(length)
27
+ if (Number.isSafeInteger(declared) && declared >= 0 && declared <= limit) return native
28
+ }
29
+ }
30
+ const url = requestUrl(ctx)
31
+ const headers = requestHeaders(ctx)
32
+ if (bodyless) return new Request(url, { method, headers, signal: ctx.signal })
33
+ return ctx.rawBody().then(body => new Request(url, { method, headers, body, signal: ctx.signal }))
34
+ }
35
+
36
+ /** The absolute URL of the request: the raw target on the request's protocol and host. */
37
+ function requestUrl(ctx: HttpContext): string {
38
+ const host = ctx.headers['host']
39
+ const target = ctx.url
40
+ if (host !== undefined && target.startsWith('/') && !target.startsWith('//')) {
41
+ const url = `${ctx.protocol}://${host}${target}`
42
+ if (URL.canParse(url)) return url
43
+ }
44
+ // no host header, or one a URL cannot hold: the path and query string still route the request
45
+ return new URL(target.startsWith('/') ? target.replace(/^\/+/, '/') : `/${target}`, `${ctx.protocol}://localhost`)
46
+ .href
47
+ }
48
+
49
+ function requestHeaders(ctx: HttpContext): Headers {
50
+ const headers = new Headers()
51
+ const source = ctx.headers
52
+ for (const name in source) {
53
+ const value = source[name]
54
+ if (value !== undefined) headers.append(name, value)
55
+ }
56
+ return headers
57
+ }
@@ -90,8 +90,12 @@ const GET_NOT_ALLOWED = errorReply(
90
90
  Object.freeze({ allow: 'POST' }),
91
91
  )
92
92
 
93
- /** `content-type` values of JSON bodies seen most often, compared before parsing. */
94
- function isJsonRequest(contentType: string | undefined): boolean {
93
+ /**
94
+ * Whether a `POST` body is JSON (`application/json`, UTF-8): the only body the endpoint takes (and
95
+ * the Yoga driver), so that a cross-site request, which cannot send it without a CORS preflight,
96
+ * never executes an operation. `content-type` values seen most often are compared before parsing.
97
+ */
98
+ export function isJsonRequest(contentType: string | undefined): boolean {
95
99
  if (contentType === 'application/json' || contentType === 'application/json; charset=utf-8') return true
96
100
  // parsed once per distinct value (memoised by @znight/http)
97
101
  const media = parseMediaType(contentType)
@@ -99,6 +103,11 @@ function isJsonRequest(contentType: string | undefined): boolean {
99
103
  return media.charset === undefined || media.charset === 'utf-8' || media.charset === 'utf8'
100
104
  }
101
105
 
106
+ /** The 415 of a `POST` body that is not JSON, in the negotiated JSON type. */
107
+ export function notJson(media: MediaKind): HttpResponse<FormattedExecutionResult> {
108
+ return errorReply(media, 415, errorBody('Send GraphQL requests as application/json (content-type: application/json)'))
109
+ }
110
+
102
111
  /** Why the parameters of a request are invalid, `undefined` when they are valid. */
103
112
  function invalidParams(params: Readonly<Record<string, unknown>>): string | undefined {
104
113
  const query = params['query']
@@ -211,13 +220,7 @@ export class HttpTransport {
211
220
  const negotiated = this.#post.choose(headers['accept'])
212
221
  const media = (negotiated & MEDIA) as MediaKind
213
222
  if (media === Media.None) return NOT_ACCEPTABLE
214
- if (!isJsonRequest(headers['content-type'])) {
215
- return errorReply(
216
- media,
217
- 415,
218
- errorBody('Send GraphQL requests as application/json (content-type: application/json)'),
219
- )
220
- }
223
+ if (!isJsonRequest(headers['content-type'])) return notJson(media)
221
224
  const encoding = headers['content-encoding']
222
225
  if (encoding !== undefined && encoding.trim().toLowerCase() !== 'identity' && encoding.trim() !== '') {
223
226
  return errorReply(media, 415, errorBody(`Unsupported content encoding "${encoding}"`))