@setu-ts/graphql-plugin
GraphQL plugin for the Setu-TS framework. Provides schema-first and code-first GraphQL support over the kernel router.
Features
- Schema-first construction (SDL + resolver map)
- Code-first construction (application-built schema)
- GraphQL-over-HTTP transport with media-type negotiation
- Bounded parse+validate document cache
- Error masking, query-depth limiting, introspection control
- GraphiQL UI for development
- Health indicator integration
Installation
deno add npm:graphql npm:@setu-ts/graphql-plugin
Usage
Plugins are passed to
createApplication() —
there is no new Application() / app.use() API.
Schema-First
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
GraphqlPlugin({
typeDefs: `
type Query {
hello(name: String!): String
}
`,
resolvers: {
Query: {
hello: (_, { name }) => `Hello, ${name}!`,
},
},
}),
],
});
await app.start({ port: 3000 });
Code-First
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';
import { buildSchema } from 'graphql';
const schema = buildSchema(`
type Query {
hello(name: String!): String
}
`);
const app = createApplication({
plugins: [RuntimePlugin(), GraphqlPlugin({ schema })],
});
await app.start({ port: 3000 });
Injecting Your Own graphql Module
If your application uses its own copy of graphql, inject it to avoid cross-copy issues:
import { adaptGraphqlModule, GraphqlPlugin } from '@setu-ts/graphql-plugin';
import { buildSchema } from 'graphql';
import * as graphql from 'graphql';
GraphqlPlugin({
schema: buildSchema('type Query { hello: String }'),
graphqlModule: adaptGraphqlModule(graphql),
});
Options
| Option | Type | Default | Description |
|---|---|---|---|
typeDefs | string | - | SDL schema definition (schema-first mode) |
resolvers | ResolverMap | - | Resolver map (schema-first mode) |
schema | GraphqlSchemaLike | - | Pre-built schema (code-first mode) |
path | string | /graphql | Endpoint path |
graphiql | boolean | true | Enable GraphiQL UI |
introspection | boolean | true | Enable schema introspection |
maxDepth | number | 10 | Maximum query depth — nesting only (0 to disable) |
maxNodes | number | 0 | Maximum fields a document’s largest operation may resolve, aliases counted separately and fragment spreads expanded at each site. 0 (the default) is unbounded |
validationRules | unknown[] | [] | Additional validation rules |
maskInternalErrors | boolean | true | Mask internal server errors |
formatError | (error) => error | - | Custom error formatter |
documentCacheSize | number | 1000 | Max cached documents (0 to disable) |
buildContext | (input) => context | - | Custom context builder |
rootValue | unknown | - | Root value for resolvers |
graphqlModule | GraphqlModuleLike | - | Injected graphql module |
Platform Notes
Deno
The graphql package reads process.env.NODE_ENV at import time. If you run into permission
errors, add the --allow-env flag:
deno run --allow-env --allow-net main.ts
Cloudflare Workers
Enable nodejs_compat in your wrangler.toml:
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
Subscriptions
Subscriptions are opt-in. Without a subscriptions option no transport route is registered, and
POST/GET /graphql continues to refuse a subscription operation with
400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP.
GraphqlPlugin({
typeDefs,
resolvers,
subscriptions: {
// WebSocket registers only when CAPABILITIES.WEBSOCKET is present and the
// adapter can upgrade; otherwise the plugin logs a notice and carries on.
websocket: {
onConnect: (info) => {
const token = info.connectionParams?.authorization;
if (!isValid(token)) return false; // closes 4403: Forbidden
info.data.set('user', userFor(token)); // read back by the resolver context
},
},
sse: { heartbeatMs: 15_000 },
},
});
Endpoints default to the GraphQL path plus /ws and /stream, so path: '/api/graphql' gives
/api/graphql/ws and /api/graphql/stream. Both transports take their timers from
IRuntimeServices, so subscriptions requires CAPABILITIES.RUNTIME and throws at registration
without it.
A schema-first subscription field takes a { subscribe, resolve? } entry — subscribe returns the
async iterable, and graphql reads the event source from there:
const resolvers: ResolverMap = {
Subscription: {
tick: {
subscribe: () => tickStream(),
resolve: (payload) => (payload as { tick: number }).tick,
},
},
};
onConnect and a route guard cannot both authenticate the socket
connectionParams is the protocol’s auth channel, and onConnect is where you read it — but
onConnect runs on connection_init, which is a message on an already-open socket. Since M70a
every inbound request, upgrade included, runs the middleware pipeline BEFORE the handshake, so a
guard covering the /graphql prefix refuses GET /graphql/ws with 401 and onConnect never
runs. Following this section and a pipeline guard at the same time cannot work, and the failure is
silent from the client’s side: an ordinary 401, with nothing naming the interaction.
Pick one:
- Authenticate in
onConnectand keep the socket path out of the guard — scope the guard to the HTTP endpoint (/graphqlexactly, not the prefix) or exclude/graphql/ws. This is the option a browser client usually needs, because aWebSocketconstructor can set no headers, soconnectionParamsis the only credential channel it has. - Authenticate on the upgrade and let the guard do the work, using a credential a browser CAN
put on an upgrade — a cookie, via
auth-plugin’s session strategy (M73) — or a header when the client is a server.onConnectthen needs no token check, andctx.request.useris already populated when the handshake is decided.
Do not read a guard’s 401 on the socket path as a broken onConnect; it means the request never
reached the handshake.
Resolver Context
The default context a resolver receives is DefaultGraphqlContext, and its shape is
per-transport:
- HTTP —
services(the live, request-scoped registry) andrequestContextare present;user/tenantare present when the request carried a principal or tenant. - WebSocket —
servicesandconnection(aGraphqlConnectionInfo) are present;requestContextis absent, because the runtime closes the upgrade request once the handshake response is returned — a synthesized one would be dead by the time a resolver runs. The upgrade request’s headers and query live onconnection.headersandconnection.query; identity set by youronConnecthook (info.data.set('user', …)) surfaces asctx.user.
requestContext is typed optional for exactly this reason: the compiler enforces the caveat.
The WebSocket route opts out of websocket-plugin’s shared heartbeat sweep, because that sweeper
sends a raw text frame a graphql-transport-ws client must answer by closing 4400. Liveness on
this route is the protocol’s own ping/pong via subscriptions.websocket.heartbeatMs.
The SSE transport follows the graphql-sse protocol for distinct-connections mode: a GraphQL request
error is delivered inside the accepted event stream as a next event, not as a 400, because a
400 makes the user agent fail the connection and leaves native EventSource with nothing to read.
Batching and Automatic Persisted Queries
Both are opt-in:
GraphqlPlugin({
typeDefs,
resolvers,
maxBatchSize: 25, // 0 (the default) keeps refusing an array body with 400
apq: { ttlSeconds: 300 }, // omitted disables APQ entirely
});
APQ stores documents under an apq: prefix in CAPABILITIES.CACHE when a cache store is
registered, and in a bounded in-process LRU otherwise. A submitted hash is verified against the
submitted document before it is persisted, so a shared store cannot be poisoned with a document
under another client’s hash. Hashing uses IRuntimeServices.subtle, so apq requires
CAPABILITIES.RUNTIME.
Security
- Query depth limiting is enabled by default (max 10 levels).
maxDepthbounds NESTING only — a document can be two levels deep and still ask for a hundred thousand fields, because every alias is a separate field.maxNodesbounds that dimension by counting the fields a document resolves; it is off by default, so set it from the largest query your own clients issue. Neither option stands in for the other - Internal errors are masked by default — including errors raised inside a live subscription, which are masked by the same code path the HTTP transport uses
- APQ verifies a submitted hash against the submitted document before persisting it
- Introspection is enabled by default (disable in production if needed)
- GraphiQL is enabled by default (disable in production with
graphiql: false) - The request body itself is bounded by
RuntimePlugin({ maxBodyBytes }), not by this plugin — a document large enough to matter is refused before it is parsed, somaxNodesnever sees it. Both HTTP transports report that refusal as413withextensions.code: 'REQUEST_BODY_TOO_LARGE', distinct from the400 INVALID_JSONa body that was read and was not JSON still gets: a client told its JSON is bad re-sends the same oversized document, where one told its body is too large does not
License
MIT
Exports
| Export | Kind |
|---|---|
adaptGraphqlModule | function |
createDepthLimitRule | function |
encodeSseComment | function |
encodeSseComplete | function |
encodeSseEvent | function |
extractPersistedQuery | function |
graphiqlHtml | function |
GraphqlPlugin | function |
loadGraphqlModule | function |
persistedQueryHash | function |
ApqResolver | class |
GraphqlRuntimeLoadError | class |
GraphqlSchemaError | class |
GraphqlService | class |
GRAPHQL_TRANSPORT_WS | const |
AnySubscriptionResolver | interface |
DefaultGraphqlContext | interface |
GraphqlApqOptions | interface |
GraphqlCodeFirstOptions | interface |
GraphqlConnectionInfo | interface |
GraphqlContextInput | interface |
GraphqlExecutionResult | interface |
GraphqlFormattedError | interface |
GraphqlModuleLike | interface |
GraphqlOperationContext | interface |
GraphqlRequestParams | interface |
GraphqlScalarResolver | interface |
GraphqlScalarTypeLike | interface |
GraphqlSchemaFirstOptions | interface |
GraphqlSchemaLike | interface |
GraphqlSseTransportOptions | interface |
GraphqlSubscriptionsOptions | interface |
GraphqlWsTransportOptions | interface |
IApqResolver | interface |
IGraphqlService | interface |
SubscriptionResolver | interface |
AnyFieldResolver | type |
ApqResolveResult | type |
FieldResolver | type |
GraphqlPluginOptions | type |
GraphqlSubscriptionOutcome | type |
ResolverMap | type |
TypeResolverMap | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
Full API
Every export and option is documented in PUBLIC_API.md.
API reference
GraphQL (@setu-ts/graphql-plugin)
Schema-first and code-first GraphQL support over the kernel router.
Overview
GraphQL plugin providing schema construction, execution, and HTTP transport. Supports both SDL-based
schema definition with resolver maps and pre-built schemas. Includes media-type negotiation for
application/graphql-response+json, error masking, query-depth limiting, and optional GraphiQL UI.
Capability Token
CAPABILITIES.GRAPHQL('graphql') — the capability token under whichGraphqlPluginregisters theIGraphqlService.
Usage
Pass the plugin to createApplication({ plugins: [...] }) — there is no new Application() /
app.use() API:
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';
const app = createApplication({
plugins: [
RuntimePlugin(),
// Schema-first
GraphqlPlugin({
typeDefs: `
type Query {
hello(name: String!): String
}
`,
resolvers: {
Query: {
hello: (_, { name }) => `Hello, ${name}!`,
},
},
}),
],
});
await app.start({ port: 3000 });
// Resolve the service AFTER start() — plugins register during start().
const graphql = app.services.get<IGraphqlService>(CAPABILITIES.GRAPHQL);
Code-first differs only in passing a pre-built schema instead of typeDefs + resolvers:
import { buildSchema } from 'graphql';
const schema = buildSchema(`type Query { hello: String }`);
GraphqlPlugin({ schema });
Options
| Option | Type | Default | Description |
|---|---|---|---|
typeDefs | string | - | SDL schema definition (schema-first mode) |
resolvers | ResolverMap | - | Resolver map (schema-first mode) |
schema | GraphqlSchemaLike | - | Pre-built schema (code-first mode) |
path | string | /graphql | Endpoint path |
graphiql | boolean | true | Enable GraphiQL UI |
introspection | boolean | true | Enable schema introspection |
maxDepth | number | 10 | Maximum query depth (0 to disable) |
validationRules | unknown[] | omitted | Extra rules, appended after the built-ins, assembled once at registration |
maskInternalErrors | boolean | true | Mask internal server errors |
formatError | (error: unknown) => unknown | omitted | Custom error formatter applied after masking |
documentCacheSize | number | 1000 | Max cached documents (0 to disable) |
buildContext | (input: GraphqlContextInput) => unknown | omitted | Custom context builder |
rootValue | unknown | omitted | Root value for resolvers |
graphqlModule | GraphqlModuleLike | omitted | Injected graphql module (for testing or code-first scenarios) |
subscriptions | GraphqlSubscriptionsOptions | omitted | Subscription transports. Omitted registers no transport route at all. |
apq | GraphqlApqOptions | omitted | Automatic Persisted Queries. Omitted disables APQ; requires CAPABILITIES.RUNTIME |
maxBatchSize | number | 0 | 0 disables batching and an array body is still refused with 400 |
subscriptions
| Option | Type | Default | Purpose |
|---|---|---|---|
websocket | GraphqlWsTransportOptions | false | enabled | false disables the WebSocket transport |
websocket.path | string | `${path}/ws` | WebSocket endpoint |
websocket.connectionInitWaitMs | number | 3000 | Close 4408 when no connection_init arrives |
websocket.heartbeatMs | number | 0 | Protocol ping interval; 0 disables |
websocket.onConnect | (info) => false | void | omitted | Returning false closes 4403: Forbidden |
sse | GraphqlSseTransportOptions | false | enabled | false disables the SSE transport |
sse.path | string | `${path}/stream` | SSE endpoint |
sse.heartbeatMs | number | 0 | :keep-alive comment interval; 0 disables |
apq
| Option | Type | Default | Purpose |
|---|---|---|---|
ttlSeconds | number | 300 | TTL for entries in a registered ICacheStore |
maxEntries | number | 1000 | Bound on the in-process LRU used when no cache store is registered |
Exports
| Export | Kind | Purpose |
|---|---|---|
GraphqlPlugin | function | Plugin factory — registers IGraphqlService under CAPABILITIES.GRAPHQL |
GraphqlService | class | The IGraphqlService implementation; exported for testing |
adaptGraphqlModule | function | Structural adaptation of graphql module into internal runtime port |
graphiqlHtml | function | Generates GraphiQL UI HTML page |
createDepthLimitRule | function | Creates a validation rule for query depth limiting |
GraphqlSchemaError | class | Thrown when schema construction or resolver attachment fails |
GraphqlRuntimeLoadError | class | Thrown when graphql runtime cannot be loaded |
loadGraphqlModule | function | Loads graphql through a real dynamic import |
GraphqlPluginOptions | type | The factory parameter shape (union of the two arms) |
GraphqlSchemaFirstOptions | type | The schema-first arm of that union |
GraphqlCodeFirstOptions | type | The code-first arm of that union |
ResolverMap | type | Resolver map for schema-first mode |
TypeResolverMap | type | The resolver entries for one object or interface type |
FieldResolver | type | Field resolver function type |
AnyFieldResolver | type | The bivariant entry type a TypeResolverMap field holds — accepts a narrowly annotated resolver AND contextually types an unannotated one |
AnySubscriptionResolver | interface | The bivariant entry type a TypeResolverMap subscription field holds — same rule as AnyFieldResolver, for { subscribe, resolve? } entries |
SubscriptionResolver | type | A subscription field’s { subscribe, resolve? } pair |
GraphqlScalarResolver | type | Custom scalar serialize/parseValue/parseLiteral methods |
GraphqlSubscriptionsOptions | type | The subscriptions option (WebSocket and SSE arms) |
GraphqlWsTransportOptions | type | WebSocket transport options, including onConnect |
GraphqlSseTransportOptions | type | SSE transport options |
GraphqlApqOptions | type | Automatic Persisted Queries options |
ApqResolver | class | Verifies and resolves persisted-query hashes |
IApqResolver | type | The port the transports consume; implemented by ApqResolver |
ApqResolveResult | type | The resolved query, or a refusal carrying its code |
extractPersistedQuery | function | Reads { version, sha256Hash } from a request’s extensions |
persistedQueryHash | function | SHA-256 hex of a query, over an injected SubtleCrypto |
encodeSseEvent | function | Encodes a next SSE frame |
encodeSseComplete | function | Encodes the complete SSE frame, empty data: field included |
encodeSseComment | function | Encodes a :keep-alive comment frame |
GRAPHQL_TRANSPORT_WS | const | The 'graphql-transport-ws' subprotocol identifier |
GraphqlScalarTypeLike | type | Structural constraint for a custom scalar type |
GraphqlSchemaLike | type | Structural constraint for pre-built schemas |
GraphqlModuleLike | type | Structural constraint for injected graphql modules |
DefaultGraphqlContext | type | Default context shape passed to resolvers |
GraphqlContextInput | type | Input type for custom context builder |
GraphqlRuntimeand the structural graphql facades are not exported. They are an internal port.
Notes
- Two schema construction arms. Schema-first (
typeDefs+resolvers) and code-first (schema) are mutually exclusive; supplying both is a compile error. - Resolver context. Without
buildContext, resolvers receive aDefaultGraphqlContextwhose shape is per-transport. Over HTTP:{ services, requestContext, user?, tenant? }—servicesis the liveIServiceRegistry, so a resolver reaches any other capability through it, anduser/tenantare whatever the auth and multi-tenancy middleware published on the request. Over WebSocket:{ services, connection }—requestContextis absent (notundefined-valued): the runtime closes the upgrade request once the handshake response is returned, so a synthesized one would be dead by the time a resolver runs; that is why the member is typed optional. The upgrade request’s headers and query live onGraphqlConnectionInfo.headers/.query, and identity set by anonConnecthook viainfo.data.set('user', …)surfaces asctx.user. SupplyingbuildContextreplaces that object wholesale. - Media-type negotiation and the status watershed. Responds with
application/graphql-response+jsonwhen the client requests it, otherwiseapplication/json— including for failures raised before execution, so a client is never handed a media type it did not ask for. Undergraphql-response, a request error (parse, validation, operation resolution) is400, a subscription over HTTP is400, and a mutation overGETis405. Underapplication/json, every request the endpoint processed as GraphQL answers200with the error in the body, because a client predating the newer media type reads a non-200 as a network failure and never reads theerrorsarray. Exactly three cases keep their status underapplication/json— an unsupported request content type (415), a malformed JSON body (400), and a mutation overGET(405) — because none of them is a GraphQL result. An APQ refusal follows the watershed too: underapplication/jsonit answers200withPersistedQueryNotFoundin the body (it is exactly the error a client must read and retry), while undergraphql-responseit carries the resolver’s own status. Batching remains refused before per-element resolution, so an APQ miss inside a batch undergraphql-responsesurfaces as400 BATCHING_NOT_SUPPORTED. The status is decided from the outcome alone and never from the response body, so aformatErrorhook cannot change it. - Two resolver authoring styles, both supported.
FieldResolver<TSource, TContext, TArgs>is generic withunknowndefaults, so a resolver may be annotated narrowly (FieldResolver<IssueRow, DefaultGraphqlContext, { id: string }>) and still assign to aResolverMap. A resolver written unannotated —(source, args) => …, the ordinary schema-first shape — takes its parameter types contextually fromAnyFieldResolver, soargsisRecord<string, unknown>rather thannever. The map entry is bivariant for exactly this reason: a non-bivariant entry can serve one style or the other, never both. Subscription entries follow the same rule throughAnySubscriptionResolver, so a typed{ subscribe, resolve: (payload: Book) => … }assigns too. - An executed operation is always
200. A field error that nullsdatais not a request error, so it does not become a400even undergraphql-response. 405carriesAllow: POST. A mutation sent overGETis refused withMETHOD_NOT_ALLOWEDand the endpoint advertises the verb that would work.- Ambiguous operations. A document whose operation cannot be resolved — several operations with
no
operationName, or a name absent from the document — is a400with codeOPERATION_RESOLUTION_FAILED, decided after parse and before validation. - Error masking. Internal errors (resolvers throwing without a
codein extensions) are masked by default. The original is logged through the plugin context’sloggerwhen one is registered, so masking never silently discards it. - Query depth limiting. Defaults to 10 levels; set to 0 to disable.
- Document caching. Parse and validation results are cached together, keyed on the raw query
string, and the operation guard reads the cached AST — so a repeated query is neither re-parsed
nor re-validated.
documentCacheSize: 0disables the cache. - GraphiQL UI. Enabled by default, served on
GET /graphqlwhen the request has noqueryparameter and acceptstext/html. - Platform notes. The
graphqlpackage readsprocess.env.NODE_ENVat import time. Deno requires--allow-env; Cloudflare Workers requiresnodejs_compat. - Subscriptions are opt-in and never ride the HTTP endpoint. Supply
subscriptionsto register the transports; omit it and no transport route exists.POST/GET /graphqlcontinues to refuse a subscription operation with400 SUBSCRIPTIONS_NOT_SUPPORTED_OVER_HTTP, unchanged — a subscription is served only on`${path}/ws`and`${path}/stream`. - Declaring a subscription (schema-first). A
Subscriptionfield’s resolver entry is aSubscriptionResolver—{ subscribe, resolve? }— not a bare function.subscribereturns the async iterable the field streams from and is attached to the schema field’s ownsubscribeslot; graphql reads the event source from there and nowhere else. - Both transports require
CAPABILITIES.RUNTIME. They take their timers (the connection-init timeout, the protocol ping, the SSE keep-alive) fromIRuntimeServicesrather than global timers, so configuringsubscriptionswithout a registered runtime throws at registration naming the requirement — rather than failing at the first connection. - The WebSocket transport is optional and self-limiting. It registers only when
CAPABILITIES.WEBSOCKETis present ANDIWebSocketService.availableistrue; otherwise the plugin logs a notice and everything else carries on. The route claimsheartbeat: falseonWebSocketRouteOptions, becausewebsocket-plugin’s shared sweeper sends a raw text frame that a conformantgraphql-transport-wsclient must answer by closing4400. Liveness on that route is the protocol’s ownping/pong, configured bysubscriptions.websocket.heartbeatMs. - Authenticating a subscription.
subscriptions.websocket.onConnectruns onconnection_initbefore the ack, receives theGraphqlConnectionInfo(includingconnectionParams— the protocol’s auth channel — plus the upgrade headers and query), and closes the socket with4403: Forbiddenwhen it returnsfalse. Writing toconn.datathere sets theuser/tenantthe default resolver context reads back. With no hook configured the socket is accepted.onConnectand a pipeline guard on the socket path are mutually exclusive:connection_initis a message on an already-open socket, and since M70a a guard runs on the upgrade itself, so a guard covering the/graphqlprefix answersGET /graphql/wswith401andonConnectnever runs. Either scope the guard to the HTTP endpoint and authenticate inonConnect, or authenticate on the upgrade with a credential a browser can attach there — a cookie, throughauth-plugin’s session strategy. See the package README’s “onConnectand a route guard cannot both authenticate the socket”. - Resolver context over a socket. A WebSocket carries no
IRequestContext, soIGraphqlService.subscribetakes aGraphqlOperationContextinstead. On that pathservicesis the plugin-levelIServiceRegistry, so a subscription resolver reaches every capability an HTTP resolver can. - Errors inside a live subscription are masked.
maskInternalErrorsapplies identically on every transport: masking happens once, in the service, and the transports put the already-masked payload on the wire. - SSE follows the graphql-sse protocol, not the HTTP watershed. In distinct-connections mode a
GraphQL request error (parse, validation, operation resolution) is delivered INSIDE the
accepted
text/event-streamas anextevent followed bycomplete— not as a400, which would make the user agent fail the connection and give nativeEventSourcenothing to read. A transport failure that happens before any GraphQL request exists (unsupported content type, unparseable body, a missingqueryparameter) is still an ordinary buffered HTTP error. A persisted-query miss counts as a request error and is therefore delivered in-stream too. Thecompleteframe always carries an emptydata:field, without whichEventSourcenever fires the listener. - Automatic Persisted Queries verify the hash. A request carrying both a query and a hash is
persisted only when
sha256(query)matches the submittedsha256Hash; a mismatch answersPERSISTED_QUERY_HASH_MISMATCH. Without that check any client could store a document under a hash another client later executes — a cache-poisoning primitive, and worst when the store is a shared Redis. A hash-only request that misses answersPersistedQueryNotFound/PERSISTED_QUERY_NOT_FOUND, the standard retry signal. Entries live under theapq:namespace inCAPABILITIES.CACHEwhen one is registered, and in a bounded in-process LRU otherwise. Hashing usesIRuntimeServices.subtle, so configuringapqwithoutCAPABILITIES.RUNTIMEthrows at registration. - Batching is opt-in.
maxBatchSize: 0(the default) keeps refusing an array body with400. Above0, an array body executes its elements concurrently and answers a JSON array in request order; over the limit is400 BATCH_TOO_LARGE. A batch always answersapplication/json— theapplication/graphql-response+jsonmedia type describes a single result and cannot express an array, so an array body from a client negotiating it is refused with400 BATCHING_NOT_SUPPORTED. - Custom scalars. In the schema-first arm a resolver-map entry for a scalar type supplies any
subset of
serialize,parseValue, andparseLiteral; omitted members keep graphql’s identity default.