Files
Charis 9d9fdcd9fc feat (docs): on-demand revalidation for graphql and wrappers (#35177)
Before:

Revalidation was turned off entirely for wrappers pages, and done daily for GraphQL pages.

After:

Revalidation is via tag. There is an /api/revalidate endpoint that is used by GitHub Actions on the federated repos to trigger the revalidation. This decreases the number of unnecessary page generations by Vercel, and gives the maintainers of federated repos more fine-grained control over when to reploy their docs (see the docs deployment playbook for more info).
2025-04-24 14:59:38 -04:00

118 lines
3.5 KiB
TypeScript

import { createClient } from '@supabase/supabase-js'
import { type Database } from 'common'
import { revalidateTag } from 'next/cache'
import { headers } from 'next/headers'
import { type NextRequest } from 'next/server'
import { z } from 'zod'
import { VALID_REVALIDATION_TAGS } from '~/features/helpers.fetch'
enum AuthorizationLevel {
Unauthorized,
Basic,
Override,
}
const requestBodySchema = z.object({
tags: z.array(z.enum(VALID_REVALIDATION_TAGS)),
})
export const POST = handleError(_handleRevalidateRequest)
export async function _handleRevalidateRequest(request: NextRequest) {
const requestHeaders = headers()
const authorization = requestHeaders.get('Authorization')
if (!authorization) {
return new Response('Missing Authorization header', { status: 401 })
}
const basicKeys = process.env.DOCS_REVALIDATION_KEYS?.split(/\s*,\s*/) ?? []
const overrideKeys = process.env.DOCS_REVALIDATION_OVERRIDE_KEYS?.split(/\s*,\s*/) ?? []
if (basicKeys.length === 0 && overrideKeys.length === 0) {
console.error('No keys configured for revalidation')
return new Response('Internal server error', {
status: 500,
})
}
let authorizationLevel = AuthorizationLevel.Unauthorized
const token = authorization.replace(/^Bearer /, '')
if (overrideKeys.includes(token)) {
authorizationLevel = AuthorizationLevel.Override
} else if (basicKeys.includes(token)) {
authorizationLevel = AuthorizationLevel.Basic
}
if (authorizationLevel === AuthorizationLevel.Unauthorized) {
return new Response('Invalid Authorization header', { status: 401 })
}
let result: z.infer<typeof requestBodySchema>
try {
result = requestBodySchema.parse(await request.json())
} catch (error) {
console.error(error)
return new Response(
'Malformed request body: should be a JSON object with a "tags" array of strings.',
{ status: 400 }
)
}
const supabaseAdmin = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
)
if (authorizationLevel === AuthorizationLevel.Basic) {
const { data: lastRevalidation, error } = await supabaseAdmin.rpc(
'get_last_revalidation_for_tags',
{
tags: result.tags,
}
)
if (error) {
console.error(error)
return new Response('Internal server error', { status: 500 })
}
const sixHoursAgo = new Date()
sixHoursAgo.setHours(sixHoursAgo.getHours() - 6)
if (lastRevalidation?.some((revalidation) => new Date(revalidation.created_at) > sixHoursAgo)) {
return new Response(
'Your request includes a tag that has been revalidated within the last 6 hours. You can override this limit by authenticating with Override permissions.',
{
status: 429,
}
)
}
}
const { error } = await supabaseAdmin
.from('validation_history')
.insert(result.tags.map((tag) => ({ tag })))
if (error) {
console.error('Failed to update revalidation table: %o', error)
}
result.tags.forEach((tag) => {
revalidateTag(tag)
})
return new Response(null, {
status: 204,
headers: {
'Cache-Control': 'no-cache',
},
})
}
function handleError(handleRequest: (request: NextRequest) => Promise<Response>) {
return async function (request: NextRequest) {
try {
const response = await handleRequest(request)
return response
} catch (error) {
console.error(error)
return new Response('Internal server error', { status: 500 })
}
}
}