refactor: move /incident-banner to app router (#43930)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Refactor

## What is the current behavior?

The `/incident-banner` endpoint is implemented using the Pages Router.

## What is the new behavior?

The `/incident-banner` endpoint is moved to the App Router, enabling
caching of the upstream fetch. This does not turn on the querying from
the frontend yet, making that a separate PR so we can revert easily if
needed.

## Additional context
This commit is contained in:
Charis authored and GitHub committed 2026-03-19 09:26:21 +01:00
1 parent df7e98bae6
commit fe0da16820
2 files changed
+88 -38

No files matched your search

@@ -0,0 +1,63 @@
import { IS_PLATFORM } from 'common'
import { NextResponse } from 'next/server'
import { InternalServerError } from '@/lib/api/apiHelpers'
import { getBannerIncidents } from '@/lib/api/incident-banner'
/**
* Cache on CDN for 5 minutes
* Allow serving stale content for 1 minute while revalidating
*/
const CACHE_CONTROL_SETTINGS = 'public, s-maxage=300, stale-while-revalidate=60'
export async function OPTIONS() {
if (!IS_PLATFORM) return new Response(null, { status: 404 })
return new Response(null, {
status: 204,
headers: {
Allow: 'GET, HEAD, OPTIONS',
},
})
}
export async function HEAD() {
if (!IS_PLATFORM) return new Response(null, { status: 404 })
return new Response(null, {
status: 200,
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
})
}
export async function GET() {
if (!IS_PLATFORM) return new Response(null, { status: 404 })
try {
const incidents = await getBannerIncidents()
return NextResponse.json(
{ incidents },
{ headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS } }
)
} catch (error) {
let errorCode = 500
const headers = new Headers()
if (error instanceof InternalServerError) {
if (typeof error.details?.status === 'number') errorCode = error.details.status
if (errorCode === 420) errorCode = 429
if (errorCode === 429 && typeof error.details?.retryAfter === 'string') {
headers.set('Retry-After', error.details.retryAfter)
}
console.error('Failed to fetch incident.io incidents: %O', {
message: error.message,
details: error.details,
})
} else {
console.error('Unexpected error fetching incident.io incidents: %O', error)
}
return NextResponse.json(
{ error: { message: 'Internal server error' } },
{ status: errorCode, headers }
)
}
}
@@ -1,7 +1,8 @@
import { IS_PROD } from 'common'
import { createHash } from 'crypto'
import { IS_PLATFORM, IS_PROD } from 'common'
import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
import z from 'zod'
import { InternalServerError } from 'lib/api/apiHelpers'
const INCIDENT_IO_BASE_URL = 'https://api.incident.io/v2'
@@ -15,13 +16,6 @@ const SENTINEL_VALUE_FORCE_BANNER = '100'
const FALLBACK_METADATA = { affected_regions: null, affects_project_creation: false }
/**
* Cache on browser for 5 minutes
* Cache on CDN for 5 minutes
* Allow serving stale content for 1 minute while revalidating
*/
const CACHE_CONTROL_SETTINGS = 'public, max-age=300, s-maxage=300, stale-while-revalidate=60'
const MetadataSchema = z.object({
affected_regions: z.union([z.array(z.string()), z.null()]),
affects_project_creation: z.boolean(),
@@ -51,9 +45,9 @@ interface IncidentIoListResponse {
pagination_meta?: { after?: string }
}
type ShowBannerValue = true | 'force'
export type ShowBannerValue = true | 'force'
interface BannerIncident {
export interface BannerIncident {
id: string
show_banner: ShowBannerValue
metadata: z.infer<typeof MetadataSchema> & { force: boolean }
@@ -83,11 +77,18 @@ async function fetchAllIncidents(apiKey: string, mode: string): Promise<Array<In
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
next: { revalidate: 180 },
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
const cause = await response.text()
throw new Error(`incident.io API responded with status ${response.status}`, { cause })
const retryAfter = response.headers.get('Retry-After') ?? undefined
const body = await response.text()
throw new InternalServerError(`incident.io API responded with ${response.status}`, {
status: response.status,
body,
...(retryAfter !== undefined && { retryAfter }),
})
}
const data: IncidentIoListResponse = await response.json()
@@ -98,31 +99,20 @@ async function fetchAllIncidents(apiKey: string, mode: string): Promise<Array<In
return incidents
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!IS_PLATFORM) {
return res.status(404).end()
}
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET'])
return res.status(405).json({ error: { message: `Method ${req.method} Not Allowed` } })
}
/**
* Fetches active banner incidents from the incident.io API.
*
* @returns Array of banner incidents
* @throws Error if INCIDENT_IO_API_KEY is not set or the API returns an error
*/
export async function getBannerIncidents(): Promise<Array<BannerIncident>> {
const apiKey = process.env.INCIDENT_IO_API_KEY
if (!apiKey) {
console.error('INCIDENT_IO_API_KEY is not set')
return res.status(500).json({ error: { message: 'Internal server error' } })
throw new Error('INCIDENT_IO_API_KEY is not set')
}
const incidentMode = IS_PROD ? 'standard' : 'test'
let allIncidents: Array<Incident>
try {
allIncidents = await fetchAllIncidents(apiKey, incidentMode)
} catch (error) {
console.error('Error fetching incidents from incident.io: %O', error)
return res.status(502).json({ error: { message: 'Internal server error' } })
}
const allIncidents = await fetchAllIncidents(apiKey, incidentMode)
const bannerIncidents: Array<BannerIncident> = []
@@ -152,8 +142,5 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
})
}
res.setHeader('Cache-Control', CACHE_CONTROL_SETTINGS)
return res.status(200).json({ incidents: bannerIncidents })
return bannerIncidents
}
export default handler