feat(flags): server-side getFlags for the frontend
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This commit is contained in:
1 parent
c30ad1db07
commit
54a30de0d8
2 files changed
+74
No files matched your search
@@ -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<unknown>) {
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -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<string, boolean>;
|
||||
|
||||
/*
|
||||
* 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<Flags> {
|
||||
try {
|
||||
const res = await fetch(`${API}/flags`, {
|
||||
next: { revalidate: FLAGS_REVALIDATE },
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user