@znight/graphql
v0.6.1
Published
Znight GraphQL: code-first GraphQL whose schema is derived from TypeScript types at build time, on graphql-js 17
Maintainers
Readme
@znight/graphql
Code-first GraphQL for Znight: the TypeScript types are the schema. Resolvers
are providers marked with @Resolver, @Query, @Mutation, @Subscription and @ResolveField;
the compiler extension (@znight/graphql/compiler) follows every type their signatures reach and
derives the complete schema at build time, with descriptions and deprecations taken from JSDoc.
At runtime, GraphQLPlugin serves it on graphql-js 17 through HttpPlugin, with DataLoader
batching per execution and subscriptions over Server-Sent Events. The design is
RFC 0005.
- Schema errors at build time. A
Uint8Arrayreached from a query, two types namedReview, a type used as input and output, a union that cannot be told apart at runtime, a field resolver without parent, a loader of the wrong type: each is aZNIGHT06xdiagnostic with a code frame and the path from the root field (reached from Query.book → Book). - No schema work at boot beyond graphql-js constructors. The generated metadata holds the type
graph as data and one
invokearrow per resolver method; no SDL parsing, no reflection. - DI everywhere. Resolvers, guards, loaders and error mappers are providers, bound and scope-checked by the compiler. Request-scoped providers work in resolvers as in controllers.
- One transport for every adapter. The endpoint is a route of
HttpPlugin: built-in server, Fastify, Express and Hono, on Node.js and Bun, through the same pipeline as controllers. - One access-control vocabulary. The route decorators of
@znight/http(createRouteDecorator, with required groups) apply to resolvers, and one guard class can protect routes and fields.
No @ObjectType, no @Field(() => Int), no reflect-metadata, no SDL file to keep in sync.
Your .graphql files are the contract? The opt-in schema-first mode reads them
instead, binds resolvers to their fields by name (NestJS style) and checks at build time that every
field is resolved and that every resolver signature conforms to the SDL
(RFC 0007).
Example: graphql-api (a
book catalog with loaders, subscriptions over SSE, per-field access decisions and a committed
schema.graphql, on Node.js and Bun).
# bun
bun add @znight/core @znight/http @znight/graphql graphql
bun add -d @znight/compiler vite
# npm
npm install @znight/core @znight/http @znight/graphql graphql
npm install -D @znight/compiler viteRuns on Node.js >= 24 and Bun >= 1.3 (ESM only). @znight/http and graphql 17 are required peer
dependencies, dataloader is a dependency, @znight/testing an optional peer (for
@znight/graphql/testing), graphql-yoga 6 an optional peer (for the Yoga driver,
@znight/graphql/yoga).
Contents
- Quick start
- Types are the schema
- Resolvers
- Arguments and validation
- Loaders
- Subscriptions and PubSub
- Unions, generic types and scalars
- Context and injection
- Authorization
- Errors
- Plugin options
- GraphQL over HTTP and SSE
- Drivers and the engine
- Security defaults and limits
- Testing
- The generated SDL and the compiler options
- Schema-first
- Observability
- Performance
- Diagnostics
- Compiler contract
- Limitations
Quick start
// src/catalog/book.ts
import type { ID, Int } from '@znight/graphql'
/** A book of the catalog. */
export interface Book {
readonly id: ID
readonly title: string
/** Year of first publication, `null` when unknown. */
readonly publishedYear: Int | null
}// src/catalog/book.resolver.ts
import { Args, type ID, Query, Resolver } from '@znight/graphql'
import type { Book } from './book.js'
import { CatalogService } from './catalog.service.js'
@Resolver()
export class BookResolver {
constructor(private readonly catalog: CatalogService) {}
/**
* One book.
* @param id The id of the book.
*/
@Query()
book(@Args('id') id: ID): Promise<Book | null> {
return this.catalog.find(id)
}
}// src/main.ts
import { Application } from '@znight/core'
import { GraphQLPlugin } from '@znight/graphql'
import { HttpPlugin } from '@znight/http'
import { __ZNIGHT_METADATA__ } from './.znight/container.generated.js'
const app = await Application.create({
metadata: __ZNIGHT_METADATA__,
plugins: [HttpPlugin.configure({ port: 3000 }), GraphQLPlugin.configure()],
})
await app.start() // POST /graphql, GET /graphql (queries; GraphiQL in development), SSE subscriptionsThe compiler extension is discovered as soon as the code imports @znight/graphql: nothing else to
configure. The schema it derives:
"""
A book of the catalog.
"""
type Book {
id: ID!
title: String!
"""
Year of first publication, `null` when unknown.
"""
publishedYear: Int
}
type Query {
"""
One book.
"""
book(
"""
The id of the book.
"""
id: ID!
): Book
}curl -H 'content-type: application/json' -d '{"query":"{ book(id: \"1\") { title } }"}' http://localhost:3000/graphqlTypes are the schema
The schema is made of the root fields (@Query, @Mutation, @Subscription) and of every type
they reach. Output position is a resolver return type (Promise<T> unwrapped) and the fields
of object types; input position is an argument and the fields of input types.
| TypeScript | GraphQL | Position |
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------- |
| string, one string literal | String | both |
| number, one number literal | Float ('Int' with the number compiler option) | both |
| Int / Float / ID (aliases of @znight/graphql) | Int / Float / ID | both |
| boolean, true, false | Boolean | both |
| Date | DateTime (RFC 3339 with offset) | both |
| bigint | BigInt (a decimal string on the wire) | both |
| JSONValue (@znight/graphql) | JSON, always nullable (the type includes null) | both |
| the T of a scalar<T>(name, ...) declaration | the scalar name | both |
| TypeScript enum (string or numeric members) | enum: names = member names, internal values = member values | both |
| named alias of a union of string literals (type BookSort = 'title' \| 'newest') | enum named after the alias, names = values = the literals | both |
| class, interface, named alias of an object type or of an intersection of those | object type (output) or input object type (input) | both |
| generic instance (Page<Book>) | BookPage (type arguments, then the declaration name), or the name of an alias | both |
| named alias of Partial, Required, Readonly, Pick, Omit of an object type | object or input type named after the alias | both |
| named alias of a union of object types | union | output |
| T[], readonly T[], Array<T>, ReadonlyArray<T> | [T] | both |
| Set<T>, ReadonlySet<T>, Iterable<T> | [T] | output |
| AsyncIterable<T>, AsyncGenerator<T>, AsyncIterableIterator<T> (also in a Promise) | the field type T | @Subscription return only |
| T \| null, T \| undefined, an optional property or parameter | nullable T | both |
| anything else (any, unknown, object, void, functions, tuples, Map, Record, ...) | ZNIGHT060, with a hint | |
Non-null is the default: a type without null, undefined or ? is T!, and list items follow
the same rule (readonly (Book | null)[] is [Book]!). ID, Int, Float and JSONValue are
plain aliases (type Int = number): values need no cast, and the compiler recognises them by
identity.
Object and input types. Their fields are the public, non-static properties (constructor
parameter properties included), getters (output only), and the members of analysable base classes
and extended interfaces, in declaration order, base members first. Methods, function-typed
properties, #private, private and protected members, symbol and computed keys and @gqlHidden
members are left out. Only types reachable from a root field are part of the schema.
Input or output, not both. A declaration reached in both positions is ZNIGHT063, with the fix
export interface MoneyInput extends Money {}. Input types take no getters, unions, sets or async
iterables, and a cycle of non-null input fields is ZNIGHT063 (no value could be written).
Naming. A type's GraphQL name is its declaration name, the name of the alias the annotation goes
through, or its @gqlName. Nothing is case-converted: enum member names, literals and properties
are used as written. Query, Mutation, Subscription, names starting with __, the specified
scalar names, invalid names and the enum values true, false and null are ZNIGHT061, and so are
two declarations with one GraphQL name.
JSDoc is read on types, properties, enum members and resolver methods:
| Tag / text | On | Effect |
| -------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- |
| Comment text | type, property, enum member, resolver method | description |
| @param name text | resolver method | description of the argument name |
| @deprecated reason | property, enum member, resolver method, argument | @deprecated(reason:) (on arguments and input fields: only when nullable) |
| @default value | property of an args or input type | default value (a JSON literal checked against the type, ZNIGHT063) |
| @gqlHidden | property, getter | not part of the GraphQL type |
| @gqlName Name | type declaration | GraphQL name of the type |
import type { ID, Int } from '@znight/graphql'
/** A book of the catalog. */
export interface Book {
readonly kind: 'book'
readonly id: ID
readonly title: string
readonly genre: Genre
readonly tags: readonly string[]
readonly createdAt: Date
/** @deprecated Use `tags`. */
readonly category?: string
/** @gqlHidden Exposed as `author` by BookResolver. */
readonly authorId: ID
}
/** Literary genres. */
export enum Genre {
Fantasy = 'fantasy',
ScienceFiction = 'science-fiction',
}
/** A page of results. */
export interface Page<T> {
readonly items: readonly T[]
readonly total: Int
readonly hasMore: boolean
}
/** Sort orders of the book list. */
export type BookSort = 'title' | 'newest'Resolvers
import {
Args,
Ctx,
type GraphQLContext,
type ID,
Loader,
Mutation,
Parent,
PubSub,
Query,
ResolveField,
Resolver,
Subscription,
} from '@znight/graphql'
@Resolver()
export class BookResolver {
constructor(
private readonly catalog: CatalogService,
private readonly events: PubSub<CatalogEvents>,
) {}
/** Books of the catalog, filtered and paginated. */
@Query()
books(@Args() filter: BookFilter): Promise<Page<Book>> {
return this.catalog.list(filter)
}
/** Adds a book to the catalog. */
@Mutation()
async addBook(@Args('input', NewBookInput) input: NewBookInput): Promise<Book> {
const book = await this.catalog.add(input)
await this.events.publish('bookAdded', book)
return book
}
/** Who wrote the book. Batched: one repository call per execution. */
@ResolveField()
author(@Parent() book: Book, @Loader() authors: Loader<AuthorsById>): Promise<Author> {
return authors.load(book.authorId)
}
/** Books added from now on, optionally of one genre. */
@Subscription()
async *bookAdded(@Args('genre') genre: Genre | undefined, @Ctx() ctx: GraphQLContext): AsyncGenerator<Book> {
for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal })) {
if (genre === undefined || book.genre === genre) yield book
}
}
}@Resolver({ scope? })registers the class as a provider, a singleton by default;requestcreates one resolver per operation. A class is the provider of one extension:@Resolverand@Controlleron one class is ZNIGHT005 (both may call the same service).@Query(name?),@Mutation(name?)and@Subscription(name?)declare root fields named after the method unless a name is given. Two root fields with one name, across classes, are ZNIGHT061.@ResolveField(name?)adds a field to the type of the@Parent()parameter, which is mandatory (ZNIGHT062), or replaces the property of the same name: it must then resolve the same GraphQL type (ZNIGHT061). Field resolvers come after the properties, ordered by resolver class then source order. A field resolver whose parent type no root field reaches is a ZNIGHT062 warning.- Field methods are public, non-static, non-generic instance methods with an explicit return
type (the compiler does not infer; ZNIGHT060 otherwise). They are not inherited: a field
decorator on a class without
@Resolveris ZNIGHT062. - Every parameter carries exactly one GraphQL parameter decorator:
@Args,@Parent(),@Ctx(),@Info()(graphql-jsGraphQLResolveInfo, an escape hatch) or@Loader(). An undecorated parameter, orCtx/Queryimported from@znight/httpby mistake, is ZNIGHT005 with the import to use. - A
@Subscriptionmethod returns an async iterable: anasync *generator, orpubsub.subscribe(...). - Resolvers in a compiled provider library (
library: true) are ZNIGHT062: the schema is derived from sources, so publish such a package as a source library.
Arguments and validation
import type { Int, StandardSchemaV1 } from '@znight/graphql'
import { z } from 'zod'
/** Criteria of `Query.books`: each property becomes an argument. */
export interface BookFilter {
/** Case-insensitive search in titles. */
readonly search?: string
readonly genre?: Genre
/** @default "title" */
readonly sort?: BookSort
/** @default 20 */
readonly limit?: Int
}
/** A new book. The interface is the GraphQL input type, the schema of the same name validates it. */
export interface NewBookInput {
readonly title: string
readonly isbn: string
readonly authorId: string
readonly genre: Genre
readonly publishedYear?: Int | null | undefined
}
export const NewBookInput: StandardSchemaV1<unknown, NewBookInput> = z.object({
title: z.string().trim().min(1, 'A title is required'),
isbn: z.string().regex(/^\d{13}$/),
authorId: z.string(),
genre: z.enum(Genre), // receives the internal value ('fantasy'): GraphQL already mapped the name
publishedYear: z.number().int().nullish(),
})@Args()without name spreads the properties of the parameter type into arguments:books(search: String, genre: Genre, sort: BookSort = title, limit: Int = 20). It is not a GraphQL type.@Args('name')declares one argument; mixing both is fine when names differ.@Args('input', Schema)(or@Args(Schema)for the whole arguments object) validates the value with any Standard Schema (Zod, Valibot, ArkType...) after GraphQL coerced it. The resolver receives the schema output (transforms applied); a failure is aBAD_USER_INPUTerror withextensions.issues: [{ path, message }]. The annotation stays the GraphQL type: the compiler never executes schemas. A schema and an interface may share a name (export interface NewBookInput+export const NewBookInput).- Defaults: a
@defaultJSDoc tag or a statically evaluable parameter default (@Args('limit') limit: Int = 20, which giveslimit: Int! = 20) is the schema default, checked against the type (ZNIGHT063), printed in the SDL and applied by graphql-js. A default the compiler cannot evaluate leaves the argument nullable, and TypeScript applies it. nullversusundefined: an argument or input field whose TypeScript type does not acceptnull(limit?: Int) receivesundefinedwhen the client sendsnull; types that acceptnullreceive it as is. The resolver always gets values of its declared type.
Loaders
import { Injectable } from '@znight/core'
import { type BatchLoad, type ID, orderByKeys } from '@znight/graphql'
@Injectable()
export class AuthorsById implements BatchLoad<ID, Author> {
/** Optional: DataLoader options, read once per loader class. */
readonly options = { maxBatchSize: 100 }
constructor(private readonly authors: AuthorRepository) {}
async load(ids: readonly ID[]): Promise<readonly (Author | Error)[]> {
return orderByKeys(ids, await this.authors.findMany(ids), author => author.id)
}
}- A loader is a provider implementing
BatchLoad<K, V>:load(keys)returns one value (or anErrorfailing that key only) per key, in the order of the keys. Another number of values fails every key of the batch with ZNIGHT065. @Loader() authors: Loader<AuthorsById>injects the DataLoader of the current execution: created on first use, cached for the operation (or for one subscription event), then dropped. The compiler bindsAuthorsByIdlike a constructor dependency (ZNIGHT001 when it is not a provider, ZNIGHT065 when it does not implementBatchLoador the parameter is not typedLoader<L>). The edge is lazy: a singleton resolver may use a request-scoped loader.ctx.loader(AuthorsById)on theGraphQLContextreturns the same DataLoader to services called by a resolver.options(LoaderOptions):maxBatchSize,cache(defaulttrue),cacheKeyFn(for object keys). Batches are scheduled per tick;batchScheduleFnis not exposed.orderByKeys(keys, rows, keyOf, { missing? })reorders rows to match the keys; a missing key gets aNOT_FOUNDGraphQLException(missing: 'error', the default) ornull(missing: 'null').
Subscriptions and PubSub
A @Subscription method returns an AsyncIterable<T>; each value is the field value of one event.
Clients subscribe over Server-Sent Events (GraphQL over SSE, distinct connections mode):
curl -N -H 'accept: text/event-stream' -H 'content-type: application/json' \
-d '{"query":"subscription { bookAdded(genre: Fantasy) { title } }"}' http://localhost:3000/graphql
# event: next
# data: {"data":{"bookAdded":{"title":"The Hobbit"}}}The events of a subscription are born elsewhere (a mutation, a scheduled job, a message consumer),
while the stream stays open. PubSub<Events> is the mailbox between them: producers
publish(topic, payload), every open subscription subscribe(topic, { signal })s and receives what
is published on the topic from then on (nothing is stored or replayed). It is an injectable abstract
class (singleton, provided by GraphQLPlugin), where Events maps topics to payload types:
/** Topics of the application PubSub and their payloads. */
export interface CatalogEvents {
bookAdded: Book
}
// in a provider: constructor(private readonly events: PubSub<CatalogEvents>) {}
await this.events.publish('bookAdded', book)
for await (const book of this.events.subscribe('bookAdded', { signal: ctx.signal })) yield book- The default is a
MemoryPubSub: in-process fan-out, one boundedEventChannel(RFC 0006) per subscriber (subscriptions.bufferSize, 100 events). A subscriber that falls behind gets a finalSUBSCRIPTION_OVERFLOWerror and its stream ends, rather than losing events silently or buffering without bound. The plugin closes theMemoryPubSubit created when the application is disposed. MemoryPubSubreaches the subscribers of its own process only. Several instances need a shared implementation (Redis pub/sub, NATS, PostgresLISTEN/NOTIFY, a broker): extendPubSuband pass an instance as thepubsuboption.subscribe()returns its iterator synchronously, ends whensignalaborts orreturn()is called, buffers a bounded number of events per subscriber (SUBSCRIPTION_OVERFLOWbeyond), and payloads must survive serialization. The plugin does not close aPubSubit did not create. The guide's Write your own PubSub recipe is a tested Redis implementation (ioredis).subscribe()ends when itssignalaborts: the client left, the application shuts down, the stream reachedsubscriptions.maxDuration, or (plugin.subscribe()) itsreturn()was called. Always passctx.signal.- The source starts with the subscription: the plugin requests its first event before the SSE
response starts (or
plugin.subscribe()resolves), so a generator that callssubscribe()before its firstawaithas subscribed to the PubSub by the time the client is connected, and an event published right after is not lost. The stream reads that one event ahead; the next ones are read as the client consumes them. - The request scope of a subscription lasts as long as its stream: request-scoped providers live for the subscription, while loaders are renewed for every event. Root subscription guards run once, when the subscription starts (per-event authorization is the generator's job).
subscriptions.maxActive(10 000) caps active subscriptions on every transport: beyond it, a new subscription is answered 503 (SERVICE_UNAVAILABLE).plugin.activeSubscriptionsgives the count.
Unions, generic types and scalars
/** A search hit: told apart at runtime by `kind`, found by the compiler. */
export type SearchResult = Book | Author
// search(text: String!): [SearchResult!]! union SearchResult = Author | BookA union is a named alias of a union of object types. The compiler picks the runtime
discriminator: instanceof when every member is an exported class (subclasses first), otherwise
__typename or a property whose literal type differs in every member (kind here). Neither is
ZNIGHT064.
Generic object types are named after their type arguments: Page<Book> is BookPage,
Page<Edge<Book>> is BookEdgePage. An alias wins: export type BookList = Page<Book> names it
BookList. Generic instances count per instance for the input/output rule.
import { scalar } from '@znight/graphql'
export type Isbn = string & { readonly __brand: 'Isbn' }
export const IsbnScalar = scalar<Isbn>('Isbn', {
description: 'An ISBN-13, thirteen digits.',
specifiedByURL: 'https://www.isbn-international.org/content/what-isbn',
serialize: value => value,
parseValue: value => {
if (typeof value !== 'string' || !/^[0-9]{13}$/.test(value)) throw new TypeError('An ISBN has 13 digits')
return value as Isbn
},
})scalar<T>(name, definition) maps the TypeScript type T (a branded alias, or a global such as
URL) to the GraphQL scalar name wherever it appears. name is static; the result must be an
exported const (the generated module imports it). The definition is a ScalarDefinition
(serialize, parseValue, optional parseLiteral, description, specifiedByURL, mapped to the
graphql-js 17 coercers) or a graphql-js GraphQLScalarType such as those of graphql-scalars,
whose name must be name (ZNIGHT066 at boot otherwise).
Built-in scalars appear in the schema only when used: String, Float, Int, ID, Boolean,
DateTime (Date; RFC 3339 with a mandatory offset, @specifiedBy RFC 3339), BigInt
(bigint; a decimal string on the wire) and JSON (JSONValue).
Context and injection
GraphQLContext is the context of one operation, a request-scoped external
(@znight/graphql#GraphQLContext, GraphQLTokens.Context):
| Member | What |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| requestId | Id of the request carrying the operation (HttpContext.requestId over HTTP, a UUID or requestId option otherwise) |
| transport | 'http', 'sse' or 'direct' (plugin.execute() / subscribe()); 'ws' is reserved for graphql-ws |
| http | The HttpContext of the request, undefined for direct executions outside a request |
| operationType / operationName | 'query', 'mutation' or 'subscription'; the selected operation name |
| document / variables | The parsed document (shared by the document cache: never mutate it) and the coerced variables |
| signal | Over HTTP, the request's ctx.signal: client gone, SSE stream closed; passed to graphql-js as abortSignal |
| state | A Map for per-operation storage |
| loader(Class \| token) | The DataLoader of the current execution for a loader |
| serverContext | With a driver, the context its server gave the engine; undefined otherwise |
- Resolvers receive it with
@Ctx(); request-scoped providers injectGraphQLContext, singletonsLazy<GraphQLContext>, exactly likeHttpContext. - The context is not a bag for user data: the authenticated user is a request-scoped provider filled by a guard (see Authorization).
- Over HTTP, one request scope per HTTP request, which carries exactly one operation (no batching).
plugin.execute()andplugin.subscribe()run in the current scope when one is active (a controller calling GraphQL), otherwise in a scope of their own.
Custom parameter decorators
A decorator defined with createParamDecorator (@znight/core, RFC 0010) works on resolver
methods when it has a graphql resolver, which receives the field's ResolverContext (parent,
args, info, operation, http, route.meta). One definition serves HTTP routes and GraphQL
fields:
import { createParamDecorator } from '@znight/core'
import type { User } from './user.ts'
export const CurrentUser = createParamDecorator<User>({
http: ctx => ctx.state.get('user') as User,
graphql: ctx => ctx.operation.state.get('user') as User,
})
// @Query() me(@CurrentUser() user: User): User { return user }The resolver runs after the guards (it sees what they stored) and before the method; what it
throws fails the field like the method would (errors[], or a result mapper). A custom parameter
is not a GraphQL argument: it never appears in the schema. Resolver classes implementing
ParamResolver are resolved in the operation scope. Custom parameters belong to the field
resolvers of the schema, so they work the same through the built-in transport, plugin.execute()
and a driver such as yogaDriver() (RFC 0009).
Authorization
The route decorators of @znight/http (createRouteDecorator, with RFC 0001's groups) apply to
resolver classes and methods, and one guard class can implement CanActivate (routes) and
GraphQLGuard (fields):
// src/auth/access.ts: shared by controllers and resolvers
import { createRouteDecorator } from '@znight/http'
export type Role = 'reader' | 'editor'
export const Public = createRouteDecorator('public', { group: 'access', required: true })
export const Authenticated = createRouteDecorator('authenticated', { group: 'access', required: true })
export const Roles = createRouteDecorator<readonly Role[]>('roles', { group: 'access', required: true })// src/auth/access.guard.ts
import { Injectable, type Lazy } from '@znight/core'
import type { GraphQLGuard, ResolverContext } from '@znight/graphql'
import type { CanActivate, HttpContext, RouteMetaCarrier } from '@znight/http'
import { Authenticated, Public, Roles } from './access.js'
import type { CurrentUser } from './current-user.js'
@Injectable()
export class AccessGuard implements CanActivate, GraphQLGuard {
/** This guard enforces the "access" group, on routes and on root fields. */
static readonly enforces = [Public, Authenticated, Roles]
constructor(private readonly user: Lazy<CurrentUser>) {}
canActivate(ctx: HttpContext): boolean {
return this.#decide(ctx)
}
canResolve(ctx: ResolverContext): boolean {
return this.#decide(ctx)
}
#decide(ctx: RouteMetaCarrier): boolean {
if (Public.get(ctx)) return true
const principal = this.user.get().principal // UnauthorizedException → 401 / UNAUTHENTICATED
const roles = Roles.get(ctx)
if (roles !== undefined) return roles.some(role => principal.roles.includes(role))
return Authenticated.get(ctx) === true
}
}HttpPlugin.configure({ guards: [AuthenticationGuard, AccessGuard] })
GraphQLPlugin.configure({
guards: [AccessGuard],
http: { meta: [Public.meta()] }, // the endpoint itself is open: decisions are taken per field
})- The endpoint is an HTTP route: the global HTTP guards run on it,
http.metaandhttp.guardsgive it route metadata and guards, and with a required group it needs a decision like any route. - Fields follow RFC 0001: the decorators of a group are alternatives, the method's decision
replaces the class's, a
requiredgroup must be decided on every root field (ZNIGHT023 at build time, the code HTTP routes use), and every decided group must be enforced by a guard of the field's chain (static enforces, checked at boot: ZNIGHT023, or a warning withenforcement: 'warn'). ResolverContextsatisfiesRouteMetaCarrier, soRoles.get(ctx)works:route: { meta, coordinate }(effective metadata, schema coordinateQuery.books),operation(GraphQLContext),parentType,field,args,parent,info,http.- Guards run global (
GraphQLPlugin.configure({ guards })), then class@UseGuard, then method@UseGuard.falsefails the field withFORBIDDEN; a thrown error is mapped like any error (Errors). Guards stay synchronous until one returns a promise. - Global guards run on root fields and on field resolvers carrying their own metadata (a route decorator on the method or its class). Other field resolvers are covered by the decision of the root field the selection descends from; plain property fields never run guards.
- Guards run once per resolver call (a guard on a field resolver of a list runs once per item): keep
them cheap.
@UseGuardclasses are bound with lazy edges, so a singleton resolver may use a request-scoped guard.
Errors
import { Injectable } from '@znight/core'
import { GraphQLException, type GraphQLErrorMapper } from '@znight/graphql'
@Injectable()
export class CatalogErrorMapper implements GraphQLErrorMapper {
map(error: unknown): GraphQLException | undefined {
if (error instanceof BookNotFoundError) {
return new GraphQLException(error.message, { code: 'NOT_FOUND', extensions: { bookId: error.bookId } })
}
return undefined
}
}Every error of errors[] is resolved in this order:
- a
GraphQLException(or a graphql-jsGraphQLErrorthrown by user code) is shown as is:{ message, locations, path, extensions: { ...extensions, code } }; itscause(new GraphQLException(message, { code, cause })) is never sent, and logged once aterrorlevel with the request id, the schema coordinate, the path and the code; - the
errorMappers, in order (synchronous:map(error, ctx); the first exception wins, a mapper that throws is logged and skipped); - an
HttpExceptionof@znight/httpis mapped by status: aValidationExceptionisBAD_USER_INPUTwithextensions.issues, 400BAD_USER_INPUT, 401UNAUTHENTICATED, 403FORBIDDEN, 404NOT_FOUND, 409CONFLICT, 429TOO_MANY_REQUESTS, another 4xxBAD_REQUEST, 5xx masked; - anything else is masked as
Unexpected error.withINTERNAL_SERVER_ERROR, and logged once aterrorlevel with the request id, the schema coordinate, the path and the cause (withexposeErrors: true, the original message is sent). This includes the errors graphql-js raises while completing a resolver's value, whose message holds that value: a built-in scalar or an enum that cannot represent it (String cannot represent value: { passwordHash: ... }), a list value that is not iterable, a union value that resolves to no member.
HttpException headers (www-authenticate) are not sent from field errors: one response carries
many fields. Request errors (parse, validation, variables, limits) are never logged above debug.
| GraphQLErrorCode | extensions.code | When |
| ---------------------- | --------------------------- | ------------------------------------------------------------------------ |
| ParseFailed | GRAPHQL_PARSE_FAILED | The document does not parse (syntax, limits.maxTokens) |
| ValidationFailed | GRAPHQL_VALIDATION_FAILED | The document is not valid against the schema |
| BadUserInput | BAD_USER_INPUT | Invalid variables, a schema of @Args rejecting a value, HTTP 400 |
| QueryTooComplex | QUERY_TOO_COMPLEX | limits.maxDepth, maxAliases or maxDirectives exceeded |
| BadRequest | BAD_REQUEST | Unknown operation name, several operations without a name, another 4xx |
| Unauthenticated | UNAUTHENTICATED | HTTP 401 |
| Forbidden | FORBIDDEN | A guard returned false, HTTP 403 |
| NotFound | NOT_FOUND | HTTP 404, a key missing from orderByKeys |
| Conflict | CONFLICT | HTTP 409 |
| TooManyRequests | TOO_MANY_REQUESTS | HTTP 429 |
| SubscriptionOverflow | SUBSCRIPTION_OVERFLOW | A subscriber fell behind its buffer: its stream ends with this error |
| ServiceUnavailable | SERVICE_UNAVAILABLE | subscriptions.maxActive reached (HTTP 503) |
| InternalServerError | INTERNAL_SERVER_ERROR | An unexpected error (masked), or an error graphql-js raised on execution |
Applications may use their own codes: new GraphQLException(message, { code: 'OUT_OF_STOCK' }).
Errors as data
Errors a client is expected to handle can be values of the field instead of entries of
errors[] (the "result union" pattern, RFC 0008). The field says which ones it can return through
its TypeScript return type, a union; a result mapper turns errors into those values:
import { Injectable } from '@znight/core'
import {
Args,
GraphQLErrorCode,
GraphQLException,
GraphQLPlugin,
type GraphQLResultMapper,
Query,
Resolver,
} from '@znight/graphql'
import { NotFoundException } from '@znight/http'
export interface NotFoundRejection {
readonly __typename: 'NotFoundRejection'
readonly message: string
}
export interface ValidationRejection {
readonly __typename: 'ValidationRejection'
readonly issues: readonly string[]
}
export type BookResult = Book | NotFoundRejection | ValidationRejection
@Resolver()
export class BookResolver {
constructor(private readonly books: BookService) {}
@Query()
book(@Args('id') id: string): Promise<BookResult> {
return this.books.get(id) // throws NotFoundException
}
}
@Injectable()
export class Rejections implements GraphQLResultMapper {
/** The object types this mapper produces: only fields whose union holds one of them use it. */
static readonly types = ['NotFoundRejection', 'ValidationRejection']
toResult(error: unknown): NotFoundRejection | ValidationRejection | undefined {
if (error instanceof NotFoundException) return { __typename: 'NotFoundRejection', message: error.message }
if (error instanceof GraphQLException && error.code === GraphQLErrorCode.BadUserInput) {
const issues = (error.extensions?.['issues'] ?? []) as readonly { readonly message: string }[]
return { __typename: 'ValidationRejection', issues: issues.map(issue => issue.message) }
}
return undefined
}
}
GraphQLPlugin.configure({ resultMappers: [Rejections] }){ book(id: "7") { ... on Book { title } ... on NotFoundRejection { message } } } answers
{ "data": { "book": { "message": "No book 7" } } }, without errors.
- Which fields. A resolver-backed field whose type, without
!, is a union (not a list) holding one of the mapper'stypes; nothing else per field. This is decided at boot: every other field keeps its resolve function, with no wrapper and no cost. - Which errors. Everything the field's pipeline throws or rejects: its guards (a guard returning
falseis aGraphQLExceptionwith codeFORBIDDEN, a thrownUnauthorizedExceptionis passed as is), the Standard Schema validation of@Args(BAD_USER_INPUT, withextensions.issues), the method (synchronously or as a rejected promise). Guards run before validation, so input errors are only shown to clients allowed to call the field.toResult(error, ctx)receives the raw error and theResolverContextguards receive (the field's coordinate and metadata, the arguments, the parent, the operation). - The value. The first mapper returning a value that the union resolves to one of its members
(its discriminator, then
__typename) wins.undefinedpasses to the next mapper; a value of another object type is skipped (logged atdebug), a value of no type is skipped (warn), a mapper that throws is skipped (error).toResult()is synchronous: a returned promise is skipped (error). When no mapper produces a member, the error goes toerrors[]as above. - Per field. An error of a child field stays an error of the child, even when its parent's union holds the rejection; give the child a union to return a value there. A non-null union field resolved to a value is no longer nulled.
- Masking and logs. Values are data chosen by the application: never masked, and an error
resolved as a value is logged at
debugonly. A mapper turning unexpected errors into values (anInternalErrorRejection) must log them itself; prefer leaving them inerrors[]. - Hook and transports. A value is data: an operation whose only failure became a value has
the
successoutcome ingraphql:operation, with noerrors. HTTP, SSE andplugin.execute()send it like any result. On a root subscription field, an error when the subscription starts (a guard, or the method before it returns its stream) becomes a stream of that single event (onenext, thencomplete). The body of anasync *method runs in the stream, after the start: its errors stay inerrors[]; check before returning the generator to map them. - At boot. A result mapper is a provider of any scope (a class that is not a provider is
ZNIGHT001) or an instance (its
typesproperty, or its class'sstatic types), with atoResult()method. Itstypesmust name object types of the schema: a typo is ZNIGHT066.
The thrown error can be the value itself (Pothos style) when its class is a member of the union:
toResult: error => (error instanceof OutOfStock ? error : undefined) with
static types = ['OutOfStock'].
Plugin options
GraphQLPlugin.configure(options | (container) => options), name graphql. A function is evaluated
once, when first needed after the container is initialised. Unknown keys and invalid values are
ZNIGHT066.
| Option | Default | Description |
| --------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| path | '/graphql' | Path of the endpoint, joined with the HttpPlugin prefix |
| http | {} | false: no HTTP transport (schema, execute() and subscribe() only). Object: { meta?, guards?, bodyLimit = '100kb', get = true } |
| guards | [] | Global GraphQL guards (classes or instances implementing GraphQLGuard) |
| errorMappers | [] | GraphQLErrorMapper classes or instances, tried in order |
| resultMappers | [] | GraphQLResultMapper classes or instances, tried in order: errors as data on the fields whose union holds one of their types (Errors as data) |
| exposeErrors | false | Unmasked messages for unexpected errors (development) |
| introspection | NODE_ENV !== 'production' | Off: graphql-js NoSchemaIntrospectionCustomRule, and suggestions are hidden |
| graphiql | NODE_ENV === 'development' | GET path with accept: text/html serves GraphiQL; with a driver, the driver's IDE |
| limits | { maxTokens: 2000, maxDepth: 12, maxAliases: 30, maxDirectives: 50 } | Request limits (introspection fields exempt from maxDepth); false disables one |
| documentCache | 1000 | Distinct documents kept parsed and validated (LRU); 0 disables |
| 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) |
| pubsub | a MemoryPubSub | The PubSub provided to the container |
| enforcement | 'error' | How the boot check of field decisions reports a problem: 'error' fails the boot (ZNIGHT023), 'warn' logs |
| driver | the built-in transport | The GraphQL server serving the schema (Drivers and the engine); heartbeat and maxDuration are refused with one (ZNIGHT066) |
HttpPlugin must be registered unless http: false (ZNIGHT066). The plugin instance exposes:
| Member | What |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| schema | The graphql-js GraphQLSchema (after bootstrap) |
| printSchema() | Its SDL (graphql-js printSchema), byte-identical to the compiler's SDL artifact |
| execute(request, options?) | A query or mutation outside of HTTP; returns the result directly when every resolver is synchronous (await it) |
| subscribe(request, options?) | A stream of results, one per event, until the source ends, options.signal aborts, return() is called or the application stops |
| pubsub | The PubSub of the application |
| activeSubscriptions | Active subscriptions, every transport |
| engine | The GraphQLEngine drivers call (after bootstrap): Drivers and the engine |
| initRoots() / httpRoutes() | The PluginInitRoots contract (TestApp) and the PluginHttpRoutes contract of @znight/http, onHttpDrain() included |
request is { query, operationName?, variables?, extensions? }; options is { signal?,
requestId? }. A direct execution has no HTTP guards of its own: inside a controller, it runs in the
route's request scope, after the route's guards. With http: false, a controller can hand its
requests to another GraphQL server that delegates the execution to execute() and subscribe();
a driver does it without the copying, on the parsed document (GraphQL
Yoga: yogaDriver()).
const plugin = app.getPlugin<GraphQLPlugin>('graphql')
const result = await plugin?.execute({ query: '{ books { total } }' })onStart logs GraphQL endpoint ready with the path, the number of types and the schema hash
(GraphQL schema ready with http: false). On shutdown, onStop ends the direct subscriptions
and those of the engine; HttpPlugin drains the requests in flight and closes the SSE streams
(their ctx.signal aborts with 'shutdown', then the stream ends with a jittered retry and no
complete event, so clients reconnect to another instance). When HttpPlugin starts draining, it
calls GraphQLPlugin.onHttpDrain(), which ends the engine's subscriptions too (a driver serves them
as streams HttpPlugin does not close) and refuses new ones (503). A route-mounted driver stops in
onDispose, once the drain is over: requests in flight are served. Plugin order does not matter.
GraphQL over HTTP and SSE
GraphQLPlugin contributes two routes to HttpPlugin on path: GET (when http.get or
graphiql is on) and POST, with the route metadata, guards and body limit of the http option.
They run through the HTTP pipeline like a controller route (request scope, request id,
http:request hooks, global then endpoint guards, exception mappers, CORS and security headers,
drain), without interceptors. A path colliding with an HTTP route is ZNIGHT007 at boot. The
endpoint follows the GraphQL-over-HTTP specification draft:
| Request | Answer |
| ------------------------------------------------------ | ---------------------------------------------------------------------- |
| POST with content-type: application/json | { query, operationName?, variables?, extensions? } executed |
| POST with another content type or a content encoding | 415 (no form or text body: cross-site requests are always preflighted) |
| POST with an array body (batching) | 400 |
| POST with malformed JSON, a non-object, no query | 400 |
| POST past the body limit | 413, with a GraphQL error body |
| GET ?query=...&variables=... (JSON parameters) | queries only; a mutation or a subscription is 405 with allow: POST |
| GET with http.get: false | 405 with allow: POST |
| GET with accept: text/html and graphiql on | the GraphiQL page, with its own content-security-policy |
| A subscription without accept: text/event-stream | a request error with a hint: 400, or 200 in application/json |
| accept that lists none of the produced types | 406 |
The accept header picks the answer: application/graphql-response+json when listed (200 with
data, 400 for parse, validation, variable and limit errors), otherwise application/json (200 for
every well-formed request; no accept header, */* and application/* mean application/json),
or text/event-stream for GraphQL over SSE (distinct connections mode): one next event per
result, then complete. An event stream preferred to the JSON types (a higher q), or the only
type listed, streams any operation; listed at the same quality as a JSON type (urql lists
application/graphql-response+json, application/json, text/event-stream), it streams subscriptions
only, and queries and mutations get one JSON body. The stream is RFC 0006's HttpResponse.sse() with the
subscriptions options (heartbeat, maxDuration): a client that leaves aborts ctx.signal and
ends the source, a stream closed by the shutdown ends without complete, and past
subscriptions.maxActive a new subscription is 503. An asynchronous query whose client leaves is
aborted (graphql-js abortSignal). The endpoint passes the serverAudits() of graphql-http on
the four adapters, on Node.js and Bun.
Adapters need nothing specific. With Express, do not mount express.json() before Znight routes
(the body would be consumed). In tests, TestApp.inject() runs queries and mutations through every
adapter in memory and injectStream() / injectSse() stream subscriptions.
Drivers and the engine
The built-in GraphQL-over-HTTP and SSE endpoint is the default and the fastest path. A driver serves the same schema with another GraphQL server, for its protocol features and its plugins (RFC 0009): Znight stays the engine, the driver owns the transport.
import { yogaDriver } from '@znight/graphql/yoga'
GraphQLPlugin.configure({ guards: [AccessGuard], driver: yogaDriver() })Resolvers, guards, loaders, result mappers, request-scoped providers and tests do not change: the
server calls plugin.engine, which runs the compiled schema with everything this README describes.
GraphQL Yoga is the first driver (@znight/graphql/yoga); Apollo Server
follows once it accepts graphql-js 17. Any other graphql-js-shaped server takes the engine as it
is: a driver is a small object, checked with the conformance suite.
The engine. plugin.engine (after bootstrap, and the first argument of driver.start()):
| Member | What |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schema | The executable schema; its resolvers are Znight's pipelines |
| settings | The resolved options a server needs: path (prefix included), introspection, ide (graphiql), exposeErrors, maxTokens, subscriptions (false or { maxActive }), http (false or { get, bodyLimit }) |
| 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) |
| 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 |
| rules | The engine's own rules (the limits, NoSchemaIntrospectionCustomRule without introspection), for servers that validate themselves |
| 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 |
| 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 |
options is { transport?: 'http' | 'sse' | 'ws', signal?, requestId? }; the signal defaults to
args.abortSignal, then to the HTTP request's. The functions are bound and have graphql-js's
signatures, so they fit graphql-http, graphql-sse, graphql-ws and Envelop's useEngine as
they are. Every operation produces one graphql:operation event with the driver's name, and
documents rejected by parse or validate are observed too.
The driver contract. A GraphQLDriver has a name (builtin is reserved), optional
routes(), start(engine, context) and an optional stop():
import type { HttpContext } from '@znight/http'
import type { GraphQLDriver } from '@znight/graphql'
import { HttpResponse } from '@znight/http'
import { createHandler, type Handler } from 'graphql-http'
/** graphql-http's handler on the engine: GraphQL over HTTP served by the reference implementation. */
export function graphqlHttpDriver(): GraphQLDriver {
let handler: Handler<HttpContext> | undefined
return {
name: 'graphql-http',
routes: ({ path }) => {
const handle = async (ctx: HttpContext) => {
const body = ctx.method === 'POST' ? new TextDecoder().decode(await ctx.rawBody()) : null
const [text, init] = await (handler as Handler<HttpContext>)({
method: ctx.method,
url: ctx.url,
headers: { get: name => ctx.header(name.toLowerCase()) ?? null },
body,
raw: ctx,
context: undefined,
})
return new HttpResponse(text ?? undefined, { status: init.status, headers: init.headers ?? {} })
}
return [
{ method: 'GET', path, name: 'graphql', handler: handle },
{ method: 'POST', path, name: 'graphql', handler: handle },
]
},
start(engine) {
handler = createHandler<HttpContext>({
schema: engine.schema,
parse: engine.parse,
validate: engine.validate,
execute: args => engine.execute(args),
})
},
}
}- Routes. A route-mounted driver returns its routes:
GraphQLPlugin.httpRoutes()gives them toHttpPluginwith thehttpoption'smetaandguards, and itsbodyLimiton the routes that are notGET. They run in the HTTP pipeline like the built-in endpoint (request id, request scope,http:requesthooks, global and endpoint guards, route decisions, CORS, security headers, drain), so the engine finds the request scope:GraphQLContext.httpis the request, and HTTP guards and GraphQL guards see the same request-scoped providers.routes()is called once, possibly beforestart(). A driver without routes is server-mounted: it attaches itself tocontext.http.adapterinstart()and runs outside the pipeline. - Lifecycle.
onBootstrapbuilds the engine, then awaitsstart(engine, { logger, container, http }); a failure fails the boot (ZNIGHT066, the original error ascause). The engine's subscriptions end whenHttpPluginstarts draining (onHttpDrain(), whatever the plugin order) and inonStop; new ones are then refused (503).stop()is awaited inonDisposefor a route-mounted driver, afterHttpPlugindrained its requests, and inonStopfor a server-mounted one (onDisposedoes, for an application that never started). - Options. The engine options (
guards,errorMappers,resultMappers,exposeErrors,introspection,limits,documentCache,subscriptions: false,maxActive,bufferSize,pubsub,enforcement) apply to every driver.path,httpandgraphiqlare read by the driver through its routes andengine.settings.subscriptions.heartbeatandmaxDurationconfigure the built-in SSE transport only: with a driver they are ZNIGHT066 (use the driver's keep-alives). A driver with routes needsHttpPluginand refuseshttp: false(ZNIGHT066). - Errors. The engine maps and masks errors once; a driver keeps the engine's errors as they are. Request errors raised by the server itself (malformed JSON, a persisted query not found) are the server's; an unexpected error thrown outside the engine is masked like the engine masks one.
- Cost. Without
driver, the plugin runs the built-in transport's handlers, which call the internal executor directly: an application without driver pays nothing for the abstraction.
executeOperation() and subscribeOperation() of @znight/graphql/testing send their requests to
the driver's routes like to the built-in endpoint. The repository's tests run the graphql-http
audits against the driver above and the Yoga driver on the four adapters, on Node.js and Bun.
GraphQL Yoga
@znight/graphql/yoga serves the schema with GraphQL Yoga
6 (graphql-yoga, an optional peer dependency loaded by this entry only):
import { GraphQLPlugin } from '@znight/graphql'
import { yogaDriver } from '@znight/graphql/yoga'
GraphQLPlugin.configure({ driver: yogaDriver({ plugins: [usePersistedOperations({ getPersistedOperation })] }) })| Export | What |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| yogaDriver(options?) | The driver (name: 'yoga'); driver.yoga is the Yoga server once started |
| YogaDriverOptions | Yoga's options without those Znight owns (below); graphiql takes GraphiQL's options |
| yogaContext(ctx) | Yoga's context of an operation (request, params, what plugins added, znight: the HttpContext), ZNIGHT066 otherwise |
| YogaContext, YogaServerContext | Their types |
- The engine is Yoga's engine. The driver installs Envelop's
useEnginewith the engine'sparse,validate,executeandsubscribe, before the application's plugins: one document cache (Yoga'sparserAndValidationCacheis off), the engine's limits and introspection rule, rejected documents observed by the hook, onegraphql:operationevent per operation withdriver: 'yoga'(transport: 'sse'for subscriptions). Plugins wrapping the execution (useResponseCache) call the engine; an operation executed by a plugin that replaces it (useGraphQlJit) fails with ZNIGHT066, logged once: its resolvers would not get theGraphQLContext. - Owned options, refused with ZNIGHT066 (a type error first):
schema,context,graphqlEndpoint(path),maskedErrors(the engine masks once, see below),cors(HttpPlugin's),maxRequestBodySize(http.bodyLimit),parserAndValidationCache,requestId(Yoga usesHttpContext.requestId),healthCheckEndpoint,landingPage,fetchAPI(the runtime's own),disposeOnProcessTerminate(the application stops Yoga),batching(one operation per request: the operations of a batch would share the request scope, where each one'sGraphQLContextwould replace the other's) andmultipart(JSON bodies only, below). Yoga'sloggingis off unless set. - Routes.
GETandPOSTonpath.GETserves Yoga's GraphiQL withgraphiqlon, and queries withhttp.geton (405 withallow: POSTotherwise); a mutation overGETis Yoga's 405 (allow: POST). APOSTbody isapplication/jsononly, like the built-in transport:
