Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

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

@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

Terminal
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:

app.module.ts
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.

app.module.ts
@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:

main.ts
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's providers);
  • builds and validates the schema. A problem is a SchemaBuildError whose message names the class and field, for example Post.author: type thunk returned undefined — is Author imported?. A schema needs at least one @Query();
  • writes the sorted SDL to schema.graphql when 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

blog.types.ts
@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:

OptionTypeDefaultDescription
nullableboolean | 'items' | 'list' | 'both'falseWhich part of the type may be null. See nullability and lists.
descriptionstringnoneDescription in the schema.
deprecationReasonstringnoneMarks the field @deprecated(reason: ...).
defaultValuethe field's typenoneDefault for input type fields and @ArgsType() arguments.
complexitynumber1Cost 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 null or undefined, so declare it name!: string;
  • a nullable field must include both, so declare it bio?: string | null;
  • a list field takes T[] or readonly T[];
  • a defaultValue must 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 returnsGraphQL type
StringString
BooleanBoolean
NumberFloat
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 arrayA 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:

blog.types.ts
@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]
}
nullableSDL 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

blog.types.ts
@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

blog.types.ts
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

blog.types.ts
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

blog.types.ts
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

blog.types.ts
@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

blog.types.ts
@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:

blog.module.ts
@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:

post.resolver.ts
@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:

PropertyDescription
argsThe field's arguments, after GraphQL coercion and validate.
parentThe object the field belongs to (@ResolveField), or the event (subscriptions). undefined for queries and mutations.
infographql-js's GraphQLResolveInfo.
httpThe request Ctx. For subscriptions, a per-operation view of the WebSocket upgrade request.
stateScratch space shared by every resolver of one operation.
metaMetadata: 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: PostsArgs uses 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:

post.resolver.ts
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:

post.resolver.ts
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:

JSON
{
  "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:

post.resolver.ts
@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:

sessions.ts
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:

blog.service.ts
/** `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.

guards.ts
/** 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:

account.resolver.ts
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;
  }
}
app.module.ts
@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:

  1. With defaultDeny: true, a field that is not @Public() and has no guard of its own (steps 3 to 5, or authorize) is denied.
  2. The module's guards, unless the field or its class is @Public().
  3. The class's @UseGuards() guards.
  4. The field's guards option.
  5. The method's @UseGuards() guards.
  6. The validate schemas.
  7. The field's authorize guards, which see the validated arguments.
  8. 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:

stats.resolver.ts
/** 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:

account.ts
/** 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:

post.subscriptions.ts
@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() {}
}
OptionDescription
topicA 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.
recheckGuardsRe-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 empty ctx.state, before its filter and its recheckGuards recheck run, so a guard that memoises roles in ctx.state or loads them through ctx.loader() never reuses what subscribe time or an earlier event cached. The event's resolvers see what its filter and recheck set. ctx.meta and 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-memory PubSub freezes it in publish(), even with no subscriber. Class instances, Dates and Maps are not frozen or walked into. Publish plain DTOs, not entities, and build new objects in resolve or 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: true re-runs the module, class and method guards and then the authorize guards before every event. An event they deny is delivered as FORBIDDEN (or the guard's own error) and the subscription completes. Rechecked guards see the connect-time identity; set subscriptions.refreshContext({ http, context }) to look it up again before every recheck. Its object result is merged over the operation's context, and false ends the subscription with FORBIDDEN.
  • subscriptions.maxConnectionLifetime (milliseconds) closes every socket with 4408 after that long, so clients reconnect and pass onConnect again.
  • GraphQLSubscriptions.closeWhere(predicate) closes the sockets whose connection matches, for example on sign-out:
sign-out.service.ts
@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:

app.module.ts
/** 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:

Thrownextensions.codeMessage
BadRequestException (and subclasses such as ArgsValidationError)BAD_USER_INPUTKept
UnauthorizedExceptionUNAUTHENTICATEDKept
ForbiddenException, a denying guardFORBIDDENKept
NotFoundExceptionNOT_FOUNDKept
TooManyRequestsExceptionRATE_LIMITEDKept
A GraphQLError thrown by resolver codeits own, else BAD_USER_INPUTKept
A LeavenErrorits ownKept
Anything elseINTERNAL_SERVER_ERRORMasked 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:

JSON
{
  "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:

posts.errors.ts
/** 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';
  }
}
app.module.ts
@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:

editor.resolver.ts
@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

  • POST requires Content-Type: application/json (415 otherwise) and runs queries and mutations. A batched (array) body is a 400.
  • GET runs queries only: a mutation is 405 with Allow: POST. As a CSRF defense, a GET must carry one of csrf.requiredHeaders (default x-graphql-request), otherwise it is a 400. A GET carries the query and its variables in the URL, which proxies, access logs and browser history record: send tokens and other secrets in a POST body.
  • Responses carry Cache-Control: no-store (they often hold per-user data; change it with cacheControl, or false to send none) and X-Content-Type-Options: nosniff.
  • A client that names application/graphql-response+json in Accept (at a q value at least that of application/json) gets that content type and GraphQL-over-HTTP status codes: 400 for a response without data caused by the request, and 500 only for an INTERNAL_SERVER_ERROR. Every other client gets application/json and 200, errors included.
  • Rejections before GraphQL runs (415, the CSRF check, a too-long query, a malformed body) are application/problem+json responses, like any other @knead-js/http error.

#Limits

Every operation, over HTTP and WebSocket, is checked against these limits before it executes. The defaults are exported as GRAPHQL_LIMITS.

OptionDefaultRejected with
maxQueryLength102400 characters (100 KiB)400 over HTTP; an error message over WebSocket. Checked before parsing.
parseOptions.maxTokens3000A syntax error. Checked while parsing.
maxFields1000 field selectionsFIELD_LIMIT
maxDepth10DEPTH_LIMIT
maxIntrospectionDepth20DEPTH_LIMIT
maxAliases30BAD_USER_INPUT
maxComplexity1000COMPLEXITY_LIMIT
  • maxFields counts 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.
  • maxDepth counts with fragments expanded, and skips the selections inside __schema and __type so introspection works; maxIntrospectionDepth caps those, counting __schema or __type as level 1. graphql-js's getIntrospectionQuery() (depth 15) passes every default.
  • Complexity sums, over every selected field, the field's cost plus half its depth (0 at the root), where the cost is the complexity option (default 1).
reports.resolver.ts
@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 { ... } } }):

app.module.ts
@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

OptionDefault
introspectiontrue in development, or when playground is set to anything but false; otherwise false
playgroundtrue in development, otherwise false
maskErrorsfalse 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.

app.module.ts
@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:

print-schema.ts
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.

blog.test.ts
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.