From ac41753df31a6a752f93e1e03ca8040726917918 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 20 Aug 2026 10:47:46 -0400 Subject: [PATCH] chore: Add @launchdarkly/openfeature-cloudflare-server provider package --- package.json | 1 + .../sdk/openfeature-cloudflare-server/LICENSE | 13 + .../openfeature-cloudflare-server/README.md | 203 ++++++++++ .../__tests__/LaunchDarklyProvider.test.ts | 381 ++++++++++++++++++ .../__tests__/TestLogger.ts | 25 ++ .../jest.config.mjs | 7 + .../package.json | 73 ++++ .../src/LaunchDarklyProvider.ts | 32 ++ .../src/index.ts | 10 + .../tsconfig.json | 21 + .../tsconfig.ref.json | 7 + .../tsup.config.ts | 14 + .../typedoc.json | 5 + tsconfig.json | 3 + 14 files changed, 795 insertions(+) create mode 100644 packages/sdk/openfeature-cloudflare-server/LICENSE create mode 100644 packages/sdk/openfeature-cloudflare-server/README.md create mode 100644 packages/sdk/openfeature-cloudflare-server/__tests__/LaunchDarklyProvider.test.ts create mode 100644 packages/sdk/openfeature-cloudflare-server/__tests__/TestLogger.ts create mode 100644 packages/sdk/openfeature-cloudflare-server/jest.config.mjs create mode 100644 packages/sdk/openfeature-cloudflare-server/package.json create mode 100644 packages/sdk/openfeature-cloudflare-server/src/LaunchDarklyProvider.ts create mode 100644 packages/sdk/openfeature-cloudflare-server/src/index.ts create mode 100644 packages/sdk/openfeature-cloudflare-server/tsconfig.json create mode 100644 packages/sdk/openfeature-cloudflare-server/tsconfig.ref.json create mode 100644 packages/sdk/openfeature-cloudflare-server/tsup.config.ts create mode 100644 packages/sdk/openfeature-cloudflare-server/typedoc.json diff --git a/package.json b/package.json index 1be36967b3..7fcd5c422d 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "packages/sdk/browser/example-fdv2", "packages/sdk/openfeature-node-server", "packages/sdk/openfeature-node-server/examples/getting-started", + "packages/sdk/openfeature-cloudflare-server", "packages/sdk/vue", "packages/sdk/vue/contract-tests", "packages/sdk/vue/examples/getting-started" diff --git a/packages/sdk/openfeature-cloudflare-server/LICENSE b/packages/sdk/openfeature-cloudflare-server/LICENSE new file mode 100644 index 0000000000..26ba5e4b84 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/LICENSE @@ -0,0 +1,13 @@ +Copyright 2026 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/sdk/openfeature-cloudflare-server/README.md b/packages/sdk/openfeature-cloudflare-server/README.md new file mode 100644 index 0000000000..ef6cf1f6e8 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/README.md @@ -0,0 +1,203 @@ +# LaunchDarkly OpenFeature Provider for the Cloudflare Server-Side SDK + +[![NPM][sdk-openfeature-cloudflare-server-npm-badge]][sdk-openfeature-cloudflare-server-npm-link] +[![Actions Status][sdk-openfeature-cloudflare-server-ci-badge]][sdk-openfeature-cloudflare-server-ci] +[![Documentation][sdk-openfeature-cloudflare-server-ghp-badge]][sdk-openfeature-cloudflare-server-ghp-link] +[![NPM][sdk-openfeature-cloudflare-server-dm-badge]][sdk-openfeature-cloudflare-server-npm-link] +[![NPM][sdk-openfeature-cloudflare-server-dt-badge]][sdk-openfeature-cloudflare-server-npm-link] + +> [!CAUTION] +> This provider is in pre-release and not subject to backwards compatibility +> guarantees. The API may change based on feedback. +> +> Pin to a specific minor version and review the [changelog](CHANGELOG.md) before upgrading. + +This package provides an [OpenFeature](https://openfeature.dev/) provider that wraps the [LaunchDarkly Cloudflare SDK](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/cloudflare). + +This provider is designed primarily for use in multi-user Cloudflare Workers. It follows the server-side LaunchDarkly model for multi-user contexts. It is not intended for use in desktop and embedded systems applications. + +## Installation + +```bash +npm install @openfeature/server-sdk @launchdarkly/cloudflare-server-sdk @launchdarkly/openfeature-cloudflare-server +``` + +Then turn on the Node.js compatibility flag in your `wrangler.toml`. This allows the underlying Cloudflare SDK to use `node:events`: + +```toml +compatibility_flags = [ "nodejs_compat" ] +``` + +## Usage + +`OpenFeature.setProviderAndWait` registers the provider on a registry shared by every +concurrent request in the Workers isolate. Calling it on every request races concurrent +requests against that shared state. Initialize the provider once per isolate and reuse it +for every subsequent request, matching the [Cloudflare SDK's own +guidance](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/cloudflare#usage) +that applications should instantiate a single instance for the lifetime of the worker: + +```typescript +import { OpenFeature } from '@openfeature/server-sdk'; +import { LaunchDarklyProvider } from '@launchdarkly/openfeature-cloudflare-server'; + +const clientSideID = 'your-client-side-id'; + +let provider: LaunchDarklyProvider | undefined; +let providerReady: Promise | undefined; + +function ensureProviderReady(env: Bindings): Promise { + if (!providerReady) { + // env.LD_KV is the Cloudflare KV namespace binding configured for LaunchDarkly. + // See https://developers.cloudflare.com/workers/runtime-apis/kv#kv-bindings + provider = new LaunchDarklyProvider(clientSideID, env.LD_KV /*, LDOptions here */); + providerReady = OpenFeature.setProviderAndWait(provider); + } + return providerReady; +} + +export default { + async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise { + // setProviderAndWait throws if initialization fails; catch as needed. + await ensureProviderReady(env); + + const client = OpenFeature.getClient(); + const flagValue = await client.getBooleanValue('flag-key', false, { + targetingKey: 'user-key', + }); + + // Flushing (not closing) is safe to call per request -- it only sends this + // request's queued events and never tears down the shared provider/client. + ctx.waitUntil(provider!.getClient().flush()); + + return new Response(`flag-key: ${flagValue}`); + }, +}; +``` + +See the full [getting-started example](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/openfeature-cloudflare-server/examples/getting-started). + +## ConfigurationChanged is not supported on this platform + +Unlike the [LaunchDarkly OpenFeature provider for Node.js](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/openfeature-node-server), this provider does not emit the OpenFeature `ConfigurationChanged` event. + +The Cloudflare SDK reads flag data out of a Cloudflare KV namespace via its `EdgeFeatureStore` on each evaluation; that store's `upsert` and `delete` methods -- which a streaming, long-lived SDK client would use to react to flag changes -- are hardcoded no-ops on this platform. There is no persistent connection to LaunchDarkly and no local cache that receives update notifications, so there is no underlying event for the provider to translate into `ConfigurationChanged`. Because Cloudflare Workers are short-lived and re-read the latest flag data from KV on every request, you do not need a change notification to see up-to-date flag values -- simply re-evaluate the flag on the next request. + +## OpenFeature Specific Considerations + +LaunchDarkly evaluates contexts, and it can either evaluate a single-context or a multi-context. When using OpenFeature, both single and multi-contexts must be encoded into a single `EvaluationContext`. This is accomplished by looking for an attribute named `kind` in the `EvaluationContext`. + +There are 4 different scenarios related to the `kind`: +1. There is no `kind` attribute. The provider will treat the context as a single context of kind `"user"`. +2. There is a `kind` attribute with the value `"multi"`. The provider will treat the context as a multi-context. +3. There is a `kind` attribute with a string value other than `"multi"`. The provider will treat it as a single context of the specified kind. +4. There is a `kind` attribute, but its value is not a string. The value will be discarded, the context will be treated as kind `"user"`, and a warning will be logged. + +The `kind` attribute should be a string containing only ASCII letters, numbers, `.`, `_`, or `-`. + +The OpenFeature specification allows for an optional targeting key, but LaunchDarkly requires a key for evaluation. A targeting key must be specified for each context being evaluated. It may be specified using either `targetingKey`, as defined in the OpenFeature specification, or `key`, which is the typical LaunchDarkly identifier. If both are specified, `targetingKey` takes precedence. + +There are several attributes with special handling within a single or multi-context: +- `privateAttributes` - Must be an array of strings. Equivalent to `_meta.privateAttributes` in the SDK. +- `anonymous` - Must be a boolean. Equivalent to `anonymous` in the SDK. +- `name` - Must be a string. Equivalent to `name` in the SDK. + +### Examples + +#### A single user context + +```typescript +const evaluationContext = { + targetingKey: 'my-user-key', +}; +``` + +#### A single context of kind "organization" + +```typescript +const evaluationContext = { + kind: 'organization', + targetingKey: 'my-org-key', +}; +``` + +#### A multi-context containing a "user" and an "organization" + +```typescript +const evaluationContext = { + kind: 'multi', + organization: { + targetingKey: 'my-org-key', + myCustomAttribute: 'myAttributeValue', + }, + user: { + targetingKey: 'my-user-key', + }, +}; +``` + +#### Setting private attributes in a single context + +```typescript +const evaluationContext = { + kind: 'organization', + name: 'the-org-name', + targetingKey: 'my-org-key', + myCustomAttribute: 'myCustomValue', + privateAttributes: ['myCustomAttribute'], +}; +``` + +#### Setting private attributes in a multi-context + +```typescript +const evaluationContext = { + kind: 'multi', + organization: { + targetingKey: 'my-org-key', + name: 'the-org-name', + // privateAttributes only applies to the "organization" context. + privateAttributes: ['myCustomAttribute'], + // This attribute will be private. + myCustomAttribute: 'myAttributeValue', + }, + user: { + targetingKey: 'my-user-key', + anonymous: true, + // This attribute will not be private. + myCustomAttribute: 'myAttributeValue', + }, +}; +``` + +## Contributing + +See [Contributing](../../../CONTRIBUTING.md). + +## Verifying SDK build provenance with the SLSA framework + +LaunchDarkly uses the [SLSA framework](https://slsa.dev/spec/v1.0/about) (Supply-chain Levels for Software Artifacts) to help developers make their supply chain more secure by ensuring the authenticity and build integrity of our published SDK packages. To learn more, see the [provenance guide](PROVENANCE.md). + +## About LaunchDarkly + +- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + - Grant access to certain features based on user attributes, like payment plan (eg: users on the 'gold' plan get access to more features than users in the 'silver' plan). + - Disable parts of your application to facilitate maintenance, without taking everything offline. +- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +- Explore LaunchDarkly + - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information + - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides + - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation + - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates + +[sdk-openfeature-cloudflare-server-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/openfeature-cloudflare-server.yaml/badge.svg +[sdk-openfeature-cloudflare-server-ci]: https://github.com/launchdarkly/js-core/actions/workflows/openfeature-cloudflare-server.yaml +[sdk-openfeature-cloudflare-server-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/openfeature-cloudflare-server.svg?style=flat-square +[sdk-openfeature-cloudflare-server-npm-link]: https://www.npmjs.com/package/@launchdarkly/openfeature-cloudflare-server +[sdk-openfeature-cloudflare-server-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 +[sdk-openfeature-cloudflare-server-ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/openfeature-cloudflare-server/docs/ +[sdk-openfeature-cloudflare-server-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/openfeature-cloudflare-server.svg?style=flat-square +[sdk-openfeature-cloudflare-server-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/openfeature-cloudflare-server.svg?style=flat-square diff --git a/packages/sdk/openfeature-cloudflare-server/__tests__/LaunchDarklyProvider.test.ts b/packages/sdk/openfeature-cloudflare-server/__tests__/LaunchDarklyProvider.test.ts new file mode 100644 index 0000000000..1f056a1d0a --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/__tests__/LaunchDarklyProvider.test.ts @@ -0,0 +1,381 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; +import { Client, ErrorCode, OpenFeature, ProviderStatus } from '@openfeature/server-sdk'; + +import type { LDClient } from '@launchdarkly/cloudflare-server-sdk'; +import { translateContext } from '@launchdarkly/openfeature-js-server-common'; + +import { LaunchDarklyProvider } from '../src'; +import TestLogger from './TestLogger'; + +const basicContext = { targetingKey: 'the-key' }; +const testFlagKey = 'a-key'; +const clientSideID = 'test-client-side-id'; + +function makeKvNamespace(): KVNamespace { + return { get: jest.fn() } as unknown as KVNamespace; +} + +it('can be initialized', async () => { + const ldProvider = new LaunchDarklyProvider(clientSideID, makeKvNamespace()); + await ldProvider.initialize({}); + + await OpenFeature.setProviderAndWait(ldProvider); + + const ofClient = OpenFeature.getClient(); + + expect(ofClient.providerStatus).toEqual(ProviderStatus.READY); + await ldProvider.onClose(); +}); + +it('can fail to initialize client', async () => { + const ldProvider = new LaunchDarklyProvider('', makeKvNamespace()); + try { + await OpenFeature.setProviderAndWait(ldProvider); + } catch (e) { + expect((e as Error).message).toEqual('You must configure the client with a client key'); + } + const ofClient = OpenFeature.getClient(); + expect(ofClient.providerStatus).toEqual(ProviderStatus.ERROR); +}); + +describe('given a mock LaunchDarkly client', () => { + let ldClient: LDClient; + let ofClient: Client; + let ldProvider: LaunchDarklyProvider; + const logger: TestLogger = new TestLogger(); + + beforeEach(async () => { + ldProvider = new LaunchDarklyProvider(clientSideID, makeKvNamespace(), { logger }); + ldClient = ldProvider.getClient(); + await OpenFeature.setProviderAndWait(ldProvider); + + ofClient = OpenFeature.getClient(); + logger.reset(); + }); + + afterEach(async () => { + await ldProvider.onClose(); + jest.restoreAllMocks(); + }); + + it('calls the client correctly for boolean variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: true, + reason: { + kind: 'OFF', + }, + })); + await ofClient.getBooleanDetails(testFlagKey, false, basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + false, + ); + jest.clearAllMocks(); + await ofClient.getBooleanValue(testFlagKey, false, basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + false, + ); + }); + + it('handles correct return types for boolean variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: true, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getBooleanDetails(testFlagKey, false, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: true, + reason: 'OFF', + }); + }); + + it('handles incorrect return types for boolean variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: 'badness', + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getBooleanDetails(testFlagKey, false, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: false, + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + }); + }); + + it('calls the client correctly for string variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: 'test', + reason: { + kind: 'OFF', + }, + })); + await ofClient.getStringDetails(testFlagKey, 'default', basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + 'default', + ); + jest.clearAllMocks(); + await ofClient.getStringValue(testFlagKey, 'default', basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + 'default', + ); + }); + + it('handles correct return types for string variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: 'good', + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getStringDetails(testFlagKey, 'default', basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: 'good', + reason: 'OFF', + }); + }); + + it('handles incorrect return types for string variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: true, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getStringDetails(testFlagKey, 'default', basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: 'default', + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + }); + }); + + it('calls the client correctly for numeric variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: 8, + reason: { + kind: 'OFF', + }, + })); + await ofClient.getNumberDetails(testFlagKey, 0, basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + 0, + ); + jest.clearAllMocks(); + await ofClient.getNumberValue(testFlagKey, 0, basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + 0, + ); + }); + + it('handles correct return types for numeric variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: 17, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getNumberDetails(testFlagKey, 0, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: 17, + reason: 'OFF', + }); + }); + + it('handles incorrect return types for numeric variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: true, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getNumberDetails(testFlagKey, 0, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: 0, + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + }); + }); + + it('calls the client correctly for object variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: { yes: 'no' }, + reason: { + kind: 'OFF', + }, + })); + await ofClient.getObjectDetails(testFlagKey, {}, basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + {}, + ); + jest.clearAllMocks(); + await ofClient.getObjectValue(testFlagKey, {}, basicContext); + expect(ldClient.variationDetail).toHaveBeenCalledWith( + testFlagKey, + translateContext(logger, basicContext), + {}, + ); + }); + + it('handles correct return types for object variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: { some: 'value' }, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getObjectDetails(testFlagKey, {}, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: { some: 'value' }, + reason: 'OFF', + }); + }); + + it('handles incorrect return types for object variations', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: 22, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getObjectDetails(testFlagKey, {}, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: {}, + reason: 'ERROR', + errorCode: 'TYPE_MISMATCH', + }); + }); + + it.each([ + ['CLIENT_NOT_READY', ErrorCode.PROVIDER_NOT_READY], + ['MALFORMED_FLAG', ErrorCode.PARSE_ERROR], + ['FLAG_NOT_FOUND', ErrorCode.FLAG_NOT_FOUND], + ['USER_NOT_SPECIFIED', ErrorCode.TARGETING_KEY_MISSING], + ['UNSPECIFIED', ErrorCode.GENERAL], + [undefined, ErrorCode.GENERAL], + ])('handles errors from the client', async (ldError, ofError) => { + ldClient.variationDetail = jest.fn(async () => ({ + value: { yes: 'no' }, + reason: { + kind: 'ERROR', + errorKind: ldError, + }, + })); + const res = await ofClient.getObjectDetails(testFlagKey, { yes: 'no' }, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: { yes: 'no' }, + reason: 'ERROR', + errorCode: ofError, + }); + }); + + it('includes the variant', async () => { + ldClient.variationDetail = jest.fn(async () => ({ + value: { yes: 'no' }, + variationIndex: 22, + reason: { + kind: 'OFF', + }, + })); + const res = await ofClient.getObjectDetails(testFlagKey, {}, basicContext); + expect(res).toMatchObject({ + flagKey: testFlagKey, + value: { yes: 'no' }, + variant: '22', + reason: 'OFF', + }); + }); + + it('logs information about missing keys', async () => { + await ofClient.getObjectDetails(testFlagKey, {}, {}); + expect(logger.logs[0]).toEqual( + "The EvaluationContext must contain either a 'targetingKey' " + + "or a 'key' and the type must be a string.", + ); + }); + + it('logs information about double keys', async () => { + await ofClient.getObjectDetails(testFlagKey, {}, { targetingKey: '1', key: '2' }); + expect(logger.logs[0]).toEqual( + "The EvaluationContext contained both a 'targetingKey' and a" + + " 'key' attribute. The 'key' attribute will be discarded.", + ); + }); + + it('handles tracking with invalid context', () => { + ofClient.track('test-event', {}); + expect(logger.logs[0]).toEqual( + "The EvaluationContext must contain either a 'targetingKey' " + + "or a 'key' and the type must be a string.", + ); + }); + + it('handles tracking with no data or metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + undefined, + undefined, + ); + }); + + it('handles tracking with only metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext, { value: 12345 }); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + undefined, + 12345, + ); + }); + + it('handles tracking with data but no metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext, { key1: 'val1' }); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + { key1: 'val1' }, + undefined, + ); + }); + + it('handles tracking with data and metricValue', () => { + ldClient.track = jest.fn(); + ofClient.track('test-event', basicContext, { value: 12345, key1: 'val1' }); + expect(ldClient.track).toHaveBeenCalledWith( + 'test-event', + translateContext(logger, basicContext), + { key1: 'val1' }, + 12345, + ); + }); +}); diff --git a/packages/sdk/openfeature-cloudflare-server/__tests__/TestLogger.ts b/packages/sdk/openfeature-cloudflare-server/__tests__/TestLogger.ts new file mode 100644 index 0000000000..3d72fe3bee --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/__tests__/TestLogger.ts @@ -0,0 +1,25 @@ +import type { LDLogger } from '@launchdarkly/cloudflare-server-sdk'; + +export default class TestLogger implements LDLogger { + public logs: string[] = []; + + error(...args: any[]): void { + this.logs.push(args.join(' ')); + } + + warn(...args: any[]): void { + this.logs.push(args.join(' ')); + } + + info(...args: any[]): void { + this.logs.push(args.join(' ')); + } + + debug(...args: any[]): void { + this.logs.push(args.join(' ')); + } + + reset() { + this.logs = []; + } +} diff --git a/packages/sdk/openfeature-cloudflare-server/jest.config.mjs b/packages/sdk/openfeature-cloudflare-server/jest.config.mjs new file mode 100644 index 0000000000..cd70e25844 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/jest.config.mjs @@ -0,0 +1,7 @@ +export default { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/__tests__/**/*test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], +}; diff --git a/packages/sdk/openfeature-cloudflare-server/package.json b/packages/sdk/openfeature-cloudflare-server/package.json new file mode 100644 index 0000000000..ceed8da12b --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/package.json @@ -0,0 +1,73 @@ +{ + "name": "@launchdarkly/openfeature-cloudflare-server", + "version": "0.1.0", + "description": "LaunchDarkly OpenFeature provider for the Cloudflare server SDK", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/openfeature-cloudflare-server", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/src/index.d.ts", + "type": "module", + "exports": { + ".": { + "require": { + "types": "./dist/src/index.d.ts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/src/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "build": "tsup-node && npx tsc --emitDeclarationOnly --declaration --outDir dist", + "lint": "npx eslint .", + "lint:fix": "yarn run lint --fix", + "test": "jest", + "check": "yarn lint && yarn build && yarn test" + }, + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "openfeature", + "feature-flags", + "cloudflare" + ], + "author": "LaunchDarkly", + "license": "Apache-2.0", + "dependencies": { + "@cloudflare/workers-types": "^4.20230321.0", + "@launchdarkly/openfeature-js-server-common": "1.0.1" + }, + "peerDependencies": { + "@launchdarkly/cloudflare-server-sdk": "^2.7.0", + "@openfeature/server-sdk": "^1.16.0" + }, + "devDependencies": { + "@eslint/js": "^9.0.0", + "@launchdarkly/cloudflare-server-sdk": "2.7.29", + "@openfeature/core": "^1.10.0", + "@openfeature/server-sdk": "^1.16.0", + "@types/jest": "^29.5.3", + "eslint": "^9.0.0", + "eslint-import-resolver-typescript": "^4.0.0", + "eslint-plugin-import-x": "^4.0.0", + "eslint-plugin-jest": "^28.0.0", + "globals": "^16.0.0", + "jest": "^29.6.1", + "prettier": "^3.0.0", + "ts-jest": "^29.1.1", + "tsup": "^8.5.1", + "typescript": "5.1.6", + "typescript-eslint": "^8.0.0" + } +} diff --git a/packages/sdk/openfeature-cloudflare-server/src/LaunchDarklyProvider.ts b/packages/sdk/openfeature-cloudflare-server/src/LaunchDarklyProvider.ts new file mode 100644 index 0000000000..2a97b2d834 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/src/LaunchDarklyProvider.ts @@ -0,0 +1,32 @@ +import type { KVNamespace } from '@cloudflare/workers-types'; + +import type { LDClient, LDOptions } from '@launchdarkly/cloudflare-server-sdk'; +import { BasicLogger, init } from '@launchdarkly/cloudflare-server-sdk'; +import { BaseOpenFeatureProvider } from '@launchdarkly/openfeature-js-server-common'; + +/** + * An OpenFeature provider for the LaunchDarkly Cloudflare server SDK. + */ +export default class LaunchDarklyProvider extends BaseOpenFeatureProvider { + /** + * Construct a {@link LaunchDarklyProvider}. + * @param clientSideID The client side ID. This is only used to query the kvNamespace below, + * not to connect with LaunchDarkly servers. + * @param kvNamespace The Cloudflare KV namespace configured for LaunchDarkly. + * @param options Any options for the SDK. + */ + constructor(clientSideID: string, kvNamespace: KVNamespace, options: LDOptions = {}) { + super({ + logger: options.logger ?? BasicLogger.get(), + providerName: 'launchdarkly-cloudflare-provider', + }); + + // no ConfigurationChanged wiring: the KV-backed client has no update notifications to surface + try { + const client = init(clientSideID, kvNamespace, options); + this.setClient(client); + } catch (e) { + this.setClientError(e); + } + } +} diff --git a/packages/sdk/openfeature-cloudflare-server/src/index.ts b/packages/sdk/openfeature-cloudflare-server/src/index.ts new file mode 100644 index 0000000000..40382e7d3a --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/src/index.ts @@ -0,0 +1,10 @@ +/** + * This is the API reference for the LaunchDarkly OpenFeature provider for the Cloudflare + * server SDK + * + * @module @launchdarkly/openfeature-cloudflare-server + */ + +import LaunchDarklyProvider from './LaunchDarklyProvider'; + +export { LaunchDarklyProvider }; diff --git a/packages/sdk/openfeature-cloudflare-server/tsconfig.json b/packages/sdk/openfeature-cloudflare-server/tsconfig.json new file mode 100644 index 0000000000..05fb0d32ec --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "target": "es2020", + "lib": ["es2020"], + "module": "ESNext", + "strict": true, + "noImplicitOverride": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true, + "stripInternal": true, + "moduleResolution": "bundler" + }, + "include": ["src"], + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/packages/sdk/openfeature-cloudflare-server/tsconfig.ref.json b/packages/sdk/openfeature-cloudflare-server/tsconfig.ref.json new file mode 100644 index 0000000000..34a1cb607a --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/openfeature-cloudflare-server/tsup.config.ts b/packages/sdk/openfeature-cloudflare-server/tsup.config.ts new file mode 100644 index 0000000000..615e8c638f --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/tsup.config.ts @@ -0,0 +1,14 @@ +// It is a dev dependency and the linter doesn't understand. +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { + index: 'src/index.ts', + }, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: true, + clean: true, + dts: false, +}); diff --git a/packages/sdk/openfeature-cloudflare-server/typedoc.json b/packages/sdk/openfeature-cloudflare-server/typedoc.json new file mode 100644 index 0000000000..7ac616b544 --- /dev/null +++ b/packages/sdk/openfeature-cloudflare-server/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/tsconfig.json b/tsconfig.json index 2011774354..131c473428 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -106,6 +106,9 @@ { "path": "./packages/sdk/openfeature-node-server/tsconfig.ref.json" }, + { + "path": "./packages/sdk/openfeature-cloudflare-server/tsconfig.ref.json" + }, { "path": "./packages/sdk/node-client/tsconfig.ref.json" }