Last active
August 17, 2026 03:45
-
-
Save timkelty/8549269b718efc1efe1a1588cdaaeda0 to your computer and use it in GitHub Desktop.
Craft Cloud: resilient signed GraphQL requests from Next.js and Astro
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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