From 54a30de0d837390e5ec94ea4562390c0ade0ce1a Mon Sep 17 00:00:00 2001 From: Tudor Date: Sun, 23 Aug 2026 10:55:32 +0100 Subject: [PATCH] feat(flags): server-side getFlags for the frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships without a consumer, deliberately. The first flag needs none — the backend withholds the field and the page follows — but 'UI elements on existing pages' is one of the three surfaces this capability exists for, and a flag layer that cannot gate one is incomplete. Never throws: an unreadable flag is a dark one, which matches the backend's fail-closed default. A page that 500s because the flags endpoint blinked would be a worse outcome than a hidden feature. Reading flags pins the calling route to a 300s ISR floor, since Next takes the lowest revalidate among a route's fetches. That matches what /school/[slug] already sits at, and it is the same property that makes a flip propagate without a webhook. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- nextjs-app/__tests__/lib/flags.test.ts | 34 ++++++++++++++++++++++ nextjs-app/lib/flags.ts | 40 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 nextjs-app/__tests__/lib/flags.test.ts create mode 100644 nextjs-app/lib/flags.ts diff --git a/nextjs-app/__tests__/lib/flags.test.ts b/nextjs-app/__tests__/lib/flags.test.ts new file mode 100644 index 0000000..ddadc7e --- /dev/null +++ b/nextjs-app/__tests__/lib/flags.test.ts @@ -0,0 +1,34 @@ +import { getFlags } from '@/lib/flags'; + +// jsdom provides no global fetch, so there is nothing for jest.spyOn to attach +// to — assign it and restore the original afterwards. This is the first test +// here to mock fetch; later ones should follow this shape. +const realFetch = global.fetch; + +function mockFetch(impl: () => Promise) { + global.fetch = jest.fn(impl) as unknown as typeof fetch; +} + +describe('getFlags', () => { + afterEach(() => { global.fetch = realFetch; }); + + it('returns the flags the API reports', async () => { + mockFetch(async () => ({ + ok: true, + json: async () => ({ admission_distance: true }), + })); + await expect(getFlags()).resolves.toEqual({ admission_distance: true }); + }); + + it('returns no flags rather than throwing when the API is down', async () => { + // A page that cannot read flags must render everything dark, not 500. + // Fail-closed is the same direction as the backend's default. + mockFetch(async () => { throw new Error('ECONNREFUSED'); }); + await expect(getFlags()).resolves.toEqual({}); + }); + + it('returns no flags rather than throwing on a non-200', async () => { + mockFetch(async () => ({ ok: false, status: 503 })); + await expect(getFlags()).resolves.toEqual({}); + }); +}); diff --git a/nextjs-app/lib/flags.ts b/nextjs-app/lib/flags.ts new file mode 100644 index 0000000..0f21e44 --- /dev/null +++ b/nextjs-app/lib/flags.ts @@ -0,0 +1,40 @@ +/** + * Reading feature flags. + * + * Server-side only. No flag value reaches the browser bundle, and there is no + * Unleash dependency in package.json — the SDK lives in FastAPI, which already + * owns every other piece of data this app renders. + * + * Flags are declared in backend/flags.py. A purely front-end flag still has to + * be declared there; it is a flat data edit, and the return is that one list + * answers "what flags exist" for the whole system. + */ + +export type Flags = Record; + +/* + * Reading flags pins the calling route to this ISR floor: Next uses the LOWEST + * revalidate among a route's fetches to set the whole route's revalidation + * frequency. 300s matches what /school/[slug] already sits at, so a page that + * reads flags is no more dynamic than a school page already is. + * + * It is also what makes a flip propagate without a webhook: five minutes on + * school pages, an hour on place pages, against flags that flip monthly. + */ +export const FLAGS_REVALIDATE = 300; + +const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL + || 'http://localhost:8000/api'; + +/** Every flag and its value. Never throws: an unreadable flag is a dark one. */ +export async function getFlags(): Promise { + try { + const res = await fetch(`${API}/flags`, { + next: { revalidate: FLAGS_REVALIDATE }, + }); + if (!res.ok) return {}; + return await res.json(); + } catch { + return {}; + } +}