diff --git a/packages/vercel-flags-core/src/client.test.ts b/packages/vercel-flags-core/src/client.test.ts index aa8c0571..9c5ab6e0 100644 --- a/packages/vercel-flags-core/src/client.test.ts +++ b/packages/vercel-flags-core/src/client.test.ts @@ -1,32 +1,38 @@ import { describe, expect, it } from 'vitest'; -import { createClient } from './client'; +import { createRawClient } from './client'; import { InMemoryDataSource } from './data-source/in-memory-data-source'; -describe('createClient', () => { +describe('createRawClient', () => { it('should be a function', () => { - expect(typeof createClient).toBe('function'); + expect(typeof createRawClient).toBe('function'); }); it('should allow a custom data source', () => { - const inlineDataSource = new InMemoryDataSource({ definitions: {} }); - const flagsClient = createClient({ - dataSource: inlineDataSource, + const inlineDataSource = new InMemoryDataSource({ + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const flagsClient = createRawClient({ + dataSource: inlineDataSource, + }); - expect(flagsClient.environment).toEqual('production'); expect(flagsClient.dataSource).toEqual(inlineDataSource); }); it('should evaluate', async () => { const customDataSource = new InMemoryDataSource({ - definitions: { - 'summer-sale': { environments: { production: 0 }, variants: [false] }, + data: { + definitions: { + 'summer-sale': { environments: { production: 0 }, variants: [false] }, + }, + segments: {}, }, + projectId: 'test', + environment: 'production', }); - const flagsClient = createClient({ + const flagsClient = createRawClient({ dataSource: customDataSource, - environment: 'production', }); await expect( diff --git a/packages/vercel-flags-core/src/data-source/flag-network-data-source.test.ts b/packages/vercel-flags-core/src/data-source/flag-network-data-source.test.ts new file mode 100644 index 00000000..78dfe6d2 --- /dev/null +++ b/packages/vercel-flags-core/src/data-source/flag-network-data-source.test.ts @@ -0,0 +1,157 @@ +import { HttpResponse, http } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { FlagNetworkDataSource } from './flag-network-data-source'; + +const server = setupServer(); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +function createNdjsonStream(messages: object[], delayMs = 0): ReadableStream { + return new ReadableStream({ + async start(controller) { + for (const message of messages) { + if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs)); + controller.enqueue( + new TextEncoder().encode(JSON.stringify(message) + '\n'), + ); + } + controller.close(); + }, + }); +} + +describe('FlagNetworkDataSource', () => { + it('should parse datafile messages from NDJSON stream', async () => { + const definitions = { + projectId: 'test-project', + definitions: { 'my-flag': { variants: [true, false] } }, + }; + + server.use( + http.get('https://flags.vercel.com/v1/stream', () => { + return new HttpResponse( + createNdjsonStream([{ type: 'datafile', data: definitions }]), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + + const dataSource = new FlagNetworkDataSource({ sdkKey: 'test-key' }); + await dataSource.subscribe(); + + expect(dataSource.definitions).toEqual(definitions); + }); + + it('should ignore ping messages', async () => { + const definitions = { + projectId: 'test-project', + definitions: {}, + }; + + server.use( + http.get('https://flags.vercel.com/v1/stream', () => { + return new HttpResponse( + createNdjsonStream([ + { type: 'ping' }, + { type: 'datafile', data: definitions }, + { type: 'ping' }, + ]), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + + const dataSource = new FlagNetworkDataSource({ sdkKey: 'test-key' }); + await dataSource.subscribe(); + + expect(dataSource.definitions).toEqual(definitions); + }); + + it('should stop reconnecting on terminate message', async () => { + const definitions = { + projectId: 'test-project', + definitions: {}, + }; + + server.use( + http.get('https://flags.vercel.com/v1/stream', () => { + return new HttpResponse( + createNdjsonStream([ + { type: 'datafile', data: definitions }, + { type: 'terminate', reason: 'sdk-key-revoked' }, + ]), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + + const dataSource = new FlagNetworkDataSource({ sdkKey: 'test-key' }); + await dataSource.subscribe(); + + // Wait for the loop to process the terminate message + await dataSource._loopPromise; + + expect(dataSource.breakLoop).toBe(true); + }); + + it('should handle messages split across chunks', async () => { + const definitions = { + projectId: 'test-project', + definitions: { flag: { variants: [1, 2, 3] } }, + }; + + const fullMessage = JSON.stringify({ type: 'datafile', data: definitions }); + const part1 = fullMessage.slice(0, 20); + const part2 = fullMessage.slice(20) + '\n'; + + server.use( + http.get('https://flags.vercel.com/v1/stream', () => { + return new HttpResponse( + new ReadableStream({ + async start(controller) { + controller.enqueue(new TextEncoder().encode(part1)); + await new Promise((r) => setTimeout(r, 10)); + controller.enqueue(new TextEncoder().encode(part2)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + + const dataSource = new FlagNetworkDataSource({ sdkKey: 'test-key' }); + await dataSource.subscribe(); + + expect(dataSource.definitions).toEqual(definitions); + }); + + it('should update definitions when new datafile messages arrive', async () => { + const definitions1 = { projectId: 'test', definitions: { v: 1 } }; + const definitions2 = { projectId: 'test', definitions: { v: 2 } }; + + server.use( + http.get('https://flags.vercel.com/v1/stream', () => { + return new HttpResponse( + createNdjsonStream([ + { type: 'datafile', data: definitions1 }, + { type: 'datafile', data: definitions2 }, + { type: 'terminate', reason: 'sdk-key-revoked' }, + ]), + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + + const dataSource = new FlagNetworkDataSource({ sdkKey: 'test-key' }); + await dataSource.subscribe(); + + // Wait for stream to complete + await dataSource._loopPromise; + + expect(dataSource.definitions).toEqual(definitions2); + }); +}); diff --git a/packages/vercel-flags-core/src/data-source/flag-network-data-source.ts b/packages/vercel-flags-core/src/data-source/flag-network-data-source.ts index 71a867a6..7cf7bd88 100644 --- a/packages/vercel-flags-core/src/data-source/flag-network-data-source.ts +++ b/packages/vercel-flags-core/src/data-source/flag-network-data-source.ts @@ -2,6 +2,11 @@ import type { BundledDefinitions } from '../types'; import { readBundledDefinitions } from '../utils/read-bundled-definitions'; import type { DataSource, DataSourceMetadata } from './interface'; +const debugLog = (...args: any[]) => { + if (process.env.DEBUG !== '1') return; + console.log(...args); +}; + async function* streamAsyncIterable(stream: ReadableStream) { const reader = stream.getReader(); try { @@ -16,7 +21,7 @@ async function* streamAsyncIterable(stream: ReadableStream) { } /** - * Implements the DataSource interface for Edge Config. + * Implements the DataSource interface for flags.vercel.com. */ export class FlagNetworkDataSource implements DataSource { sdkKey?: string; @@ -89,7 +94,7 @@ export class FlagNetworkDataSource implements DataSource { async createLoop() { while (!this.breakLoop) { try { - console.log(process.pid, 'createLoop → MAKE STREAM'); + debugLog(process.pid, 'createLoop → MAKE STREAM'); const response = await fetch(`${this.host}/v1/stream`, { headers: { Authorization: `Bearer ${this.sdkKey}`, @@ -100,12 +105,20 @@ export class FlagNetworkDataSource implements DataSource { const error = new Error( `Failed to fetch stream: ${response.statusText}`, ); + // Stop retrying on 4xx client errors (won't fix itself on retry) + if (response.status >= 400 && response.status < 500) { + this.breakLoop = true; + if (!this.hasReceivedData) { + this.rejectStreamInitPromise!(error); + } + throw error; + } // Only reject the init promise if we haven't received data yet if (!this.hasReceivedData) { this.rejectStreamInitPromise!(error); throw error; } - // Otherwise, throw to trigger retry + // Otherwise, throw to trigger retry (5xx errors, etc.) throw error; } @@ -121,51 +134,35 @@ export class FlagNetworkDataSource implements DataSource { // Reset retry count on successful connection this.retryCount = 0; + const decoder = new TextDecoder(); let buffer = ''; - // Wait for the server to push some data for await (const chunk of streamAsyncIterable(response.body)) { if (this.breakLoop) break; - buffer += new TextDecoder().decode(chunk); - - // SSE events are separated by double newlines - let eventBoundary = buffer.indexOf('\n\n'); - while (eventBoundary !== -1) { - const eventBlock = buffer.slice(0, eventBoundary); - buffer = buffer.slice(eventBoundary + 2); - - // Parse the SSE event block - let eventType: string | null = null; - let eventData: string | null = null; - - for (const line of eventBlock.split('\n')) { - // Skip empty lines and comment lines (like ": ping") - if (line === '' || line.startsWith(':')) continue; - - if (line.startsWith('event: ')) { - eventType = line.slice(7); - } else if (line.startsWith('data: ')) { - eventData = line.slice(6); - } - } + buffer += decoder.decode(chunk, { stream: true }); - // Only process datafile events - if (eventType === 'datafile' && eventData) { - const data = JSON.parse(eventData) as BundledDefinitions; - this.definitions = data; + const lines = buffer.split('\n'); + buffer = lines.pop()!; // Keep incomplete line in buffer + + for (const line of lines) { + if (line === '') continue; + + const message = JSON.parse(line) as + | { type: 'datafile'; data: BundledDefinitions } + | { type: 'ping' }; + + if (message.type === 'datafile') { + this.definitions = message.data; this.hasReceivedData = true; - console.log(process.pid, 'loop → data', data); - this.resolveStreamInitPromise!(data); + debugLog(process.pid, 'loop → data', message.data); + this.resolveStreamInitPromise!(message.data); } - - // Check for more events in the buffer - eventBoundary = buffer.indexOf('\n\n'); } } // Stream ended - if not intentional, retry if (!this.breakLoop) { - console.log(process.pid, 'loop → stream closed, will retry'); + debugLog(process.pid, 'loop → stream closed, will retry'); } } catch (error) { // If we haven't received data and this is the initial connection, @@ -189,7 +186,7 @@ export class FlagNetworkDataSource implements DataSource { } } - console.log(process.pid, 'loop → done'); + debugLog(process.pid, 'loop → done'); } async fetchData(): Promise { @@ -217,22 +214,22 @@ export class FlagNetworkDataSource implements DataSource { // but it's okay since we only ever read from memory here async getData() { if (!this.initialized) { - console.log(process.pid, 'getData → init'); + debugLog(process.pid, 'getData → init'); await this.subscribe(); } if (this.streamInitPromise) { - console.log(process.pid, 'getData → await'); + debugLog(process.pid, 'getData → await'); await this.streamInitPromise; } if (this.definitions) { - console.log(process.pid, 'getData → definitions'); + debugLog(process.pid, 'getData → definitions'); return this.definitions; } if (this.bundledDefinitions) { - console.log(process.pid, 'getData → bundledDefinitions'); + debugLog(process.pid, 'getData → bundledDefinitions'); return this.bundledDefinitions; } - console.log(process.pid, 'getData → throw'); + debugLog(process.pid, 'getData → throw'); throw new Error('No definitions found'); } } diff --git a/packages/vercel-flags-core/src/index.test.ts b/packages/vercel-flags-core/src/index.test.ts index a8f63a77..1b935cc7 100644 --- a/packages/vercel-flags-core/src/index.test.ts +++ b/packages/vercel-flags-core/src/index.test.ts @@ -1,19 +1,25 @@ import { afterAll, describe, expect, it, vi } from 'vitest'; import { - createClient, + createRawClient, getDefaultFlagsClient, getFlagsEnvironment, parseFlagsConnectionString, } from '.'; -describe('createClient', () => { +describe('createRawClient', () => { it('should allow creating a client', () => { - const client = createClient({ - environment: 'production', + const client = createRawClient({ dataSource: { - projectId: 'prj_fakeProjectId', async getData() { - return { definitions: {} }; + return { + definitions: {}, + segments: {}, + projectId: 'test', + environment: 'production', + }; + }, + async getMetadata() { + return { projectId: 'test' }; }, }, }); @@ -23,10 +29,11 @@ describe('createClient', () => { describe('getDefaultFlagsClient', () => { it('works', () => { - process.env.FLAGS = - 'flags:projectId=someProjectId&edgeConfigId=ecfg_fakeEdgeConfigId&edgeConfigItemKey=fake-item-key&edgeConfigToken=fake'; + process.env.FLAGS = 'vf_server_testkey'; process.env.VERCEL_ENV = 'development'; - expect(getDefaultFlagsClient().environment).toEqual('development'); + const client = getDefaultFlagsClient(); + expect(client).toBeDefined(); + expect(client.dataSource).toBeDefined(); delete process.env.VERCEL_ENV; }); }); diff --git a/packages/vercel-flags-core/src/integration.test.ts b/packages/vercel-flags-core/src/integration.test.ts index 5856b4f7..c1ea14e1 100644 --- a/packages/vercel-flags-core/src/integration.test.ts +++ b/packages/vercel-flags-core/src/integration.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { createClientFromConnectionString, type FlagsClient } from '.'; import { evaluate } from './evaluate'; import { @@ -11,7 +11,6 @@ import { describe('integration evaluate', () => { let client: FlagsClient; - let defaultEnvironment: string; beforeAll(async () => { // It's okay that this is commited as it's public @@ -24,11 +23,6 @@ describe('integration evaluate', () => { } client = createClientFromConnectionString(connectionString); - defaultEnvironment = client.environment; - }); - - beforeEach(() => { - client.environment = defaultEnvironment; }); it('should evaluate active flags', async () => { @@ -56,15 +50,6 @@ describe('integration evaluate', () => { errorMessage: 'Definition not found for flag "does-not-exist"', }); }); - - it('should error for missing environment config', async () => { - client.environment = 'this-env-does-not-exist-and-will-cause-an-error'; - expect(await client.evaluate('active')).toEqual({ - reason: ResolutionReason.ERROR, - errorMessage: - 'Could not find envConfig for "this-env-does-not-exist-and-will-cause-an-error"', - }); - }); }); it('should evaluate with an entity', async () => { @@ -99,17 +84,9 @@ describe('integration evaluate', () => { }); }); - it('should reuse an active environment', async () => { - client.environment = 'preview'; - - expect( - await client.evaluate('reuse', undefined, { user: { name: 'Joe' } }), - ).toEqual({ - value: true, - reason: ResolutionReason.RULE_MATCH, - outcomeType: OutcomeType.VALUE, - }); - }); + // Note: The 'reuse' test requires setting environment to 'preview' which + // is no longer possible on the client directly. This behavior is tested + // in evaluate.test.ts instead. describe('targets', () => { it('should respect targeting', async () => { diff --git a/packages/vercel-flags-core/src/openfeature.test.ts b/packages/vercel-flags-core/src/openfeature.test.ts index 0e46592d..c56d4d9a 100644 --- a/packages/vercel-flags-core/src/openfeature.test.ts +++ b/packages/vercel-flags-core/src/openfeature.test.ts @@ -1,6 +1,6 @@ import { StandardResolutionReasons } from '@openfeature/server-sdk'; import { describe, expect, it } from 'vitest'; -import { createClient } from './client'; +import { createRawClient } from './client'; import { InMemoryDataSource } from './data-source/in-memory-data-source'; import { VercelProvider } from './openfeature'; import type { Packed } from './types'; @@ -9,13 +9,11 @@ describe('VercelProvider', () => { describe('constructor', () => { it('should accept a FlagsClient', () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); expect(provider.metadata.name).toBe('vercel-nodejs-provider'); @@ -35,18 +33,19 @@ describe('VercelProvider', () => { describe('resolveBooleanEvaluation', () => { it('should resolve a boolean flag', async () => { const dataSource = new InMemoryDataSource({ - definitions: { - 'boolean-flag': { - environments: { production: 0 }, - variants: [true], - } as Packed.FlagDefinition, + data: { + definitions: { + 'boolean-flag': { + environments: { production: 0 }, + variants: [true], + } as Packed.FlagDefinition, + }, + segments: {}, }, - segments: {}, - }); - const client = createClient({ - dataSource, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveBooleanEvaluation( @@ -61,13 +60,11 @@ describe('VercelProvider', () => { it('should return default value when flag is not found', async () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveBooleanEvaluation( @@ -83,22 +80,23 @@ describe('VercelProvider', () => { it('should use fallthrough outcome for active flags', async () => { const dataSource = new InMemoryDataSource({ - definitions: { - 'active-flag': { - environments: { - production: { - fallthrough: 1, + data: { + definitions: { + 'active-flag': { + environments: { + production: { + fallthrough: 1, + }, }, - }, - variants: [false, true], - } as Packed.FlagDefinition, + variants: [false, true], + } as Packed.FlagDefinition, + }, + segments: {}, }, - segments: {}, - }); - const client = createClient({ - dataSource, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveBooleanEvaluation( @@ -115,18 +113,19 @@ describe('VercelProvider', () => { describe('resolveStringEvaluation', () => { it('should resolve a string flag', async () => { const dataSource = new InMemoryDataSource({ - definitions: { - 'string-flag': { - environments: { production: 0 }, - variants: ['variant-a'], - } as Packed.FlagDefinition, + data: { + definitions: { + 'string-flag': { + environments: { production: 0 }, + variants: ['variant-a'], + } as Packed.FlagDefinition, + }, + segments: {}, }, - segments: {}, - }); - const client = createClient({ - dataSource, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveStringEvaluation( @@ -141,13 +140,11 @@ describe('VercelProvider', () => { it('should return default value when flag is not found', async () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveStringEvaluation( @@ -165,18 +162,19 @@ describe('VercelProvider', () => { describe('resolveNumberEvaluation', () => { it('should resolve a number flag', async () => { const dataSource = new InMemoryDataSource({ - definitions: { - 'number-flag': { - environments: { production: 0 }, - variants: [42], - } as Packed.FlagDefinition, + data: { + definitions: { + 'number-flag': { + environments: { production: 0 }, + variants: [42], + } as Packed.FlagDefinition, + }, + segments: {}, }, - segments: {}, - }); - const client = createClient({ - dataSource, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveNumberEvaluation( @@ -191,13 +189,11 @@ describe('VercelProvider', () => { it('should return default value when flag is not found', async () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveNumberEvaluation( @@ -215,18 +211,19 @@ describe('VercelProvider', () => { describe('resolveObjectEvaluation', () => { it('should resolve an object flag', async () => { const dataSource = new InMemoryDataSource({ - definitions: { - 'object-flag': { - environments: { production: 0 }, - variants: ['value'], - } as Packed.FlagDefinition, + data: { + definitions: { + 'object-flag': { + environments: { production: 0 }, + variants: ['value'], + } as Packed.FlagDefinition, + }, + segments: {}, }, - segments: {}, - }); - const client = createClient({ - dataSource, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveObjectEvaluation( @@ -241,13 +238,11 @@ describe('VercelProvider', () => { it('should return default value when flag is not found', async () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveObjectEvaluation( @@ -265,13 +260,11 @@ describe('VercelProvider', () => { describe('initialize', () => { it('should initialize without errors', async () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); await expect(provider.initialize()).resolves.toBeUndefined(); @@ -281,13 +274,11 @@ describe('VercelProvider', () => { describe('onClose', () => { it('should close without errors', async () => { const dataSource = new InMemoryDataSource({ - definitions: {}, - segments: {}, - }); - const client = createClient({ - dataSource, + data: { definitions: {}, segments: {} }, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); await expect(provider.onClose()).resolves.toBeUndefined(); @@ -297,23 +288,24 @@ describe('VercelProvider', () => { describe('context passing', () => { it('should pass evaluation context to the client', async () => { const dataSource = new InMemoryDataSource({ - definitions: { - 'context-flag': { - environments: { - production: { - targets: [{}, { user: { id: ['user-123'] } }], - fallthrough: 0, + data: { + definitions: { + 'context-flag': { + environments: { + production: { + targets: [{}, { user: { id: ['user-123'] } }], + fallthrough: 0, + }, }, - }, - variants: ['variant-a', 'variant-b'], - } as Packed.FlagDefinition, + variants: ['variant-a', 'variant-b'], + } as Packed.FlagDefinition, + }, + segments: {}, }, - segments: {}, - }); - const client = createClient({ - dataSource, + projectId: 'test', environment: 'production', }); + const client = createRawClient({ dataSource }); const provider = new VercelProvider(client); const result = await provider.resolveStringEvaluation(