route.ts88 lines · main
1import { fetchIncidents, type IncidentEntry } from '../../../../lib/incidents';
2
3/**
4 * RSS feed of the incident history. Operators + customers can subscribe
5 * to get alerted on new entries without checking the status page. Mirrors
6 * the changelog feed shape so subscribers can use the same reader setup.
7 *
8 * Served at /api/status/incidents.xml — when the status page DNS
9 * cutover to status.briven.tech lands, the feed moves to
10 * status.briven.tech/incidents.xml via a redirect.
11 *
12 * Reads from the api (/v1/status/incidents) at request time. The api
13 * being unreachable yields an empty feed rather than a 5xx, so RSS
14 * subscribers don't get a poison-pilled feed on a transient outage.
15 */
16
17export const dynamic = 'force-dynamic';
18export const revalidate = 0;
19
20const SITE_URL = process.env.BRIVEN_DOCS_ORIGIN ?? 'https://docs.briven.tech';
21const STATUS_URL = process.env.BRIVEN_STATUS_ORIGIN ?? `${SITE_URL}/status`;
22
23export async function GET(): Promise<Response> {
24 // Mirror the status-page cutover: once status.briven.tech is canonical,
25 // RSS readers still pointed at the old feed URL get redirected. Gated
26 // by the same env so it flips with the page. See
27 // docs/runbooks/status-domain-cutover.md §3.
28 if (process.env.BRIVEN_STATUS_CANONICAL === 'status') {
29 return Response.redirect(
30 'https://status.briven.tech/api/status/incidents.xml',
31 308,
32 );
33 }
34 const items = await fetchIncidents({ limit: 50, fresh: true });
35 const lastBuild = items[0]?.startedAt ?? new Date().toISOString();
36
37 const xml = `<?xml version="1.0" encoding="UTF-8"?>
38<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
39<channel>
40<title>briven · incidents</title>
41<link>${STATUS_URL}</link>
42<description>incident history for briven.tech — outages, degraded performance, planned maintenance</description>
43<language>en</language>
44<lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
45<atom:link href="${SITE_URL}/api/status/incidents.xml" rel="self" type="application/rss+xml" />
46${items
47 .map(
48 (inc) => `<item>
49<title>${escapeXml(`[${inc.severity}] ${inc.summary}`)}</title>
50<link>${STATUS_URL}#${inc.id}</link>
51<guid isPermaLink="false">${inc.id}</guid>
52<pubDate>${new Date(inc.startedAt).toUTCString()}</pubDate>
53<category>${inc.severity}</category>
54${inc.services.map((s) => `<category>${escapeXml(s)}</category>`).join('\n')}
55<description>${escapeXml(buildDescription(inc))}</description>
56</item>`,
57 )
58 .join('\n')}
59</channel>
60</rss>
61`;
62
63 return new Response(xml, {
64 status: 200,
65 headers: {
66 'content-type': 'application/rss+xml; charset=utf-8',
67 'cache-control': 'public, max-age=60, s-maxage=60',
68 },
69 });
70}
71
72function buildDescription(inc: IncidentEntry): string {
73 const status = inc.resolvedAt
74 ? `resolved ${inc.resolvedAt}`
75 : 'ongoing';
76 const parts = [`started ${inc.startedAt}`, status, `services: ${inc.services.join(', ')}`];
77 if (inc.postmortem) parts.push('---', inc.postmortem);
78 return parts.join('\n');
79}
80
81function escapeXml(s: string): string {
82 return s
83 .replace(/&/g, '&amp;')
84 .replace(/</g, '&lt;')
85 .replace(/>/g, '&gt;')
86 .replace(/"/g, '&quot;')
87 .replace(/'/g, '&apos;');
88}