@znight/graphql 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,8 @@ npm install -D @znight/compiler vite
44
44
 
45
45
  Runs on Node.js >= 24 and Bun >= 1.3 (ESM only). `@znight/http` and `graphql` 17 are required peer
46
46
  dependencies, `dataloader` is a dependency, `@znight/testing` an optional peer (for
47
- `@znight/graphql/testing`).
47
+ `@znight/graphql/testing`), `graphql-yoga` 6 an optional peer (for the Yoga driver,
48
+ `@znight/graphql/yoga`).
48
49
 
49
50
  ## Contents
50
51
 
@@ -61,6 +62,7 @@ dependencies, `dataloader` is a dependency, `@znight/testing` an optional peer (
61
62
  - [Errors as data](#errors-as-data)
62
63
  - [Plugin options](#plugin-options)
63
64
  - [GraphQL over HTTP and SSE](#graphql-over-http-and-sse)
65
+ - [Drivers and the engine](#drivers-and-the-engine)
64
66
  - [Security defaults and limits](#security-defaults-and-limits)
65
67
  - [Testing](#testing)
66
68
  - [The generated SDL and the compiler options](#the-generated-sdl-and-the-compiler-options)
@@ -425,8 +427,11 @@ curl -N -H 'accept: text/event-stream' -H 'content-type: application/json' \
425
427
  # data: {"data":{"bookAdded":{"title":"The Hobbit"}}}
426
428
  ```
427
429
 
428
- `PubSub<Events>` is an injectable abstract class (singleton, provided by `GraphQLPlugin`), where
429
- `Events` maps topics to payload types:
430
+ The events of a subscription are born elsewhere (a mutation, a scheduled job, a message consumer),
431
+ while the stream stays open. `PubSub<Events>` is the mailbox between them: producers
432
+ `publish(topic, payload)`, every open subscription `subscribe(topic, { signal })`s and receives what
433
+ is published on the topic from then on (nothing is stored or replayed). It is an injectable abstract
434
+ class (singleton, provided by `GraphQLPlugin`), where `Events` maps topics to payload types:
430
435
 
431
436
  ```ts
432
437
  /** Topics of the application PubSub and their payloads. */
@@ -443,8 +448,14 @@ for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal
443
448
  subscriber (`subscriptions.bufferSize`, 100 events). A subscriber that falls behind gets a final
444
449
  `SUBSCRIPTION_OVERFLOW` error and its stream ends, rather than losing events silently or buffering
445
450
  without bound. The plugin closes the `MemoryPubSub` it created when the application is disposed.
446
- - Several instances need a shared implementation (Redis, a broker): pass any object with
447
- `publish(topic, payload)` and `subscribe(topic, { signal })` as the `pubsub` option.
451
+ - `MemoryPubSub` reaches the subscribers of its own process only. Several instances need a shared
452
+ implementation (Redis pub/sub, NATS, Postgres `LISTEN`/`NOTIFY`, a broker): extend `PubSub` and
453
+ pass an instance as the `pubsub` option. `subscribe()` returns its iterator synchronously, ends
454
+ when `signal` aborts or `return()` is called, buffers a bounded number of events per subscriber
455
+ (`SUBSCRIPTION_OVERFLOW` beyond), and payloads must survive serialization. The plugin does not
456
+ close a `PubSub` it did not create. The guide's
457
+ [Write your own PubSub](../../docs/guide/graphql.md#custom-pubsub) recipe is a tested Redis
458
+ implementation (ioredis).
448
459
  - `subscribe()` ends when its `signal` aborts: the client left, the application shuts down, the
449
460
  stream reached `subscriptions.maxDuration`, or (`plugin.subscribe()`) its `return()` was called.
450
461
  Always pass `ctx.signal`.
@@ -512,13 +523,14 @@ Built-in scalars appear in the schema only when used: `String`, `Float`, `Int`,
512
523
  | Member | What |
513
524
  | --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
514
525
  | `requestId` | Id of the request carrying the operation (`HttpContext.requestId` over HTTP, a UUID or `requestId` option otherwise) |
515
- | `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`) |
526
+ | `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`); `'ws'` is reserved for `graphql-ws` |
516
527
  | `http` | The `HttpContext` of the request, `undefined` for direct executions outside a request |
517
528
  | `operationType` / `operationName` | `'query'`, `'mutation'` or `'subscription'`; the selected operation name |
518
529
  | `document` / `variables` | The parsed document (shared by the document cache: never mutate it) and the coerced variables |
519
530
  | `signal` | Over HTTP, the request's `ctx.signal`: client gone, SSE stream closed; passed to graphql-js as `abortSignal` |
520
531
  | `state` | A `Map` for per-operation storage |
521
532
  | `loader(Class \| token)` | The DataLoader of the current execution for a loader |
533
+ | `serverContext` | With a [driver](#drivers-and-the-engine), the context its server gave the engine; `undefined` otherwise |
522
534
 
523
535
  - Resolvers receive it with `@Ctx()`; request-scoped providers inject `GraphQLContext`, singletons
524
536
  `Lazy<GraphQLContext>`, exactly like `HttpContext`.
@@ -777,12 +789,13 @@ ZNIGHT066.
777
789
  | `resultMappers` | `[]` | `GraphQLResultMapper` classes or instances, tried in order: errors as data on the fields whose union holds one of their `types` ([Errors as data](#errors-as-data)) |
778
790
  | `exposeErrors` | `false` | Unmasked messages for unexpected errors (development) |
779
791
  | `introspection` | `NODE_ENV !== 'production'` | Off: graphql-js `NoSchemaIntrospectionCustomRule`, and suggestions are hidden |
780
- | `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL |
792
+ | `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL; with a `driver`, the driver's IDE |
781
793
  | `limits` | `{ maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 }` | Request limits (introspection fields exempt from `maxDepth`); `false` disables one |
782
794
  | `documentCache` | `1000` | Distinct documents kept parsed and validated (LRU); `0` disables |
783
795
  | `subscriptions` | `{ maxActive: 10_000, bufferSize: 100 }` | `false` disables subscriptions over SSE. `heartbeat` (ms or `false`) and `maxDuration` (ms) are passed to `HttpResponse.sse()` (default: the `HttpPlugin` `sse` options) |
784
796
  | `pubsub` | a `MemoryPubSub` | The `PubSub` provided to the container |
785
797
  | `enforcement` | `'error'` | How the boot check of field decisions reports a problem: `'error'` fails the boot (ZNIGHT023), `'warn'` logs |
798
+ | `driver` | the built-in transport | The GraphQL server serving the schema ([Drivers and the engine](#drivers-and-the-engine)); `heartbeat` and `maxDuration` are refused with one (ZNIGHT066) |
786
799
 
787
800
  `HttpPlugin` must be registered unless `http: false` (ZNIGHT066). The plugin instance exposes:
788
801
 
@@ -794,10 +807,15 @@ ZNIGHT066.
794
807
  | `subscribe(request, options?)` | A stream of results, one per event, until the source ends, `options.signal` aborts, `return()` is called or the application stops |
795
808
  | `pubsub` | The `PubSub` of the application |
796
809
  | `activeSubscriptions` | Active subscriptions, every transport |
797
- | `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http` |
810
+ | `engine` | The `GraphQLEngine` drivers call (after bootstrap): [Drivers and the engine](#drivers-and-the-engine) |
811
+ | `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http`, `onHttpDrain()` included |
798
812
 
799
813
  `request` is `{ query, operationName?, variables?, extensions? }`; `options` is `{ signal?,
800
- requestId? }`. A direct execution has no HTTP guards.
814
+ requestId? }`. A direct execution has no HTTP guards of its own: inside a controller, it runs in the
815
+ route's request scope, after the route's guards. With `http: false`, a controller can hand its
816
+ requests to another GraphQL server that delegates the execution to `execute()` and `subscribe()`;
817
+ a [driver](#drivers-and-the-engine) does it without the copying, on the parsed document (GraphQL
818
+ Yoga: [`yogaDriver()`](#graphql-yoga)).
801
819
 
802
820
  ```ts
803
821
  const plugin = app.getPlugin<GraphQLPlugin>('graphql')
@@ -805,10 +823,13 @@ const result = await plugin?.execute({ query: '{ books { total } }' })
805
823
  ```
806
824
 
807
825
  `onStart` logs `GraphQL endpoint ready` with the path, the number of types and the schema hash
808
- (`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions;
809
- `HttpPlugin` drains the requests in flight and closes the SSE streams (their `ctx.signal` aborts with
810
- `'shutdown'`, then the stream ends with a jittered `retry` and **no** `complete` event, so clients
811
- reconnect to another instance). Plugin order does not matter.
826
+ (`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions
827
+ and those of the engine; `HttpPlugin` drains the requests in flight and closes the SSE streams
828
+ (their `ctx.signal` aborts with `'shutdown'`, then the stream ends with a jittered `retry` and **no**
829
+ `complete` event, so clients reconnect to another instance). When `HttpPlugin` starts draining, it
830
+ calls `GraphQLPlugin.onHttpDrain()`, which ends the engine's subscriptions too (a driver serves them
831
+ as streams `HttpPlugin` does not close) and refuses new ones (503). A route-mounted driver stops in
832
+ `onDispose`, once the drain is over: requests in flight are served. Plugin order does not matter.
812
833
 
813
834
  ## GraphQL over HTTP and SSE
814
835
 
@@ -850,6 +871,201 @@ Adapters need nothing specific. With Express, do not mount `express.json()` befo
850
871
  (the body would be consumed). In tests, `TestApp.inject()` runs queries and mutations through every
851
872
  adapter in memory and `injectStream()` / `injectSse()` stream subscriptions.
852
873
 
874
+ ## Drivers and the engine
875
+
876
+ The built-in GraphQL-over-HTTP and SSE endpoint is the default and the fastest path. A **driver**
877
+ serves the same schema with another GraphQL server, for its protocol features and its plugins
878
+ (RFC 0009): Znight stays the engine, the driver owns the transport.
879
+
880
+ ```ts
881
+ import { yogaDriver } from '@znight/graphql/yoga'
882
+
883
+ GraphQLPlugin.configure({ guards: [AccessGuard], driver: yogaDriver() })
884
+ ```
885
+
886
+ Resolvers, guards, loaders, result mappers, request-scoped providers and tests do not change: the
887
+ server calls `plugin.engine`, which runs the compiled schema with everything this README describes.
888
+ **GraphQL Yoga** is the first driver ([`@znight/graphql/yoga`](#graphql-yoga)); **Apollo Server**
889
+ follows once it accepts graphql-js 17. Any other graphql-js-shaped server takes the engine as it
890
+ is: a driver is a small object, checked with the [conformance suite](#driver-conformance).
891
+
892
+ **The engine.** `plugin.engine` (after bootstrap, and the first argument of `driver.start()`):
893
+
894
+ | Member | What |
895
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
896
+ | `schema` | The executable schema; its resolvers are Znight's pipelines |
897
+ | `settings` | The resolved options a server needs: `path` (prefix included), `introspection`, `ide` (`graphiql`), `exposeErrors`, `maxTokens`, `subscriptions` (`false` or `{ maxActive }`), `http` (`false` or `{ get, bodyLimit }`) |
898
+ | `parse(source)` | graphql-js `parse` through the document cache, with `maxTokens` and the depth, aliases and directives limits; throws a `GraphQLError` (`GRAPHQL_PARSE_FAILED`, `QUERY_TOO_COMPLEX`) |
899
+ | `validate(schema, doc, rules?)` | graphql-js `validate` with `rules` (default `specifiedRules`) plus the engine's, suggestions hidden without introspection; a valid document is cached (default rules), its errors carry `GRAPHQL_VALIDATION_FAILED` |
900
+ | `rules` | The engine's own rules (the limits, `NoSchemaIntrospectionCustomRule` without introspection), for servers that validate themselves |
901
+ | `execute(args, options?)` | A validated query or mutation, in the current request scope (or one of its own): the `GraphQLContext` is the `contextValue`, `args.contextValue` its `serverContext`; errors mapped and masked, as `GraphQLError` instances |
902
+ | `subscribe(args, options?)` | A validated subscription: a stream of results (loaders renewed per event), or a single error result; the `maxActive` cap is shared with every transport, and the plugin ends the stream when the application stops |
903
+
904
+ `options` is `{ transport?: 'http' | 'sse' | 'ws', signal?, requestId? }`; the signal defaults to
905
+ `args.abortSignal`, then to the HTTP request's. The functions are bound and have graphql-js's
906
+ signatures, so they fit `graphql-http`, `graphql-sse`, `graphql-ws` and Envelop's `useEngine` as
907
+ they are. Every operation produces one `graphql:operation` event with the driver's name, and
908
+ documents rejected by `parse` or `validate` are observed too.
909
+
910
+ **The driver contract.** A `GraphQLDriver` has a `name` (`builtin` is reserved), optional
911
+ `routes()`, `start(engine, context)` and an optional `stop()`:
912
+
913
+ ```ts
914
+ import type { HttpContext } from '@znight/http'
915
+ import type { GraphQLDriver } from '@znight/graphql'
916
+
917
+ import { HttpResponse } from '@znight/http'
918
+ import { createHandler, type Handler } from 'graphql-http'
919
+
920
+ /** graphql-http's handler on the engine: GraphQL over HTTP served by the reference implementation. */
921
+ export function graphqlHttpDriver(): GraphQLDriver {
922
+ let handler: Handler<HttpContext> | undefined
923
+ return {
924
+ name: 'graphql-http',
925
+ routes: ({ path }) => {
926
+ const handle = async (ctx: HttpContext) => {
927
+ const body = ctx.method === 'POST' ? new TextDecoder().decode(await ctx.rawBody()) : null
928
+ const [text, init] = await (handler as Handler<HttpContext>)({
929
+ method: ctx.method,
930
+ url: ctx.url,
931
+ headers: { get: name => ctx.header(name.toLowerCase()) ?? null },
932
+ body,
933
+ raw: ctx,
934
+ context: undefined,
935
+ })
936
+ return new HttpResponse(text ?? undefined, { status: init.status, headers: init.headers ?? {} })
937
+ }
938
+ return [
939
+ { method: 'GET', path, name: 'graphql', handler: handle },
940
+ { method: 'POST', path, name: 'graphql', handler: handle },
941
+ ]
942
+ },
943
+ start(engine) {
944
+ handler = createHandler<HttpContext>({
945
+ schema: engine.schema,
946
+ parse: engine.parse,
947
+ validate: engine.validate,
948
+ execute: args => engine.execute(args),
949
+ })
950
+ },
951
+ }
952
+ }
953
+ ```
954
+
955
+ - **Routes.** A route-mounted driver returns its routes: `GraphQLPlugin.httpRoutes()` gives them to
956
+ `HttpPlugin` with the `http` option's `meta` and `guards`, and its `bodyLimit` on the routes that
957
+ are not `GET`. They run in the HTTP pipeline like the built-in endpoint (request id, request
958
+ scope, `http:request` hooks, global and endpoint guards, route decisions, CORS, security headers,
959
+ drain), so the engine finds the request scope: `GraphQLContext.http` is the request, and HTTP
960
+ guards and GraphQL guards see the same request-scoped providers. `routes()` is called once,
961
+ possibly before `start()`. A driver without routes is server-mounted: it attaches itself to
962
+ `context.http.adapter` in `start()` and runs outside the pipeline.
963
+ - **Lifecycle.** `onBootstrap` builds the engine, then awaits `start(engine, { logger, container,
964
+ http })`; a failure fails the boot (ZNIGHT066, the original error as `cause`). The engine's
965
+ subscriptions end when `HttpPlugin` starts draining (`onHttpDrain()`, whatever the plugin order)
966
+ and in `onStop`; new ones are then refused (503). `stop()` is awaited in `onDispose` for a
967
+ route-mounted driver, after `HttpPlugin` drained its requests, and in `onStop` for a
968
+ server-mounted one (`onDispose` does, for an application that never started).
969
+ - **Options.** The engine options (`guards`, `errorMappers`, `resultMappers`, `exposeErrors`,
970
+ `introspection`, `limits`, `documentCache`, `subscriptions: false`, `maxActive`, `bufferSize`,
971
+ `pubsub`, `enforcement`) apply to every driver. `path`, `http` and `graphiql` are read by the
972
+ driver through its routes and `engine.settings`. `subscriptions.heartbeat` and `maxDuration`
973
+ configure the built-in SSE transport only: with a driver they are ZNIGHT066 (use the driver's
974
+ keep-alives). A driver with routes needs `HttpPlugin` and refuses `http: false` (ZNIGHT066).
975
+ - **Errors.** The engine maps and masks errors once; a driver keeps the engine's errors as they
976
+ are. Request errors raised by the server itself (malformed JSON, a persisted query not found) are
977
+ the server's; an unexpected error thrown outside the engine is masked like the engine masks one.
978
+ - **Cost.** Without `driver`, the plugin runs the built-in transport's handlers, which call the
979
+ internal executor directly: an application without driver pays nothing for the abstraction.
980
+
981
+ `executeOperation()` and `subscribeOperation()` of `@znight/graphql/testing` send their requests to
982
+ the driver's routes like to the built-in endpoint. The repository's tests run the `graphql-http`
983
+ audits against the driver above and the Yoga driver on the four adapters, on Node.js and Bun.
984
+
985
+ ### GraphQL Yoga
986
+
987
+ `@znight/graphql/yoga` serves the schema with [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
988
+ 6 (`graphql-yoga`, an optional peer dependency loaded by this entry only):
989
+
990
+ ```ts
991
+ import { GraphQLPlugin } from '@znight/graphql'
992
+ import { yogaDriver } from '@znight/graphql/yoga'
993
+
994
+ GraphQLPlugin.configure({ driver: yogaDriver({ plugins: [usePersistedOperations({ getPersistedOperation })] }) })
995
+ ```
996
+
997
+ | Export | What |
998
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
999
+ | `yogaDriver(options?)` | The driver (`name: 'yoga'`); `driver.yoga` is the Yoga server once started |
1000
+ | `YogaDriverOptions` | Yoga's options without those Znight owns (below); `graphiql` takes GraphiQL's options |
1001
+ | `yogaContext(ctx)` | Yoga's context of an operation (`request`, `params`, what plugins added, `znight`: the `HttpContext`), ZNIGHT066 otherwise |
1002
+ | `YogaContext`, `YogaServerContext` | Their types |
1003
+
1004
+ - **The engine is Yoga's engine.** The driver installs Envelop's `useEngine` with the engine's
1005
+ `parse`, `validate`, `execute` and `subscribe`, before the application's plugins: one document
1006
+ cache (Yoga's `parserAndValidationCache` is off), the engine's limits and introspection rule,
1007
+ rejected documents observed by the hook, one `graphql:operation` event per operation with
1008
+ `driver: 'yoga'` (`transport: 'sse'` for subscriptions). Plugins wrapping the execution
1009
+ (`useResponseCache`) call the engine; an operation executed by a plugin that replaces it
1010
+ (`useGraphQlJit`) fails with ZNIGHT066, logged once: its resolvers would not get the
1011
+ `GraphQLContext`.
1012
+ - **Owned options**, refused with ZNIGHT066 (a type error first): `schema`, `context`,
1013
+ `graphqlEndpoint` (`path`), `maskedErrors` (the engine masks once, see below),
1014
+ `cors` (`HttpPlugin`'s), `maxRequestBodySize` (`http.bodyLimit`), `parserAndValidationCache`,
1015
+ `requestId` (Yoga uses `HttpContext.requestId`), `healthCheckEndpoint`, `landingPage`,
1016
+ `fetchAPI` (the runtime's own), `disposeOnProcessTerminate` (the application stops Yoga),
1017
+ `batching` (one operation per request: the operations of a batch would share the request scope,
1018
+ where each one's `GraphQLContext` would replace the other's) and `multipart` (JSON bodies only,
1019
+ below). Yoga's `logging` is off unless set.
1020
+ - **Routes.** `GET` and `POST` on `path`. `GET` serves Yoga's GraphiQL with `graphiql` on, and
1021
+ queries with `http.get` on (405 with `allow: POST` otherwise); a mutation over `GET` is Yoga's 405
1022
+ (`allow: POST`). A `POST` body is `application/json` only, like the built-in transport: any other
1023
+ content type (a form, `multipart/form-data`, `text/plain`, `application/graphql`) is the built-in
1024
+ transport's 415, before Yoga parses it, so a cross-site page cannot run a mutation with the
1025
+ user's cookies (no CSRF: a JSON body always needs a CORS preflight).
1026
+ - **Errors.** The engine's errors (masked already) and `GraphQLError`s thrown on purpose (a plugin's
1027
+ rate limit, Yoga's request errors) are sent as they are. Any other error thrown outside the engine
1028
+ (an Envelop plugin's hook, Yoga itself) is logged once by Znight and masked like the engine masks
1029
+ one: `Unexpected error.` with code `INTERNAL_SERVER_ERROR` (its message with `exposeErrors`), a
1030
+ 500 for a request error.
1031
+ - **The Fetch bridge.** On `honoAdapter()` the native Web `Request` is handed to Yoga as is (a `GET`,
1032
+ or a body with a `content-length` within the limit); otherwise a `Request` is built from
1033
+ `HttpContext` with the body read by `ctx.rawBody()`, so a body past `http.bodyLimit` is
1034
+ `HttpPlugin`'s 413 on every adapter. Yoga's `Response` is sent as is by `HttpPlugin`.
1035
+ - **Subscriptions** use Yoga's SSE; the engine caps them (`subscriptions.maxActive`) and ends them
1036
+ when `HttpPlugin` starts draining, whatever the plugin order, when Yoga sends `complete` (not the
1037
+ built-in transport's `retry` hint); a subscription sent during the drain gets a
1038
+ `SERVICE_UNAVAILABLE` error. Queries in flight are served until the drain is over; `stop()` then
1039
+ awaits `yoga.dispose()` (the plugins' `onDispose`), and a request reaching the stopped driver gets
1040
+ a 503.
1041
+ - **Cost.** Yoga's request parsing, its plugin chain and the Web objects cost about 13 to 18 µs per
1042
+ request on Bun and 27 to 45 µs on Node.js in the in-process benchmark (`test/bench/http.bench.ts`),
1043
+ against about 1.6 µs and 5.5 µs for the built-in transport.
1044
+
1045
+ ### Driver conformance
1046
+
1047
+ `describeDriverConformance()` of `@znight/graphql/testing` describes the scenarios every driver
1048
+ passes (RFC 0009 §12), with the test framework's own functions (`bun:test`, Vitest, Jest,
1049
+ `node:test`) and `node:assert`: queries, mutations, a field guard reading a request-scoped
1050
+ provider, loaders batching, request-scoped providers per request, result mappers, masking,
1051
+ introspection off, limits, HTTP guards on the endpoint, the hook, `POST` bodies other than JSON
1052
+ (415, never executed) and mutations over `GET` (405), and over SSE (unless `subscriptions: false`)
1053
+ events, completion, client abort, `maxActive` and shutdown in both plugin orders.
1054
+
1055
+ ```ts
1056
+ import { describeDriverConformance } from '@znight/graphql/testing'
1057
+ import { yogaDriver } from '@znight/graphql/yoga'
1058
+ import { expressAdapter } from '@znight/http/express'
1059
+ import { honoAdapter } from '@znight/http/hono'
1060
+ import { afterAll, beforeAll, describe, it } from 'bun:test'
1061
+
1062
+ describeDriverConformance({
1063
+ driver: () => yogaDriver(), // a new driver per application; () => undefined for the built-in transport
1064
+ test: { describe, it, beforeAll, afterAll },
1065
+ adapters: { express: () => expressAdapter(), hono: () => honoAdapter() },
1066
+ })
1067
+ ```
1068
+
853
1069
  ## Security defaults and limits
854
1070
 
855
1071
  - **Introspection** off in production (`NODE_ENV === 'production'`), and suggestions hidden with it
@@ -1128,8 +1344,9 @@ Boot and execution are those of code-first.
1128
1344
  ## Observability
1129
1345
 
1130
1346
  Every operation goes through the `graphql:operation` hook of `@znight/core` (`GraphQLOperationEvent`:
1131
- `operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`,
1132
- `requestId`, `context`, `startTime`, then `outcome` (`success`, `partial`, `error`, `rejected`) and
1347
+ `operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`, `driver`
1348
+ (`builtin`, or the name of the [driver](#drivers-and-the-engine)), `requestId`, `context`,
1349
+ `startTime`, then `outcome` (`success`, `partial`, `error`, `rejected`) and
1133
1350
  `errors` with their codes). A subscription produces one event for its whole life. The event is built
1134
1351
  only when the hook has wrappers or listeners, so it costs nothing otherwise.
1135
1352
 
@@ -1173,8 +1390,9 @@ on Node.js, use `node --conditions=@znight/source` instead of `bun`):
1173
1390
  `GraphQLPlugin.execute()` over graphql-js `execute()` for a cached 50-field query (target: under
1174
1391
  10 %; measured 0–9 % on Bun 1.4, 1–7 % on Node.js 24).
1175
1392
  - `bun --conditions=@znight/source test/bench/http.bench.ts`: the endpoint through the `HttpPlugin`
1176
- pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js and to a
1177
- controller route answering a constant. `ABORT_REASON=1` removes the cost graphql-js 17.0 adds to
1393
+ pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js, to a
1394
+ controller route answering a constant, and to `GraphQLEngine.execute()` (what a driver calls: on
1395
+ a cached `{ hello }`, within 2 % of `plugin.execute()`). `ABORT_REASON=1` removes the cost graphql-js 17.0 adds to
1178
1396
  every execution (a `DOMException` built when it aborts its own `AbortController`, about 3 µs on
1179
1397
  Node.js): compare with other servers at the same graphql-js version.
1180
1398
 
@@ -1328,11 +1546,13 @@ are emitted by schema-first only: code-first sections, hashes and artifacts are
1328
1546
  custom internal enum values (an enum value is its SDL name).
1329
1547
  - **Subscriptions over SSE only.** No `graphql-ws` (WebSocket), no SSE single connection mode, no
1330
1548
  multipart subscriptions.
1549
+ - **Drivers**: the built-in transport and GraphQL Yoga (`@znight/graphql/yoga`); Apollo Server once it
1550
+ accepts graphql-js 17. Yoga plugins that replace the execution (`useGraphQlJit`) are refused.
1331
1551
  - No `@defer` / `@stream`, no persisted or trusted documents, no query cost analysis, no response
1332
1552
  cache, no federation or schema stitching, no file uploads (use an HTTP upload route), no operation
1333
1553
  batching.
1334
1554
  - `MemoryPubSub` serves one process; a multi-instance `PubSub` is the application's (`pubsub`
1335
- option).
1555
+ option, Redis recipe in the guide).
1336
1556
  - Types must be written: the compiler reads annotations, it never infers return or property types.
1337
1557
  - The SDL artifact and `schemaHash` see the `description` and `specifiedByURL` of a
1338
1558
  `ScalarDefinition` written as literals only; a graphql-js `GraphQLScalarType` (`graphql-scalars`)
@@ -1,6 +1,6 @@
1
- import { Token } from "@znight/core";
2
- import { GuardEnforcement, HttpContext, RouteMetaCarrier } from "@znight/http";
3
- import { ConstValueNode, DocumentNode, GraphQLResolveInfo } from "graphql";
1
+ import { Container, CoreLogger, Token } from "@znight/core";
2
+ import { ByteSize, GuardEnforcement, HttpContext, HttpPlugin, PluginRoute, RouteMetaCarrier } from "@znight/http";
3
+ import { ConstValueNode, DocumentNode, ExecutionArgs, ExecutionResult, FormattedExecutionResult, GraphQLError, GraphQLFormattedError, GraphQLResolveInfo, GraphQLSchema, Source, ValidationRule } from "graphql";
4
4
  /**
5
5
  * GraphQL errors shown to clients (RFC 0005 §5.10, §8.8): `GraphQLException` and the
6
6
  * `extensions.code` values the plugin produces (Apollo conventions, which clients already know).
@@ -70,8 +70,11 @@ declare class GraphQLException extends Error {
70
70
  type GraphQLArgs = Readonly<Record<string, unknown>>;
71
71
  /** Kind of a GraphQL operation. */
72
72
  type GraphQLOperationKind = "query" | "mutation" | "subscription";
73
- /** How an operation reached the server: GraphQL over HTTP, GraphQL over SSE, or `plugin.execute()`. */
74
- type GraphQLTransport = "http" | "sse" | "direct";
73
+ /**
74
+ * How an operation reached the server: GraphQL over HTTP, GraphQL over SSE, GraphQL over WebSocket
75
+ * (`ws`, reserved for `graphql-ws`), or `plugin.execute()` / `plugin.subscribe()` (`direct`).
76
+ */
77
+ type GraphQLTransport = "http" | "sse" | "ws" | "direct";
75
78
  /**
76
79
  * Options of a loader (§5.5), read once per loader class: the `options` property of a
77
80
  * {@link BatchLoad} provider. They are DataLoader's, except `batchScheduleFn` (Znight batches per
@@ -160,6 +163,13 @@ interface GraphQLContext {
160
163
  readonly signal: AbortSignal;
161
164
  /** Per-operation storage. */
162
165
  readonly state: Map<string | symbol, unknown>;
166
+ /**
167
+ * The context of the GraphQL server that received the operation through a driver (RFC 0009): the
168
+ * `contextValue` it passed to `GraphQLEngine.execute()` (Yoga's initial context, Apollo's context
169
+ * value). `undefined` for the built-in transport and direct calls. Resolvers do not need it: the
170
+ * request is `http`, the user a request-scoped provider.
171
+ */
172
+ readonly serverContext: unknown;
163
173
  /** The DataLoader of the current execution for a loader class (`ctx.loader(AuthorsById)`). */
164
174
  loader<L extends BatchLoad<unknown, unknown>>(loader: BatchLoadClass<L>): BatchLoader<LoaderKey<L>, LoaderValue<L>>;
165
175
  /**
@@ -262,5 +272,172 @@ interface ScalarDefinition<T> {
262
272
  /** Literal of the document → internal value. Default: `parseValue` of the literal's JSON value. */
263
273
  parseLiteral?(literal: ConstValueNode): T;
264
274
  }
265
- export { GraphQLExceptionOptions as C, GraphQLException as S, LoaderValue as _, GraphQLContext as a, GraphQLErrorCode as b, GraphQLGuard as c, GraphQLResultMapper as d, GraphQLResultMapperClass as f, LoaderOptions as g, LoaderKey as h, GraphQLArgs as i, GraphQLGuardClass as l, GraphQLTransport as m, BatchLoadClass as n, GraphQLErrorMapper as o, GraphQLResultTypes as p, BatchLoader as r, GraphQLErrorMapperClass as s, BatchLoad as t, GraphQLOperationKind as u, ResolverContext as v, GraphQLErrorCodeValue as x, ScalarDefinition as y };
266
- //# sourceMappingURL=types-DQ713UgI.d.mts.map
275
+ /** Options of {@link orderByKeys}. */
276
+ interface OrderByKeysOptions {
277
+ /**
278
+ * What a key without value gets: `'error'` (default) a `NOT_FOUND` `GraphQLException` failing that
279
+ * key only, `'null'` the value `null`.
280
+ */
281
+ readonly missing?: "error" | "null";
282
+ }
283
+ /**
284
+ * Reorders `values` (rows in any order, some keys possibly missing) to match `keys`, what a
285
+ * `BatchLoad.load()` must return. Keys are compared with `Map` semantics (`===`, `NaN` equal).
286
+ *
287
+ * ```ts
288
+ * async load(ids: readonly ID[]): Promise<readonly (Author | Error)[]> {
289
+ * return orderByKeys(ids, await this.authors.findMany(ids), author => author.id)
290
+ * }
291
+ * ```
292
+ */
293
+ declare function orderByKeys<K, V>(keys: readonly K[], values: Iterable<V>, keyOf: (value: V) => K, options?: OrderByKeysOptions & {
294
+ readonly missing?: "error";
295
+ }): (V | Error)[];
296
+ declare function orderByKeys<K, V>(keys: readonly K[], values: Iterable<V>, keyOf: (value: V) => K, options: OrderByKeysOptions & {
297
+ readonly missing: "null";
298
+ }): (V | null)[];
299
+ /** A GraphQL request: the body of a GraphQL-over-HTTP request, or the argument of `plugin.execute()`. */
300
+ interface GraphQLRequest {
301
+ readonly query: string;
302
+ readonly operationName?: string | null | undefined;
303
+ readonly variables?: Readonly<Record<string, unknown>> | null | undefined;
304
+ /** Request extensions (ignored by the executor; available to transports). */
305
+ readonly extensions?: Readonly<Record<string, unknown>> | null | undefined;
306
+ }
307
+ /** The results of a subscription (one per event), or the single result of a query sent as a stream. */
308
+ interface GraphQLResultStream extends AsyncIterableIterator<FormattedExecutionResult> {
309
+ /** Ends the stream: the source event stream is returned (unsubscribed). */
310
+ return(): Promise<IteratorResult<FormattedExecutionResult>>;
311
+ }
312
+ /** A value of type `T` or a promise of it (graphql-js `PromiseOrValue`). */
313
+ type PromiseOrValue<T> = T | Promise<T>;
314
+ /** Options of {@link GraphQLEngine.execute} and {@link GraphQLEngine.subscribe}. */
315
+ interface EngineExecuteOptions {
316
+ /**
317
+ * The protocol that carried the operation (`GraphQLOperationEvent.transport`,
318
+ * `GraphQLContext.transport`): `'http'` (default), `'sse'` for an event stream, `'ws'` for
319
+ * `graphql-ws`.
320
+ */
321
+ readonly transport?: "http" | "sse" | "ws";
322
+ /**
323
+ * Cancels the operation (graphql-js `abortSignal`, `GraphQLContext.signal`). Default:
324
+ * `ExecutionArgs.abortSignal`, else the signal of the HTTP request of the current scope.
325
+ */
326
+ readonly signal?: AbortSignal;
327
+ /** `GraphQLContext.requestId`. Default: the request id of the HTTP request of the scope. */
328
+ readonly requestId?: string;
329
+ }
330
+ /** The resolved options a GraphQL server needs to configure itself (read-only). */
331
+ interface GraphQLEngineSettings {
332
+ /** The endpoint path, the `HttpPlugin` prefix included (`/api/graphql`). */
333
+ readonly path: string;
334
+ readonly introspection: boolean;
335
+ /** The `graphiql` option: serve the server's IDE. */
336
+ readonly ide: boolean;
337
+ readonly exposeErrors: boolean;
338
+ /** `limits.maxTokens`, for servers that parse documents themselves. */
339
+ readonly maxTokens: number | undefined;
340
+ readonly subscriptions: false | {
341
+ readonly maxActive: number;
342
+ };
343
+ readonly http: false | {
344
+ readonly get: boolean;
345
+ readonly bodyLimit: ByteSize;
346
+ };
347
+ }
348
+ /**
349
+ * The engine of a bootstrapped `GraphQLPlugin` (`plugin.engine`, and the first argument of
350
+ * `GraphQLDriver.start()`). Its functions are bound: pass them around as they are.
351
+ *
352
+ * ```ts
353
+ * const handler = createHandler({
354
+ * schema: engine.schema,
355
+ * parse: engine.parse,
356
+ * validate: engine.validate,
357
+ * execute: args => engine.execute(args),
358
+ * })
359
+ * ```
360
+ */
361
+ interface GraphQLEngine {
362
+ /** The executable schema: its resolvers are Znight's pipelines. */
363
+ readonly schema: GraphQLSchema;
364
+ readonly settings: GraphQLEngineSettings;
365
+ /**
366
+ * graphql-js `parse` through the document cache: `maxTokens`, then the depth, aliases and
367
+ * directives limits. Throws a `GraphQLError` (`GRAPHQL_PARSE_FAILED`, `QUERY_TOO_COMPLEX`), like
368
+ * graphql-js. A text the engine validated returns the same `DocumentNode`.
369
+ */
370
+ parse(source: string | Source): DocumentNode;
371
+ /**
372
+ * graphql-js `validate` with `rules` (default: graphql-js `specifiedRules`), to which the engine
373
+ * adds its own ({@link rules}), with `hideSuggestions` when introspection is off. With the
374
+ * default rules on {@link schema}, a document is validated once (then cached).
375
+ */
376
+ validate(schema: GraphQLSchema, document: DocumentNode, rules?: readonly ValidationRule[]): readonly GraphQLError[];
377
+ /** The engine's own rules (limits, introspection), for servers that validate documents themselves. */
378
+ readonly rules: readonly ValidationRule[];
379
+ /**
380
+ * Executes a validated query or mutation in the current request scope (or a scope of its own),
381
+ * with the operation's `GraphQLContext` as `contextValue` (`args.contextValue` becomes
382
+ * `GraphQLContext.serverContext`). Errors are mapped and masked, as `GraphQLError` instances.
383
+ */
384
+ execute(args: ExecutionArgs, options?: EngineExecuteOptions): PromiseOrValue<ExecutionResult>;
385
+ /**
386
+ * Starts a validated subscription: a stream of results, or a single error result (rejected, past
387
+ * `subscriptions.maxActive`, a source that could not start). The plugin ends the stream when the
388
+ * application stops.
389
+ */
390
+ subscribe(args: ExecutionArgs, options?: EngineExecuteOptions): PromiseOrValue<AsyncGenerator<ExecutionResult, void, void> | ExecutionResult>;
391
+ }
392
+ /** What {@link GraphQLDriver.routes} receives. */
393
+ interface GraphQLDriverRoutesContext {
394
+ /** The `path` option: the endpoint path, relative to the `HttpPlugin` prefix (`/graphql`). */
395
+ readonly path: string;
396
+ /** The absolute endpoint path, the prefix included (`/api/graphql`). */
397
+ readonly endpoint: string;
398
+ }
399
+ /**
400
+ * A route of a driver: a plugin route of `HttpPlugin` without `meta`, `guards` and `bodyLimit`,
401
+ * which `GraphQLPlugin` adds from its `http` option (`bodyLimit` on the routes that are not `GET`).
402
+ */
403
+ type GraphQLDriverRoute = Pick<PluginRoute, "method" | "path" | "name" | "handler">;
404
+ /** What {@link GraphQLDriver.start} receives besides the engine. */
405
+ interface GraphQLDriverContext {
406
+ /** The application logger. */
407
+ readonly logger: CoreLogger;
408
+ readonly container: Container;
409
+ /** The `HttpPlugin` of the application, when registered (its adapter, for server-mounted drivers). */
410
+ readonly http: HttpPlugin | undefined;
411
+ }
412
+ /**
413
+ * A GraphQL driver, given to `GraphQLPlugin.configure({ driver })`. A route-mounted driver returns
414
+ * its routes, which run in `HttpPlugin`'s pipeline (request scope, hooks, guards, CORS, drain) and
415
+ * call the engine from there; a server-mounted one has no routes and attaches itself to the
416
+ * adapter in `start()`. One driver serves one application at a time.
417
+ */
418
+ interface GraphQLDriver {
419
+ /**
420
+ * The driver's name (`yoga`, `apollo`...): `GraphQLOperationEvent.driver`, logs and errors.
421
+ * `builtin` is reserved for the built-in transport.
422
+ */
423
+ readonly name: string;
424
+ /**
425
+ * The routes `HttpPlugin` serves for the driver, with the `http` option's meta, guards and body
426
+ * limit applied by the plugin. Called once per application, possibly before {@link start}: the
427
+ * handlers MUST NOT use the engine before the first request. Not called with `http: false`.
428
+ */
429
+ routes?(context: GraphQLDriverRoutesContext): readonly GraphQLDriverRoute[];
430
+ /**
431
+ * Called in `GraphQLPlugin.onBootstrap`, once the engine is built: create the server. A failure
432
+ * fails the boot (ZNIGHT066, the original error as `cause`).
433
+ */
434
+ start(engine: GraphQLEngine, context: GraphQLDriverContext): void | Promise<void>;
435
+ /**
436
+ * Stops the server, after the engine ended its subscriptions: in `GraphQLPlugin.onDispose` for a
437
+ * route-mounted driver (once `HttpPlugin` drained its requests, which the driver keeps serving),
438
+ * in `GraphQLPlugin.onStop` for a server-mounted one (drain and stop the server).
439
+ */
440
+ stop?(): void | Promise<void>;
441
+ }
442
+ export { ScalarDefinition as A, GraphQLResultMapperClass as C, LoaderOptions as D, LoaderKey as E, GraphQLErrorCodeValue as M, GraphQLException as N, LoaderValue as O, GraphQLExceptionOptions as P, GraphQLResultMapper as S, GraphQLTransport as T, GraphQLErrorMapper as _, EngineExecuteOptions as a, GraphQLGuardClass as b, GraphQLRequest as c, orderByKeys as d, BatchLoad as f, GraphQLContext as g, GraphQLArgs as h, GraphQLDriverRoutesContext as i, GraphQLErrorCode as j, ResolverContext as k, GraphQLResultStream as l, BatchLoader as m, GraphQLDriverContext as n, GraphQLEngine as o, BatchLoadClass as p, GraphQLDriverRoute as r, GraphQLEngineSettings as s, GraphQLDriver as t, OrderByKeysOptions as u, GraphQLErrorMapperClass as v, GraphQLResultTypes as w, GraphQLOperationKind as x, GraphQLGuard as y };
443
+ //# sourceMappingURL=driver-Cnze8Oyr.d.mts.map