Skip to content

Serve GraphQL with Yoga and Pothos ​

How-to. Build a GraphQL schema in TypeScript, run it in its own HTTP process, and call a backend through a typed client.

Install @btravstack/http-server, @btravstack/graphql-server, @btravstack/config, graphql, and graphql-yoga. Add @pothos/core when building the schema in TypeScript. @btravstack/graphql-server accepts any GraphQLSchema; Pothos is the choice of this example, not a framework requirement. A Pothos schema whose fields call oRPC procedures can take @btravstack/pothos.

Declare the deployment with GraphqlModule; keep schema and authentication options on its graphql() answerer:

ts
import { GraphqlModule, graphql } from "@btravstack/graphql-server";
import { defineAuth } from "@btravstack/http-server";
import { createSchema } from "graphql-yoga";

const schema = createSchema({
  typeDefs: "type Query { hello: String! }",
  resolvers: { Query: { hello: () => "world" } },
});

export const GatewayApi = GraphqlModule("GatewayApi")({
  answerer: graphql(defineAuth(), { schema }),
});

GraphqlModule accepts the HTTP runtime's socket options and ordinary module imports, providers, needs and exports. Set CORS on graphql(); the module excludes cors and compression, since the answerer does not apply HTTP compression. It supplies the answerer's authenticators and exports the runtime ports plus available Logger and HealthChecks. Business ports still need explicit exports. A root that imports no logger gets no logger provider or logger export.

The GraphQL gateway boots its own HttpRuntime and mounts the schema at /graphql. Its resolvers call the order API through a typed oRPC client. The gateway and order API are separate processes. The gateway needs the HTTP runtime and GraphQL packages; it does not install the oRPC server.

graphql(defineAuth(), { schema }) makes the mount public. Pass requires to authenticate the whole mount through the shared HTTP auth registry, and pass unit to inject request-scoped services into resolvers. Declare the selected unit modules with units: { anonymous: RequestModule } in the same graphql() call. GraphqlModule binds that record to the runtime so it can report its units and apply test overrides. A manually composed root passes the same record to httpServer({ unit: units }). The compiler then refuses a port that module does not export. In a manual composition, include ...answerer.authenticators in provides when the mount has requires. The resolver context receives principal, unit, signal, and the Node incoming request. The example forwards its bearer header to the backend, which enforces the order contract's authentication and authorization.

A resolver answers through fieldResult(result): it folds the backend's modeled errors into GraphQLErrors with an extensions.code — an exhaustive mapErrCases, or flatMapErrCases where a refusal is Ok(null) — and fieldResult returns the value, reports a refusal on that field's own path while sibling fields resolve, and masks a defect. Masked defects are reported to Observers, never to Yoga's console: compose observability() to have them logged, with the field's path, and counted — without an observer they reach nothing. They open no span of their own. A GraphQL error normally retains HTTP 200; the framework does not impose a status or error schema on application outcomes. One HTTP request is one unit, closed after the response finishes and included in the runtime's drain.

What the example gateway does with that ​

examples/order-graphql-api is the worked case, and its specs pin each behaviour against a stub of the order API that counts calls:

  • Each field answers for itself. Aliased siblings resolve independently: a found order is data, an absent one null, a malformed id that field's BAD_REQUEST.
  • The request scope is the cache. The unit module provides the request's order reads, a DataLoader behind them, so an id is fetched once however many fields ask, and the next request starts empty. No cache outlives the unit it was built in.
  • A write updates the cache. placeOrder primes the loader with the order it placed, and its payload exposes query: Query, so a read after the write in the same operation sees it without calling the API. Mutations run in order: placing an order twice in one operation is data, then that field's CONFLICT.
  • Arguments are parsed by the contract. Each resolver validates its arguments through the procedure's own input schemas before any call, so a GraphQL argument is parsed exactly as the API parses it, branded ids included.
  • Refusals stay the service's. The gateway authenticates nobody: it forwards the caller's credentials, and the API's UNAUTHORIZED — which its client reports as a defect, being undeclared — is recovered into that field's error.
  • Limits run before side effects. An operation running more than ten aliases — its fragments' included — is refused once it is selected and before any resolver runs, so a refused mutation places nothing. It is checked at execution rather than as a validation rule because validation sees the whole document, not the operation operationName selects.

Yoga's wildcard CORS default is disabled here. HTTP_CORS_ORIGIN sets the allowed origin; cors on graphql() can specify Yoga's full policy, including credentials, or false to disable it explicitly. Yoga's console logger is disabled so an unexpected resolver error cannot print sensitive details outside the application's logging path. Pass Yoga plugins — inline, or typed by the context they read as Plugin<{ unit: … }> — for validation rules, depth or complexity limits, and other schema-specific controls before exposing expensive fields to untrusted callers. A protected mount lets browser CORS preflights reach Yoga before authentication, and 401/403 refusals receive the same CORS policy. Plugin resources are disposed when the application stops.

Generate the SDL with pnpm --filter @btravstack/example-order-graphql-api graphql:schema. The checked-in schema.graphql is consumable without the gateway or Yoga. Its freshness test detects schema drift.

GraphiQL and introspection are one switch, off by default: GRAPHQL_DEVELOPER_TOOLS=true in a developer's environment turns both on, and a production deployment that sets nothing exposes neither. developerTools on graphql() pins it. Off, an operation naming __schema or __type is refused at validation; __typename still answers. Off, an error's "Did you mean …" suggestion is removed too, since it names the schema back to the caller. On a protected mount the GraphiQL page itself renders before authentication, and the operations it sends are authenticated like any other.

A document is parsed with a token limit, GRAPHQL_MAX_TOKENS (default 1000), pinned by maxTokens; maxTokens: false turns it off. Over it, the parser refuses the document before any validation rule runs, so a document of repeated aliases or directives costs one aborted parse. A finer budget — aliases, directives, depth or cost — is a Yoga plugin such as graphql-armor's, passed through plugins.

A schema's Subscription type is served over SSE, as Yoga serves it by default, and an event's resolver answers through fieldResult like any field, so its refusal rides that event. GraphQL over WebSocket is not served.

Released under the MIT License.