Skip to content

Commit 8efd0ae

Browse files
feat(js): Next.js Pages Router support (#393)
1 parent 569d4d2 commit 8efd0ae

15 files changed

Lines changed: 1179 additions & 22 deletions

File tree

Dockerfile

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -336,23 +336,26 @@ FROM openfeature-provider-js-base AS openfeature-provider-js.build
336336

337337
RUN make build
338338

339-
# Verify no bundle splitting occurred
339+
# Verify no bundle splitting occurred. rolldown's default chunkFileNames is
340+
# `[name]-[hash].js` (8+ alphanumeric hash), so any .js whose basename ends in
341+
# `-<hash>.js` is an auto-split chunk. Entry outputs are dot-separated
342+
# (`index.inlined.js`) or path-nested (`pages-router/api.js`), so they don't
343+
# match this pattern.
340344
RUN set -e; \
341345
echo "Verifying no bundle splitting in JS artifacts..."; \
342-
UNEXPECTED_FILES=$(find dist -name '*.js' ! -name 'index.node.js' ! -name 'index.inlined.js' ! -name 'index.fetch.js' ! -name 'server.js' ! -name 'client.js' | head -10); \
343-
if [ -n "$UNEXPECTED_FILES" ]; then \
346+
SPLIT_CHUNKS=$(find dist -type f -name '*.js' | grep -E -- '-[A-Za-z0-9]{8,}\.js$' || true); \
347+
if [ -n "$SPLIT_CHUNKS" ]; then \
344348
echo ""; \
345349
echo "❌ ERROR: Bundle splitting detected!"; \
346350
echo ""; \
347-
echo "Found unexpected JavaScript files in dist/:"; \
348-
echo "$UNEXPECTED_FILES"; \
351+
echo "Found auto-split chunks in dist/ (filenames matching <name>-<hash>.js):"; \
352+
echo "$SPLIT_CHUNKS"; \
349353
echo ""; \
350-
echo "Only expected entry point files should be present."; \
351-
echo "Check tsdown.config.ts configuration to prevent code splitting."; \
354+
echo "Each public entry should be a self-contained bundle. Check tsdown.config.ts."; \
352355
echo ""; \
353356
exit 1; \
354357
fi; \
355-
echo "✅ No bundle splitting detected - only expected files present"
358+
echo "✅ No bundle splitting detected"
356359

357360
# ==============================================================================
358361
# Pack OpenFeature Provider (JS) - Create tarball for publishing

openfeature-provider/js/.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ coverage/*
77
api/*
88
CHANGELOG.md
99
README.md
10+
CLAUDE.md

openfeature-provider/js/README-REACT.md

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,29 +37,32 @@ yarn add @spotify-confidence/openfeature-server-provider-local react
3737

3838
### 1. Set up the provider (server-side)
3939

40-
Create a file to initialize the OpenFeature provider:
40+
Use Next.js's [`instrumentation.ts`](https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation) hook to register the provider once at server startup:
4141

4242
```ts
43-
// lib/confidence.ts
44-
import { OpenFeature } from '@openfeature/server-sdk';
45-
import { createConfidenceServerProvider } from '@spotify-confidence/openfeature-server-provider-local';
43+
// instrumentation.ts (at the project root, or in src/ if you use a src folder)
44+
export async function register() {
45+
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
4646

47-
const provider = createConfidenceServerProvider({
48-
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
49-
});
47+
const { OpenFeature } = await import('@openfeature/server-sdk');
48+
const { createConfidenceServerProvider } = await import('@spotify-confidence/openfeature-server-provider-local');
5049

51-
// Initialize once at startup
52-
await OpenFeature.setProviderAndWait(provider);
50+
const provider = createConfidenceServerProvider({
51+
flagClientSecret: process.env.CONFIDENCE_FLAG_CLIENT_SECRET!,
52+
});
53+
await OpenFeature.setProviderAndWait(provider);
54+
}
5355
```
5456

57+
`register` runs once when a Next.js server instance boots and is awaited before requests are served. It does not run during `next build`, so it's safe to perform network setup here.
58+
5559
### 2. Wrap your app with ConfidenceProvider
5660

5761
In your layout or page (Server Component):
5862

5963
```tsx
6064
// app/layout.tsx
6165
import { ConfidenceProvider } from '@spotify-confidence/openfeature-server-provider-local/react-server';
62-
import './lib/confidence'; // Initialize provider
6366

6467
export default async function RootLayout({ children }: { children: React.ReactNode }) {
6568
// Get user context from session, cookies, etc.
@@ -387,6 +390,98 @@ export default async function Page() {
387390
}
388391
```
389392

393+
## Next.js Pages Router
394+
395+
When using the Pages Router, three subexports under `./pages-router/*` cover the equivalent of the App Router integration above. The client hooks (`useFlag`, `useFlagDetails`) are the same — only the server plumbing differs.
396+
397+
- **`pages-router/server`**`withConfidence`, a `getServerSideProps` decorator that resolves the flag bundle for the request and merges it into `pageProps`.
398+
- **`pages-router/client`**`<ConfidencePagesProvider>`, which reads the bundle from `pageProps` and exposes it to the `useFlag` / `useFlagDetails` hooks (re-used as-is from `react-client`).
399+
- **`pages-router/api`**`applyHandler`, the `/api/confidence/apply` POST handler the client uses to log exposure when a flag is read.
400+
401+
Provider registration is router-agnostic: use the same [`instrumentation.ts`](#1-set-up-the-provider-server-side) setup shown for the App Router.
402+
403+
### 1. Resolve flags in `getServerSideProps`
404+
405+
`withConfidence` wraps a single `getServerSideProps`-shaped function that does your data fetching **and** returns the evaluation context to use for flag resolution (and optionally a `flags` allow-list). The decorator resolves the bundle without firing exposure, seals the resolve token, and merges it into `pageProps.confidence`.
406+
407+
```tsx
408+
// pages/index.tsx
409+
import { useFlag } from '@spotify-confidence/openfeature-server-provider-local/react-client';
410+
import { withConfidence } from '@spotify-confidence/openfeature-server-provider-local/pages-router/server';
411+
412+
export const getServerSideProps = withConfidence(async ({ req }) => {
413+
const visitorId = req.cookies.uid ?? 'anon';
414+
const data = await fetchSomeData();
415+
return {
416+
props: { data },
417+
context: { visitor_id: visitorId },
418+
flags: ['my-feature'], // optional — defaults to all flags
419+
};
420+
});
421+
422+
export default function Home({ data }: { data: SomeData }) {
423+
const enabled = useFlag('my-feature.enabled', false);
424+
return enabled ? <Feature data={data} /> : null;
425+
}
426+
```
427+
428+
Returning `{ redirect }` or `{ notFound }` short-circuits before flag resolution, just like a normal `getServerSideProps`. For pages without their own data fetching, the body collapses to a single return:
429+
430+
```tsx
431+
export const getServerSideProps = withConfidence(async ({ req }) => ({
432+
props: {},
433+
context: { visitor_id: req.cookies.uid ?? 'anon' },
434+
}));
435+
```
436+
437+
### 2. Wrap your tree with `<ConfidencePagesProvider>`
438+
439+
Any component that calls `useFlag` / `useFlagDetails` must sit under a `<ConfidencePagesProvider>`. The simplest placement is `_app.tsx`, which covers every page in one spot — but the wrapper works anywhere in the tree, so per-page wrapping is also fine if you'd rather not touch `_app.tsx`.
440+
441+
```tsx
442+
// pages/_app.tsx
443+
import type { AppProps } from 'next/app';
444+
import { ConfidencePagesProvider } from '@spotify-confidence/openfeature-server-provider-local/pages-router/client';
445+
446+
export default function App({ Component, pageProps }: AppProps) {
447+
const { confidence, ...rest } = pageProps;
448+
return (
449+
<ConfidencePagesProvider confidence={confidence}>
450+
<Component {...rest} />
451+
</ConfidencePagesProvider>
452+
);
453+
}
454+
```
455+
456+
Pages whose `getServerSideProps` doesn't use `withConfidence` simply have no `confidence` key on `pageProps`; the wrapper short-circuits and any `useFlag` calls in their tree fall back to default values.
457+
458+
### 3. Mount the apply API route
459+
460+
When a flag is read on the client (e.g. `useFlag` firing on mount), the wrapper POSTs `{ resolveToken, flagName }` to the apply route, which opens the sealed token and logs exposure server-side. Mount the handler at `/api/confidence/apply`:
461+
462+
```ts
463+
// pages/api/confidence/apply.ts
464+
import { applyHandler } from '@spotify-confidence/openfeature-server-provider-local/pages-router/api';
465+
export default applyHandler();
466+
```
467+
468+
If you need to mount it elsewhere, pass the same path to `<ConfidencePagesProvider apiPath="...">`.
469+
470+
### Resolve token security
471+
472+
In the App Router, the resolve token stays in the encrypted closure of a server action. The Pages Router has no equivalent, so the lib **seals the token with AES-256-GCM** before it ever reaches the browser. Set a server-only key:
473+
474+
```bash
475+
CONFIDENCE_TOKEN_KEY=$(openssl rand -hex 32)
476+
```
477+
478+
The client only ever sees the sealed value; the apply API route opens it server-side.
479+
480+
### What's not in this integration
481+
482+
- No equivalent to `getFlag` / `getFlagDetails` server functions — call `OpenFeature.getClient().getXxxValue(...)` directly inside `getServerSideProps` if you need eager-exposure server-only resolution.
483+
- No `getStaticProps` support: flag resolution is per-request and depends on evaluation context.
484+
390485
## Troubleshooting
391486

392487
### "ConfidenceProvider requires a ConfidenceServerProviderLocal"

openfeature-provider/js/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@
3232
"types": "./dist/client.d.ts",
3333
"default": "./dist/client.js"
3434
},
35+
"./pages-router/server": {
36+
"types": "./dist/pages-router/server.d.ts",
37+
"default": "./dist/pages-router/server.js"
38+
},
39+
"./pages-router/client": {
40+
"types": "./dist/pages-router/client.d.ts",
41+
"default": "./dist/pages-router/client.js"
42+
},
43+
"./pages-router/api": {
44+
"types": "./dist/pages-router/api.d.ts",
45+
"default": "./dist/pages-router/api.js"
46+
},
3547
"./package.json": "./package.json"
3648
},
3749
"scripts": {
@@ -60,6 +72,7 @@
6072
"debug": "^4.4.3",
6173
"dotenv": "^17.2.2",
6274
"happy-dom": "^20.3.4",
75+
"next": "^16.0.0",
6376
"prettier": "^2.8.8",
6477
"react": "^19",
6578
"react-dom": "^19.2.3",
@@ -72,6 +85,7 @@
7285
"peerDependencies": {
7386
"@openfeature/core": "^1.0.0",
7487
"debug": "^4.4.3",
88+
"next": ">=13.0.0",
7589
"react": "^18.0.0 || ^19.0.0"
7690
},
7791
"peerDependenciesMeta": {
@@ -81,6 +95,9 @@
8195
"debug": {
8296
"optional": true
8397
},
98+
"next": {
99+
"optional": true
100+
},
84101
"react": {
85102
"optional": true
86103
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { OpenFeature } from '@openfeature/server-sdk';
2+
import type { NextApiRequest, NextApiResponse } from 'next';
3+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
4+
import { applyHandler } from './api';
5+
import { __resetKeyCacheForTests, sealResolveToken } from './token';
6+
7+
function makeReqRes(
8+
method: string,
9+
body?: unknown,
10+
): { req: NextApiRequest; res: NextApiResponse; status: () => number } {
11+
const headers: Record<string, string> = {};
12+
let statusCode = 200;
13+
const res = {
14+
setHeader: vi.fn((k: string, v: string) => {
15+
headers[k] = v;
16+
}),
17+
status: vi.fn((code: number) => {
18+
statusCode = code;
19+
return res;
20+
}),
21+
end: vi.fn(),
22+
} as unknown as NextApiResponse;
23+
const req = { method, body } as NextApiRequest;
24+
return { req, res, status: () => statusCode };
25+
}
26+
27+
describe('applyHandler', () => {
28+
beforeAll(() => {
29+
process.env.CONFIDENCE_TOKEN_KEY = 'test-key-do-not-use-in-prod';
30+
__resetKeyCacheForTests();
31+
});
32+
33+
afterEach(() => {
34+
OpenFeature.clearProviders();
35+
});
36+
37+
it('returns 405 for non-POST', async () => {
38+
const handler = applyHandler();
39+
const { req, res, status } = makeReqRes('GET');
40+
await handler(req, res);
41+
expect(status()).toBe(405);
42+
expect(res.setHeader).toHaveBeenCalledWith('Allow', 'POST');
43+
});
44+
45+
it('returns 400 when body is missing', async () => {
46+
const handler = applyHandler();
47+
const { req, res, status } = makeReqRes('POST');
48+
await handler(req, res);
49+
expect(status()).toBe(400);
50+
});
51+
52+
it('returns 400 when fields have wrong types', async () => {
53+
const handler = applyHandler();
54+
const { req, res, status } = makeReqRes('POST', { resolveToken: 123, flagName: 'x' });
55+
await handler(req, res);
56+
expect(status()).toBe(400);
57+
});
58+
59+
it('returns 503 when no Confidence provider is registered', async () => {
60+
const handler = applyHandler();
61+
const sealed = sealResolveToken('opaque');
62+
const { req, res, status } = makeReqRes('POST', { resolveToken: sealed, flagName: 'my-flag' });
63+
await handler(req, res);
64+
expect(status()).toBe(503);
65+
});
66+
67+
it('returns 400 when the resolveToken cannot be opened', async () => {
68+
// Register a real-shaped provider so the 503 branch doesn't shortcut.
69+
OpenFeature.setProvider({
70+
metadata: { name: 'ConfidenceServerProviderLocal' },
71+
applyFlag: vi.fn(),
72+
} as never);
73+
const handler = applyHandler();
74+
const { req, res, status } = makeReqRes('POST', { resolveToken: 'not-a-real-handle', flagName: 'x' });
75+
await handler(req, res);
76+
expect(status()).toBe(400);
77+
});
78+
79+
it('skips applyFlag and returns 204 when the opened token is empty (error bundle)', async () => {
80+
const applyFlag = vi.fn();
81+
OpenFeature.setProvider({
82+
metadata: { name: 'ConfidenceServerProviderLocal' },
83+
applyFlag,
84+
} as never);
85+
const sealed = sealResolveToken(''); // FlagBundle.error() ships resolveToken: ''
86+
const handler = applyHandler();
87+
const { req, res, status } = makeReqRes('POST', { resolveToken: sealed, flagName: 'my-flag' });
88+
await handler(req, res);
89+
expect(applyFlag).not.toHaveBeenCalled();
90+
expect(status()).toBe(204);
91+
});
92+
93+
it('calls applyFlag on success and returns 204', async () => {
94+
const applyFlag = vi.fn();
95+
OpenFeature.setProvider({
96+
metadata: { name: 'ConfidenceServerProviderLocal' },
97+
applyFlag,
98+
} as never);
99+
const sealed = sealResolveToken('the-real-token');
100+
const handler = applyHandler();
101+
const { req, res, status } = makeReqRes('POST', { resolveToken: sealed, flagName: 'my-flag' });
102+
await handler(req, res);
103+
expect(applyFlag).toHaveBeenCalledWith('the-real-token', 'my-flag');
104+
expect(status()).toBe(204);
105+
});
106+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { NextApiHandler } from 'next';
2+
import { getConfidenceProvider } from './common';
3+
import { openResolveToken } from './token';
4+
5+
export interface ApplyHandlerOptions {
6+
/** Use a non-default OpenFeature provider by name. */
7+
providerName?: string;
8+
}
9+
10+
/**
11+
* Builds the POST handler that the client `ConfidencePagesProvider` calls to
12+
* fire exposure events. Mount it at `/api/confidence/apply` (or pass a custom
13+
* `apiPath` to `ConfidencePagesProvider` if you mount it elsewhere).
14+
*
15+
* @example
16+
* // pages/api/confidence/apply.ts
17+
* import { applyHandler } from '@spotify-confidence/openfeature-server-provider-local/pages-router/api';
18+
* export default applyHandler();
19+
*/
20+
export function applyHandler(opts: ApplyHandlerOptions = {}): NextApiHandler {
21+
return async (req, res) => {
22+
if (req.method !== 'POST') {
23+
res.setHeader('Allow', 'POST');
24+
res.status(405).end();
25+
return;
26+
}
27+
28+
const body = req.body as { resolveToken?: unknown; flagName?: unknown } | undefined;
29+
if (!body || typeof body.resolveToken !== 'string' || typeof body.flagName !== 'string') {
30+
res.status(400).end();
31+
return;
32+
}
33+
34+
const provider = getConfidenceProvider(opts.providerName);
35+
if (!provider) {
36+
res.status(503).end();
37+
return;
38+
}
39+
40+
let openedToken: string;
41+
try {
42+
openedToken = openResolveToken(body.resolveToken);
43+
} catch {
44+
res.status(400).end();
45+
return;
46+
}
47+
48+
// Error bundles carry an empty resolveToken (see FlagBundle.error); skip
49+
// applyFlag in that case to match the App Router's `!bundle.errorCode` gate.
50+
if (!openedToken) {
51+
res.status(204).end();
52+
return;
53+
}
54+
55+
provider.applyFlag(openedToken, body.flagName);
56+
res.status(204).end();
57+
};
58+
}

0 commit comments

Comments
 (0)