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 },
    },
  },
});
KeyDefault
path"/api/graphql"Where the endpoint answers. It is matched below app.baseURL: with baseURL: "/shop/" it is /shop/api/graphql.
maskErrorsfalse in dev, true in a buildHide the message of an unexpected resolver error. See Errors.
suggestionstrue in dev, false in a buildAdd Did you mean "title"? to validation messages.
limitssee Limits
cost{}Cost of a type or a field, by schema coordinate.
cache.documents1000How many documents the server keeps parsed. 0 turns the cache off.
heartbeat15000Milliseconds between : ping comments on a subscription stream, so idle timeouts do not close it. false or 0 turns them off. See Server-sent events.
introspectiontrue in dev, false in a buildAnswer __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.

FileWhat
server/graphql/**/*.graphqlThe 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.tsResolvers. Each file default-exports defineResolvers({ ... }).
server/graphql/context.tsOptional. 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 a defineScalar result.
  • A field without a resolver reads parent[fieldName]. A field of Query or Mutation must have a resolver.
  • __resolveType may 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.signal is 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+json when it is acceptable;
  • else application/json, which is also the answer when there is no Accept header;
  • if neither is acceptable, 406.

The status depends on that type:

Caseapplication/graphql-response+jsonapplication/json
The operation ran, with or without field errors200200
Syntax error, unknown or missing operationName, document too large400200
Validation error, a limit exceeded, variables that do not coerce422200
The resolvers could not be loaded500500

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:

CodeWhen
GRAPHQL_PARSE_FAILEDSyntax error, too many tokens, document too large
GRAPHQL_VALIDATION_FAILEDThe document breaks a spec rule
OPERATION_NOT_FOUNDoperationName matches nothing, or is missing while the document has several operations
DEPTH_LIMIT, ALIAS_LIMIT, COST_LIMITA limit
INTROSPECTION_DISABLED__schema or __type while introspection is off
PERSISTED_DOCUMENT_NOT_FOUND, PERSISTED_DOCUMENT_REQUIREDSee Operations and trusted documents
BAD_USER_INPUTA variable or an argument does not coerce
INTERNAL_SERVER_ERRORA 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 GraphQLError keeps its message and extensions.
  • A SilgiError from createError keeps its message and gets extensions: { status, data? }.
  • Anything else is unexpected. With maskErrors on, the client gets Internal server error with extensions.code: "INTERNAL_SERVER_ERROR". With it off (the dev default) it gets the real message and the stack in extensions.stack. Either way the error is logged once, with its schema coordinate and path, and passed to the error server 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 keyDefaultCounts
depth12The longest chain of fields, fragments expanded. A root field is depth 1.
aliases30The aliased fields of the operation, fragments expanded.
cost2000The cost of the operation, below.
tokens5000Tokens in the document; the parser stops there.
documentBytes65536The size of the document in UTF-8 bytes, checked before parsing.
listSize10The 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.

SchemaTypeScript
ID, String; Int, Float; Booleanstring; number; boolean
nullable| null; an argument or input field that is nullable and has no default is also optional
listIterable in results, Array in arguments
enumthe union of its value names
OneOf inputa 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-operations exports one value per operation, named after it, with the documentId, the operationName and the kind. 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 documentId skips 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

ValueA request with documentIdA request with query
"any" (default)answered if the ID is one of the app'sanswered, parsed and validated as before
"trusted"answered if the ID is one of the app'srefused 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.