@znight/graphql 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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`.
@@ -537,6 +540,32 @@ Built-in scalars appear in the schema only when used: `String`, `Float`, `Int`,
537
540
  `plugin.execute()` and `plugin.subscribe()` run in the current scope when one is active (a
538
541
  controller calling GraphQL), otherwise in a scope of their own.
539
542
 
543
+ ### Custom parameter decorators
544
+
545
+ A decorator defined with `createParamDecorator` (`@znight/core`, RFC 0010) works on resolver
546
+ methods when it has a `graphql` resolver, which receives the field's `ResolverContext` (`parent`,
547
+ `args`, `info`, `operation`, `http`, `route.meta`). One definition serves HTTP routes and GraphQL
548
+ fields:
549
+
550
+ ```ts
551
+ import { createParamDecorator } from '@znight/core'
552
+ import type { User } from './user.ts'
553
+
554
+ export const CurrentUser = createParamDecorator<User>({
555
+ http: ctx => ctx.state.get('user') as User,
556
+ graphql: ctx => ctx.operation.state.get('user') as User,
557
+ })
558
+
559
+ // @Query() me(@CurrentUser() user: User): User { return user }
560
+ ```
561
+
562
+ The resolver runs after the guards (it sees what they stored) and before the method; what it
563
+ throws fails the field like the method would (`errors[]`, or a result mapper). A custom parameter
564
+ is not a GraphQL argument: it never appears in the schema. Resolver classes implementing
565
+ `ParamResolver` are resolved in the operation scope. Custom parameters belong to the field
566
+ resolvers of the schema, so they work the same through the built-in transport, `plugin.execute()`
567
+ and a driver such as `yogaDriver()` (RFC 0009).
568
+
540
569
  ## Authorization
541
570
 
542
571
  The route decorators of `@znight/http` (`createRouteDecorator`, with RFC 0001's groups) apply to
@@ -786,12 +815,13 @@ ZNIGHT066.
786
815
  | `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
816
  | `exposeErrors` | `false` | Unmasked messages for unexpected errors (development) |
788
817
  | `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 |
818
+ | `graphiql` | `NODE_ENV === 'development'` | `GET path` with `accept: text/html` serves GraphiQL; with a `driver`, the driver's IDE |
790
819
  | `limits` | `{ maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 }` | Request limits (introspection fields exempt from `maxDepth`); `false` disables one |
791
820
  | `documentCache` | `1000` | Distinct documents kept parsed and validated (LRU); `0` disables |
792
821
  | `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
822
  | `pubsub` | a `MemoryPubSub` | The `PubSub` provided to the container |
794
823
  | `enforcement` | `'error'` | How the boot check of field decisions reports a problem: `'error'` fails the boot (ZNIGHT023), `'warn'` logs |
824
+ | `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
825
 
796
826
  `HttpPlugin` must be registered unless `http: false` (ZNIGHT066). The plugin instance exposes:
797
827
 
@@ -803,14 +833,15 @@ ZNIGHT066.
803
833
  | `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
834
  | `pubsub` | The `PubSub` of the application |
805
835
  | `activeSubscriptions` | Active subscriptions, every transport |
