Techniques
GraphQL
Build a code-first GraphQL API from decorated classes, with resolvers that use dependency injection, subscriptions over WebSocket and production-safe limits.
On this page 42 sections
- Install
- Register the module
- Static options
- Options from providers
- What happens at bootstrap
- Define types
- Object types and fields
- Type references
- Nullability and lists
- Interfaces
- Enums
- Custom scalars
- Unions
- Input types
- Argument classes
- Write resolvers
- The resolver context
- Arguments
- Field resolvers
- Validate arguments
- Unions and interfaces in resolvers
- Context
- The context factory
- Batch loading
- Guards
- Order of checks
- Public fields and default deny
- Field guards
- Subscriptions
- The WebSocket transport
- Events
- Ending subscriptions
- Several instances
- Errors
- Masking
- Mapping your own errors
- Request errors
- HTTP transport
- Limits
- Introspection and the playground
- The schema file
- Testing
@knead-js/graphql builds a GraphQL schema from TC39-decorated classes and serves it with the
Leaven engine. Types are classes, resolvers are ordinary
providers that use inject(), and a GraphQL field type that disagrees with its TypeScript type is a
compile error. The endpoint is an ordinary @knead-js/http route, so CORS, rate limiting, security
headers and global guards apply to it unchanged.
This page builds a small blog API step by step. Every option and export is listed in the package reference.
#Install
bun add @knead-js/core @knead-js/http @knead-js/graphql graphql@knead-js/core, @knead-js/http and graphql (16.x) are peer dependencies.
#Register the module
GraphQLModule is a global module. Import it once, in the root module, next to the feature modules
that hold your resolvers.
#Static options
GraphQLModule.forRoot(options) takes the options directly:
const sessions = new SessionService();
@Module({
imports: [
GraphQLModule.forRoot({
path: '/api/graphql',
context: (http) => ({
member: sessions.fromAuthorization(http.req.headers.get('authorization')),
}),
}),
BlogModule,
],
})
export class StaticAppModule {}path defaults to /graphql. It is the URL of both the HTTP endpoint and the WebSocket endpoint
for subscriptions, and it must be exact: a path with : or * is an HttpConfigError.
#Options from providers
When the options need a provider (a session service, configuration), use
GraphQLModule.forRootAsync(). useFactory runs in GraphQLModule's injection context, so it can
call inject() for anything that imports exports. path stays a static option, because the HTTP
routes are compiled from decorators before the factory runs.
@Module({
imports: [
GraphQLModule.forRootAsync({
// The factory runs in GraphQLModule's injection context, which sees these imports.
imports: [SessionModule],
useFactory: () => {
const sessions = inject(SessionService);
return {
// Over HTTP: once per request. Over WebSocket: on connect and once per subscribe.
context: (http) => ({
member: sessions.fromAuthorization(http.req.headers.get('authorization')),
}),
subscriptions: {
// Browsers cannot set Authorization on a WebSocket: the token is a connectionParam.
onConnect: (_http, params) => {
const member = sessions.verify(params.token);
return member ? { member } : false;
},
},
};
},
}),
BlogModule,
],
})
export class AppModule {}Start the application as usual:
import { KneadFactory } from '@knead-js/http';
import { AppModule } from './app.module.js';
const app = await KneadFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen({ port: 3000 });#What happens at bootstrap
When the application is created, the module:
- finds every class provider decorated with
@Resolver(), in every module (a resolver must be listed in some module'sproviders); - builds and validates the schema. A problem is a
SchemaBuildErrorwhose message names the class and field, for examplePost.author: type thunk returned undefined — is Author imported?. A schema needs at least one@Query(); - writes the sorted SDL to
schema.graphqlwhen the application is in development (see the schema file); - registers the subscription endpoint when the schema has a
@Subscription()field.
It also provides three exported, injectable values: PUBSUB (the event engine), GraphQLRuntime
(the schema and executor) and GraphQLSubscriptions (revokes subscription sockets).
#Define types
Types are classes. A class decorator names the GraphQL type (the class name by default), and
@Field() exposes a property. Properties without @Field() stay private to the server.
#Object types and fields
@ObjectType({ implements: () => [Node] })
export class Author extends Node {
@Field(() => String) name!: string;
@Field(() => String, { nullable: true }) bio?: string | null;
}
@ObjectType({ implements: () => [Node], description: 'A blog post' })
export class Post extends Node {
@Field(() => String) title!: string;
@Field(() => PostStatusType) status!: PostStatus;
@Field(() => [String]) tags!: string[];
@Field(() => Int, { description: 'Number of words in the body' }) words!: number;
@Field(() => Float, { nullable: true }) rating?: number | null;
@Field(() => DateTime) createdAt!: Date;
@Field(() => DateTime, { nullable: true }) publishedAt?: Date | null;
/** Not a GraphQL field: `author` is resolved by PostResolver. */
authorId!: string;
}@Field(() => Type, options) takes a thunk that returns the field's type, and these options:
| Option | Type | Default | Description |
|---|---|---|---|
nullable | boolean | 'items' | 'list' | 'both' | false | Which part of the type may be null. See nullability and lists. |
description | string | none | Description in the schema. |
deprecationReason | string | none | Marks the field @deprecated(reason: ...). |
defaultValue | the field's type | none | Default for input type fields and @ArgsType() arguments. |
complexity | number | 1 | Cost of the field for maxComplexity. See limits. |
The TypeScript type of the property is checked against the GraphQL type at compile time:
- a non-null field must not include
nullorundefined, so declare itname!: string; - a nullable field must include both, so declare it
bio?: string | null; - a list field takes
T[]orreadonly T[]; - a
defaultValuemust be a value of the field's type.
A mismatch is a compile error on the decorator that names the expected type.
#Type references
The thunk returns one of these:
| Thunk returns | GraphQL type |
|---|---|
String | String |
Boolean | Boolean |
Number | Float |
Int, Float, ID (exported handles) | Int, Float, ID |
A class decorated with @ObjectType(), @InterfaceType() or @InputType() | That type |
A handle from defineEnum(), defineScalar() or defineUnion() | That enum, scalar or union |
[T], one of the above in an array | A list of T |
The type is a thunk, not a value, so a field can refer to a class declared later in the file or in a
module that imports this one. A thunk that returns undefined (usually a circular import) or throws
is a SchemaBuildError that names the field and the class it guessed from the thunk.
Use Int for whole numbers: Number maps to Float.
#Nullability and lists
For a single value, nullable: true makes it nullable. For a list, nullable chooses the level:
@ObjectType()
export class Lists {
@Field(() => [String]) required!: string[]; // [String!]!
@Field(() => [String], { nullable: 'items' }) items!: (string | null | undefined)[]; // [String]!
@Field(() => [String], { nullable: 'list' }) list?: string[] | null; // [String!]
@Field(() => [String], { nullable: 'both' }) both?: (string | null | undefined)[] | null; // [String]
}nullable | SDL for () => [String] | TypeScript type |
|---|---|---|
omitted or false | [String!]! | string[] |
'items' | [String]! | (string | null | undefined)[] |
'list' or true | [String!] | string[] | null | undefined |
'both' | [String] | (string | null | undefined)[] | null | undefined |
'items', 'list' and 'both' on a type that is not a list is a SchemaBuildError.
#Interfaces
@InterfaceType({ description: 'An object with a globally unique id' })
export class Node {
@Field(() => ID) id!: string;
}An object type lists its interfaces in implements (a thunk), and inherits the interface's fields
when its class extends the interface class, as Author and Post do above. A subclass that
redeclares a field replaces the inherited one.
A field that returns an interface needs to know the concrete type of each value. Without a
resolveType option, Knead picks the most derived implementing class that the value is an
instanceof, and then falls back to the value's __typename property. Return class instances, or
give @InterfaceType({ resolveType }) a function that returns the class or the type name.
Implementations that no resolver returns directly are not reachable from the schema's roots; list
them in the orphanedTypes option so they are built.
#Enums
export enum PostStatus {
DRAFT = 'DRAFT',
PUBLISHED = 'PUBLISHED',
}
export const PostStatusType = defineEnum(PostStatus, {
name: 'PostStatus',
description: 'Whether readers can see a post',
valuesConfig: { DRAFT: { description: 'Visible to its author only' } },
});defineEnum(values, { name, description, valuesConfig }) accepts a TypeScript enum or a const
object. The GraphQL names are the keys and the runtime values are the object's values, so a field
typed PostStatus serializes PostStatus.DRAFT as DRAFT. The reverse-mapping keys of numeric
enums are skipped. valuesConfig adds a description or deprecationReason per value.
#Custom scalars
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
function parseDate(value: unknown): Date {
const date = typeof value === 'string' && ISO_8601.test(value) ? new Date(value) : undefined;
if (!date || Number.isNaN(date.getTime())) {
throw new GraphQLError('DateTime must be an ISO-8601 date-time string with a time zone.');
}
return date;
}
export const DateTime = defineScalar<Date>(
new GraphQLScalarType<Date, string>({
name: 'DateTime',
description: 'An ISO-8601 date-time string, such as 2026-01-31T12:00:00.000Z.',
serialize: (value) => (value instanceof Date ? value : parseDate(value)).toISOString(),
parseValue: parseDate,
parseLiteral: (ast) => {
if (ast.kind !== Kind.STRING) throw new GraphQLError('DateTime must be a string.');
return parseDate(ast.value);
},
}),
);defineScalar<T>(scalar) wraps a GraphQLScalarType from graphql in a typed handle: a field
typed DateTime must be a Date in TypeScript. Throw a GraphQLError from parseValue and
parseLiteral to reject input: an invalid variable is reported as BAD_USER_INPUT, and an invalid
literal in the document fails validation (see request errors).
#Unions
export const SearchResult = defineUnion({
name: 'SearchResult',
description: 'A post or an author matching a search term',
types: () => [Post, Author],
});defineUnion({ name, description, types, resolveType }) takes the member classes as a thunk; each
must be an @ObjectType(). Like interfaces, a value is matched to its class with instanceof, then
by __typename, unless you pass resolveType. A field typed with the union must accept every
member in TypeScript (Post | Author).
#Input types
@InputType({ description: 'A new post' })
export class CreatePostInput {
@Field(() => String) title!: string;
@Field(() => [String], { nullable: true }) tags?: string[] | null;
@Field(() => Boolean, { nullable: true, defaultValue: false }) publish?: boolean | null;
}An @InputType() class can only be used for arguments and input fields, and an @ObjectType()
only for output; mixing them up is a SchemaBuildError. Input fields take defaultValue.
#Argument classes
@ArgsType()
export class PageArgs {
@Field(() => Int, { defaultValue: 10 }) first!: number;
@Field(() => Int, { defaultValue: 0 }) offset!: number;
}
@ArgsType()
export class PostsArgs extends PageArgs {
@Field(() => PostStatusType, { nullable: true }) status?: PostStatus | null;
}An @ArgsType() class is a reusable set of arguments: its fields become the arguments of every
resolver that passes args: PostsArgs, and subclasses inherit them. It is not a GraphQL type
itself.
#Write resolvers
A resolver is a class decorated with @Resolver(). It is a singleton provider, so it uses
inject() like any other, and it must be listed in a module's providers:
@Module({
providers: [BlogService, AuthorsById, PostResolver, SearchResolver, PostSubscriptions],
exports: [BlogService],
})
export class BlogModule {}@Query(), @Mutation() and @Subscription() add root fields; @ResolveField() adds a field to
the resolver's type. Each method receives one argument, the resolver context:
@Resolver(() => Post)
export class PostResolver {
readonly #blog = inject(BlogService);
readonly #pubsub = inject(PUBSUB);
@Query(() => [Post], { args: PostsArgs, validate: { first: PageSize } })
posts(ctx: ResolverCtx<PostsArgs>): Post[] {
return this.#blog.posts(ctx.args);
}
@Query(() => Post, postOptions)
post(ctx: ResolverCtx<PostArgs>): Post | undefined {
return this.#blog.post(ctx.args.id);
}
@ResolveField(() => Author, { description: 'Batched through AuthorsById' })
author(ctx: ResolverCtx<{}, Post>): Promise<Author> {
return ctx.loader(AuthorsById).load(ctx.parent.authorId);
}
@Mutation(() => Post, { args: { input: CreatePostInput }, validate: { input: NewPost } })
createPost(ctx: ResolverCtx<{ input: CreatePostInput }>): Post {
const member = ctx.member;
if (!member) throw new UnauthorizedException('Sign in to write posts.');
// validate has replaced the input with the schema's output: trimmed, defaults applied.
const { title, tags, publish } = ctx.args.input;
const post = this.#blog.createPost(member.id, {
title,
tags: tags ?? [],
publish: publish ?? false,
});
if (post.publishedAt) {
void this.#pubsub.publish(POST_PUBLISHED, { id: post.id, authorId: post.authorId });
}
return post;
}
}The declared return type is checked against the method: @Query(() => Post) must return a Post
(or a promise of one), and a method that may return undefined needs nullable: true. The method
name is the field name; pass name to change it.
#The resolver context
ResolverCtx<Args, Parent> holds:
| Property | Description |
|---|---|
args | The field's arguments, after GraphQL coercion and validate. |
parent | The object the field belongs to (@ResolveField), or the event (subscriptions). undefined for queries and mutations. |
info | graphql-js's GraphQLResolveInfo. |
http | The request Ctx. For subscriptions, a per-operation view of the WebSocket upgrade request. |
state | Scratch space shared by every resolver of one operation. |
meta | Metadata: the resolver's meta option and createRouteDecorator() values from the class and method. |
loader(Cls) | A per-operation DataLoader. See batch loading. |
The object returned by the module's context factory is spread into the same object, so its keys
sit next to these (ctx.member below). The fixed properties are written after it: a context key
named args, parent, info, http, state, meta or loader is overwritten, so do not use
those names.
#Arguments
Declare arguments in the args option, either as an inline map or as an @ArgsType() class:
- an inline map takes type references (
{ id: ID, tags: [String] }) or full specs ({ after: { type: () => ID, nullable: true, defaultValue: 'p1', description: '...' } }); args: PostsArgsuses the fields of an@ArgsType()class. A map and a class cannot be combined.
The method's ResolverCtx<Args> must agree with what was declared, and a defaultValue must be a
value of its argument's type; both are compile errors otherwise. To avoid writing the argument type
twice, derive it from the options with ArgsOf:
const postOptions = { nullable: true, args: { id: ID } } as const;
type PostArgs = ArgsOf<typeof postOptions>; // { id: string }#Field resolvers
@ResolveField(() => Author) in a @Resolver(() => Post) class adds an author field to Post,
resolved by the method; ctx.parent is the Post. A @ResolveField() in a @Resolver() without a
type is a SchemaBuildError, and so is a second resolver for the same field.
An @ObjectType() subclass of Post inherits author, with its resolver and guards; a
@Resolver(() => Subclass) may declare its own author, which wins for the subclass. The resolver
type must be an object type: graphql-js resolves an interface's fields on the concrete type, so
@ResolveField() in a @Resolver(() => SomeInterface) is a SchemaBuildError. Resolve the field
on each implementing type instead.
In a typed resolver, every public method whose first parameter is a ResolverCtx<Args, Parent>
must have a Parent that accepts the resolver's type; a mismatch is a compile error on
@Resolver(). Queries and mutations use the default parent, undefined, and pass. Subscription
methods take a SubscriptionCtx instead (see subscriptions). A helper method that
takes a context for another parent must be #private.
#Validate arguments
The validate option maps argument names to Standard Schema
validators, such as valibot schemas:
const PageSize = v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(50));
const Term = v.pipe(v.string(), v.trim(), v.minLength(2), v.maxLength(100));
const NewPost = v.object({
title: v.pipe(v.string(), v.trim(), v.minLength(3), v.maxLength(120)),
tags: v.pipe(
v.nullish(v.array(v.pipe(v.string(), v.trim(), v.toLowerCase())), []),
v.maxLength(5),
),
publish: v.nullish(v.boolean(), false),
});Each schema runs after GraphQL has coerced the argument, and its output replaces the argument in
ctx.args, so transforms such as v.trim() and defaults reach the resolver (the createPost
mutation above relies on this). When any schema fails, the resolver is not called and the field
fails with an ArgsValidationError, reported as BAD_USER_INPUT with every issue:
{
"data": null,
"errors": [
{
"message": "Argument validation failed",
"locations": [{ "line": 1, "column": 3 }],
"path": ["posts"],
"extensions": {
"issues": [
{ "arg": "first", "message": "Invalid value: Expected <=50 but received 100", "path": [] }
],
"code": "BAD_USER_INPUT"
}
}
]
}issues[].path is the path inside the argument (for an input object, the field names). A
validate key that names no declared argument is a SchemaBuildError. Guards run before
validation; see order of checks.
#Unions and interfaces in resolvers
A field typed with a union or an interface returns instances of the member classes:
@Resolver()
export class SearchResolver {
readonly #blog = inject(BlogService);
@Query(() => [SearchResult], { args: { term: String }, validate: { term: Term } })
search(ctx: ResolverCtx<{ term: string }>): (Post | Author)[] {
return this.#blog.search(ctx.args.term);
}
@Query(() => Node, { nullable: true, args: { id: ID } })
node(ctx: ResolverCtx<{ id: string }>): Node | undefined {
return this.#blog.node(ctx.args.id);
}
}#Context
#The context factory
The module's context option is a function of the request Ctx that returns an object (or a
promise of one). Over HTTP it runs once per request. Over WebSocket it runs with the upgrade request
once when the connection initializes (for closeWhere) and again for every subscribe operation.
If it throws when the connection initializes, the failure is logged and the socket is closed with
4403, so no connection runs without the identity closeWhere matches on. Type what it returns by
augmenting GraphQLContext:
declare module '@knead-js/graphql' {
interface GraphQLContext {
/** The signed-in member, if any. */
member?: Member;
}
}Every ResolverCtx then has a typed ctx.member. The augmentation is global for the program, so
declare each property once.
Over HTTP the factory runs before GraphQL execution starts. An HttpException it throws becomes
that exception's HTTP problem response (a ForbiddenException is a 403), and any other error a
500 problem response.
#Batch loading
A field resolver on a list runs once per item. To fetch the authors of twenty posts in one query instead of twenty, write a batch loader:
/** `ctx.loader(AuthorsById)` gives each operation its own DataLoader over this singleton. */
@Injectable()
export class AuthorsById implements BatchLoader<string, Author> {
readonly #blog = inject(BlogService);
/** How many times batch() ran (the tests read it). */
calls = 0;
async batch(ids: readonly string[]): Promise<(Author | Error)[]> {
this.calls++;
return this.#blog.authorsByIds(ids);
}
}ctx.loader(AuthorsById) returns a DataLoader created for
the current operation, so every Post.author of one request lands in a single batch() call, and
nothing is cached across requests. Each module gets its own DataLoader for a loader class, built
over the provider that module sees. The loader class itself is a singleton: it is the provider when
the resolver's module can see one (as AuthorsById is in BlogModule), and otherwise it is
constructed once with inject() available. batch(keys) must return one value or Error per key,
in key order. An options property is passed to DataLoader as its options.
#Guards
Resolvers use the same guards as controllers: a function, an object with canActivate() or a class
with a canActivate() method, receiving the ResolverCtx. A guard passes only when it returns (or
resolves to) exactly true. Any other value, including truthy objects, strings and numbers, makes
the field FORBIDDEN with the message Forbidden; a guard can also throw an HttpException to
report another code. See guards for the general model.
/** Passes when a member is signed in. A guard passes only on `true`. */
export const SignedIn = (ctx: ResolverCtx) => ctx.member !== undefined;
const ROLES = Symbol('roles');
/** Route metadata: shows up in `ctx.meta[ROLES]`. */
export const Roles = createRouteDecorator<readonly string[]>(ROLES);
export class RolesGuard implements Guard<ResolverCtx> {
canActivate(ctx: ResolverCtx): boolean {
const required = ctx.meta[ROLES] as readonly string[] | undefined;
if (!required) return true;
return required.some((role) => ctx.member?.roles.includes(role) === true);
}
}Attach guards with @UseGuards() on the resolver class or method (from @knead-js/http), with the
guards option of a field, or for every root field with the module's guards option:
const Amount = v.pipe(v.number(), v.integer(), v.minValue(1));
/** Runs after validate, so it sees the validated arguments. */
const SmallTransfer = (ctx: ResolverCtx<{ amount: number }>) => ctx.args.amount <= 100;
@Resolver()
@UseGuards(RolesGuard)
export class AccountResolver {
@Query(() => String)
@Public()
health(): string {
return 'ok';
}
@Query(() => Account, { nullable: true })
me(ctx: ResolverCtx): Account | undefined {
return ctx.member && { id: ctx.member.id, name: ctx.member.name };
}
@Query(() => Account, { nullable: true, args: { id: ID } })
account(ctx: ResolverCtx<{ id: string }>): Account {
return { id: ctx.args.id, name: 'Someone else' };
}
@Mutation(() => Int, {
args: { amount: Int },
validate: { amount: Amount },
authorize: [SmallTransfer],
})
@Roles(['admin'])
transfer(ctx: ResolverCtx<{ amount: number }>): number {
return ctx.args.amount;
}
}@Module({
imports: [
GraphQLModule.forRootAsync({
imports: [SessionModule],
useFactory: () => {
const sessions = inject(SessionService);
return {
context: (http) => ({
member: sessions.fromAuthorization(http.req.headers.get('authorization')),
}),
// Every root field runs SignedIn first, unless it is @Public().
guards: [SignedIn],
// A root field without a guard of its own (or @Public()) is FORBIDDEN.
defaultDeny: true,
};
},
}),
],
providers: [AccountResolver, StatsResolver],
})
export class GuardedAppModule {}#Order of checks
For a query, mutation or subscription field, in this order:
- With
defaultDeny: true, a field that is not@Public()and has no guard of its own (steps 3 to 5, orauthorize) is denied. - The module's
guards, unless the field or its class is@Public(). - The class's
@UseGuards()guards. - The field's
guardsoption. - The method's
@UseGuards()guards. - The
validateschemas. - The field's
authorizeguards, which see the validated arguments. - The resolver method.
Guards before validate see the coerced but unvalidated arguments. Put authorization that depends
on the validated or transformed arguments in authorize, as transfer does above: amount: 500 is
FORBIDDEN, amount: 0 fails validation, and a member without the admin role is FORBIDDEN
before validation runs.
A @ResolveField() runs steps 3 to 8 only: the module's guards and defaultDeny protect entry
points, not nested fields.
#Public fields and default deny
@Public() on a resolver class or root field skips the module's guards and satisfies
defaultDeny. The field's own guards still run, and it sets ctx.meta[IS_PUBLIC] to true.
@Public() is GraphQL-only: an HTTP global guard (APP_GUARD) still runs once on the /graphql
request itself.
defaultDeny: true makes forgetting a guard fail closed: a root field that is neither @Public()
nor protected by a guard of its own (class or method @UseGuards(), or the guards or authorize
option) is
FORBIDDEN. The module's guards do not count, so every field states its authorization:
/** No guard of its own and not @Public(): FORBIDDEN under defaultDeny. */
@Resolver()
export class StatsResolver {
@Query(() => Int)
postCount(): number {
return 3;
}
}#Field guards
@UseFieldGuards(...guards) on an @ObjectType() class runs its guards before every field of that
type, plain @Field() properties and @ResolveField() methods alike, wherever the type appears in
a response:
/** Field guards run before every field of the type, wherever it appears in a response. */
const OwnAccountOnly = (ctx: ResolverCtx<{}, Account>) => ctx.parent.id === ctx.member?.id;
@ObjectType()
@UseFieldGuards(OwnAccountOnly)
export class Account {
@Field(() => ID) id!: string;
// Denied fields become null with a FORBIDDEN error, so make them nullable.
@Field(() => String, { nullable: true }) name?: string | null;
}Each guard receives a ResolverCtx whose parent is the object being resolved, info.fieldName the
field and meta the type's createRouteDecorator() values. A denied field is null with a
FORBIDDEN error, and a @ResolveField() method is not called. For a non-null field, graphql-js
nulls the nearest nullable parent instead: in the example, account(id: "a1") for another member
returns account: null, because id is non-null.
Field guards stack when the decorator is repeated, and a subclass inherits its parent's field guards.
Decorate concrete object types: field guards on an @InterfaceType() or @InputType() would never
run, so they are a SchemaBuildError. Guard classes are
resolved from the root module. Types without field guards keep graphql-js's default resolvers and
cost nothing extra per field.
For permission rules, catalogs and per-field abilities built on these mechanisms, see authorization.
#Subscriptions
A @Subscription() field streams events from the PUBSUB engine. Publish an event with
inject(PUBSUB).publish(topic, payload), as the createPost mutation does, and subscribe to it:
@Resolver()
export class PostSubscriptions {
readonly #blog = inject(BlogService);
@Subscription(() => Post, {
args: { authorId: { type: () => ID, nullable: true } },
topic: POST_PUBLISHED,
// Runs for every event; false skips it for this subscriber.
filter: ({ payload, args }) =>
isPostPublishedEvent(payload) &&
(args.authorId == null || payload.authorId === args.authorId),
})
postPublished(ctx: SubscriptionCtx<{ authorId?: string | null }, PostPublishedEvent>): Post {
// ctx.parent is the event. A method that returns nothing sends the event itself.
const post = this.#blog.post(ctx.parent.id);
if (!post) throw new NotFoundException(`No post ${ctx.parent.id}`);
return post;
}
@Subscription(() => String, {
args: { room: ID },
// A computed topic: one per room. Wildcards ("*", "#") in it are rejected.
topic: (ctx) => `chat.${ctx.args.room}`,
resolve: (payload: { text: string }) => payload.text,
})
message() {}
}| Option | Description |
|---|---|
topic | A topic string, or a function of the SubscriptionCtx that returns one or more topics. |
filter | ({ payload, args, ctx }) => boolean, run per event; false skips the event for this subscriber. ctx carries the current context (after the latest refreshContext). |
resolve | (payload) => value, applied to each event before the method. |
recheckGuards | Re-run the guards before every event. Default false. See ending subscriptions. |
The method receives a SubscriptionCtx<Args, Event> whose parent is the event (after
resolve). It returns the field's value, or nothing, in which case the event itself is sent.
Arguments, validate, guards, authorize and the module's guards work as for queries; guards run
once, when the operation subscribes.
#The WebSocket transport
Subscriptions use the graphql-transport-ws protocol on the GraphQL path. The socket runs
subscription operations only: a query or mutation sent over it gets the error
Only subscription operations are allowed over WebSocket. and never executes. Over HTTP, a
subscription is a 400.
Browsers cannot set an Authorization header on a WebSocket, so a client sends its credentials as
connectionParams in connection_init. subscriptions.onConnect(http, params) receives them:
return false to close the socket with code 4403, or an object to merge into the context of every
operation on that socket (over the context factory's result). The app.module.ts above verifies a
token this way.
Browser upgrades must come from the server's own origin, or from one of
subscriptions.allowedOrigins ('*' allows any); others get 403. Upgrades without an Origin
header (non-browser clients) are allowed. The upgrade request also passes global middleware and
global guards; see WebSockets for the origin rules behind proxies and the
socket limits.
#Events
- Each event gets fresh
ctx.loader()DataLoaders and an emptyctx.state, before itsfilterand itsrecheckGuardsrecheck run, so a guard that memoises roles inctx.stateor loads them throughctx.loader()never reuses what subscribe time or an earlier event cached. The event's resolvers see what its filter and recheck set.ctx.metaand the connection's context are the same for every event. - One payload is shared by every subscriber, so its plain objects and arrays are deep-frozen when it
arrives (
deepFreeze); the in-memoryPubSubfreezes it inpublish(), even with no subscriber. Class instances,Dates andMaps are not frozen or walked into. Publish plain DTOs, not entities, and build new objects inresolveor the method instead of changing the payload. - A subscriber more than 1000 events behind loses the oldest, and a warning is logged once per subscription.
#Ending subscriptions
By default, authorization is decided when the socket connects and when each subscription starts. Three tools revoke access later:
recheckGuards: truere-runs the module, class and method guards and then theauthorizeguards before every event. An event they deny is delivered asFORBIDDEN(or the guard's own error) and the subscription completes. Rechecked guards see the connect-time identity; setsubscriptions.refreshContext({ http, context })to look it up again before every recheck. Its object result is merged over the operation's context, andfalseends the subscription withFORBIDDEN.subscriptions.maxConnectionLifetime(milliseconds) closes every socket with4408after that long, so clients reconnect and passonConnectagain.GraphQLSubscriptions.closeWhere(predicate)closes the sockets whose connection matches, for example on sign-out:
@Injectable()
export class SignOutService {
readonly #sessions = inject(SessionService);
readonly #subscriptions = inject(GraphQLSubscriptions);
/** Ends the token and every subscription socket opened with it; returns how many closed. */
signOut(token: string, memberId: string): number {
this.#sessions.revoke(token);
return this.#subscriptions.closeWhere(({ context }) => {
// The connection context is the context factory result with the onConnect result over it.
const member = context.member as Member | undefined;
return member?.id === memberId;
});
}
}closeWhere closes with code 1008 and reason Connection revoked by default and returns how many
sockets it closed. connection.context is the context factory result (computed once, when the
connection initializes) with the onConnect result merged over it. A socket still in onConnect is
checked again when onConnect completes, so it cannot slip through.
#Several instances
The default engine, the in-memory PubSub, delivers events within one process. When several
instances serve subscriptions, pass a RedisPubSub:
/** Shared by every instance, so an event published on one reaches subscribers on all. */
export function createPubSub(env: Record<string, string | undefined>): RedisPubSub {
return new RedisPubSub({
url: env.REDIS_URL,
// Throw at startup for a plaintext redis:// URL to another host.
requireTls: env.NODE_ENV === 'production',
// `openssl rand -base64 32`, the same on every instance.
signingKey: env.REDIS_PUBSUB_SIGNING_KEY,
encryptionKey: env.REDIS_PUBSUB_ENCRYPTION_KEY,
});
}
@Module({
imports: [
GraphQLModule.forRootAsync({
useFactory: () => ({ pubsub: createPubSub(process.env) }),
}),
BlogModule,
],
})
export class ScaledAppModule {}RedisPubSub uses Bun.redis, with a dedicated subscriber connection. publish() never rejects (a
failed publish is logged and the event is lost); publishOrThrow() rejects. Without signingKey,
anything that can publish to the Redis server can inject events, so set it (at least 32 bytes, the
same on every instance) whenever the server is shared or remote; encryptionKey also hides the
payloads from the server. Messages are JSON, so a Date arrives as a string. If the subscriber
connection drops, every subscription on the instance ends with an error (clients resubscribe) and
the next subscription opens a new connection. The application closes the engine on shutdown. The
package reference lists every option.
#Errors
Errors thrown by resolvers, guards and validation are reported in the response's errors with an
extensions.code:
| Thrown | extensions.code | Message |
|---|---|---|
BadRequestException (and subclasses such as ArgsValidationError) | BAD_USER_INPUT | Kept |
UnauthorizedException | UNAUTHENTICATED | Kept |
ForbiddenException, a denying guard | FORBIDDEN | Kept |
NotFoundException | NOT_FOUND | Kept |
TooManyRequestsException | RATE_LIMITED | Kept |
A GraphQLError thrown by resolver code | its own, else BAD_USER_INPUT | Kept |
A LeavenError | its own | Kept |
| Anything else | INTERNAL_SERVER_ERROR | Masked outside development |
An HttpException's extensions are sent next to the code. A variable that does not match its
type is BAD_USER_INPUT, and graphql-js quotes the offending value in the message
(Variable "$first" got invalid value "x"; ...), so a client sees its own input echoed back.
#Masking
Every other error, including graphql-js output errors (a resolver returning an object for a String
field), is logged under a correlation id, and the client gets that id:
{
"message": "Internal server error",
"locations": [{ "line": 1, "column": 3 }],
"path": ["report"],
"extensions": { "code": "INTERNAL_SERVER_ERROR", "correlationId": "…" }
}The message is masked (MASKED_MESSAGE) unless the application is in development or maskErrors
is false. Outside development the log entry holds only the error's name and its message, with
credentials redacted and truncated to 500 characters; in development it holds the full error,
redacted. Neither the stack nor the error object reaches the log outside development.
#Mapping your own errors
mapError(error) runs on errors thrown by resolver code, hooks, subscription filters and the
context factory over WebSocket, before masking. Return an HttpException to report it with that
exception's code and message, or undefined to leave the error as it is:
/** A domain error from the service layer, unknown to Knead. */
export class PostLockedError extends Error {
constructor(readonly postId: string) {
super(`Post ${postId} is locked`);
this.name = 'PostLockedError';
}
}@Module({
imports: [
GraphQLModule.forRoot({
mapError: (error) =>
error instanceof PostLockedError ? new ForbiddenException(error.message) : undefined,
}),
],
providers: [EditorResolver],
})
export class ErrorsAppModule {}mapError does not see GraphQLErrors or LeavenErrors. A mapper that throws is logged, and the
original error is masked as usual. With these resolvers:
@Resolver()
export class EditorResolver {
@Query(() => String, { args: { id: ID } })
draft(ctx: ResolverCtx<{ id: string }>): string {
// NOT_FOUND, with this message, even in production.
throw new NotFoundException(`No draft ${ctx.args.id}`);
}
@Mutation(() => Boolean, { args: { id: ID } })
rename(ctx: ResolverCtx<{ id: string }>): boolean {
// BAD_USER_INPUT; extensions are sent next to the code.
throw new BadRequestException(`The new title of ${ctx.args.id} is taken.`, {
extensions: { field: 'title' },
});
}
@Mutation(() => Boolean, { args: { id: ID } })
edit(ctx: ResolverCtx<{ id: string }>): boolean {
throw new PostLockedError(ctx.args.id);
}
@Query(() => String)
report(): string {
// Unexpected: INTERNAL_SERVER_ERROR, masked outside development, logged with a correlation id.
throw new Error('connect ECONNREFUSED postgres://reporting:hunter2@10.0.0.7:5432');
}
}draft reports NOT_FOUND with its message, rename reports BAD_USER_INPUT with
extensions.field, edit reports FORBIDDEN through mapError, and report is masked.
#Request errors
Errors in the document itself keep their graphql-js message under maskErrors and carry a code:
a syntax error or too many tokens is PARSE_ERROR, and an unknown field, an invalid literal
argument or a disabled introspection field is VALIDATION_ERROR. A missing or unknown operation
name, or a variable that does not coerce, is BAD_USER_INPUT.
#HTTP transport
POSTrequiresContent-Type: application/json(415otherwise) and runs queries and mutations. A batched (array) body is a400.GETruns queries only: a mutation is405withAllow: POST. As a CSRF defense, aGETmust carry one ofcsrf.requiredHeaders(defaultx-graphql-request), otherwise it is a400. AGETcarries the query and its variables in the URL, which proxies, access logs and browser history record: send tokens and other secrets in aPOSTbody.- Responses carry
Cache-Control: no-store(they often hold per-user data; change it withcacheControl, orfalseto send none) andX-Content-Type-Options: nosniff. - A client that names
application/graphql-response+jsoninAccept(at a q value at least that ofapplication/json) gets that content type and GraphQL-over-HTTP status codes:400for a response without data caused by the request, and500only for anINTERNAL_SERVER_ERROR. Every other client getsapplication/jsonand200, errors included. - Rejections before GraphQL runs (
415, the CSRF check, a too-long query, a malformed body) areapplication/problem+jsonresponses, like any other@knead-js/httperror.
#Limits
Every operation, over HTTP and WebSocket, is checked against these limits before it executes. The
defaults are exported as GRAPHQL_LIMITS.
| Option | Default | Rejected with |
|---|---|---|
maxQueryLength | 102400 characters (100 KiB) | 400 over HTTP; an error message over WebSocket. Checked before parsing. |
parseOptions.maxTokens | 3000 | A syntax error. Checked while parsing. |
maxFields | 1000 field selections | FIELD_LIMIT |
maxDepth | 10 | DEPTH_LIMIT |
maxIntrospectionDepth | 20 | DEPTH_LIMIT |
maxAliases | 30 | BAD_USER_INPUT |
maxComplexity | 1000 | COMPLEXITY_LIMIT |
maxFieldscounts fields both as written in the document (including fragments that are never spread) and with fragments expanded where they are spread. It runs before validation, whose overlapping-fields rule is quadratic in repeated fields.maxDepthcounts with fragments expanded, and skips the selections inside__schemaand__typeso introspection works;maxIntrospectionDepthcaps those, counting__schemaor__typeas level 1. graphql-js'sgetIntrospectionQuery()(depth 15) passes every default.- Complexity sums, over every selected field, the field's cost plus half its depth (
0at the root), where the cost is thecomplexityoption (default1).
@ObjectType()
export class Report {
@Field(() => Int) id!: number;
// Each selection of `rows` costs 25 instead of 1.
@Field(() => [String], { complexity: 25 }) rows!: string[];
}
@Resolver()
export class ReportResolver {
@Query(() => [Report], { complexity: 10 })
reports(): Report[] {
return [{ id: 1, rows: ['a', 'b'] }];
}
}Here { reports { id rows } } costs 10 + (1 + 0.5) + (25 + 0.5) = 37. Costs are looked up by field
name, so two fields with the same name on different types share the higher cost.
Raise a limit when your largest legitimate operation needs it, for example a client that sends
large generated documents, or a Relay schema, which spends three levels per connection
(users { edges { node { ... } } }):
@Module({
imports: [
GraphQLModule.forRoot({
// Measured against the largest operation the web client sends, plus headroom.
maxDepth: 15,
maxFields: 5000,
parseOptions: { maxTokens: 20_000 },
maxComplexity: 2000,
}),
],
providers: [ReportResolver],
})
export class ReportsAppModule {}Infinity or false disables a limit. Any other value that is not a positive integer is an
HttpConfigError thrown by forRoot(). Validation still costs CPU per request, so also
rate-limit the GraphQL route (see security). WebSocket messages have their own
limits: the message size, the message rate per socket (subscriptions.messageRate) and the sockets
per IP, described in WebSockets.
#Introspection and the playground
| Option | Default |
|---|---|
introspection | true in development, or when playground is set to anything but false; otherwise false |
playground | true in development, otherwise false |
maskErrors | false in development, otherwise true |
emitSchemaFile | 'schema.graphql' in development, otherwise false |
With introspection off, a query that selects __schema or __type fails validation; __typename
still works.
The playground (GraphQL Playground, through Leaven) is served for GET requests that list
text/html explicitly in Accept, which a browser navigation does; other GET requests are
GraphQL queries. Turning it on outside development logs a warning at bootstrap, because it exposes
the schema.
@Module({
imports: [
GraphQLModule.forRoot({
// On in development by default. An object turns it on and sets where the page connects
// for subscriptions (a ws:// or wss:// URL, or an absolute path).
playground: { subscriptionEndpoint: 'wss://api.example.com/graphql' },
}),
BlogModule,
],
})
export class PlaygroundAppModule {}The page's subscription URL is playground.subscriptionEndpoint when set. Otherwise it is derived
from the request's Host header when that is a plain host[:port], and is the relative path for
anything else, so a forged Host never reaches the page.
The page sends its own Content-Security-Policy, which @knead-js/security-headers leaves in
place: it allows its inline script by its sha256 hash and only the exact cdn.jsdelivr.net asset
URLs it loads, each pinned with a Subresource Integrity hash (PLAYGROUND_ASSETS), and it sends
Referrer-Policy: no-referrer. If Leaven's page ever references an asset without a pinned hash, or
its inline script no longer matches PLAYGROUND_SCRIPT_HASHES, this is logged once at bootstrap
and the playground answers 503.
#The schema file
In development, the sorted SDL is written to schema.graphql (relative to the working directory)
at every bootstrap; set emitSchemaFile to another path, or false. To produce the file in CI,
build the application without listening and print the schema with printSortedSchema(), which
sorts types and fields so the output is stable:
import { GraphQLRuntime, printSortedSchema } from '@knead-js/graphql';
import { KneadFactory } from '@knead-js/http';
import { AppModule } from './app.module.js';
// Builds the schema without listening and writes it, for CI or codegen.
const app = await KneadFactory.create(AppModule, { development: false });
await Bun.write('schema.graphql', printSortedSchema(app.get(GraphQLRuntime).schema));
await app.close();#Testing
@knead-js/testing starts the application on a random port and has GraphQL clients:
app.graphql(query, { variables, headers }) posts an operation, and
app.graphqlSubscribe(query, { connectionParams }) opens a graphql-transport-ws connection and
yields each event.
import { afterAll, beforeAll, expect, test } from 'bun:test';
import { Test, type TestApp, type TestingModule } from '@knead-js/testing';
import { AppModule } from './app.module.js';
let mod: TestingModule;
let app: TestApp;
beforeAll(async () => {
// development: false keeps production behaviour: masked errors, no schema.graphql written.
mod = await Test.createModule({ imports: [AppModule] }).compile({ development: false });
app = await mod.createApp();
});
afterAll(() => mod.close());
test('lists published posts with their authors', async () => {
const res = await app.graphql<{ posts: { title: string; author: { name: string } }[] }>(
'query ($status: PostStatus) { posts(status: $status) { title author { name } } }',
{ variables: { status: 'PUBLISHED' } },
);
expect(res.status).toBe(200);
expect(res.data?.posts[0]).toEqual({
title: 'Notes on the Analytical Engine',
author: { name: 'Ada Lovelace' },
});
});
test('streams published posts to subscribers', async () => {
const sub = await app.graphqlSubscribe<{ postPublished: { title: string } }>(
'subscription { postPublished { title } }',
{ connectionParams: { token: 'grace-token' } },
);
// graphqlSubscribe resolves on connection_ack; give the server a moment to subscribe.
await Bun.sleep(50);
await app.graphql('mutation { createPost(input: { title: "Hello", publish: true }) { id } }', {
headers: { authorization: 'Bearer grace-token' },
});
const event = await sub.next();
expect(event.value).toEqual({ data: { postPublished: { title: 'Hello' } } });
await sub.close();
});See testing for overrides and the rest of the testing API.