@alxia/graphql
GraphQL for alxia, served by GraphQL Yoga. The endpoint is a route like any other: behind the app's middlewares, guarded by its guards, its resolvers reading the context those middlewares built — typed, and checked. Yoga's plugin system is yours whole: Envelop's plugins and Yoga's own.
bun add @alxia/graphql graphql-yoga graphql @alxia/core
bun add -d typescript
graphql-yoga and graphql are peers: the package declares no dependency.
graphql-ws is an optional one, for ws alone.
graphql 16 and 17 both work; with 17, graphql-yoga must be 5.22 or
later, the first to accept it. graphql-yoga 5 (from 5.16) and 6 both work;
the specs run on 6. Yoga 6 takes a @graphql-hive/logger Logger as a custom
logging value, and its @graphql-yoga/plugin-* 4.x need Yoga 6.
Yoga's own request id is off (requestId: false): a client-sent x-request-id
is never echoed or logged, so the app's own id is the only one. Passing
requestId: true or an object keeps trustHeader: false unless you set it.
Yoga 5 ignores requestId. logging defaults to 'warn' on both majors, so
Yoga 6's per-request INFO line is off and an app that never set logging no
longer sees Yoga's info lines; pass logging: true or 'info' to get them.
Usage
import { alxia } from '@alxia/core';
import { graphql, type GraphQLContext } from '@alxia/graphql';
import { bearer } from '@alxia/jwt';
import { createSchema } from 'graphql-yoga';
const base = alxia()
.decorate({ db })
.use(bearer({ jwt, schema: Claims })); // every route after it needs a token
const schema = createSchema<GraphQLContext<typeof base>>({
typeDefs: /* GraphQL */ `type Query { me: String! }`,
resolvers: {
Query: { me: (_, __, { user, db }) => db.name(user.sub) }, // user and db typed
},
});
const app = base.plugin((app) => graphql(app, { schema })); // POST and GET /graphql
app.listen(3000);
graphql(app, options) adds GET and POST routes at path —
/graphql by default, under the app's prefix — and returns the app. Given
to app.plugin as a function, it stays in the chain and sees the app's type.
The context
A resolver's context is Yoga's (request, params), the app's — every
decorate, every derive, every middleware's: user, db, log,
requestId — and set, through which it sets a header or a cookie:
login: async (_, { name }, { set }) => {
set.cookies.set('session', await sign(name), { httpOnly: true });
return true;
},
GraphQLContext<typeof app> is that type. A schema whose resolvers read
what the app does not build is a compile error:
the schema's resolvers read a context the app does not build: missing user
Batching (N+1)
A field that loads a record per parent — Note.author over 50 notes — is
51 queries. Build DataLoaders in
the context option and type them through GraphQLContext's second
argument. (Yoga's batching option, below, is another thing: several
operations in one HTTP request.)
bun add dataloader
import DataLoader from 'dataloader';
const createLoaders = () => ({
user: new DataLoader(async (ids: readonly string[]) => findUsers(ids)), // one query for all ids
});
type Loaders = ReturnType<typeof createLoaders>;
const schema = createSchema<GraphQLContext<typeof base, { loaders: Loaders }>>({
typeDefs,
resolvers: { Note: { author: (note: { authorId: string }, _, { loaders }) => loaders.user.load(note.authorId) } },
});
const app = base.plugin((app) => graphql(app, { schema, context: () => ({ loaders: createLoaders() }) }));
Per request, never once for the process: a DataLoader caches what it
loads, so a shared one would serve stale records and hand one user's data
to the next. Over WebSocket, context runs for each operation. See
Batching with DataLoader.
One value per request
A field that resolves the same value for many parents (the category of
twenty items) should load it once. oncePerRequest(context, key, load)
runs load the first time a request asks for key and returns that value
to every later call of the same request. The cache is released with the
context object, and a load that throws or rejects is not kept.
import DataLoader from 'dataloader';
import { oncePerRequest } from '@alxia/graphql';
const categories = (context: object) =>
oncePerRequest(context, 'categories', () => new DataLoader(loadCategories)); // one DataLoader per request and key
const category = (item: { categoryId: string }, _args: unknown, context: object) =>
categories(context).load(item.categoryId);
A mutation runs its root fields one after another, and one may change what a
later one reads. Wrap the resolvers with freshPerMutation so each root
mutation field starts with an empty cache (its sub-fields share it); a query
keeps one cache:
import { freshPerMutation } from '@alxia/graphql';
const resolvers = freshPerMutation({ Query, Mutation, Ward });
It takes no dependency: dataloader stays yours. See
One value per request.
Yoga's plugins
Every Yoga option passes through, but graphqlEndpoint, which path
sets, and graphiql, which ide decides:
import { useDepthLimit } from '@envelop/depth-limit';
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
graphql(app, {
schema,
plugins: [useDepthLimit({ maxDepth: 8 }), useResponseCache({ session: ({ request }) => ... })],
maskedErrors: true, // Yoga's default: an error's message never leaks
ide: Bun.env.NODE_ENV === 'development' && 'apollo-sandbox', // at runtime, never process.env: bun build inlines it
batching: true,
});
-
Subscriptions are served over server-sent events, Yoga's default:
async *subscribein a resolver,Accept: text/event-streamon the request; over WebSocket too withws: true.@alxia/compressleaves an event stream alone by default. -
An IDE answers a
GETfrom a browser at the endpoint, with aContent-Security-Policythat lets it load — its pinned files on unpkg alone, framed by no one — which@alxia/secure-headerskeeps. By default, GraphiQL in the serving app's dev alone (NODE_ENV=development, oralxia({ dev: true })), none otherwise;idechooses one everywhere:graphql(app, { schema }); // GraphiQL in dev, nothing deployed graphql(app, { schema, ide: 'apollo-sandbox', sandbox: { initialDocument: '{ me }' } });idenone (default) Yoga's GraphiQL in dev ( isDev), nothing otherwise'graphiql'Yoga's GraphiQL, everywhere; graphiqltakes its options'apollo-sandbox'Apollo Sandbox, embedded, pointed at the address the page was opened at, prefix included — httpsbehind a proxy that terminates TLS (IDE guide).sandboxtakestitle,initialDocument,initialHeaders,pollForSchemaUpdates,includeCookiesfalsenone -
CORS is
@alxia/cors's for the whole app: Yoga's own is off unlesscorsis given.
Which operation ran
Every call is a POST /graphql; graphql() tells the observers around it
which operation it executes. Behind use(logger()) the request's line
carries operationName and operationType, and behind
use(telemetry({ … })) its span is named query GetNotes, with
OpenTelemetry's graphql.operation.name and graphql.operation.type. A
batched body is one line and one span, batch, with every name.
const app = alxia()
.use(logger())
.use(telemetry({ service: 'notes' }))
.plugin((app) => graphql(app, { schema }));
A request
refused before it executes names none. Over ws: true, each operation on
the socket is a line and a span of its own, from its subscribe message
to its end, marked when answered with errors.
See the endpoint guide
and the WebSocket guide.
WebSocket
ws: true serves the endpoint over WebSocket too, with the
graphql-transport-ws protocol of graphql-ws,
the default of Apollo Client's GraphQLWsLink and urql's
subscriptionExchange. graphql-ws is an optional peer; server-sent
events stay served at the same path. Install it with bun add graphql-ws.
const app = base.plugin((app) => graphql(app, { schema, ws: true })); // ws://…/graphql too
import { createClient } from 'graphql-ws';
const client = createClient({ url: 'wss://api.example.com/graphql', connectionParams: { device: 'web' } });
The upgrade runs the app's middlewares — a guard's 401 refuses the
socket, what they add is in the context, beside the connectionParams
the client sent — each operation runs through
Yoga's plugins, and a shutdown closes the sockets with 1001, completing
their subscriptions. ws: { path, keepAlive } moves the socket or spaces
its pings. See the WebSocket guide,
Apollo Client's and urql's setup included.
Bound a subscription
import { boundedStream } from '@alxia/graphql';
// Your pub/sub reader and your session check.
declare function subscribeToTopic(topicId: string): AsyncIterable<unknown>;
const subscribe = (
_: unknown,
args: { topicId: string },
ctx: { assertSession(): Promise<void> },
) =>
boundedStream(subscribeToTopic(args.topicId), {
idleMs: 90_000,
totalMs: 10 * 60_000,
checkEveryMs: 30_000,
check: () => ctx.assertSession(),
});
The stream ends cleanly when idle or old, throws when check throws, and
releases the source as soon as the client leaves. See
Yoga's plugins and options.
Limits
Four limits bound what a request costs before any resolver runs. A document
may hold at most 15 000 tokens (maxTokens, false for no cap): a longer
one is refused by the parser as GRAPHQL_PARSE_FAILED, 400 under
application/graphql-response+json, and never reaches the schema. It may
nest at most 1 000 levels of {, ( and [ (maxNesting, false for
no limit): graphql-js parses by recursion and overflows the stack a few
thousand levels down, so a deeper document is refused the same way before the
parser recurses into it, never a 500. It counts {, ( and [ together,
arguments and lists included, so it is not a selection-depth rule; use a
validation rule for that. The
documents Yoga parses are kept in a cache bounded by weight, 64 MiB per
endpoint, 1 024 documents, one hour (parseCache), where Yoga's own
has no size bound. A POST body may hold 1 MiB (bodyLimit) unless the
app declared its own bodyLimit() before graphql(), which is kept; a body
past it is core's 413 and never reaches Yoga. bodyLimit bounds a POST
alone: a WebSocket subscribe message is bounded by Bun's maxPayloadLength
(16 MiB by default):
graphql(app, {
schema,
maxTokens: 20_000, // false: no cap
maxNesting: 2_000, // 1 000 by default; false: no cap
parseCache: { maxBytes: 32 * 1024 * 1024, max: 512, ttl: 30 * 60_000 }, // false: no cache
bodyLimit: 5 * 1024 * 1024, // 1 MiB by default; a multipart upload needs more
});
A refused document is a 200 with errors under application/json (400 under
application/graphql-response+json). A bad option value throws a TypeError
where graphql() is declared.
A client query is a few thousand tokens; the introspection query is 163. See Cap what a request costs.
Errors, health and shutdown
A GraphQL error stays GraphQL's — an entry of errors[], inside a 200 —
while @alxia/core's alxia({ errors: 'problem' }) makes the HTTP layer
around the endpoint answer RFC 9457 problems: a guard's 401, a 413, a
500. health() mounts beside it, and on SIGTERM the queries in flight
are answered and the subscriptions ended before the process exits:
import { alxia, health } from '@alxia/core';
import { graphql } from '@alxia/graphql';
const app = alxia({ errors: 'problem' })
.plugin(health())
.plugin((app) => graphql(app, { schema }));
app.listen({ port: 4000 });
See the endpoint guide.
In process
graphqlExecutor(options) runs a query or a mutation in the process, with the
same plugins, limits, parse cache and error masking as graphql(), and a
server context you pass: for an MCP tool or a job, not a browser.
import { graphqlExecutor } from '@alxia/graphql';
const execute = graphqlExecutor({ schema, plugins: [useAuth()] });
const { data, errors } = await execute({ query: '{ me { id } }' }, { viewer });
See the guide.
Testing
graphqlClient from @alxia/graphql/testing sends operations to the app
in process, through app.fetch: no port. It is a subpath, so the main entry
and a production bundle never carry it.
import { graphqlClient } from '@alxia/graphql/testing';
const client = graphqlClient(app, { headers: { authorization: `Bearer ${token}` } });
const { status, data, errors } = await client.query(
'query ($name: String!) { hello(name: $name) }',
{ variables: { name: 'Ada' } },
);
A TypedDocumentNode types data and the variables. method: 'GET' sends
the operation in the URL; persisted sends the hash of a persisted operation,
alone when the app allows nothing else (you name TData); the client adds no
CSRF header, put yours in headers:
const result = await client.query<{ hello: string }>({ persisted: hash, method: 'GET' });
See the testing guide.
API
| export | |
|---|---|
graphql(app, options) | the endpoint: schema, path, ide, sandbox, ws, maxTokens, maxNesting, parseCache, bodyLimit, and every Yoga option |
graphqlExecutor(options) | (request: { query, operationName?, variables? }, context, { signal? }?) => Promise<{ data?, errors? }>: a query or mutation in process, through the options of graphql() without the route's (path, ide, ws, bodyLimit, cors) |
ExecutorRequest, ExecutorResult, ExecutorError, ExecutorCall, GraphQLExecutor, GraphQLExecutorOptions | its types |
GraphQLWsOptions | ws's options: path, keepAlive |
GraphQLWsContext | what an operation over WebSocket adds to the context: connectionParams |
renderSandbox(endpoint, options?), SANDBOX_POLICY | the Sandbox page, and the policy it loads under |
SandboxOptions | its options: title, initialDocument, initialHeaders, pollForSchemaUpdates, includeCookies |
GraphQLContext<App, UserContext?> | what a resolver reads |
oncePerRequest(request, key, load) | load()'s value, computed at most once per request object and key (a string); returns T, a promise kept as the same promise, a throw or rejection not kept |
clearRequestCache(request) | empties what oncePerRequest kept for that request object; the next call loads again; returns void |
freshPerMutation(resolvers) | the same resolvers (same type) with each function of Mutation wrapped to call clearRequestCache(context) first: each root mutation field starts empty, its sub-fields share, a query is untouched |
boundedStream(source, options) | source (an AsyncIterable or AsyncIterator) as an AsyncIterableIterator<T> for a subscription's subscribe: ends cleanly after idleMs without an event and totalMs in all, runs check every checkEveryMs and before an event when the last check is checkDebounceMs old, throws mapError(error) when check fails or the source itself throws, and calls the source's return() on disconnect or any end |
BoundedStreamOptions | its options: idleMs, totalMs, checkEveryMs, checkDebounceMs?, check, mapError?, signal? |
ServerContext<Ctx>, GraphQLOptions | its types |
graphqlClient(app, { path?, headers?, method? }) (@alxia/graphql/testing) | query(document, { variables?, operationName?, headers?, method?, extensions?, persisted? }), or query({ persisted }) with no document, in process: { status, data?, errors?, response } |
GraphQLClient, GraphQLResult, QueryDocument, QueryOptions, PersistedQueryOptions, HttpMethod, GraphQLClientOptions, ResponseError (@alxia/graphql/testing) | its types |
Documentation
- Guide: a page per area — mounting the endpoint, the typed context, Yoga's plugins and options, GraphQL over WebSocket, running operations in process, GraphiQL and Apollo Sandbox, and hardening an API for production (rate limits, depth limits, introspection, errors, body size, CSRF, persisted operations).
- Troubleshooting: an error message, and what to do about it.
- Roadmap: what is coming, and what is not planned.
- Recipes: A GraphQL API, Authenticate requests, Test an alxia app, Health checks and graceful shutdown, and more.