@alxia/graphql

中文文档
0.14.0发布于 2026/11/04MITTypeScriptES Module零依赖近 30 天下载 3231

GraphQL for alxia with GraphQL Yoga: its plugins, subscriptions over SSE or WebSocket (graphql-ws) and GraphiQL, behind the app's hooks, its resolvers reading the app's typed context

原始使用文档
文档目录14 节
  1. Usage
  2. The context
  3. Batching (N+1)
  4. One value per request
  5. Yoga's plugins
  6. Which operation ran
  7. WebSocket
  8. Bound a subscription
  9. Limits
  10. Errors, health and shutdown
  11. In process
  12. Testing
  13. API
  14. Documentation

@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 *subscribe in a resolver, Accept: text/event-stream on the request; over WebSocket too with ws: true. @alxia/compress leaves an event stream alone by default.

  • An IDE answers a GET from a browser at the endpoint, with a Content-Security-Policy that lets it load — its pinned files on unpkg alone, framed by no one — which @alxia/secure-headers keeps. By default, GraphiQL in the serving app's dev alone (NODE_ENV=development, or alxia({ dev: true })), none otherwise; ide chooses one everywhere:

    graphql(app, { schema }); // GraphiQL in dev, nothing deployed
    graphql(app, { schema, ide: 'apollo-sandbox', sandbox: { initialDocument: '{ me }' } });
    
    ide
    none (default)Yoga's GraphiQL in dev (isDev), nothing otherwise
    'graphiql'Yoga's GraphiQL, everywhere; graphiql takes its options
    'apollo-sandbox'Apollo Sandbox, embedded, pointed at the address the page was opened at, prefix included — https behind a proxy that terminates TLS (IDE guide). sandbox takes title, initialDocument, initialHeaders, pollForSchemaUpdates, includeCookies
    falsenone
  • CORS is @alxia/cors's for the whole app: Yoga's own is off unless cors is 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, GraphQLExecutorOptionsits types
GraphQLWsOptionsws's options: path, keepAlive
GraphQLWsContextwhat an operation over WebSocket adds to the context: connectionParams
renderSandbox(endpoint, options?), SANDBOX_POLICYthe Sandbox page, and the policy it loads under
SandboxOptionsits 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
BoundedStreamOptionsits options: idleMs, totalMs, checkEveryMs, checkDebounceMs?, check, mapError?, signal?
ServerContext<Ctx>, GraphQLOptionsits 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

版本与发布记录

@alxia/graphql 共发布 32 个版本,首次发布于 2026/10/04;过去 12 个月发布了 32 个版本。

版本发布日期距今
0.14.0latest2026/11/043 天前
0.13.32026/11/025 天前
0.13.22026/11/016 天前
0.13.12026/10/317 天前
0.13.02026/10/317 天前
0.12.02026/10/299 天前

关于 @alxia/graphql 的常见问题

@alxia/graphql 是什么?

GraphQL for alxia with GraphQL Yoga: its plugins, subscriptions over SSE or WebSocket (graphql-ws) and GraphiQL, behind the app's hooks, its resolvers reading the app's typed context

如何安装 @alxia/graphql?

在项目目录运行 npm install @alxia/graphql,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:graphql(^16.11.0 || ^17.0.0)、typescript(^6.0.3 || ^7.0.0)、@alxia/core(^0.19.0)、graphql-yoga(^5.16.0 || ^6.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@alxia/graphql 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@alxia/graphql 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@alxia/graphql 可以商用吗?

@alxia/graphql 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@alxia/graphql 的下载量有多少?

根据 npm 官方下载统计,@alxia/graphql 在 2026/10/06 – 2026/11/04 这 30 天内被下载 3,231 次,日均约 108 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@alxia/graphql 还在维护吗?

最新版本 0.14.0 发布于 2026/11/04(3 天前);至今共发布 32 个版本,其中过去 12 个月发布了 32 个;首次发布于 2026/10/04。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。