Skip to content

GraphQL Yoga

This page takes a GraphQL Yoga 5 origin through four steps: export the schema GraphPilot deploys, make responses cacheable, check that requests really came through GraphPilot, and purge when data changes. Each step stands on its own.

A deployment is built from a schema.graphql file next to your gpilot.toml (see deployments). It has to be the schema Yoga serves, with its directives left in: @cacheControl and @surrogateKey are read from that file, not from your server.

printSchema from the graphql package drops every directive you applied, so print with printSchemaWithDirectives from @graphql-tools/utils. It works on the schema createSchema returns:

scripts/export-schema.ts
import { writeFileSync } from "node:fs"
import { printSchemaWithDirectives } from "@graphql-tools/utils"
import { schema } from "../src/schema" // the result of createSchema({ typeDefs, resolvers })
writeFileSync("schema.graphql", printSchemaWithDirectives(schema))

Run it before every deployment, for example as a step in your CI workflow. Deploy the new schema to GraphPilot before you release the server that adds fields to it: a query selecting a field the deployed schema does not declare is answered but never cached (CACHE_SKIPPED_UNRESOLVED_SELECTION). See deploy the schema before the origin.

Yoga sends no Cache-Control header of its own, so GraphPilot takes the cache policy from the @cacheControl hints in your schema. Declare the directive in your type definitions, so the schema stays valid for Yoga too, and annotate what may be cached:

src/schema.graphql
enum CacheControlScope {
PUBLIC
PRIVATE
}
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
inheritMaxAge: Boolean
swr: Int
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
type Query {
products: [Product!]! @cacheControl(maxAge: 300, swr: 3600)
me: User @cacheControl(maxAge: 60, scope: PRIVATE)
}

Yoga itself ignores the directive. A hint without scope is PUBLIC, so a field whose answer depends on the caller needs a scope: PRIVATE for data about one person, a named scope for data shared within an organization; see partitioning your cache. A field hint that states no scope keeps its return type’s scope; see a field hint overrides only the arguments it sets.

If you would rather decide the freshness lifetime (TTL) in code, send a Cache-Control header from your origin instead. GraphPilot follows it before the schema (see how long an entry lives).

GraphPilot signs every request it forwards with your service’s signing key (see request signing). Checking that signature lets your origin refuse anything that did not come through GraphPilot.

You need the signing key. The portal and gpilot service create each show it once, when the service is created, and neither shows it again. If you did not keep it, rotate it in the portal to get a new one. Store it as GRAPHPILOT_SIGNING_KEY.

Copy the Node.js verifyGraphPilotSignature function from verify the signature on your origin into src/graphpilot-signature.ts. The signature covers the raw request body. A Yoga plugin’s onRequest hook runs before Yoga reads the body, so it can hash a copy of it:

src/graphpilot-plugin.ts
import type { Plugin } from "graphql-yoga"
import { verifyGraphPilotSignature } from "./graphpilot-signature"
export function useGraphPilotSignature(keys: string[]): Plugin {
return {
async onRequest({ request, url, fetchAPI, endResponse }) {
// A clone, so Yoga can still read the original body.
const rawBody = Buffer.from(await request.clone().arrayBuffer())
const headers = Object.fromEntries(request.headers)
const valid = keys.some((signingKey) =>
verifyGraphPilotSignature({
method: request.method,
pathAndQuery: url.pathname + url.search,
rawBody,
headers,
signingKey,
}),
)
if (!valid) endResponse(new fetchAPI.Response(null, { status: 401 }))
},
}
}
src/server.ts
import { createServer } from "node:http"
import { createYoga } from "graphql-yoga"
import { schema } from "./schema"
import { useGraphPilotSignature } from "./graphpilot-plugin"
const yoga = createYoga({
schema,
// During a key rotation, add the pending key here as a second entry.
plugins: [useGraphPilotSignature([process.env.GRAPHPILOT_SIGNING_KEY!])],
})
createServer(yoga).listen(4000)

onRequest runs for every request that reaches your plugin. Yoga’s built-in health check (/health) answers before it, so probes to that path are unaffected; if your load balancer probes any other path directly, return early for it before verifying. url has been through the URL parser, which can re-encode unusual characters; if your clients send such characters in the query string, take the target from the raw request line instead. The known test vector checks your setup byte for byte.

A mutation that returns the changed object purges it for you. GraphPilot tags the mutation response with the keys of the objects in it and purges every cached response that contained them (see automatic invalidation). So let your mutations return the object they changed, not a Boolean:

type Mutation {
updateProduct(id: ID!, input: ProductInput!): Product! # purges Product:id:<id>
deleteProduct(id: ID!): Boolean! # purges nothing
}

The client does not have to select id for this to work: GraphPilot adds the key fields to the query and removes them again before the response reaches the client.

When data changes outside GraphQL (an admin tool, an import job, a queue consumer), purge the keys from that code with gpilot purge or the purgeBySurrogateKey mutation, for example Product:id:<id>. The API key needs the cache:purge permission. See purging through the API and CLI for the call, its payload and where the service id comes from.