GraphQL
Silgi serves a GraphQL endpoint of its own. You write the schema in SDL and the resolvers in TypeScript; the build checks the schema and the server answers queries and mutations over HTTP. It has no dependency on graphql or any other GraphQL package, and it is off by default: an app that does not turn it on ships none of its code.
It follows the September 2025 edition of the GraphQL spec and the GraphQL over HTTP draft. The endpoint is not a server route and is not listed in the OpenAPI document.
Subscriptions are answered as server-sent events; see Subscriptions. A WebSocket transport is not planned.
Turn it on
Set server.graphql to true, or to an object with the keys below.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "html",
server: {
graphql: {
path: "/api/graphql",
maskErrors: true,
limits: { depth: 8, cost: 1000, aliases: 20, listSize: 20 },
cost: { "Query.posts": 5, "Post.author": 2 },
},
},
});
| Key | Default | |
|---|---|---|
path | "/api/graphql" | Where the endpoint answers. It is matched below app.baseURL: with baseURL: "/shop/" it is /shop/api/graphql. |
maskErrors | false in dev, true in a build | Hide the message of an unexpected resolver error. See Errors. |
suggestions | true in dev, false in a build | Add Did you mean "title"? to validation messages. |
limits | see Limits | |
cost | {} | Cost of a type or a field, by schema coordinate. |
cache.documents | 1000 | How many documents the server keeps parsed. 0 turns the cache off. |
heartbeat | 15000 | Milliseconds between : ping comments on a subscription stream, so idle timeouts do not close it. false or 0 turns them off. See Server-sent events. |
introspection | true in dev, false in a build | Answer __schema and __type. See Introspection. |
documents | "any" | "trusted" takes only the app's own operations in a build. See Operations and trusted documents. |
The config fails to load when path does not start with /, holds ? or # or is the server.openapi path; when a limit is not a positive integer (or false, where allowed); when limits.documentBytes is above server.maxBodySize; when a cost key is not "Type" or "Type.field" or its value is negative; and when heartbeat is not false or an integer from 0 to 2147483647.
Files
Everything lives in server/graphql/. These files are not server routes.
| File | What |
|---|---|
server/graphql/**/*.graphql | The schema, in SDL. Every file is read in path order and merged into one schema, so a type can be split with extend. |
server/graphql/**/*.resolver.ts | Resolvers. Each file default-exports defineResolvers({ ... }). |
server/graphql/context.ts | Optional. Default-exports defineGraphQLContext(factory). |
Any other .ts file in the folder (helpers, models) is plain code and is not scanned. .d.ts and *.test.ts files are skipped everywhere. The app's own operations are in app/graphql/; see Operations and trusted documents.
The schema
"""A blog post."""
type Post {
id: ID!
title: String!
author: User!
publishedAt: DateTime
}
type User {
id: ID!
name: String!
}
scalar DateTime
input RenamePostInput {
id: ID!
title: String!
}
type Query {
post(id: ID!): Post
posts(first: Int = 10): [Post!]!
viewer: String
}
type Mutation {
renamePost(input: RenamePostInput!): Post!
}
type Subscription {
postAdded(authorId: ID): Post!
}
A schema without a schema { ... } definition uses Query and Mutation as its roots, and a Query type is required. The build parses every file and checks the schema against the spec's type-system rules. Each problem is reported with its file, line and column, and the build fails when there is one.
Two more build errors: a server route whose path is the GraphQL path (for any method), and a cost key that names a type or field the schema does not have.
With server.graphql off, .graphql files under server/graphql/ only produce a warning: server/graphql has 2 .graphql files but server.graphql is off.
Resolvers
import { GraphQLError, createError, defineResolvers, defineScalar, getRequestHeader } from "silgi/server";
import type { PostRow } from "./graphql-models.ts";
const posts = new Map<string, PostRow>([["1", { id: "1", title: "Hello", authorId: "7", publishedAt: new Date("2026-10-01") }]]);
export default defineResolvers({
Query: {
post: (_parent, { id }) => posts.get(id) ?? null,
posts: (_parent, { first }) => [...posts.values()].slice(0, first ?? 10),
viewer: (_parent, _args, { viewer }) => viewer,
},
Mutation: {
renamePost: (_parent, { input }, { event }) => {
if (!getRequestHeader(event, "authorization")) throw createError({ status: 401, message: "Sign in first" });
const post = posts.get(input.id);
if (!post) throw new GraphQLError("No such post", { extensions: { code: "NOT_FOUND" } });
post.title = input.title;
return post;
},
},
Post: {
author: (post) => ({ id: post.authorId, name: `User ${post.authorId}` }),
},
DateTime: defineScalar({
serialize: (value: Date) => value.toISOString(),
parseValue: (value) => new Date(String(value)),
}),
});
A resolver is (parent, args, context, info) => result and may return a promise.
- The keys of the object are type names. An object type maps to its field resolvers; an interface or union maps to
{ __resolveType }; a custom scalar maps to adefineScalarresult. - A field without a resolver reads
parent[fieldName]. A field ofQueryorMutationmust have a resolver. __resolveTypemay be left out when the value has a string__typename. The name must be a possible type, or the field fails.- Enum values are their names, as strings, in both directions.
- Query fields run in parallel; the fields of a mutation run one after another.
info.signalis the request's signal. After it aborts, no further resolver is called.- A field defined in two files, a key that names no type or field of the schema, and a root field without a resolver are errors that name the files. They are found when the resolvers load, on the first GraphQL request: every request then answers 500 and the error is logged. In dev the 500 body carries the message.
The types of parent, args, the context and the result come from the schema; see Types. The API is in Server API.
Context
import { defineGraphQLContext, getRequestHeader } from "silgi/server";
export default defineGraphQLContext((event) => ({ viewer: getRequestHeader(event, "x-viewer") ?? null }));
The context of a resolver is { event } merged with what the factory returns. The factory runs once per request, after the server middleware, so what a middleware put in event.context is visible to it. Without context.ts the context is { event }. A middleware can also guard the endpoint: it runs before it, as for any request.
The HTTP endpoint
The endpoint answers GET and POST at path. Any other method is 405 with allow: GET, POST.
POST sends a JSON object. The content type must be application/json (a charset other than utf-8 is refused); another type or none is 415.
{ "query": "query Posts($first: Int) { posts(first: $first) { title } }", "operationName": "Posts", "variables": { "first": 5 } }
query is required. operationName is required when the document has more than one operation. variables is an object. extensions is accepted and ignored. Unknown keys and null values are ignored. Invalid JSON, an empty body and an array (batching is not supported) are 400, and a body above server.maxBodySize is 413.
GET takes the same parameters from the query string: query, operationName, and variables as a JSON string. An empty parameter counts as absent. A GET can only run queries: a mutation over GET is 405 with allow: POST and is not run. A cross-site form cannot send a JSON POST without a CORS preflight, so mutations need a deliberate request.
The request itself is also 400 when query is missing, when variables is not an object or operationName is not a string.
The response
The body is { "data": ..., "errors": [...] }, with errors only when there are some. The content type follows Accept:
application/graphql-response+jsonwhen it is acceptable;- else
application/json, which is also the answer when there is noAcceptheader; - if neither is acceptable,
406.
The status depends on that type:
| Case | application/graphql-response+json | application/json |
|---|---|---|
| The operation ran, with or without field errors | 200 | 200 |
Syntax error, unknown or missing operationName, document too large | 400 | 200 |
| Validation error, a limit exceeded, variables that do not coerce | 422 | 200 |
| The resolvers could not be loaded | 500 | 500 |
An error before the operation runs has errors and no data. Failures that are not about the document (400 for bad JSON or parameters, 405, 406, 413, 415) answer with the standard error JSON of the server, like any server route.
Headers that a middleware or a hook set on the event are merged into the response.
Subscriptions
A Subscription type in the schema (or a subscription root in schema { … }) adds subscriptions. The schema sample above declares type Subscription { postAdded(authorId: ID): Post! }, and the sample in app/graphql/ has the operation subscription PostAdded($authorId: ID). Each of its fields is resolved by { subscribe, resolve? }: subscribe returns an async iterable of events, and each event is executed with the client's selection. Without resolve, the event's own field of the same name is the value; with it, resolve(event, args, context, info) returns the value.
export default defineResolvers({
Subscription: {
postAdded: {
async *subscribe(_parent, { authorId }) {
for await (const post of posts.watch(authorId)) yield { postAdded: post };
},
},
},
});
A subscription needs accept: text/event-stream, by GET (as an EventSource sends it) or POST; without it the answer is 406. The response follows the "distinct connections" mode of graphql-sse: one event: next per event, its data the execution result as JSON, and event: complete when the source ends.
event: next
data: {"data":{"postAdded":{"title":"Hello"}}}
event: complete
data:
An error before the stream starts (syntax, validation, variables, limits) is answered as JSON, as for a query. An error thrown by subscribe is one next event with errors and no data, then complete. A source that throws later ends the same way, with the error masked like a resolver error. When the client goes away, Silgi calls the iterator's return(), so release what the subscription holds in a finally block or in return(). A subscription from app/graphql/ is sent by its documentId like any operation, also with documents: "trusted". While the stream waits for the next event it writes a : ping comment every heartbeat milliseconds; an EventSource ignores it. A stream also ends at the platform's limit on one response (60 s on netlify, the function's maxDuration on vercel); see Server-sent events.
$graphql does not run subscriptions. In the browser, open an EventSource on the endpoint with the documentId and the variables, as JSON, in the query string; the sample uses the default path with no app.baseURL. EventSource connects again when a stream ends, so close it on complete:
import type { GraphQLResponse } from "silgi/app";
import { PostAdded } from "#silgi/graphql-operations";
type PostAddedData = { postAdded: { id: string; title: string } };
export function watchPosts(authorId: string, onPost: (post: PostAddedData["postAdded"]) => void): () => void {
const params = new URLSearchParams({ documentId: PostAdded.documentId, variables: JSON.stringify({ authorId }) });
const source = new EventSource(`/api/graphql?${params}`);
source.addEventListener("next", (event) => {
const { data, errors }: GraphQLResponse<PostAddedData> = JSON.parse(event.data);
if (errors) console.error(errors[0]!.message);
else onPost(data!.postAdded);
});
source.addEventListener("complete", () => source.close());
return () => source.close();
}
Errors
Each entry of errors is { message, locations?, path?, extensions? }:
{
"errors": [
{
"message": "Cannot query field \"titel\" on type \"Post\".",
"locations": [{ "line": 1, "column": 12 }]
}
]
}
Errors Silgi makes carry extensions.code:
| Code | When |
|---|---|
GRAPHQL_PARSE_FAILED | Syntax error, too many tokens, document too large |
GRAPHQL_VALIDATION_FAILED | The document breaks a spec rule |
OPERATION_NOT_FOUND | operationName matches nothing, or is missing while the document has several operations |
DEPTH_LIMIT, ALIAS_LIMIT, COST_LIMIT | A limit |
INTROSPECTION_DISABLED | __schema or __type while introspection is off |
PERSISTED_DOCUMENT_NOT_FOUND, PERSISTED_DOCUMENT_REQUIRED | See Operations and trusted documents |
BAD_USER_INPUT | A variable or an argument does not coerce |
INTERNAL_SERVER_ERROR | A masked resolver error, or the resolvers could not be loaded |
What a resolver throws decides what the client sees. The field becomes null (or the nearest nullable parent), the error gets the field's path and locations, and the HTTP status stays 200.
- A
GraphQLErrorkeeps its message andextensions. - A
SilgiErrorfromcreateErrorkeeps its message and getsextensions: { status, data? }. - Anything else is unexpected. With
maskErrorson, the client getsInternal server errorwithextensions.code: "INTERNAL_SERVER_ERROR". With it off (the dev default) it gets the real message and the stack inextensions.stack. Either way the error is logged once, with its schema coordinate and path, and passed to theerrorserver hook.
Syntax and validation messages are never masked. With suggestions off they do not list schema names the client did not write.
Limits
A request is refused before it runs when it breaks a limit. Set a limit to false to turn it off, except documentBytes and listSize.
limits key | Default | Counts |
|---|---|---|
depth | 12 | The longest chain of fields, fragments expanded. A root field is depth 1. |
aliases | 30 | The aliased fields of the operation, fragments expanded. |
cost | 2000 | The cost of the operation, below. |
tokens | 5000 | Tokens in the document; the parser stops there. |
documentBytes | 65536 | The size of the document in UTF-8 bytes, checked before parsing. |
listSize | 10 | The size assumed for a list when no argument tells it. |
The cost is the sum, over every field, of the field's cost times the size of the lists around it. A field costs cost["Type.field"], else cost["Type"] of the type it returns, else 1 for an object, interface or union and 0 for a scalar or enum. A list field multiplies what is below it by its first first, last or limit argument, as an Int (a variable counts), and by listSize otherwise. With the defaults, posts(first: 20) { title author { name } } costs 1 + 20 × 1 = 21.
The server keeps each document it has parsed, with its validation errors, in a cache of cache.documents entries, so a repeated document costs one lookup.
Introspection
Tools such as GraphiQL read the schema with the standard introspection query, which asks for __schema and __type. The introspection key turns the answer on or off. It is on in dev and off in a build, so a deployed app does not publish its schema unless you set introspection: true.
When it is on, the standard query is answered as the spec defines it, including isOneOf and specifiedByURL. __typename works either way.
When it is off, a query that selects __schema or __type fails validation with extensions.code: "INTROSPECTION_DISABLED" and the message GraphQL introspection is not allowed, but the query contains __schema or __type. Nothing is run. The introspection code and the schema descriptions are then not in the server bundle at all.
Types
With server.graphql on, silgi dev, silgi build and silgi typegen write two files in .silgi/types/: graphql.d.ts for the server and graphql-operations.d.ts for operations. Both are referenced from silgi.d.ts, so editors pick them up with no import. They are rewritten on every change to a .graphql file in dev; while the schema has errors the last good file stays.
defineResolvers is typed from the schema: parent, args, the context and the return value are all checked, and a resolver needs no annotation. A key that is not a type of the schema is a type error, as it is a run-time error.
| Schema | TypeScript |
|---|---|
ID, String; Int, Float; Boolean | string; number; boolean |
| nullable | | null; an argument or input field that is nullable and has no default is also optional |
| list | Iterable in results, Array in arguments |
| enum | the union of its value names |
| OneOf input | a union of one-key objects |
| context | { event } and what context.ts returns |
By default a resolver of Post gets the schema's own Post shape as parent. To use your own row type, map the GraphQL type to it by augmenting GraphQLModels. A custom scalar is unknown until you say what it is inside the server and what the client sees, by augmenting GraphQLScalars with its internal and external types.
export interface PostRow {
id: string;
title: string;
authorId: string;
publishedAt: Date | null;
}
declare module "silgi/server" {
interface GraphQLModels {
Post: PostRow;
}
interface GraphQLScalars {
DateTime: { internal: Date; external: string };
}
}
The resolvers sample above relies on both: post returns a PostRow, Post.author reads post.authorId, and viewer comes from the context factory.
Operations and trusted documents
The queries, mutations and subscriptions of your own app live in app/graphql/**/*.graphql, one or many per file.
query PostPage($id: ID!) {
post(id: $id) {
title
author { name }
publishedAt
}
}
mutation RenamePost($id: ID!, $title: String!) {
renamePost(input: { id: $id, title: $title }) {
id
title
}
}
subscription PostAdded($authorId: ID) {
postAdded(authorId: $authorId) {
id
title
}
}
- Every operation needs a name, and names are unique across all files. Fragment names are shared across all files too, so an operation can spread a fragment from another file.
- The build validates the operations against the schema, with the same rules and limits as a request (variables at their defaults). A mistake fails the build with
file:line:column, in a block of its own after any schema errors. - Each operation is printed in a canonical form, with the fragments it uses, and gets a
documentId:sha256:and the SHA-256 of that text in lowercase hex. Editing a fragment that an operation does not use leaves its ID as it is. #silgi/graphql-operationsexports one value per operation, named after it, with thedocumentId, theoperationNameand thekind. It never holds the text of the operation, so a client bundle carries IDs only.- The server holds each document already parsed and validated, so a request with a
documentIdskips both steps. Limits that depend on the document alone are checked from values computed in the build; cost is still computed per request, since it reads the variables.
A client sends { "documentId": "sha256:...", "variables": { ... } } to the endpoint instead of query.
documents
| Value | A request with documentId | A request with query |
|---|---|---|
"any" (default) | answered if the ID is one of the app's | answered, parsed and validated as before |
"trusted" | answered if the ID is one of the app's | refused with PERSISTED_DOCUMENT_REQUIRED in a build |
An unknown documentId is refused with PERSISTED_DOCUMENT_NOT_FOUND, in either mode. Both are request errors: 400 for application/graphql-response+json, 200 for application/json, with no data.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "html",
server: {
graphql: {
documents: "trusted",
introspection: false,
},
},
});
A trusted build has no GraphQL parser or validator in the server bundle, which is the reason to choose it; nothing a client sends can be a document the app did not build. In dev, query is always accepted so that tools work; with "trusted" each such document logs one warning.
$graphql
$graphql(operation, variables?, options?) from silgi/app runs one of these queries or mutations; a subscription is read with an EventSource, see Subscriptions. It sends the documentId through $fetch to the endpoint (path below app.baseURL), so on the server it is answered in process with no socket. It is typed from the operation: variables must fit the operation's variables (and may be left out only when none is required), and data has the shape of the selection, with enums as literal unions, custom scalars in their external type and fields under @skip or @include optional.
import { $graphql, createError, type Load } from "silgi/app";
import { PostPage } from "#silgi/graphql-operations";
export const load = (async ({ params }) => {
const { data, errors } = await $graphql(PostPage, { id: params.id! });
if (errors) throw createError({ status: 502, message: errors[0]!.message });
if (!data?.post) throw createError({ status: 404, message: "Post not found" });
return data.post;
}) satisfies Load;
It returns the body of any GraphQL response, { data?, errors?, extensions? }, whatever the HTTP status, so check errors before you use data. It throws a FetchError only when the answer is not a GraphQL response at all (a 404 page, a 413, an error JSON of the server).
options takes method, headers and signal. The default is POST; method: "GET" puts the ID and the variables in the query string and works for queries only, and a mutation or a subscription with GET throws a TypeError before anything is sent. A subscription sent with POST is refused with 406, and $graphql throws a FetchError. $graphql asks for application/graphql-response+json unless you set accept.
import { $graphql } from "silgi/app";
import { RenamePost } from "#silgi/graphql-operations";
export async function rename(id: string, title: string) {
const { data, errors } = await $graphql(RenamePost, { id, title }, { headers: { authorization: "Bearer token" } });
if (errors) return { error: errors[0]!.message };
return { post: data!.renamePost };
}
Call it in a load to render on the server, or in an event handler or after a click on the client. A page cannot write into the load data, so for a change such as a like, copy the load list into a local ref and update that. See Data and the signatures in App API.
Query with $fetch
To send a query that is not one of the app's operations, $fetch posts to the endpoint like to any route. On the server a URL that starts with / is answered in process, with no socket. $fetch asks for application/json, so a request error arrives as a 200 with errors; check errors before you read data.
import { $fetch } from "silgi/server";
interface GraphQLResult<Data> {
data?: Data;
errors?: Array<{ message: string; path?: Array<string | number>; extensions?: Record<string, unknown> }>;
}
const POSTS = `query Posts($first: Int) {
posts(first: $first) { id title author { name } }
}`;
export async function loadPosts(first: number) {
const result = await $fetch<GraphQLResult<{ posts: Array<{ id: string; title: string; author: { name: string } }> }>>("/api/graphql", {
method: "POST",
body: { query: POSTS, operationName: "Posts", variables: { first } },
});
if (result.errors) throw new Error(result.errors[0]!.message);
return result.data!.posts;
}
export function getPost(id: string) {
return $fetch<GraphQLResult<{ post: { title: string } | null }>>("/api/graphql", {
query: { query: "query Post($id: ID!) { post(id: $id) { title } }", variables: JSON.stringify({ id }) },
});
}
Call it in a load to render on the server. See Data.