Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cool-hats-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'flags': patch
'@vercel/flags-core': patch
---

Guard internal flag hooks when Vercel does not expose the expected runtime helpers during evaluation.
8 changes: 6 additions & 2 deletions packages/flags/src/lib/report-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import { version } from '../../package.json';
export function reportValue(key: string, value: unknown) {
const symbol = Symbol.for('@vercel/request-context');
const ctx = Reflect.get(globalThis, symbol)?.get();
ctx?.flags?.reportValue(key, value, {
const reportFlagValue = ctx?.flags?.reportValue;
if (typeof reportFlagValue !== 'function') return;
reportFlagValue.call(ctx.flags, key, value, {
sdkVersion: version,
});
}
Expand All @@ -40,7 +42,9 @@ export function internalReportValue(
) {
const symbol = Symbol.for('@vercel/request-context');
const ctx = Reflect.get(globalThis, symbol)?.get();
ctx?.flags?.reportValue(key, value, {
const reportFlagValue = ctx?.flags?.reportValue;
if (typeof reportFlagValue !== 'function') return;
reportFlagValue.call(ctx.flags, key, value, {
sdkVersion: version,
...data,
});
Expand Down
133 changes: 132 additions & 1 deletion packages/flags/src/next/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { IncomingMessage } from 'node:http';
import type { Socket } from 'node:net';
import { Readable } from 'node:stream';
import type { NextApiRequestCookies } from 'next/dist/server/api-utils';
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { type Adapter, encryptOverrides } from '..';
import { clearDedupeCacheForCurrentRequest, dedupe, flag, precompute } from '.';

Expand All @@ -15,6 +15,32 @@ const mocks = vi.hoisted(() => {
};
});

const requestContextSymbol = Symbol.for('@vercel/request-context');
const previousRequestContext = Reflect.get(globalThis, requestContextSymbol);

type ReportCall = {
readonly key: string;
readonly value: unknown;
readonly data: Record<string, unknown>;
};

function createRequestContext() {
const calls: ReportCall[] = [];
const flags = {
calls,
reportValue(
this: { calls: ReportCall[] },
key: string,
value: unknown,
data: Record<string, unknown>,
) {
this.calls.push({ key, value, data });
},
};

return { flags };
}

vi.mock('next/headers', async (importOriginal) => {
const mod = await importOriginal<typeof import('next/headers')>();
return {
Expand Down Expand Up @@ -65,6 +91,16 @@ describe('flag on app router', () => {
// a random secret for testing purposes
process.env.FLAGS_SECRET = 'yuhyxaVI0Zue85SguKlMIUQojvJyBPzm95fFYvOa4Rc';
});

afterEach(() => {
if (previousRequestContext === undefined) {
Reflect.deleteProperty(globalThis, requestContextSymbol);
return;
}

Reflect.set(globalThis, requestContextSymbol, previousRequestContext);
});

it('allows declaring a flag', async () => {
mocks.headers.mockReturnValueOnce(new Headers());

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

it('does not crash when override reporting hook is not a function', async () => {
Reflect.set(globalThis, requestContextSymbol, {
get() {
return { flags: { reportValue: true } };
},
});

const decide = vi.fn(() => false);
const f = flag<boolean>({
key: 'first-flag',
decide,
config: { reportValue: false },
});

const headersOfFirstRequest = new Headers();
const override = await encryptOverrides({ 'first-flag': true });
const cookieMock = vi.fn((cookieName: string) => {
if (cookieName === 'vercel-flag-overrides') {
return { name: 'vercel-flag-overrides', value: override };
}
return undefined;
});
mocks.headers.mockReturnValueOnce(headersOfFirstRequest);
mocks.cookies.mockReturnValueOnce({ get: cookieMock });

await expect(f()).resolves.toEqual(true);
expect(decide).not.toHaveBeenCalled();
});

it('preserves method binding for normal flag reporting hooks', async () => {
const requestContext = createRequestContext();
Reflect.set(globalThis, requestContextSymbol, {
get() {
return requestContext;
},
});

const f = flag<boolean>({
key: 'first-flag',
decide: () => true,
});

mocks.headers.mockReturnValueOnce(new Headers());
await expect(f()).resolves.toEqual(true);
expect(requestContext.flags.calls).toEqual([
{
key: 'first-flag',
value: true,
data: expect.objectContaining({
sdkVersion: expect.any(String),
}),
},
]);
});

it('preserves method binding for override reporting hooks', async () => {
const requestContext = createRequestContext();
Reflect.set(globalThis, requestContextSymbol, {
get() {
return requestContext;
},
});

const decide = vi.fn(() => false);
const f = flag<boolean>({
key: 'first-flag',
decide,
config: { reportValue: false },
});

const headersOfFirstRequest = new Headers();
const override = await encryptOverrides({ 'first-flag': true });
const cookieMock = vi.fn((cookieName: string) => {
if (cookieName === 'vercel-flag-overrides') {
return { name: 'vercel-flag-overrides', value: override };
}
return undefined;
});
mocks.headers.mockReturnValueOnce(headersOfFirstRequest);
mocks.cookies.mockReturnValueOnce({ get: cookieMock });

await expect(f()).resolves.toEqual(true);
expect(decide).not.toHaveBeenCalled();
expect(requestContext.flags.calls).toEqual([
{
key: 'first-flag',
value: true,
data: expect.objectContaining({
reason: 'override',
sdkVersion: expect.any(String),
}),
},
]);
});

it('uses precomputed values', async () => {
const decide = vi.fn(() => true);
const f = flag<boolean>({
Expand Down
85 changes: 85 additions & 0 deletions packages/vercel-flags-core/src/lib/report-value.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from 'vitest';

import { ResolutionReason } from '../types';
import { internalReportValue } from './report-value';

const requestContextSymbol = Symbol.for('@vercel/request-context');
const previousRequestContext = Reflect.get(globalThis, requestContextSymbol);

type ReportCall = {
readonly key: string;
readonly value: unknown;
readonly data: Record<string, unknown>;
};

function createRequestContext() {
const calls: ReportCall[] = [];
const flags = {
calls,
reportValue(
this: { calls: ReportCall[] },
key: string,
value: unknown,
data: Record<string, unknown>,
) {
this.calls.push({ key, value, data });
},
};

return { flags };
}

describe('internalReportValue', () => {
afterEach(() => {
if (previousRequestContext === undefined) {
Reflect.deleteProperty(globalThis, requestContextSymbol);
return;
}

Reflect.set(globalThis, requestContextSymbol, previousRequestContext);
});

it('does not crash when the request-context reportValue hook is not callable', () => {
Reflect.set(globalThis, requestContextSymbol, {
get() {
return { flags: { reportValue: true } };
},
});

expect(() =>
internalReportValue('flagA', true, {
originProjectId: 'prj_123',
originProvider: 'vercel',
reason: ResolutionReason.PAUSED,
}),
).not.toThrow();
});

it('preserves method binding for callable request-context hooks', () => {
const requestContext = createRequestContext();
Reflect.set(globalThis, requestContextSymbol, {
get() {
return requestContext;
},
});

internalReportValue('flagA', true, {
originProjectId: 'prj_123',
originProvider: 'vercel',
reason: ResolutionReason.PAUSED,
});

expect(requestContext.flags.calls).toEqual([
{
key: 'flagA',
value: true,
data: expect.objectContaining({
originProjectId: 'prj_123',
originProvider: 'vercel',
reason: ResolutionReason.PAUSED,
sdkVersion: expect.any(String),
}),
},
]);
});
});
5 changes: 4 additions & 1 deletion packages/vercel-flags-core/src/lib/report-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export function internalReportValue(
) {
const symbol = Symbol.for('@vercel/request-context');
const ctx = Reflect.get(globalThis, symbol)?.get();
ctx?.flags?.reportValue(key, value, {
const reportFlagValue = ctx?.flags?.reportValue;
if (typeof reportFlagValue !== 'function') return;

reportFlagValue.call(ctx.flags, key, value, {
sdkVersion: version,
...data,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ describe('readBundledDefinitions', () => {
expect(result.definitions).toBeNull();
});

it('should return missing-file when the bundled definitions module has no get export', async () => {
vi.doMock('@vercel/flags-definitions', () => ({ get: undefined }));

const { readBundledDefinitions } = await import(
'./read-bundled-definitions'
);

const result = await readBundledDefinitions('nonexistent-id');

expect(result).toEqual({
definitions: null,
state: 'missing-file',
});
});

// The detailed behavior of readBundledDefinitions is tested indirectly
// through Controller tests which mock readBundledDefinitions.
// Those tests cover:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export async function readBundledDefinitions(
return { definitions: null, state: 'unexpected-error', error };
}

if (typeof get !== 'function') {
return { definitions: null, state: 'missing-file' };
}

// try plain sdk key first
const entry = get(sdkKey);
if (entry) return { definitions: entry, state: 'ok' };
Expand Down
Loading