Skip to content

Instantly share code, notes, and snippets.

@timkelty
Last active August 17, 2026 03:45
Show Gist options
  • Select an option

  • Save timkelty/8549269b718efc1efe1a1588cdaaeda0 to your computer and use it in GitHub Desktop.

Select an option

Save timkelty/8549269b718efc1efe1a1588cdaaeda0 to your computer and use it in GitHub Desktop.
Craft Cloud: resilient signed GraphQL requests from Next.js and Astro
/**
* Astro server example: signed Craft Cloud GraphQL requests with one
* Retry-After-aware retry.
*
* Install: npm install http-message-sig
*
* Keep this module server-side. Configure CRAFT_URL, CRAFT_GRAPHQL_TOKEN, and
* CRAFT_CLOUD_SIGNING_KEY as private environment variables.
*
* Retries are safe here because queryCraft only runs GraphQL queries. Do not
* reuse the retry behavior for mutations or other requests with side effects.
*/
import { signatureHeaders } from 'http-message-sig';
const {
CRAFT_URL,
CRAFT_GRAPHQL_TOKEN,
CRAFT_CLOUD_SIGNING_KEY,
} = import.meta.env;
if (!CRAFT_URL || !CRAFT_GRAPHQL_TOKEN || !CRAFT_CLOUD_SIGNING_KEY) {
throw new Error('Missing Craft environment variables');
}
const encoder = new TextEncoder();
const signingKey = crypto.subtle.importKey(
'raw',
encoder.encode(CRAFT_CLOUD_SIGNING_KEY),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
async function createSignedRequest(request) {
const signedRequest = request.clone();
const created = new Date();
const signature = await signatureHeaders(signedRequest, {
key: 'sig',
signer: {
keyid: 'hmac',
alg: 'hmac-sha256',
async sign(data) {
return new Uint8Array(
await crypto.subtle.sign(
'HMAC',
await signingKey,
encoder.encode(data)
)
);
},
},
components: ['@method', '@target-uri'],
created,
expires: new Date(created.getTime() + 60_000),
});
for (const [name, value] of Object.entries(signature)) {
signedRequest.headers.set(name, value);
}
return signedRequest;
}
function retryDelay(response) {
const retryAfter = response.headers.get('Retry-After');
if (retryAfter !== null) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1_000);
}
const date = Date.parse(retryAfter);
if (!Number.isNaN(date)) {
return Math.max(0, date - Date.now());
}
}
return Math.random() * 500;
}
export async function fetchWithRetry(request) {
const deadline = Date.now() + 30_000;
const signal = AbortSignal.timeout(30_000);
const send = async () =>
fetch(await createSignedRequest(request), { signal });
let response = await send();
if ([429, 503].includes(response.status)) {
const delay = retryDelay(response);
if (Date.now() + delay < deadline) {
await new Promise((resolve) => setTimeout(resolve, delay));
response = await send();
}
}
return response;
}
export async function queryCraft(query, variables = {}, method = 'POST') {
if (method !== 'GET' && method !== 'POST') {
throw new Error('Craft GraphQL requests must use GET or POST');
}
const url = new URL('/api', CRAFT_URL);
const headers = new Headers({
Authorization: `Bearer ${CRAFT_GRAPHQL_TOKEN}`,
});
let body;
if (method === 'GET') {
url.searchParams.set('query', query);
if (Object.keys(variables).length) {
url.searchParams.set('variables', JSON.stringify(variables));
}
} else {
headers.set('Content-Type', 'application/json');
body = JSON.stringify({ query, variables });
}
const response = await fetchWithRetry(
new Request(url, { method, headers, body })
);
if (!response.ok) {
throw new Error(
`Craft request failed: ${response.status} ${response.statusText}`
);
}
const result = await response.json();
if (result.errors?.length) {
throw new Error(result.errors.map(({ message }) => message).join('\n'));
}
return result.data;
}
/**
* Next.js App Router example: signed Craft Cloud GraphQL requests with
* Retry-After-aware, bounded retries.
*
* Install: npm install ky http-message-sig
*
* Keep this module server-side. Configure CRAFT_URL, CRAFT_GRAPHQL_TOKEN, and
* CRAFT_CLOUD_SIGNING_KEY as server-side environment variables.
*
* This retries POST because Craft GraphQL queries are read-only. Do not use
* automatic POST retries for mutations or other requests with side effects.
*/
import crypto from 'node:crypto';
import { signatureHeadersSync } from 'http-message-sig';
import ky from 'ky';
export async function queryCraft(query, variables = {}) {
const method = 'POST';
const url = `${process.env.CRAFT_URL}/api`;
const body = JSON.stringify({ query, variables });
const created = new Date();
const signatureHeaders = signatureHeadersSync(
{ method, url },
{
key: 'sig',
signer: {
keyid: 'hmac',
alg: 'hmac-sha256',
signSync(data) {
return crypto
.createHmac('sha256', process.env.CRAFT_CLOUD_SIGNING_KEY)
.update(data)
.digest();
},
},
components: ['@method', '@target-uri'],
created,
expires: new Date(created.getTime() + 60_000),
}
);
const result = await ky
.post(url, {
body,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.CRAFT_GRAPHQL_TOKEN}`,
...signatureHeaders,
},
retry: {
limit: 1,
methods: ['post'],
statusCodes: [429, 503],
jitter: true,
},
totalTimeout: 30_000,
next: {
revalidate: 300,
tags: ['craft:blog'],
},
})
.json();
if (result.errors?.length) {
throw new Error(result.errors.map((error) => error.message).join('\n'));
}
return result.data;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment