@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/README.md +222 -15
- package/dist/{types-DQ713UgI.d.mts → driver-Cnze8Oyr.d.mts} +184 -7
- package/dist/http-BWcayUtH.mjs +2165 -0
- package/dist/http-BWcayUtH.mjs.map +1 -0
- package/dist/index.d.mts +37 -49
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-CR_wVfRU.mjs → plugin-q4oz7d6s.mjs} +214 -1774
- package/dist/plugin-q4oz7d6s.mjs.map +1 -0
- package/dist/testing.d.mts +40 -1
- package/dist/testing.mjs +649 -4
- package/dist/testing.mjs.map +1 -1
- package/dist/yoga.d.mts +64 -0
- package/dist/yoga.mjs +379 -0
- package/dist/yoga.mjs.map +1 -0
- package/package.json +19 -10
- package/src/conformance.ts +700 -0
- package/src/context.ts +13 -0
- package/src/driver.ts +63 -0
- package/src/engine.ts +146 -0
- package/src/execution.ts +602 -67
- package/src/index.ts +7 -0
- package/src/limits.ts +22 -1
- package/src/options.ts +53 -4
- package/src/plugin.ts +170 -29
- package/src/testing.ts +3 -0
- package/src/transport/builtin.ts +57 -0
- package/src/transport/fetch.ts +57 -0
- package/src/transport/http.ts +12 -9
- package/src/types.ts +12 -2
- package/src/yoga.ts +471 -0
- package/dist/plugin-CR_wVfRU.mjs.map +0 -1
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)
|
|
@@ -521,13 +523,14 @@ Built-in scalars appear in the schema only when used: `String`, `Float`, `Int`,
|
|
|
521
523
|
| Member | What |
|
|
522
524
|
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
523
525
|
| `requestId` | Id of the request carrying the operation (`HttpContext.requestId` over HTTP, a UUID or `requestId` option otherwise) |
|
|
524
|
-
| `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`)
|
|
526
|
+
| `transport` | `'http'`, `'sse'` or `'direct'` (`plugin.execute()` / `subscribe()`); `'ws'` is reserved for `graphql-ws` |
|
|
525
527
|
| `http` | The `HttpContext` of the request, `undefined` for direct executions outside a request |
|
|
526
528
|
| `operationType` / `operationName` | `'query'`, `'mutation'` or `'subscription'`; the selected operation name |
|
|
527
529
|
| `document` / `variables` | The parsed document (shared by the document cache: never mutate it) and the coerced variables |
|
|
528
530
|
| `signal` | Over HTTP, the request's `ctx.signal`: client gone, SSE stream closed; passed to graphql-js as `abortSignal` |
|
|
529
531
|
| `state` | A `Map` for per-operation storage |
|
|
530
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 |
|
|
531
534
|
|
|
532
535
|
- Resolvers receive it with `@Ctx()`; request-scoped providers inject `GraphQLContext`, singletons
|
|
533
536
|
`Lazy<GraphQLContext>`, exactly like `HttpContext`.
|
|
@@ -786,12 +789,13 @@ ZNIGHT066.
|
|
|
786
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)) |
|
|
787
790
|
| `exposeErrors` | `false` | Unmasked messages for unexpected errors (development) |
|
|
788
791
|
| `introspection` | `NODE_ENV !== 'production'` | Off: graphql-js `NoSchemaIntrospectionCustomRule`, and suggestions are hidden |
|
|
789
|
-
| `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 |
|
|
790
793
|
| `limits` | `{ maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 }` | Request limits (introspection fields exempt from `maxDepth`); `false` disables one |
|
|
791
794
|
| `documentCache` | `1000` | Distinct documents kept parsed and validated (LRU); `0` disables |
|
|
792
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) |
|
|
793
796
|
| `pubsub` | a `MemoryPubSub` | The `PubSub` provided to the container |
|
|
794
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) |
|
|
795
799
|
|
|
796
800
|
`HttpPlugin` must be registered unless `http: false` (ZNIGHT066). The plugin instance exposes:
|
|
797
801
|
|
|
@@ -803,14 +807,15 @@ ZNIGHT066.
|
|
|
803
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 |
|
|
804
808
|
| `pubsub` | The `PubSub` of the application |
|
|
805
809
|
| `activeSubscriptions` | Active subscriptions, every transport |
|
|
806
|
-
| `
|
|
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 |
|
|
807
812
|
|
|
808
813
|
`request` is `{ query, operationName?, variables?, extensions? }`; `options` is `{ signal?,
|
|
809
814
|
requestId? }`. A direct execution has no HTTP guards of its own: inside a controller, it runs in the
|
|
810
815
|
route's request scope, after the route's guards. With `http: false`, a controller can hand its
|
|
811
|
-
requests to another GraphQL server that delegates the execution to `execute()` and `subscribe()
|
|
812
|
-
the
|
|
813
|
-
|
|
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)).
|
|
814
819
|
|
|
815
820
|
```ts
|
|
816
821
|
const plugin = app.getPlugin<GraphQLPlugin>('graphql')
|
|
@@ -818,10 +823,13 @@ const result = await plugin?.execute({ query: '{ books { total } }' })
|
|
|
818
823
|
```
|
|
819
824
|
|
|
820
825
|
`onStart` logs `GraphQL endpoint ready` with the path, the number of types and the schema hash
|
|
821
|
-
(`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions
|
|
822
|
-
`HttpPlugin` drains the requests in flight and closes the SSE streams
|
|
823
|
-
`'shutdown'`, then the stream ends with a jittered `retry` and **no**
|
|
824
|
-
reconnect to another instance).
|
|
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.
|
|
825
833
|
|
|
826
834
|
## GraphQL over HTTP and SSE
|
|
827
835
|
|
|
@@ -863,6 +871,201 @@ Adapters need nothing specific. With Express, do not mount `express.json()` befo
|
|
|
863
871
|
(the body would be consumed). In tests, `TestApp.inject()` runs queries and mutations through every
|
|
864
872
|
adapter in memory and `injectStream()` / `injectSse()` stream subscriptions.
|
|
865
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
|
+
|
|
866
1069
|
## Security defaults and limits
|
|
867
1070
|
|
|
868
1071
|
- **Introspection** off in production (`NODE_ENV === 'production'`), and suggestions hidden with it
|
|
@@ -1141,8 +1344,9 @@ Boot and execution are those of code-first.
|
|
|
1141
1344
|
## Observability
|
|
1142
1345
|
|
|
1143
1346
|
Every operation goes through the `graphql:operation` hook of `@znight/core` (`GraphQLOperationEvent`:
|
|
1144
|
-
`operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`,
|
|
1145
|
-
`
|
|
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
|
|
1146
1350
|
`errors` with their codes). A subscription produces one event for its whole life. The event is built
|
|
1147
1351
|
only when the hook has wrappers or listeners, so it costs nothing otherwise.
|
|
1148
1352
|
|
|
@@ -1186,8 +1390,9 @@ on Node.js, use `node --conditions=@znight/source` instead of `bun`):
|
|
|
1186
1390
|
`GraphQLPlugin.execute()` over graphql-js `execute()` for a cached 50-field query (target: under
|
|
1187
1391
|
10 %; measured 0–9 % on Bun 1.4, 1–7 % on Node.js 24).
|
|
1188
1392
|
- `bun --conditions=@znight/source test/bench/http.bench.ts`: the endpoint through the `HttpPlugin`
|
|
1189
|
-
pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js
|
|
1190
|
-
controller route answering a constant
|
|
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
|
|
1191
1396
|
every execution (a `DOMException` built when it aborts its own `AbortController`, about 3 µs on
|
|
1192
1397
|
Node.js): compare with other servers at the same graphql-js version.
|
|
1193
1398
|
|
|
@@ -1341,6 +1546,8 @@ are emitted by schema-first only: code-first sections, hashes and artifacts are
|
|
|
1341
1546
|
custom internal enum values (an enum value is its SDL name).
|
|
1342
1547
|
- **Subscriptions over SSE only.** No `graphql-ws` (WebSocket), no SSE single connection mode, no
|
|
1343
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.
|
|
1344
1551
|
- No `@defer` / `@stream`, no persisted or trusted documents, no query cost analysis, no response
|
|
1345
1552
|
cache, no federation or schema stitching, no file uploads (use an HTTP upload route), no operation
|
|
1346
1553
|
batching.
|
|
@@ -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
|
-
/**
|
|
74
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|