806
- | `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http` |
836
+ | `engine` | The `GraphQLEngine` drivers call (after bootstrap): [Drivers and the engine](#drivers-and-the-engine) |
837
+ | `initRoots()` / `httpRoutes()` | The `PluginInitRoots` contract (`TestApp`) and the `PluginHttpRoutes` contract of `@znight/http`, `onHttpDrain()` included |
807
838
 
808
839
  `request` is `{ query, operationName?, variables?, extensions? }`; `options` is `{ signal?,
809
840
  requestId? }`. A direct execution has no HTTP guards of its own: inside a controller, it runs in the
810
841
  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 guide's [Serve the schema with GraphQL Yoga](../../docs/guide/graphql.md#yoga) recipe keeps
813
- guards, loaders, the request scope and error formatting.
842
+ requests to another GraphQL server that delegates the execution to `execute()` and `subscribe()`;
843
+ a [driver](#drivers-and-the-engine) does it without the copying, on the parsed document (GraphQL
844
+ Yoga: [`yogaDriver()`](#graphql-yoga)).
814
845
 
815
846
  ```ts
816
847
  const plugin = app.getPlugin<GraphQLPlugin>('graphql')
@@ -818,10 +849,13 @@ const result = await plugin?.execute({ query: '{ books { total } }' })
818
849
  ```
819
850
 
820
851
  `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 (their `ctx.signal` aborts with
823
- `'shutdown'`, then the stream ends with a jittered `retry` and **no** `complete` event, so clients
824
- reconnect to another instance). Plugin order does not matter.
852
+ (`GraphQL schema ready` with `http: false`). On shutdown, `onStop` ends the direct subscriptions
853
+ and those of the engine; `HttpPlugin` drains the requests in flight and closes the SSE streams
854
+ (their `ctx.signal` aborts with `'shutdown'`, then the stream ends with a jittered `retry` and **no**
855
+ `complete` event, so clients reconnect to another instance). When `HttpPlugin` starts draining, it
856
+ calls `GraphQLPlugin.onHttpDrain()`, which ends the engine's subscriptions too (a driver serves them
857
+ as streams `HttpPlugin` does not close) and refuses new ones (503). A route-mounted driver stops in
858
+ `onDispose`, once the drain is over: requests in flight are served. Plugin order does not matter.
825
859
 
826
860
  ## GraphQL over HTTP and SSE
827
861
 
@@ -863,6 +897,201 @@ Adapters need nothing specific. With Express, do not mount `express.json()` befo
863
897
  (the body would be consumed). In tests, `TestApp.inject()` runs queries and mutations through every
864
898
  adapter in memory and `injectStream()` / `injectSse()` stream subscriptions.
865
899
 
900
+ ## Drivers and the engine
901
+
902
+ The built-in GraphQL-over-HTTP and SSE endpoint is the default and the fastest path. A **driver**
903
+ serves the same schema with another GraphQL server, for its protocol features and its plugins
904
+ (RFC 0009): Znight stays the engine, the driver owns the transport.
905
+
906
+ ```ts
907
+ import { yogaDriver } from '@znight/graphql/yoga'
908
+
909
+ GraphQLPlugin.configure({ guards: [AccessGuard], driver: yogaDriver() })
910
+ ```
911
+
912
+ Resolvers, guards, loaders, result mappers, request-scoped providers and tests do not change: the
913
+ server calls `plugin.engine`, which runs the compiled schema with everything this README describes.
914
+ **GraphQL Yoga** is the first driver ([`@znight/graphql/yoga`](#graphql-yoga)); **Apollo Server**
915
+ follows once it accepts graphql-js 17. Any other graphql-js-shaped server takes the engine as it
916
+ is: a driver is a small object, checked with the [conformance suite](#driver-conformance).
917
+
918
+ **The engine.** `plugin.engine` (after bootstrap, and the first argument of `driver.start()`):
919
+
920
+ | Member | What |
921
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
922
+ | `schema` | The executable schema; its resolvers are Znight's pipelines |
923
+ | `settings` | The resolved options a server needs: `path` (prefix included), `introspection`, `ide` (`graphiql`), `exposeErrors`, `maxTokens`, `subscriptions` (`false` or `{ maxActive }`), `http` (`false` or `{ get, bodyLimit }`) |
924
+ | `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`) |
925
+ | `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` |
926
+ | `rules` | The engine's own rules (the limits, `NoSchemaIntrospectionCustomRule` without introspection), for servers that validate themselves |
927
+ | `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 |
928
+ | `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 |
929
+
930
+ `options` is `{ transport?: 'http' | 'sse' | 'ws', signal?, requestId? }`; the signal defaults to
931
+ `args.abortSignal`, then to the HTTP request's. The functions are bound and have graphql-js's
932
+ signatures, so they fit `graphql-http`, `graphql-sse`, `graphql-ws` and Envelop's `useEngine` as
933
+ they are. Every operation produces one `graphql:operation` event with the driver's name, and
934
+ documents rejected by `parse` or `validate` are observed too.
935
+
936
+ **The driver contract.** A `GraphQLDriver` has a `name` (`builtin` is reserved), optional
937
+ `routes()`, `start(engine, context)` and an optional `stop()`:
938
+
939
+ ```ts
940
+ import type { HttpContext } from '@znight/http'
941
+ import type { GraphQLDriver } from '@znight/graphql'
942
+
943
+ import { HttpResponse } from '@znight/http'
944
+ import { createHandler, type Handler } from 'graphql-http'
945
+
946
+ /** graphql-http's handler on the engine: GraphQL over HTTP served by the reference implementation. */
947
+ export function graphqlHttpDriver(): GraphQLDriver {
948
+ let handler: Handler<HttpContext> | undefined
949
+ return {
950
+ name: 'graphql-http',
951
+ routes: ({ path }) => {
952
+ const handle = async (ctx: HttpContext) => {
953
+ const body = ctx.method === 'POST' ? new TextDecoder().decode(await ctx.rawBody()) : null
954
+ const [text, init] = await (handler as Handler<HttpContext>)({
955
+ method: ctx.method,
956
+ url: ctx.url,
957
+ headers: { get: name => ctx.header(name.toLowerCase()) ?? null },
958
+ body,
959
+ raw: ctx,
960
+ context: undefined,
961
+ })
962
+ return new HttpResponse(text ?? undefined, { status: init.status, headers: init.headers ?? {} })
963
+ }
964
+ return [
965
+ { method: 'GET', path, name: 'graphql', handler: handle },
966
+ { method: 'POST', path, name: 'graphql', handler: handle },
967
+ ]
968
+ },
969
+ start(engine) {
970
+ handler = createHandler<HttpContext>({
971
+ schema: engine.schema,
972
+ parse: engine.parse,
973
+ validate: engine.validate,
974
+ execute: args => engine.execute(args),
975
+ })
976
+ },
977
+ }
978
+ }
979
+ ```
980
+
981
+ - **Routes.** A route-mounted driver returns its routes: `GraphQLPlugin.httpRoutes()` gives them to
982
+ `HttpPlugin` with the `http` option's `meta` and `guards`, and its `bodyLimit` on the routes that
983
+ are not `GET`. They run in the HTTP pipeline like the built-in endpoint (request id, request
984
+ scope, `http:request` hooks, global and endpoint guards, route decisions, CORS, security headers,
985
+ drain), so the engine finds the request scope: `GraphQLContext.http` is the request, and HTTP
986
+ guards and GraphQL guards see the same request-scoped providers. `routes()` is called once,
987
+ possibly before `start()`. A driver without routes is server-mounted: it attaches itself to
988
+ `context.http.adapter` in `start()` and runs outside the pipeline.
989
+ - **Lifecycle.** `onBootstrap` builds the engine, then awaits `start(engine, { logger, container,
990
+ http })`; a failure fails the boot (ZNIGHT066, the original error as `cause`). The engine's
991
+ subscriptions end when `HttpPlugin` starts draining (`onHttpDrain()`, whatever the plugin order)
992
+ and in `onStop`; new ones are then refused (503). `stop()` is awaited in `onDispose` for a
993
+ route-mounted driver, after `HttpPlugin` drained its requests, and in `onStop` for a
994
+ server-mounted one (`onDispose` does, for an application that never started).
995
+ - **Options.** The engine options (`guards`, `errorMappers`, `resultMappers`, `exposeErrors`,
996
+ `introspection`, `limits`, `documentCache`, `subscriptions: false`, `maxActive`, `bufferSize`,
997
+ `pubsub`, `enforcement`) apply to every driver. `path`, `http` and `graphiql` are read by the
998
+ driver through its routes and `engine.settings`. `subscriptions.heartbeat` and `maxDuration`
999
+ configure the built-in SSE transport only: with a driver they are ZNIGHT066 (use the driver's
1000
+ keep-alives). A driver with routes needs `HttpPlugin` and refuses `http: false` (ZNIGHT066).
1001
+ - **Errors.** The engine maps and masks errors once; a driver keeps the engine's errors as they
1002
+ are. Request errors raised by the server itself (malformed JSON, a persisted query not found) are
1003
+ the server's; an unexpected error thrown outside the engine is masked like the engine masks one.
1004
+ - **Cost.** Without `driver`, the plugin runs the built-in transport's handlers, which call the
1005
+ internal executor directly: an application without driver pays nothing for the abstraction.
1006
+
1007
+ `executeOperation()` and `subscribeOperation()` of `@znight/graphql/testing` send their requests to
1008
+ the driver's routes like to the built-in endpoint. The repository's tests run the `graphql-http`
1009
+ audits against the driver above and the Yoga driver on the four adapters, on Node.js and Bun.
1010
+
1011
+ ### GraphQL Yoga
1012
+
1013
+ `@znight/graphql/yoga` serves the schema with [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)
1014
+ 6 (`graphql-yoga`, an optional peer dependency loaded by this entry only):
1015
+
1016
+ ```ts
1017
+ import { GraphQLPlugin } from '@znight/graphql'
1018
+ import { yogaDriver } from '@znight/graphql/yoga'
1019
+
1020
+ GraphQLPlugin.configure({ driver: yogaDriver({ plugins: [usePersistedOperations({ getPersistedOperation })] }) })
1021
+ ```
1022
+
1023
+ | Export | What |
1024
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
1025
+ | `yogaDriver(options?)` | The driver (`name: 'yoga'`); `driver.yoga` is the Yoga server once started |
1026
+ | `YogaDriverOptions` | Yoga's options without those Znight owns (below); `graphiql` takes GraphiQL's options |
1027
+ | `yogaContext(ctx)` | Yoga's context of an operation (`request`, `params`, what plugins added, `znight`: the `HttpContext`), ZNIGHT066 otherwise |
1028
+ | `YogaContext`, `YogaServerContext` | Their types |
1029
+
1030
+ - **The engine is Yoga's engine.** The driver installs Envelop's `useEngine` with the engine's
1031
+ `parse`, `validate`, `execute` and `subscribe`, before the application's plugins: one document
1032
+ cache (Yoga's `parserAndValidationCache` is off), the engine's limits and introspection rule,
1033
+ rejected documents observed by the hook, one `graphql:operation` event per operation with
1034
+ `driver: 'yoga'` (`transport: 'sse'` for subscriptions). Plugins wrapping the execution
1035
+ (`useResponseCache`) call the engine; an operation executed by a plugin that replaces it
1036
+ (`useGraphQlJit`) fails with ZNIGHT066, logged once: its resolvers would not get the
1037
+ `GraphQLContext`.
1038
+ - **Owned options**, refused with ZNIGHT066 (a type error first): `schema`, `context`,
1039
+ `graphqlEndpoint` (`path`), `maskedErrors` (the engine masks once, see below),
1040
+ `cors` (`HttpPlugin`'s), `maxRequestBodySize` (`http.bodyLimit`), `parserAndValidationCache`,
1041
+ `requestId` (Yoga uses `HttpContext.requestId`), `healthCheckEndpoint`, `landingPage`,
1042
+ `fetchAPI` (the runtime's own), `disposeOnProcessTerminate` (the application stops Yoga),
1043
+ `batching` (one operation per request: the operations of a batch would share the request scope,
1044
+ where each one's `GraphQLContext` would replace the other's) and `multipart` (JSON bodies only,
1045
+ below). Yoga's `logging` is off unless set.
1046
+ - **Routes.** `GET` and `POST` on `path`. `GET` serves Yoga's GraphiQL with `graphiql` on, and
1047
+ queries with `http.get` on (405 with `allow: POST` otherwise); a mutation over `GET` is Yoga's 405
1048
+ (`allow: POST`). A `POST` body is `application/json` only, like the built-in transport: any other
1049
+ content type (a form, `multipart/form-data`, `text/plain`, `application/graphql`) is the built-in
1050
+ transport's 415, before Yoga parses it, so a cross-site page cannot run a mutation with the
1051
+ user's cookies (no CSRF: a JSON body always needs a CORS preflight).
1052
+ - **Errors.** The engine's errors (masked already) and `GraphQLError`s thrown on purpose (a plugin's
1053
+ rate limit, Yoga's request errors) are sent as they are. Any other error thrown outside the engine
1054
+ (an Envelop plugin's hook, Yoga itself) is logged once by Znight and masked like the engine masks
1055
+ one: `Unexpected error.` with code `INTERNAL_SERVER_ERROR` (its message with `exposeErrors`), a
1056
+ 500 for a request error.
1057
+ - **The Fetch bridge.** On `honoAdapter()` the native Web `Request` is handed to Yoga as is (a `GET`,
1058
+ or a body with a `content-length` within the limit); otherwise a `Request` is built from
1059
+ `HttpContext` with the body read by `ctx.rawBody()`, so a body past `http.bodyLimit` is
1060
+ `HttpPlugin`'s 413 on every adapter. Yoga's `Response` is sent as is by `HttpPlugin`.
1061
+ - **Subscriptions** use Yoga's SSE; the engine caps them (`subscriptions.maxActive`) and ends them
1062
+ when `HttpPlugin` starts draining, whatever the plugin order, when Yoga sends `complete` (not the
1063
+ built-in transport's `retry` hint); a subscription sent during the drain gets a
1064
+ `SERVICE_UNAVAILABLE` error. Queries in flight are served until the drain is over; `stop()` then
1065
+ awaits `yoga.dispose()` (the plugins' `onDispose`), and a request reaching the stopped driver gets
1066
+ a 503.
1067
+ - **Cost.** Yoga's request parsing, its plugin chain and the Web objects cost about 13 to 18 µs per
1068
+ request on Bun and 27 to 45 µs on Node.js in the in-process benchmark (`test/bench/http.bench.ts`),
1069
+ against about 1.6 µs and 5.5 µs for the built-in transport.
1070
+
1071
+ ### Driver conformance
1072
+
1073
+ `describeDriverConformance()` of `@znight/graphql/testing` describes the scenarios every driver
1074
+ passes (RFC 0009 §12), with the test framework's own functions (`bun:test`, Vitest, Jest,
1075
+ `node:test`) and `node:assert`: queries, mutations, a field guard reading a request-scoped
1076
+ provider, loaders batching, request-scoped providers per request, result mappers, masking,
1077
+ introspection off, limits, HTTP guards on the endpoint, the hook, `POST` bodies other than JSON
1078
+ (415, never executed) and mutations over `GET` (405), and over SSE (unless `subscriptions: false`)
1079
+ events, completion, client abort, `maxActive` and shutdown in both plugin orders.
1080
+
1081
+ ```ts
1082
+ import { describeDriverConformance } from '@znight/graphql/testing'
1083
+ import { yogaDriver } from '@znight/graphql/yoga'
1084
+ import { expressAdapter } from '@znight/http/express'
1085
+ import { honoAdapter } from '@znight/http/hono'
1086
+ import { afterAll, beforeAll, describe, it } from 'bun:test'
1087
+
1088
+ describeDriverConformance({
1089
+ driver: () => yogaDriver(), // a new driver per application; () => undefined for the built-in transport
1090
+ test: { describe, it, beforeAll, afterAll },
1091
+ adapters: { express: () => expressAdapter(), hono: () => honoAdapter() },
1092
+ })
1093
+ ```
1094
+
866
1095
  ## Security defaults and limits
867
1096
 
868
1097
  - **Introspection** off in production (`NODE_ENV === 'production'`), and suggestions hidden with it
@@ -1141,8 +1370,9 @@ Boot and execution are those of code-first.
1141
1370
  ## Observability
1142
1371
 
1143
1372
  Every operation goes through the `graphql:operation` hook of `@znight/core` (`GraphQLOperationEvent`:
1144
- `operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`,
1145
- `requestId`, `context`, `startTime`, then `outcome` (`success`, `partial`, `error`, `rejected`) and
1373
+ `operationType`, `operationName`, `documentId` (SHA-256 of the query text), `transport`, `driver`
1374
+ (`builtin`, or the name of the [driver](#drivers-and-the-engine)), `requestId`, `context`,
1375
+ `startTime`, then `outcome` (`success`, `partial`, `error`, `rejected`) and
1146
1376
  `errors` with their codes). A subscription produces one event for its whole life. The event is built
1147
1377
  only when the hook has wrappers or listeners, so it costs nothing otherwise.
1148
1378
 
@@ -1186,8 +1416,9 @@ on Node.js, use `node --conditions=@znight/source` instead of `bun`):
1186
1416
  `GraphQLPlugin.execute()` over graphql-js `execute()` for a cached 50-field query (target: under
1187
1417
  10 %; measured 0–9 % on Bun 1.4, 1–7 % on Node.js 24).
1188
1418
  - `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 and to a
1190
- controller route answering a constant. `ABORT_REASON=1` removes the cost graphql-js 17.0 adds to
1419
+ pipeline in process (`GET` and `POST`, both JSON media types), next to bare graphql-js, to a
1420
+ controller route answering a constant, and to `GraphQLEngine.execute()` (what a driver calls: on
1421
+ a cached `{ hello }`, within 2 % of `plugin.execute()`). `ABORT_REASON=1` removes the cost graphql-js 17.0 adds to
1191
1422
  every execution (a `DOMException` built when it aborts its own `AbortController`, about 3 µs on
1192
1423
  Node.js): compare with other servers at the same graphql-js version.
1193
1424
 
@@ -1281,7 +1512,17 @@ interface GraphQLResolverBinding {
1281
1512
  guards: Token[] // class @UseGuard guards, then method ones
1282
1513
  meta: Record<string, unknown> // effective route metadata (method over class); {}: none
1283
1514
  loaders?: Token[] // informational: loaders the method injects
1284
- invoke: (resolver, parent, args: GraphQLArgs, context: GraphQLContext, info: GraphQLResolveInfo) => unknown
1515
+ context?: true // custom parameters (RFC 0010): the pipeline passes the ResolverContext as `f`
1516
+ paramResolvers?: Token[] // class resolvers of custom parameters, passed as `x`
1517
+ invoke(
1518
+ resolver,
1519
+ parent,
1520
+ args: GraphQLArgs,
1521
+ context: GraphQLContext,
1522
+ info: GraphQLResolveInfo,
1523
+ f?: ResolverContext,
1524
+ x?,
1525
+ ): unknown
1285
1526
  }
1286
1527
  ```
1287
1528
 
@@ -1293,7 +1534,9 @@ Type references are strings in graphql-js' canonical form, parsed by
1293
1534
 
1294
1535
  `invoke` maps the graphql-js arguments to the method parameters: `@Args('x')` → `a['x']`,
1295
1536
  `@Args()` → `a`, `@Parent()` → `p`, `@Ctx()` → `c`, `@Info()` → `i`, `@Loader()` →
1296
- `c.loader(token)`. Unused middle parameters are `_`-prefixed and trailing ones omitted, so the code
1537
+ `c.loader(token)`, a custom parameter decorator (`createParamDecorator`, RFC 0010) →
1538
+ `(D.resolvers.graphql as ParamFunctionOf<typeof D, 'graphql'>)(f, data)` or `x[i].resolve(f, data)`
1539
+ (evaluated like a validated argument: synchronous while the resolvers return values). Unused middle parameters are `_`-prefixed and trailing ones omitted, so the code
1297
1540
  type-checks with `noUnusedParameters`. It calls these runtime helpers, kept stable:
1298
1541
 
1299
1542
  - `validateArgs(schema, value, label)`, exported from `@znight/graphql`: validates an argument with
@@ -1341,6 +1584,8 @@ are emitted by schema-first only: code-first sections, hashes and artifacts are
1341
1584
  custom internal enum values (an enum value is its SDL name).
1342
1585
  - **Subscriptions over SSE only.** No `graphql-ws` (WebSocket), no SSE single connection mode, no
1343
1586
  multipart subscriptions.
1587
+ - **Drivers**: the built-in transport and GraphQL Yoga (`@znight/graphql/yoga`); Apollo Server once it
1588
+ accepts graphql-js 17. Yoga plugins that replace the execution (`useGraphQlJit`) are refused.
1344
1589
  - No `@defer` / `@stream`, no persisted or trusted documents, no query cost analysis, no response
1345
1590
  cache, no federation or schema stitching, no file uploads (use an HTTP upload route), no operation
1346
1591
  batching.
@@ -1,6 +1,6 @@
1
1
  import { Scope } from "@znight/core";
2
2
  import { MetaEntry } from "@znight/http/compiler";
3
- import { BindingHandle, ClassInfo, ClassRef, CompilerExtension, Location, ValueRef } from "@znight/compiler";
3
+ import { BindingHandle, ClassInfo, ClassRef, CompilerExtension, Location, StaticValue, ValueRef } from "@znight/compiler";
4
4
  /** Kind of field a resolver method provides. */
5
5
  type FieldOperation = "query" | "mutation" | "subscription" | "field";
6
6
  /** A `@Resolver` class. */
@@ -71,6 +71,26 @@ type ParamData = {
71
71
  /** Identity of `L` in `Loader<L>`. */
72
72
  readonly identity: string;
73
73
  readonly location: Location;
74
+ } | {
75
+ /** A custom parameter decorator (`createParamDecorator`, RFC 0010). */
76
+ readonly kind: "custom";
77
+ /** Canonical identity of the decorator `const` (imported by the generated module). */
78
+ readonly identity: string;
79
+ readonly name: string;
80
+ /** The data argument (a value, possibly holding references, or a reference); `undefined` without argument. */
81
+ readonly data: {
82
+ readonly value: StaticValue;
83
+ } | undefined;
84
+ /** The class resolver (`x[i].resolve(f, data)`); `undefined` for a function resolver. */
85
+ readonly resolver: ClassRef | undefined;
86
+ /**
87
+ * Parameters of the class resolver's `resolve` (own, inherited or an arrow function property);
88
+ * `undefined` for a function, or when the compiler cannot read it (the call is cast).
89
+ */
90
+ readonly arity?: number;
91
+ /** The value type could not be checked against the parameter. */
92
+ readonly cast: boolean;
93
+ readonly location: Location;
74
94
  };
75
95
  /** Everything the extension stores. */
76
96
  type GraphQLData = ResolverData;
package/dist/compiler.mjs CHANGED
@@ -417,6 +417,22 @@ function parameterDecorators(param) {
417
417
  }
418
418
  function analyzeParameter(ctx, cls, param, where) {
419
419
  const decorators = parameterDecorators(param);
420
+ const custom = typeof ctx.paramDecorator === "function" ? ctx.paramDecorator(param, "graphql", cls) : void 0;
421
+ if (custom !== void 0) {
422
+ for (const other of decorators) error(ctx, Codes.InvalidDecorator, other.location, `Parameter '${param.name}' of ${where} has several parameter decorators (@${custom.decorator.name} and @${other.name})`, ["Keep a single parameter decorator"]);
423
+ if (!custom.ok || decorators.length > 0) return void 0;
424
+ const data = custom.data;
425
+ return {
426
+ kind: "custom",
427
+ identity: custom.identity,
428
+ name: custom.name,
429
+ data: data === void 0 ? void 0 : { value: data.ref ?? data.value },
430
+ resolver: custom.resolver.kind === "class" ? custom.resolver.ref : void 0,
431
+ ...custom.resolver.kind === "class" && custom.resolver.arity !== void 0 ? { arity: custom.resolver.arity } : {},
432
+ cast: custom.cast,
433
+ location: custom.location
434
+ };
435
+ }
420
436
  const decorator = decorators[0];
421
437
  if (decorator === void 0) {
422
438
  const lookAlike = param.decorators.find((found) => found.identity !== void 0 && HTTP_LOOK_ALIKES.has(found.identity));
@@ -2627,6 +2643,8 @@ function createImports(ctx) {
2627
2643
  let args;
2628
2644
  let context;
2629
2645
  let info;
2646
+ let resolverContext;
2647
+ let paramFunctionOf;
2630
2648
  let validateArgs;
2631
2649
  let isThenable;
2632
2650
  const hosts = /* @__PURE__ */ new Map();
@@ -2643,6 +2661,14 @@ function createImports(ctx) {
2643
2661
  info ??= ctx.importFrom("graphql", "GraphQLResolveInfo", { type: true });
2644
2662
  return info;
2645
2663
  },
2664
+ resolverContextType: () => {
2665
+ resolverContext ??= ctx.importFrom(RUNTIME_MODULE, "ResolverContext", { type: true });
2666
+ return resolverContext;
2667
+ },
2668
+ paramFunctionOf: () => {
2669
+ paramFunctionOf ??= ctx.importFrom("@znight/core", "ParamFunctionOf", { type: true });
2670
+ return paramFunctionOf;
2671
+ },
2646
2672
  validateArgs: () => {
2647
2673
  validateArgs ??= ctx.importFrom(RUNTIME_MODULE, "validateArgs");
2648
2674
  return validateArgs;
@@ -2734,7 +2760,10 @@ function resolverNode(ctx, imports, resolver, field) {
2734
2760
  ];
2735
2761
  const loaders = field.params.flatMap((param) => param?.kind === "loader" ? [loaderToken(ctx, param)] : []);
2736
2762
  if (loaders.length > 0) entries.push(["loaders", ctx.lit(loaders)]);
2737
- entries.push(["invoke", invokeNode(ctx, imports, resolver, field)]);
2763
+ const resolvers = paramResolvers(field);
2764
+ if (field.params.some((param) => param?.kind === "custom")) entries.push(["context", ctx.lit(true)]);
2765
+ if (resolvers.length > 0) entries.push(["paramResolvers", ctx.lit(resolvers.map((ref) => ctx.tokenOf(ref) ?? ref.token))]);
2766
+ entries.push(["invoke", invokeNode(ctx, imports, resolver, field, resolvers)]);
2738
2767
  return ctx.obj(entries);
2739
2768
  }
2740
2769
  /** The provider token a loader parameter is bound to. */
@@ -2744,13 +2773,28 @@ function loaderToken(ctx, param) {
2744
2773
  if (ref !== void 0 && "token" in ref) return ref.token;
2745
2774
  return param.identity;
2746
2775
  }
2747
- /** Which `invoke` parameters a field uses: `(r, p, a, c, i)`. */
2776
+ /** The provider classes resolving custom parameters, in order of first use (`x[i]`). */
2777
+ function paramResolvers(field) {
2778
+ const found = /* @__PURE__ */ new Map();
2779
+ for (const param of field.params) {
2780
+ const resolver = param?.kind === "custom" ? param.resolver : void 0;
2781
+ if (resolver !== void 0 && !found.has(resolver.token)) found.set(resolver.token, resolver);
2782
+ }
2783
+ return [...found.values()];
2784
+ }
2785
+ /** Which `invoke` parameters a field uses: `(r, p, a, c, i, f, x)`. */
2748
2786
  function usedParameters(field) {
2749
2787
  let p = false;
2750
2788
  let a = false;
2751
2789
  let c = false;
2752
2790
  let i = false;
2791
+ let f = false;
2792
+ let x = false;
2753
2793
  for (const param of field.params) switch (param?.kind) {
2794
+ case "custom":
2795
+ f ||= param.resolver === void 0 || param.arity !== 0;
2796
+ x ||= param.resolver !== void 0;
2797
+ break;
2754
2798
  case "parent":
2755
2799
  p = true;
2756
2800
  break;
@@ -2767,43 +2811,63 @@ function usedParameters(field) {
2767
2811
  p,
2768
2812
  a,
2769
2813
  c,
2770
- i
2814
+ i,
2815
+ f,
2816
+ x
2771
2817
  ];
2772
2818
  }
2773
2819
  /**
2774
2820
  * `(r: Resolver, p: unknown, a: GraphQLArgs, c: GraphQLContext, i: GraphQLResolveInfo) => r.m(...)`:
2775
2821
  * unused middle parameters are `_`-prefixed, trailing ones omitted (`noUnusedParameters`).
2776
2822
  */
2777
- function invokeNode(ctx, imports, resolver, field) {
2823
+ function invokeNode(ctx, imports, resolver, field, resolvers) {
2778
2824
  const used = usedParameters(field);
2779
2825
  const last = used.lastIndexOf(true);
2780
2826
  const types = [
2781
2827
  () => ctx.code`unknown`,
2782
2828
  imports.argsType,
2783
2829
  imports.contextType,
2784
- imports.infoType
2830
+ imports.infoType,
2831
+ imports.resolverContextType,
2832
+ () => tupleType(ctx, resolvers.map((ref) => ctx.importType(ref)))
2785
2833
  ];
2786
2834
  const names = [
2787
2835
  "p",
2788
2836
  "a",
2789
2837
  "c",
2790
- "i"
2838
+ "i",
2839
+ "f",
2840
+ "x"
2791
2841
  ];
2792
2842
  const params = [["r", imports.hostType(resolver.host.identity)]];
2793
2843
  for (let index = 0; index <= last; index++) params.push([`${used[index] ? "" : "_"}${names[index]}`, types[index]()]);
2794
- const validated = field.params.flatMap((param, index) => param?.kind === "args" && param.schema !== void 0 ? [index] : []);
2844
+ const deferred = field.params.flatMap((param, index) => param !== void 0 && isDeferred(param) ? [index] : []);
2845
+ const positions = new Map(resolvers.map((ref, position) => [ref.token, position]));
2795
2846
  return ctx.arrow(params, (r, ...rest) => {
2796
- const [p, a, c, i] = rest;
2847
+ const [p, a, c, i, f, x] = rest;
2797
2848
  const scope = {
2798
2849
  p,
2799
2850
  a,
2800
2851
  c,
2801
- i
2852
+ i,
2853
+ f,
2854
+ x,
2855
+ resolvers: positions,
2856
+ imports
2802
2857
  };
2803
- if (validated.length === 0) return call(ctx, r, field, (index) => argument(ctx, scope, field, index));
2804
- return validatingBlock(ctx, imports, r, scope, field, validated);
2858
+ if (deferred.length === 0) return call(ctx, r, field, (index) => argument(ctx, scope, field, index));
2859
+ return validatingBlock(ctx, imports, r, scope, field, deferred);
2805
2860
  });
2806
2861
  }
2862
+ /** `readonly [A, B]`. */
2863
+ function tupleType(ctx, nodes) {
2864
+ const joined = nodes.slice(1).reduce((all, node) => ctx.code`${all}, ${node}`, nodes[0]);
2865
+ return ctx.code`readonly [${joined}]`;
2866
+ }
2867
+ /** An argument whose expression may return a thenable: a validated `@Args`, or a custom parameter. */
2868
+ function isDeferred(param) {
2869
+ return param.kind === "args" && param.schema !== void 0 || param.kind === "custom";
2870
+ }
2807
2871
  /** `r.method(args)`. */
2808
2872
  function call(ctx, r, field, argumentOf) {
2809
2873
  return ctx.call(ctx.member(r, field.method), field.params.map((_, index) => argumentOf(index)));
@@ -2817,9 +2881,48 @@ function argument(ctx, scope, field, index) {
2817
2881
  case "info": return scope.i;
2818
2882
  case "loader": return ctx.code`${ctx.call(ctx.member(scope.c, "loader"), [loaderToken(ctx, param)])} as never`;
2819
2883
  case "args": return ctx.code`${argsValue(ctx, scope, param.name)} as never`;
2884
+ case "custom": return customArgument(ctx, scope, param);
2820
2885
  default: return ctx.lit(void 0);
2821
2886
  }
2822
2887
  }
2888
+ /**
2889
+ * A custom parameter (RFC 0010): `(D.resolvers.graphql as ParamFunctionOf<typeof D, 'graphql'>)(f, data)`
2890
+ * or `x[i].resolve(f, data)`, cast with `as never` when the value type could not be checked.
2891
+ */
2892
+ function customArgument(ctx, scope, param) {
2893
+ const f = scope.f;
2894
+ const data = param.data === void 0 ? ctx.lit(void 0) : dataNode(ctx, param.data.value);
2895
+ let node;
2896
+ if (param.resolver === void 0) {
2897
+ const decorator = ctx.importValue(param.identity);
2898
+ const type = ctx.code`${scope.imports.paramFunctionOf()}<typeof ${decorator}, 'graphql'>`;
2899
+ node = ctx.call(ctx.code`${ctx.member(decorator, "resolvers", "graphql")} as ${type}`, [f, data]);
2900
+ } else {
2901
+ const position = scope.resolvers.get(param.resolver.token);
2902
+ if (param.arity === void 0) return ctx.code`(${scope.x}[${position}] as { resolve(ctx: unknown, data: unknown): unknown }).resolve(${f}, ${data}) as never`;
2903
+ const arity = Math.min(param.arity, 2);
2904
+ let args = ctx.code``;
2905
+ if (arity === 1) args = f;
2906
+ else if (arity === 2) args = ctx.code`${f}, ${data}`;
2907
+ node = ctx.code`${scope.x}[${position}].resolve(${args})`;
2908
+ }
2909
+ return param.cast ? ctx.code`${node} as never` : node;
2910
+ }
2911
+ /** The data argument of a custom parameter: a literal (references imported) or a reference. */
2912
+ function dataNode(ctx, value) {
2913
+ return isValueRef(value) ? ctx.ref(value) : ctx.lit(value);
2914
+ }
2915
+ /** `true` for a {@link ValueRef} (the only non-plain object a static value can hold). */
2916
+ function isValueRef(value) {
2917
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2918
+ const prototype = Object.getPrototypeOf(value);
2919
+ return prototype !== Object.prototype && prototype !== null && value.kind === "ref";
2920
+ }
2921
+ /** The expression of a deferred parameter (validation or custom resolver), before any `await`. */
2922
+ function deferredValue(ctx, imports, scope, field, index) {
2923
+ const param = field.params[index];
2924
+ return param.kind === "custom" ? customArgument(ctx, scope, param) : validation(ctx, imports, scope, param);
2925
+ }
2823
2926
  /** `a['name']`, or `a` for `@Args()`. */
2824
2927
  function argsValue(ctx, scope, name) {
2825
2928
  const a = scope.a;
@@ -2853,15 +2956,13 @@ function validatingBlock(ctx, imports, r, scope, field, validated) {
2853
2956
  const statements = [];
2854
2957
  for (const index of validated) {
2855
2958
  const name = local.get(index);
2856
- const param = field.params[index];
2857
- statements.push(ctx.code`const ${name} = ${validation(ctx, imports, scope, param)}`);
2959
+ statements.push(ctx.code`const ${name} = ${deferredValue(ctx, imports, scope, field, index)}`);
2858
2960
  const resume = ctx.arrow([], () => call(ctx, r, field, (position) => {
2859
2961
  const value = local.get(position);
2860
2962
  if (value === void 0) return argument(ctx, scope, field, position);
2861
2963
  if (position < index) return ctx.code`${value} as never`;
2862
2964
  if (position === index) return ctx.code`await ${value} as never`;
2863
- const later = field.params[position];
2864
- return ctx.code`await ${validation(ctx, imports, scope, later)} as never`;
2965
+ return ctx.code`await ${deferredValue(ctx, imports, scope, field, position)} as never`;
2865
2966
  }), { async: true });
2866
2967
  statements.push(ctx.code`if (${ctx.call(imports.isThenable(), [name])}) return (${resume})()`);
2867
2968
  }