GraphQL
@zeltjs/graphql is experimental. The runtime manifest shape and generated
helper APIs may change before stable release.
GraphQL support is built around a shared runtime manifest:
schemaSdl- resolver bindings
- runtime metadata such as enum, scalar, and union mappings
The executor consumes the runtime manifest. Code-first and schema-first are frontends that produce the same manifest, which is delivered to the running app as a prebuilt module rather than loaded by the app itself.
Code-first:
Resolver code + args(schema)
-> zelt build / zelt dev
-> .zelt/graphql/<key>.runtime.ts (graphqlPrebuilt) + sibling .graphql
-> .zelt/prebuilt.ts (zeltPrebuilt)
-> entry imports zeltPrebuilt -> adapter(app, { prebuilt })
-> /graphql runtime
Schema-first:
schema.graphql
-> zelt graphql codegen
-> generated typed helpers
-> resolver code
-> zelt build / zelt dev
-> .zelt/graphql/<key>.runtime.ts (graphqlPrebuilt) + sibling .graphql
-> .zelt/prebuilt.ts (zeltPrebuilt)
-> entry imports zeltPrebuilt -> adapter(app, { prebuilt })
-> /graphql runtime
API boundary
Supported experimental app-authoring APIs:
graphql()ResolverQueryMutationResolveFieldargs()gqlScalar()GqlOutput
graphql({ path, resolvers, schema? }) declares the endpoint. Whether the
endpoint is code-first or schema-first is decided per endpoint, by whether
schema is passed: omit it for code-first, or pass the schema export from
a zelt graphql codegen-generated helper for schema-first. Either way,
graphql() never references the generated runtime directly — that is
supplied separately, through the adapter's prebuilt option (see Build flow
below).
Each endpoint has an identity key: graphql({ path, resolvers, name }) — an
optional name, defaulting to 'graphql' when omitted, same convention as
http()'s name option. The key namespaces the endpoint's prebuilt entry and
generated filename. Mounting more than one graphql() requires a distinct
name per endpoint; two endpoints sharing a key is a build-time error. name
must match /^[A-Za-z0-9_-]+$/ and cannot be a reserved Windows device name
(CON, PRN, AUX, NUL, COM1-9, LPT1-9); graphql() throws
immediately for an invalid name. Any mix of endpoints — multiple
schema-first lines, multiple code-first lines, or both together — can
coexist in the same app; each line binds only its own schema (or
resolver-derived SDL) and resolvers.
Generated-code APIs are exported for schema-first helpers only:
readGraphqlArgs()validateGraphqlArgs()
Build-time APIs such as graphqlPlugin(), generateGraphqlSdl(),
generateSdlForResolvers(), schema-first codegen, metadata inspection, and
type conversion are exported from @zeltjs/graphql/codegen only.
Runtime integration APIs used by adapters and framework internals — including
createGraphqlExecutor(), executeGraphqlRequest(), GraphqlRuntimeManifest,
GeneratedGraphqlRuntime, GraphqlPrebuiltEntry, and
computeGraphqlPrebuiltHash() — remain on @zeltjs/graphql. Application code
does not normally need them.
Code-first
import { createApp, http } from '@zeltjs/core';
import { args, graphql, Query, Resolver } from '@zeltjs/graphql';
import * as v from 'valibot';
const GetProductInput = v.object({
id: v.string(),
});
type Product = {
readonly id: string;
readonly name: string;
};
@Resolver()
class ProductResolver {
@Query()
product(input = args(GetProductInput)): Product {
return { id: input.id, name: 'Keyboard' };
}
}
export const app = createApp([
http({
children: [
graphql({
path: '/graphql',
resolvers: [ProductResolver],
}),
],
}),
]);
args(schema) defines GraphQL field arguments from a Standard Schema and
validates them at runtime.
Schema-first
type Query {
product(id: ID!): Product
}
type Product {
id: ID!
name: String!
}
zelt graphql codegen --schema src/graphql/schema.graphql --out src/generated/graphql.ts
This writes src/generated/graphql.ts with a Gql namespace of typed helpers
and a schema export ({ sdl }) that identifies this schema. It also upserts
an entry into <cwd>/.zelt/graphql-codegen.json, pairing the schema's content
hash with this helper's path — graphqlPlugin() uses that pairing later to
find where to write resolverChecks for an endpoint (see Build flow below).
import { Query, Resolver } from '@zeltjs/graphql';
import { Gql } from '../../generated/graphql';
@Resolver()
class ProductResolver {
@Query()
product(input = Gql.Query.product.args()): Gql.Query.product.Result {
return { id: input.id, name: 'Keyboard' };
}
}
Pass the helper's schema export to graphql() to bind this endpoint to it:
import { createApp, http } from '@zeltjs/core';
import { graphql } from '@zeltjs/graphql';
import { schema } from './generated/graphql';
import { ProductResolver } from './graphql/product.resolver';
export const app = createApp([
http({
children: [
graphql({
path: '/graphql',
resolvers: [ProductResolver],
schema,
}),
],
}),
]);
Each schema-first graphql() endpoint is paired with its schema this way —
in application code, not through plugin configuration. Because the pairing
lives at the graphql() call site, an app can mount several schema-first
lines (or mix schema-first and code-first lines) side by side, each with its
own name:
graphql({ name: 'storefront', path: '/graphql', resolvers: [...], schema: storefrontSchema }),
graphql({ name: 'admin', path: '/admin/graphql', resolvers: [...], schema: adminSchema }),
storefrontSchema and adminSchema come from two separate zelt graphql codegen runs, each with its own --out. The two lines never share resolvers
or schema — a query sent to /graphql only sees the storefront schema's
fields, and a query sent to /admin/graphql only sees the admin schema's.
Additional runtime validation can be layered onto generated helpers:
@Query()
product(input = Gql.Query.product.args(GetProductInput)): Gql.Query.product.Result {
return { id: input.id, name: 'Keyboard' };
}
In schema-first mode, SDL remains the source of truth. A Standard Schema passed to generated args helpers is treated as additional validation.
args<T>() is intentionally not part of the user-facing API. Schema-first types
should come from generated helpers, not handwritten generic arguments.
Build flow
Only the platform entry file imports build-generated runtime output —
everything under .zelt/. The app definition never imports it, so building
an app for the first time never hits a chicken-and-egg problem there.
Schema-first apps do import generated code (the typed helpers zelt graphql codegen writes to src/generated/graphql.ts), but codegen runs directly
from schema.graphql and doesn't require evaluating the app, so no
chicken-and-egg problem arises there either.
import { graphqlPlugin } from '@zeltjs/graphql/codegen';
Register the plugin in zelt.config.ts:
import { defineConfig } from '@zeltjs/cli';
import { graphqlPlugin } from '@zeltjs/graphql/codegen';
export default defineConfig({
app: () => import('./src/app').then((m) => m.app),
plugins: [graphqlPlugin()],
build: { entry: './src/node.ts' },
dev: { entry: './src/node.ts' },
});
zelt build and zelt dev then run two generation steps automatically:
- Each registered
graphqlPlugin()writes.zelt/graphql/<key>.runtime.ts(export const graphqlPrebuilt = { runtime, resolversHash }) and a sibling.graphqlSDL file, one pair pergraphql({ path, resolvers })endpoint.<key>is the endpoint's validated key (itsname, orgraphqlwhen omitted; see above), so twographql()endpoints only collide if they share a key — pass a distinctnameto each to disambiguate. - The CLI collects every plugin's contributions and writes
.zelt/prebuilt.ts(export const zeltPrebuilt = {...} satisfies ZeltPrebuilt), which re-exports each generated module under its key, namespaced under thegraphqlfeature. This file is always generated, even when no plugin contributes anything.
The platform entry file statically imports zeltPrebuilt and passes it to the
adapter:
import { onNode } from '@zeltjs/adapter-node';
import { app } from './app';
import { zeltPrebuilt } from '../.zelt/prebuilt';
const nodeApp = await onNode(app, { prebuilt: zeltPrebuilt });
Every adapter (onNode, onBun, onCloudflareWorkers, onElectron,
onLambda) accepts the same prebuilt option. Because the entry file uses
only a static import, this works unmodified under bundlers that require
static imports, such as the Cloudflare Workers wrangler bundle — there is no
filesystem fallback on any platform.
A single graphqlPlugin() handles every endpoint in the app — code-first,
schema-first, or a mix — because each endpoint carries its own line (see
API boundary above). There is no mode option to choose between them.
Code-first
- Write resolvers.
- Configure
graphql({ path, resolvers }). - Add
graphqlPlugin()topluginsinzelt.config.ts. - Run
zelt buildorzelt dev. - The platform entry file imports
zeltPrebuiltfrom../.zelt/prebuiltand passes it to the adapter.
Schema-first
- Write
schema.graphql. - Run
zelt graphql codegen --schema ... --out .... - Write resolvers using generated
Gqlhelpers. - Configure
graphql({ path, resolvers, schema }), passing the helper'sschemaexport. - Add
graphqlPlugin()topluginsinzelt.config.ts(same as code-first — no schema-first-specific options). - Run
zelt buildorzelt dev. - The platform entry file imports
zeltPrebuiltfrom../.zelt/prebuiltand passes it to the adapter.
Repeat steps 1-4 with a different --out and a distinct name per endpoint
to add another schema-first line.
Automatic schema-first codegen during zelt dev is not part of this release
boundary. Run zelt graphql codegen explicitly whenever schema.graphql
changes, before zelt build/zelt dev — a missing or stale codegen manifest
entry (<cwd>/.zelt/graphql-codegen.json) fails the build with an error
telling you to rerun it.
Every schema-first endpoint automatically gets a type-check file asserting
each resolver method's return type is assignable to the corresponding
generated Gql.Query/Gql.Mutation result type. It is written next to the
codegen helper the endpoint's schema hashes to (<helper>.resolver-checks.ts,
or <helper>.<name>.resolver-checks.ts when two endpoints share a helper) —
there is no configuration for this; graphqlPlugin() discovers the pairing
through the codegen manifest.
Current limitations
GraphQL requires the prebuilt module. If it is missing, if the endpoint's
prebuilt entry is missing, or if the entry file does not import zeltPrebuilt,
the endpoint throws at startup — there is no silent fallback on any platform.
Each endpoint is looked up in the prebuilt module by its key (the endpoint's
name, or graphql when omitted). If no entry exists under that key —
because the prebuilt is missing the endpoint, or it hasn't been built yet —
startup throws and tells you to rerun zelt build. Once an entry is found,
its embedded resolversHash — a SHA-256 fingerprint over the endpoint path
and the sorted resolver class names — is checked against the value recomputed
from the running graphql({ path, resolvers }) declaration. A mismatch means
the prebuilt entry is stale relative to the current resolvers, and startup
throws telling you to rerun zelt build. v1 only fingerprints the endpoint
path and resolver class names — changes to resolver method signatures,
argument types, or return types are not detected and still require rerunning
zelt build manually.
Code-first
- Output type support is intentionally narrow.
- Complex GraphQL interfaces are limited.
- Code-first supports custom scalar codecs and named unions experimentally.
- Field names default to method names. Explicit names are supported through decorators where available.
- Field args use Standard Schema runtime validation and require a schema adapter for SDL generation.
Schema-first
- Schema-first codegen currently supports built-in scalars, object types,
Query, andMutation. - Custom scalars, enums, unions, interfaces, and input objects are intentionally limited or deferred.
- Schema-first support for custom scalar codecs and named unions is still limited and will be expanded separately.
- Root
QueryandMutationfields must have resolver bindings. - Object fields may rely on GraphQL default field resolution.
- Generated
Gql.Query.<field>.args()helpers are the main schema-first args API. - User-facing
args<T>()is intentionally not supported.