Skip to content

Commit dd1396e

Browse files
Guard missing Vercel Flags runtime helpers (#326)
* Guard request-context flag reporting * Preserve request-context hook binding * Guard core request-context reporting * Guard missing bundled flag definitions --------- Co-authored-by: Dominik Ferber <dominik.ferber@gmail.com>
1 parent e4d76a9 commit dd1396e

File tree

7 files changed

+252
-4
lines changed

7 files changed

+252
-4
lines changed

.changeset/cool-hats-float.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'flags': patch
3+
'@vercel/flags-core': patch
4+
---
5+
6+
Guard internal flag hooks when Vercel does not expose the expected runtime helpers during evaluation.

packages/flags/src/lib/report-value.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ import { version } from '../../package.json';
2121
export function reportValue(key: string, value: unknown) {
2222
const symbol = Symbol.for('@vercel/request-context');
2323
const ctx = Reflect.get(globalThis, symbol)?.get();
24-
ctx?.flags?.reportValue(key, value, {
24+
const reportFlagValue = ctx?.flags?.reportValue;
25+
if (typeof reportFlagValue !== 'function') return;
26+
reportFlagValue.call(ctx.flags, key, value, {
2527
sdkVersion: version,
2628
});
2729
}
@@ -40,7 +42,9 @@ export function internalReportValue(
4042
) {
4143
const symbol = Symbol.for('@vercel/request-context');
4244
const ctx = Reflect.get(globalThis, symbol)?.get();
43-
ctx?.flags?.reportValue(key, value, {
45+
const reportFlagValue = ctx?.flags?.reportValue;
46+
if (typeof reportFlagValue !== 'function') return;
47+
reportFlagValue.call(ctx.flags, key, value, {
4448
sdkVersion: version,
4549
...data,
4650
});

packages/flags/src/next/index.test.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { IncomingMessage } from 'node:http';
22
import type { Socket } from 'node:net';
33
import { Readable } from 'node:stream';
44
import type { NextApiRequestCookies } from 'next/dist/server/api-utils';
5-
import { beforeAll, describe, expect, it, vi } from 'vitest';
5+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
66
import { type Adapter, encryptOverrides } from '..';
77
import { clearDedupeCacheForCurrentRequest, dedupe, flag, precompute } from '.';
88

@@ -15,6 +15,32 @@ const mocks = vi.hoisted(() => {
1515
};
1616
});
1717

18+
const requestContextSymbol = Symbol.for('@vercel/request-context');
19+
const previousRequestContext = Reflect.get(globalThis, requestContextSymbol);
20+
21+
type ReportCall = {
22+
readonly key: string;
23+
readonly value: unknown;
24+
readonly data: Record<string, unknown>;
25+
};
26+
27+
function createRequestContext() {
28+
const calls: ReportCall[] = [];
29+
const flags = {
30+
calls,
31+
reportValue(
32+
this: { calls: ReportCall[] },
33+
key: string,
34+
value: unknown,
35+
data: Record<string, unknown>,
36+
) {
37+
this.calls.push({ key, value, data });
38+
},
39+
};
40+
41+
return { flags };
42+
}
43+
1844
vi.mock('next/headers', async (importOriginal) => {
1945
const mod = await importOriginal<typeof import('next/headers')>();
2046
return {
@@ -65,6 +91,16 @@ describe('flag on app router', () => {
6591
// a random secret for testing purposes
6692
process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc';
6793
});
94+
95+
afterEach(() => {
96+
if (previousRequestContext === undefined) {
97+
Reflect.deleteProperty(globalThis, requestContextSymbol);
98+
return;
99+
}
100+
101+
Reflect.set(globalThis, requestContextSymbol, previousRequestContext);
102+
});
103+
68104
it('allows declaring a flag', async () => {
69105
mocks.headers.mockReturnValueOnce(new Headers());
70106

@@ -167,6 +203,101 @@ describe('flag on app router', () => {
167203
expect(decide).not.toHaveBeenCalled();
168204
});
169205

206+
it('does not crash when override reporting hook is not a function', async () => {
207+
Reflect.set(globalThis, requestContextSymbol, {
208+
get() {
209+
return { flags: { reportValue: true } };
210+
},
211+
});
212+
213+
const decide = vi.fn(() => false);
214+
const f = flag<boolean>({
215+
key: 'first-flag',
216+
decide,
217+
config: { reportValue: false },
218+
});
219+
220+
const headersOfFirstRequest = new Headers();
221+
const override = await encryptOverrides({ 'first-flag': true });
222+
const cookieMock = vi.fn((cookieName: string) => {
223+
if (cookieName === 'vercel-flag-overrides') {
224+
return { name: 'vercel-flag-overrides', value: override };
225+
}
226+
return undefined;
227+
});
228+
mocks.headers.mockReturnValueOnce(headersOfFirstRequest);
229+
mocks.cookies.mockReturnValueOnce({ get: cookieMock });
230+
231+
await expect(f()).resolves.toEqual(true);
232+
expect(decide).not.toHaveBeenCalled();
233+
});
234+
235+
it('preserves method binding for normal flag reporting hooks', async () => {
236+
const requestContext = createRequestContext();
237+
Reflect.set(globalThis, requestContextSymbol, {
238+
get() {
239+
return requestContext;
240+
},
241+
});
242+
243+
const f = flag<boolean>({
244+
key: 'first-flag',
245+
decide: () => true,
246+
});
247+
248+
mocks.headers.mockReturnValueOnce(new Headers());
249+
await expect(f()).resolves.toEqual(true);
250+
expect(requestContext.flags.calls).toEqual([
251+
{
252+
key: 'first-flag',
253+
value: true,
254+
data: expect.objectContaining({
255+
sdkVersion: expect.any(String),
256+
}),
257+
},
258+
]);
259+
});
260+
261+
it('preserves method binding for override reporting hooks', async () => {
262+
const requestContext = createRequestContext();
263+
Reflect.set(globalThis, requestContextSymbol, {
264+
get() {
265+
return requestContext;
266+
},
267+
});
268+
269+
const decide = vi.fn(() => false);
270+
const f = flag<boolean>({
271+
key: 'first-flag',
272+
decide,
273+
config: { reportValue: false },
274+
});
275+
276+
const headersOfFirstRequest = new Headers();
277+
const override = await encryptOverrides({ 'first-flag': true });
278+
const cookieMock = vi.fn((cookieName: string) => {
279+
if (cookieName === 'vercel-flag-overrides') {
280+
return { name: 'vercel-flag-overrides', value: override };
281+
}
282+
return undefined;
283+
});
284+
mocks.headers.mockReturnValueOnce(headersOfFirstRequest);
285+
mocks.cookies.mockReturnValueOnce({ get: cookieMock });
286+
287+
await expect(f()).resolves.toEqual(true);
288+
expect(decide).not.toHaveBeenCalled();
289+
expect(requestContext.flags.calls).toEqual([
290+
{
291+
key: 'first-flag',
292+
value: true,
293+
data: expect.objectContaining({
294+
reason: 'override',
295+
sdkVersion: expect.any(String),
296+
}),
297+
},
298+
]);
299+
});
300+
170301
it('uses precomputed values', async () => {
171302
const decide = vi.fn(() => true);
172303
const f = flag<boolean>({
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
3+
import { ResolutionReason } from '../types';
4+
import { internalReportValue } from './report-value';
5+
6+
const requestContextSymbol = Symbol.for('@vercel/request-context');
7+
const previousRequestContext = Reflect.get(globalThis, requestContextSymbol);
8+
9+
type ReportCall = {
10+
readonly key: string;
11+
readonly value: unknown;
12+
readonly data: Record<string, unknown>;
13+
};
14+
15+
function createRequestContext() {
16+
const calls: ReportCall[] = [];
17+
const flags = {
18+
calls,
19+
reportValue(
20+
this: { calls: ReportCall[] },
21+
key: string,
22+
value: unknown,
23+
data: Record<string, unknown>,
24+
) {
25+
this.calls.push({ key, value, data });
26+
},
27+
};
28+
29+
return { flags };
30+
}
31+
32+
describe('internalReportValue', () => {
33+
afterEach(() => {
34+
if (previousRequestContext === undefined) {
35+
Reflect.deleteProperty(globalThis, requestContextSymbol);
36+
return;
37+
}
38+
39+
Reflect.set(globalThis, requestContextSymbol, previousRequestContext);
40+
});
41+
42+
it('does not crash when the request-context reportValue hook is not callable', () => {
43+
Reflect.set(globalThis, requestContextSymbol, {
44+
get() {
45+
return { flags: { reportValue: true } };
46+
},
47+
});
48+
49+
expect(() =>
50+
internalReportValue('flagA', true, {
51+
originProjectId: 'prj_123',
52+
originProvider: 'vercel',
53+
reason: ResolutionReason.PAUSED,
54+
}),
55+
).not.toThrow();
56+
});
57+
58+
it('preserves method binding for callable request-context hooks', () => {
59+
const requestContext = createRequestContext();
60+
Reflect.set(globalThis, requestContextSymbol, {
61+
get() {
62+
return requestContext;
63+
},
64+
});
65+
66+
internalReportValue('flagA', true, {
67+
originProjectId: 'prj_123',
68+
originProvider: 'vercel',
69+
reason: ResolutionReason.PAUSED,
70+
});
71+
72+
expect(requestContext.flags.calls).toEqual([
73+
{
74+
key: 'flagA',
75+
value: true,
76+
data: expect.objectContaining({
77+
originProjectId: 'prj_123',
78+
originProvider: 'vercel',
79+
reason: ResolutionReason.PAUSED,
80+
sdkVersion: expect.any(String),
81+
}),
82+
},
83+
]);
84+
});
85+
});

packages/vercel-flags-core/src/lib/report-value.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ export function internalReportValue(
1616
) {
1717
const symbol = Symbol.for('@vercel/request-context');
1818
const ctx = Reflect.get(globalThis, symbol)?.get();
19-
ctx?.flags?.reportValue(key, value, {
19+
const reportFlagValue = ctx?.flags?.reportValue;
20+
if (typeof reportFlagValue !== 'function') return;
21+
22+
reportFlagValue.call(ctx.flags, key, value, {
2023
sdkVersion: version,
2124
...data,
2225
});

packages/vercel-flags-core/src/utils/read-bundled-definitions.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ describe('readBundledDefinitions', () => {
4848
expect(result.definitions).toBeNull();
4949
});
5050

51+
it('should return missing-file when the bundled definitions module has no get export', async () => {
52+
vi.doMock('@vercel/flags-definitions', () => ({ get: undefined }));
53+
54+
const { readBundledDefinitions } = await import(
55+
'./read-bundled-definitions'
56+
);
57+
58+
const result = await readBundledDefinitions('nonexistent-id');
59+
60+
expect(result).toEqual({
61+
definitions: null,
62+
state: 'missing-file',
63+
});
64+
});
65+
5166
// The detailed behavior of readBundledDefinitions is tested indirectly
5267
// through Controller tests which mock readBundledDefinitions.
5368
// Those tests cover:

packages/vercel-flags-core/src/utils/read-bundled-definitions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export async function readBundledDefinitions(
5959
return { definitions: null, state: 'unexpected-error', error };
6060
}
6161

62+
if (typeof get !== 'function') {
63+
return { definitions: null, state: 'missing-file' };
64+
}
65+
6266
// try plain sdk key first
6367
const entry = get(sdkKey);
6468
if (entry) return { definitions: entry, state: 'ok' };

0 commit comments

Comments
 (0)