From fe0da1682075079f00c900268eddaac752429202 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 19 Mar 2026 04:26:21 -0400 Subject: [PATCH] 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 --- apps/studio/app/api/incident-banner/route.ts | 63 +++++++++++++++++++ .../{pages => lib}/api/incident-banner.ts | 63 ++++++++----------- 2 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 apps/studio/app/api/incident-banner/route.ts rename apps/studio/{pages => lib}/api/incident-banner.ts (71%) diff --git a/apps/studio/app/api/incident-banner/route.ts b/apps/studio/app/api/incident-banner/route.ts new file mode 100644 index 00000000000..5a89908ae34 --- /dev/null +++ b/apps/studio/app/api/incident-banner/route.ts @@ -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 } + ) + } +} diff --git a/apps/studio/pages/api/incident-banner.ts b/apps/studio/lib/api/incident-banner.ts similarity index 71% rename from apps/studio/pages/api/incident-banner.ts rename to apps/studio/lib/api/incident-banner.ts index 8cf0cafc265..cfcfa58ff0b 100644 --- a/apps/studio/pages/api/incident-banner.ts +++ b/apps/studio/lib/api/incident-banner.ts @@ -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 & { force: boolean } @@ -83,11 +77,18 @@ async function fetchAllIncidents(apiKey: string, mode: string): Promise> { 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 - 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 = [] @@ -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