diff --git a/.github/workflows/fastly.yml b/.github/workflows/fastly.yml new file mode 100644 index 0000000000..51939efb3f --- /dev/null +++ b/.github/workflows/fastly.yml @@ -0,0 +1,24 @@ +name: sdk/fastly + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-fastly: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/fastly-server-sdk' + workspace_path: packages/sdk/fastly diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index b2d8f2cef4..54d03fa408 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -12,6 +12,7 @@ on: - packages/shared/sdk-server-edge - packages/shared/akamai-edgeworker-sdk - packages/sdk/cloudflare + - packages/sdk/fastly - packages/sdk/server-node - packages/sdk/vercel - packages/sdk/akamai-base diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index acd415a658..9e1bd4a909 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -22,6 +22,7 @@ on: - packages/shared/sdk-server-edge - packages/shared/akamai-edgeworker-sdk - packages/sdk/cloudflare + - packages/sdk/fastly - packages/sdk/react-native - packages/sdk/server-node - packages/sdk/react-universal diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 2538bf118c..736cc5c85a 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,6 +14,7 @@ jobs: package-sdk-server-edge-released: ${{ steps.release.outputs['packages/shared/sdk-server-edge--release_created'] }} package-akamai-edgeworker-sdk-released: ${{ steps.release.outputs['packages/shared/akamai-edgeworker-sdk--release_created'] }} package-cloudflare-released: ${{ steps.release.outputs['packages/sdk/cloudflare--release_created'] }} + package-fastly-released: ${{ steps.release.outputs['packages/sdk/fastly--release_created'] }} package-react-native-released: ${{ steps.release.outputs['packages/sdk/react-native--release_created'] }} package-server-node-released: ${{ steps.release.outputs['packages/sdk/server-node--release_created'] }} package-vercel-released: ${{ steps.release.outputs['packages/sdk/vercel--release_created'] }} @@ -153,6 +154,26 @@ jobs: workspace_path: packages/sdk/cloudflare aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + release-fastly: + runs-on: ubuntu-latest + needs: ['release-please', 'release-sdk-server'] + permissions: + id-token: write + contents: write + if: ${{ always() && !failure() && !cancelled() && needs.release-please.outputs.package-fastly-released == 'true'}} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: 'https://registry.npmjs.org' + - id: release-fastly + name: Full release of packages/sdk/fastly + uses: ./actions/full-release + with: + workspace_path: packages/sdk/fastly + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + release-react-native: runs-on: ubuntu-latest needs: ['release-please', 'release-sdk-client'] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fc4dd663b4..bfef05af4f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,6 +3,7 @@ "packages/shared/sdk-server": "2.11.1", "packages/sdk/server-node": "9.7.4", "packages/sdk/cloudflare": "2.6.5", + "packages/sdk/fastly": "0.0.1", "packages/shared/sdk-server-edge": "2.5.4", "packages/sdk/vercel": "1.3.23", "packages/sdk/akamai-base": "3.0.0", diff --git a/.sdk_metadata.json b/.sdk_metadata.json index fe6500fadc..ef0233b6e5 100644 --- a/.sdk_metadata.json +++ b/.sdk_metadata.json @@ -28,6 +28,15 @@ "tag-prefix": "cloudflare-server-sdk-" } }, + "fastly": { + "name": "Fastly SDK", + "type": "edge", + "path": "packages/sdk/fastly", + "languages": ["JavaScript", "TypeScript"], + "releases": { + "tag-prefix": "fastly-server-sdk-" + } + }, "react-native": { "name": "React Native SDK", "type": "client-side", diff --git a/package.json b/package.json index b7b0bdc24b..65d85b770b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "packages/sdk/server-node", "packages/sdk/cloudflare", "packages/sdk/cloudflare/example", + "packages/sdk/fastly", + "packages/sdk/fastly/example", "packages/sdk/react-native", "packages/sdk/react-native/example", "packages/sdk/react-universal", diff --git a/packages/sdk/fastly/README.md b/packages/sdk/fastly/README.md new file mode 100644 index 0000000000..bf91ef1282 --- /dev/null +++ b/packages/sdk/fastly/README.md @@ -0,0 +1,61 @@ +# LaunchDarkly SDK for Fastly + +The LaunchDarkly SDK for Fastly is designed for use in [Fastly Compute Platform](https://www.fastly.com/documentation/guides/compute/). It follows the server-side LaunchDarkly model for multi-user contexts. It is not intended for use in desktop and embedded systems applications. + +## Install + +```shell +# npm +npm i @launchdarkly/fastly-server-sdk + +# yarn +yarn add @launchdarkly/fastly-server-sdk +``` + +## Usage notes + +- The SDK must be initialized and used when processing requests, not during build-time initialization. +- The SDK caches all KV data during initialization to reduce the number of backend requests needed to fetch KV data. This means changes to feature flags or segments will not be picked up during the lifecycle of a single request instance. +- Events should flushed using the [`waitUntil()` method](https://js-compute-reference-docs.edgecompute.app/docs/globals/FetchEvent/prototype/waitUntil). + +## Quickstart + +See the full [example app](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/fastly/example). + +## Developing this SDK + +```shell +# at js-core repo root +yarn && yarn build && cd packages/sdk/fastly + +# run tests +yarn test +``` + +## 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. Read [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-fastly-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/fastly.yml/badge.svg +[sdk-fastly-ci]: https://github.com/launchdarkly/js-core/actions/workflows/fastly.yml +[sdk-fastly-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/fastly-server-sdk.svg?style=flat-square +[sdk-fastly-npm-link]: https://www.npmjs.com/package/@launchdarkly/fastly-server-sdk +[sdk-fastly-ghp-badge]: https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8 +[sdk-fastly-ghp-link]: https://launchdarkly.github.io/js-core/packages/sdk/fastly/docs/ +[sdk-fastly-dm-badge]: https://img.shields.io/npm/dm/@launchdarkly/fastly-server-sdk.svg?style=flat-square +[sdk-fastly-dt-badge]: https://img.shields.io/npm/dt/@launchdarkly/fastly-server-sdk.svg?style=flat-square diff --git a/packages/sdk/fastly/__mocks__/fastly:kv-store.ts b/packages/sdk/fastly/__mocks__/fastly:kv-store.ts new file mode 100644 index 0000000000..583bcc1d7e --- /dev/null +++ b/packages/sdk/fastly/__mocks__/fastly:kv-store.ts @@ -0,0 +1,8 @@ +export const KVStore = jest.fn().mockImplementation(() => ({ + get: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + getMulti: jest.fn(), + putMulti: jest.fn(), + deleteMulti: jest.fn(), +})); diff --git a/packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts b/packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts new file mode 100644 index 0000000000..24372266ac --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts @@ -0,0 +1,130 @@ +import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; + +import { EdgeFeatureStore } from '../../src/api/EdgeFeatureStore'; +import mockEdgeProvider from '../utils/mockEdgeProvider'; +import * as testData from './testData.json'; + +describe('EdgeFeatureStore', () => { + const clientSideId = 'client-side-id'; + const kvKey = `LD-Env-${clientSideId}`; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const mockGet = mockEdgeProvider.get as jest.Mock; + let featureStore: LDFeatureStore; + let asyncFeatureStore: AsyncStoreFacade; + + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + clientSideId, + 'MockEdgeProvider', + mockLogger, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('get', () => { + it('can retrieve valid flag', async () => { + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flag).toMatchObject(testData.flags.testFlag1); + }); + + it('returns undefined for invalid flag key', async () => { + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'invalid'); + + expect(flag).toBeUndefined(); + }); + + it('can retrieve valid segment', async () => { + const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'testSegment1'); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments.testSegment1); + }); + + it('returns undefined for invalid segment key', async () => { + const segment = await asyncFeatureStore.get({ namespace: 'segments' }, 'invalid'); + + expect(segment).toBeUndefined(); + }); + + it('returns null for invalid kv key', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const flag = await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + + expect(flag).toBeNull(); + }); + }); + + describe('all', () => { + it('can retrieve all flags', async () => { + const flags = await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(flags).toMatchObject(testData.flags); + }); + + it('can retrieve all segments', async () => { + const segment = await asyncFeatureStore.all({ namespace: 'segments' }); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(segment).toMatchObject(testData.segments); + }); + + it('returns empty object for invalid DataKind', async () => { + const flag = await asyncFeatureStore.all({ namespace: 'InvalidDataKind' }); + + expect(flag).toEqual({}); + }); + + it('returns empty object for invalid kv key', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const segment = await asyncFeatureStore.all({ namespace: 'segments' }); + + expect(segment).toEqual({}); + }); + }); + + describe('initialized', () => { + it('returns true when initialized', async () => { + const isInitialized = await asyncFeatureStore.initialized(); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(isInitialized).toBeTruthy(); + }); + + it('returns false when not initialized', async () => { + mockGet.mockImplementation(() => Promise.resolve(null)); + const isInitialized = await asyncFeatureStore.initialized(); + + expect(mockGet).toHaveBeenCalledWith(kvKey); + expect(isInitialized).toBeFalsy(); + }); + }); + + describe('init & getDescription', () => { + it('can initialize', (done) => { + const cb = jest.fn(() => { + done(); + }); + featureStore.init(testData, cb); + }); + + it('can retrieve description', async () => { + const description = featureStore.getDescription?.(); + + expect(description).toEqual('MockEdgeProvider'); + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/api/LDClient.test.ts b/packages/sdk/fastly/__tests__/api/LDClient.test.ts new file mode 100644 index 0000000000..b4d2e71fe1 --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/LDClient.test.ts @@ -0,0 +1,68 @@ +import { internal } from '@launchdarkly/js-server-sdk-common'; + +import LDClient from '../../src/api/LDClient'; +import { createBasicPlatform } from '../createBasicPlatform'; + +jest.mock('@launchdarkly/js-sdk-common', () => { + const actual = jest.requireActual('@launchdarkly/js-sdk-common'); + return { + ...actual, + ...{ + internal: { + ...actual.internal, + DiagnosticsManager: jest.fn(), + EventProcessor: jest.fn(), + }, + }, + }; +}); + +let mockEventProcessor = internal.EventProcessor as jest.Mock; +beforeEach(() => { + mockEventProcessor = internal.EventProcessor as jest.Mock; + mockEventProcessor.mockClear(); +}); + +describe('Edge LDClient', () => { + it('uses clientSideID endpoints', async () => { + const client = new LDClient('client-side-id', createBasicPlatform().info, { + sendEvents: true, + eventsBackendName: 'launchdarkly', + }); + await client.waitForInitialization({ timeout: 10 }); + const passedConfig = mockEventProcessor.mock.calls[0][0]; + + expect(passedConfig).toMatchObject({ + sendEvents: true, + serviceEndpoints: { + includeAuthorizationHeader: false, + analyticsEventPath: '/events/bulk/client-side-id', + diagnosticEventPath: '/events/diagnostic/client-side-id', + events: 'https://events.launchdarkly.com', + polling: 'https://sdk.launchdarkly.com', + streaming: 'https://stream.launchdarkly.com', + }, + }); + }); + it('uses custom eventsUri when specified', async () => { + const client = new LDClient('client-side-id', createBasicPlatform().info, { + sendEvents: true, + eventsBackendName: 'launchdarkly', + eventsUri: 'https://custom-base-uri.launchdarkly.com', + }); + await client.waitForInitialization({ timeout: 10 }); + const passedConfig = mockEventProcessor.mock.calls[0][0]; + + expect(passedConfig).toMatchObject({ + sendEvents: true, + serviceEndpoints: { + includeAuthorizationHeader: false, + analyticsEventPath: '/events/bulk/client-side-id', + diagnosticEventPath: '/events/diagnostic/client-side-id', + events: 'https://custom-base-uri.launchdarkly.com', + polling: 'https://custom-base-uri.launchdarkly.com', + streaming: 'https://stream.launchdarkly.com', + }, + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/api/createOptions.test.ts b/packages/sdk/fastly/__tests__/api/createOptions.test.ts new file mode 100644 index 0000000000..02fcc4b45d --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/createOptions.test.ts @@ -0,0 +1,17 @@ +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; + +import createOptions, { defaultOptions } from '../../src/api/createOptions'; + +describe('createOptions', () => { + test('default options', () => { + expect(createOptions({})).toEqual(defaultOptions); + }); + + test('override logger', () => { + const logger = new BasicLogger({ name: 'test' }); + expect(createOptions({ logger })).toEqual({ + ...defaultOptions, + logger, + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/api/testData.json b/packages/sdk/fastly/__tests__/api/testData.json new file mode 100644 index 0000000000..b9e5296c03 --- /dev/null +++ b/packages/sdk/fastly/__tests__/api/testData.json @@ -0,0 +1,171 @@ +{ + "flags": { + "testFlag1": { + "key": "testFlag1", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag2": { + "key": "testFlag2", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } + } +} diff --git a/packages/sdk/fastly/__tests__/createBasicPlatform.ts b/packages/sdk/fastly/__tests__/createBasicPlatform.ts new file mode 100644 index 0000000000..e5139ccec6 --- /dev/null +++ b/packages/sdk/fastly/__tests__/createBasicPlatform.ts @@ -0,0 +1,59 @@ +import { PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; + +import { setupCrypto } from './setupCrypto'; + +const setupInfo = () => ({ + platformData: jest.fn( + (): PlatformData => ({ + os: { + name: 'An OS', + version: '1.0.1', + arch: 'An Arch', + }, + name: 'The SDK Name', + additional: { + nodeVersion: '42', + }, + ld_application: { + key: '', + envAttributesVersion: '1.0', + id: 'com.testapp.ld', + name: 'LDApplication.TestApp', + version: '1.1.1', + }, + ld_device: { + key: '', + envAttributesVersion: '1.0', + os: { name: 'Another OS', version: '99', family: 'orange' }, + manufacturer: 'coconut', + }, + }), + ), + sdkData: jest.fn( + (): SdkData => ({ + name: 'An SDK', + version: '2.0.2', + userAgentBase: 'TestUserAgent', + wrapperName: 'Rapper', + wrapperVersion: '1.2.3', + }), + ), +}); + +export const createBasicPlatform = () => ({ + encoding: { + btoa: (s: string) => Buffer.from(s).toString('base64'), + }, + info: setupInfo(), + crypto: setupCrypto(), + requests: { + fetch: jest.fn(), + createEventSource: jest.fn(), + getEventSourceCapabilities: jest.fn(), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }, +}); diff --git a/packages/sdk/fastly/__tests__/createPlatformInfo.test.ts b/packages/sdk/fastly/__tests__/createPlatformInfo.test.ts new file mode 100644 index 0000000000..8474d190e7 --- /dev/null +++ b/packages/sdk/fastly/__tests__/createPlatformInfo.test.ts @@ -0,0 +1,19 @@ +import createPlatformInfo from '../src/createPlatformInfo'; + +const version = '0.0.1'; // x-release-please-version + +describe('Fastly Platform Info', () => { + it('platformData shows correct information', () => { + const platformData = createPlatformInfo(); + + expect(platformData.platformData()).toEqual({ + name: 'Fastly Compute', + }); + + expect(platformData.sdkData()).toEqual({ + name: '@launchdarkly/fastly-server-sdk', + version, + userAgentBase: 'FastlyEdgeSDK', + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/index.test.ts b/packages/sdk/fastly/__tests__/index.test.ts new file mode 100644 index 0000000000..37fa7ee6f8 --- /dev/null +++ b/packages/sdk/fastly/__tests__/index.test.ts @@ -0,0 +1,111 @@ +/// +import { KVStore } from 'fastly:kv-store'; + +import { LDClient } from '../src/api'; +import { init } from '../src/index'; +import * as testData from './utils/testData.json'; + +// Tell Jest to use the manual mock +jest.mock('fastly:kv-store'); + +const sdkKey = 'test-sdk-key'; +const flagKey1 = 'testFlag1'; +const flagKey2 = 'testFlag2'; +const flagKey3 = 'testFlag3'; +const context = { kind: 'user', key: 'test-user-key-1' }; + +describe('init', () => { + let ldClient: LDClient; + let mockKVStore: jest.Mocked; + + beforeAll(async () => { + mockKVStore = new KVStore('test-kv-store') as jest.Mocked; + const testDataString = JSON.stringify(testData); + + mockKVStore.get.mockResolvedValue({ + text: jest.fn().mockResolvedValue(testDataString), + json: jest.fn().mockResolvedValue(testData), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)), + body: new ReadableStream(), + bodyUsed: false, + metadata: () => null, + metadataText: () => null, + }); + ldClient = init(sdkKey, mockKVStore); + await ldClient.waitForInitialization(); + }); + + afterAll(() => { + ldClient.close(); + }); + + describe('flag tests', () => { + it('evaluates a boolean flag with a variation call', async () => { + const value = await ldClient.variation(flagKey1, context, false); + expect(value).toBeTruthy(); + }); + + it('evaluates a boolean flag with a variation and variation detail call', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey2, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false); + + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + it('evaluates a boolean flag with a targeting rule match', async () => { + const contextWithEmail = { ...context, email: 'test@gmail.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); + }); + + it('evaluates a feature flag with a context that does not match any targeting rules', async () => { + const contextWithEmail = { ...context, email: 'test@yahoo.com' }; + const value = await ldClient.variation(flagKey1, contextWithEmail, false); + const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false); + + expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 }); + expect(value).toBeTruthy(); + }); + + it('returns allFlagsState for a context', async () => { + const allFlags = await ldClient.allFlagsState(context); + + expect(allFlags).toBeDefined(); + expect(allFlags.toJSON()).toEqual({ + $flagsState: { + testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 }, + }, + $valid: true, + testFlag1: true, + testFlag2: true, + testFlag3: true, + }); + }); + }); + + describe('segment tests', () => { + it('evaluates a boolean flag with a segment targeting rule match', async () => { + const contextWithCountry = { ...context, country: 'australia' }; + const value = await ldClient.variation(flagKey3, contextWithCountry, false); + const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false); + + expect(detail).toEqual({ + reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 }, + value: false, + variationIndex: 1, + }); + expect(value).toBeFalsy(); + }); + }); +}); diff --git a/packages/sdk/fastly/__tests__/setupCrypto.ts b/packages/sdk/fastly/__tests__/setupCrypto.ts new file mode 100644 index 0000000000..bdf62024fc --- /dev/null +++ b/packages/sdk/fastly/__tests__/setupCrypto.ts @@ -0,0 +1,20 @@ +import { Hasher } from '@launchdarkly/js-server-sdk-common'; + +export const setupCrypto = () => { + let counter = 0; + const hasher = { + update: jest.fn((): Hasher => hasher), + digest: jest.fn(() => '1234567890123456'), + }; + + return { + createHash: jest.fn(() => hasher), + createHmac: jest.fn(), + randomUUID: jest.fn(() => { + counter += 1; + // Will provide a unique value for tests. + // Very much not a UUID of course. + return `${counter}`; + }), + }; +}; diff --git a/packages/sdk/fastly/__tests__/utils/mockEdgeProvider.ts b/packages/sdk/fastly/__tests__/utils/mockEdgeProvider.ts new file mode 100644 index 0000000000..fc237e93fb --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/mockEdgeProvider.ts @@ -0,0 +1,7 @@ +import { EdgeProvider } from '../../src/api'; + +const mockEdgeProvider: EdgeProvider = { + get: jest.fn(), +}; + +export default mockEdgeProvider; diff --git a/packages/sdk/fastly/__tests__/utils/mockFeatureStore.ts b/packages/sdk/fastly/__tests__/utils/mockFeatureStore.ts new file mode 100644 index 0000000000..037bed69ec --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/mockFeatureStore.ts @@ -0,0 +1,13 @@ +import type { LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; + +const mockFeatureStore: LDFeatureStore = { + all: jest.fn(), + close: jest.fn(), + init: jest.fn(), + initialized: jest.fn(), + upsert: jest.fn(), + get: jest.fn(), + delete: jest.fn(), +}; + +export default mockFeatureStore; diff --git a/packages/sdk/fastly/__tests__/utils/testData.json b/packages/sdk/fastly/__tests__/utils/testData.json new file mode 100644 index 0000000000..b9e5296c03 --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/testData.json @@ -0,0 +1,171 @@ +{ + "flags": { + "testFlag1": { + "key": "testFlag1", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "contextKind": "user", + "attribute": "/email", + "op": "contains", + "values": ["gmail"], + "negate": false + } + ], + "trackEvents": false, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }] + } + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag2": { + "key": "testFlag2", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [], + "fallthrough": { + "variation": 0, + "rollout": { + "bucketBy": "bucket", + "variations": [{ "variation": 1, "weight": 100 }], + "contextKind:": "user", + "attribute": "/email" + } + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + }, + "testFlag3": { + "key": "testFlag3", + "on": true, + "prerequisites": [], + "targets": [], + "rules": [ + { + "variation": 1, + "id": "rule1", + "clauses": [ + { + "op": "segmentMatch", + "values": ["testSegment1"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { + "variation": 0 + }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { + "usingMobileKey": true, + "usingEnvironmentId": true + }, + "clientSide": true, + "salt": "aef830243d6640d0a973be89988e008d", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": 2000, + "version": 2, + "deleted": false + } + }, + "segments": { + "testSegment1": { + "name": "testSegment1", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment1", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [ + { + "id": "rule-country", + "clauses": [ + { + "attribute": "country", + "op": "in", + "values": ["australia"], + "negate": false + } + ] + } + ], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + }, + "testSegment2": { + "name": "testSegment2", + "tags": [], + "creationDate": 1676063792158, + "key": "testSegment2", + "included": [], + "excluded": [], + "includedContexts": [], + "excludedContexts": [], + "_links": { + "parent": { "href": "/api/v2/segments/default/test", "type": "application/json" }, + "self": { + "href": "/api/v2/segments/default/test/beta-users-1", + "type": "application/json" + }, + "site": { "href": "/default/test/segments/beta-users-1", "type": "text/html" } + }, + "rules": [], + "version": 1, + "deleted": false, + "_access": { "denied": [], "allowed": [] }, + "generation": 1 + } + } +} diff --git a/packages/sdk/fastly/__tests__/utils/validateOptions.test.ts b/packages/sdk/fastly/__tests__/utils/validateOptions.test.ts new file mode 100644 index 0000000000..11926be372 --- /dev/null +++ b/packages/sdk/fastly/__tests__/utils/validateOptions.test.ts @@ -0,0 +1,46 @@ +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; + +import validateOptions from '../../src/utils/validateOptions'; +import mockFeatureStore from './mockFeatureStore'; + +describe('validateOptions', () => { + test('throws without SDK key', () => { + expect(() => { + validateOptions('', {}); + }).toThrow(/You must configure the client with a client-side id/); + }); + + test('throws without featureStore', () => { + expect(() => { + validateOptions('test-sdk-key', {}); + }).toThrow(/You must configure the client with a feature store/); + }); + + test('throws without logger', () => { + expect(() => { + validateOptions('test-sdk-key', { featureStore: mockFeatureStore }); + }).toThrow(/You must configure the client with a logger/); + }); + + test('success valid options', () => { + expect( + validateOptions('test-sdk-key', { + featureStore: mockFeatureStore, + logger: BasicLogger.get(), + sendEvents: false, + }), + ).toBeTruthy(); + }); + + test('throws with invalid options', () => { + expect(() => { + validateOptions('test-sdk-key', { + featureStore: mockFeatureStore, + logger: BasicLogger.get(), + // @ts-ignore + streamUri: 'invalid-option', + proxyOptions: 'another-invalid-option', + }); + }).toThrow(/Invalid configuration: streamUri,proxyOptions not supported/); + }); +}); diff --git a/packages/sdk/fastly/example/.gitignore b/packages/sdk/fastly/example/.gitignore new file mode 100644 index 0000000000..ab78770b99 --- /dev/null +++ b/packages/sdk/fastly/example/.gitignore @@ -0,0 +1,5 @@ +/node_modules +/bin +/build +/pkg +.env diff --git a/packages/sdk/fastly/example/README.md b/packages/sdk/fastly/example/README.md new file mode 100644 index 0000000000..e2c4ba745a --- /dev/null +++ b/packages/sdk/fastly/example/README.md @@ -0,0 +1,65 @@ +# Example test app for Fastly LaunchDarkly SDK + +This is an example test app to showcase the usage of the Fastly LaunchDarkly SDK in a [Fastly Compute](https://docs.fastly.com/products/compute-at-edge) application. The example demonstrates: + +1. Initializing the LaunchDarkly SDK with a Fastly KV Store +2. Evaluating boolean and string feature flags +3. Using multi-kind contexts to include Fastly-specific data +4. Serving different images based on feature flag variations + +Most of the LaunchDarkly-related code can be found in [src/index.ts](src/index.ts). + +#### Photo credits + +Cat photo by [Sergey Semin](https://unsplash.com/@feneek?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) on [Unsplash](https://unsplash.com/photos/brown-and-white-tabby-cat-DwHULfmhulE?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash). Dog photo by [Taylor Kopel](https://unsplash.com/@taylorkopel?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash) on [Unsplash](https://unsplash.com/photos/yellow-labrador-retriever-puppy-sitting-on-floor-WX4i1Jq_o0Y?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash). + +## Prerequisites + +A node environment of version 16 and yarn are required to develop in this repository. +You will also need the [Fastly CLI](https://developer.fastly.com/learning/tools/cli) installed and a Fastly account to setup +the test data required by this example. If you don't have a Fastly account, you can sign up for a free developer account [here](https://www.fastly.com/signup?tier=free). + +## Setting up your LaunchDarkly environment + +For simplicity, we recommend [creating a new LaunchDarkly project](https://docs.launchdarkly.com/home/organize/projects/?q=create+proj) for this example app. After creating a new project, create the following feature flags: + +- `example-flag` - (Boolean) - This flag is evaluated in the root endpoint +- `animal` - (String) - This flag determines which animal image to show (values: "cat" or "dog") + +## Setting up your development environment + +1. At the root of the js-core repo: + +```shell +yarn && yarn build +``` + +2. Replace `LAUNCHDARKLY_CLIENT_ID` in [src/index.ts](src/index.ts) with your LaunchDarkly SDK key. + +3. Create a new Fastly Compute service in the Fastly UI. + +4. Create a new Fastly KV in the Fastly UI named `launchdarkly`. + +5. Run the following command to install dependencies: + +```shell +yarn +``` + +5. Start the local development server: + +```shell +yarn start +``` + +6. Test the endpoints: + +- Visit `http://127.0.0.1:7676/` for the boolean flag evaluation +- Visit `http://127.0.0.1:7676/animal` to see an image controlled by the string flag +- Visit `http://127.0.0.1:7676/cat` or `http://127.0.0.1:7676/dog` for direct image access + +7. Deploy to Fastly: + +```shell +yarn deploy +``` diff --git a/packages/sdk/fastly/example/fastly.toml b/packages/sdk/fastly/example/fastly.toml new file mode 100644 index 0000000000..21b8c30665 --- /dev/null +++ b/packages/sdk/fastly/example/fastly.toml @@ -0,0 +1,26 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://www.fastly.com/documentation/reference/compute/fastly-toml + +authors = [] +description = "A basic example of using the LaunchDarkly SDK for Fastly" +language = "javascript" +manifest_version = 3 +name = "LaunchDarkly SDK for Fastly Example" +service_id = "" + +[scripts] +build = "yarn build" +post_init = "yarn install" + +[local_server] + +[local_server.backends] + +[local_server.backends.launchdarkly] +url = "https://events.launchdarkly.com" + +[local_server.kv_stores] + +[[local_server.kv_stores.launchdarkly_local]] +key = "LD-Env-local" +path = "./localData.json" diff --git a/packages/sdk/fastly/example/localData.json b/packages/sdk/fastly/example/localData.json new file mode 100644 index 0000000000..572030202b --- /dev/null +++ b/packages/sdk/fastly/example/localData.json @@ -0,0 +1,67 @@ +{ + "flags": { + "animal": { + "key": "animal", + "on": true, + "prerequisites": [], + "targets": [], + "contextTargets": [], + "rules": [], + "fallthrough": { + "rollout": { + "contextKind": "fastly-request", + "variations": [ + { "variation": 0, "weight": 50000 }, + { "variation": 1, "weight": 50000 } + ], + "bucketBy": "key" + } + }, + "offVariation": 1, + "variations": ["cat", "dog"], + "clientSideAvailability": { "usingMobileKey": false, "usingEnvironmentId": false }, + "clientSide": false, + "salt": "0ab7b96471ff4edb98113157355cbb9f", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": null, + "version": 5, + "deleted": false + }, + "example-flag": { + "key": "example-flag", + "on": true, + "prerequisites": [], + "targets": [], + "contextTargets": [], + "rules": [ + { + "variation": 1, + "id": "8b96123e-759f-4f73-b91e-884ac56d0a06", + "clauses": [ + { + "contextKind": "fastly-request", + "attribute": "fastly_region", + "op": "in", + "values": ["US-West"], + "negate": false + } + ], + "trackEvents": false + } + ], + "fallthrough": { "variation": 0 }, + "offVariation": 1, + "variations": [true, false], + "clientSideAvailability": { "usingMobileKey": false, "usingEnvironmentId": false }, + "clientSide": false, + "salt": "ca17f93252064631bacb2cffea217f20", + "trackEvents": false, + "trackEventsFallthrough": false, + "debugEventsUntilDate": null, + "version": 8, + "deleted": false + } + }, + "segments": {} +} diff --git a/packages/sdk/fastly/example/package.json b/packages/sdk/fastly/example/package.json new file mode 100644 index 0000000000..6f458709fe --- /dev/null +++ b/packages/sdk/fastly/example/package.json @@ -0,0 +1,23 @@ +{ + "name": "fastly-example", + "packageManager": "yarn@3.4.1", + "type": "module", + "engines": { + "node": "^16 || >=18" + }, + "devDependencies": { + "@fastly/cli": "^10.19.0", + "rimraf": "^6.0.1", + "typescript": "^5.7.2" + }, + "dependencies": { + "@fastly/js-compute": "^3.30.1", + "@launchdarkly/fastly-server-sdk": "0.0.1-beta.1" + }, + "scripts": { + "clean": "rimraf build && rimraf bin", + "build": "tsc && js-compute-runtime build/index.js bin/main.wasm", + "start": "fastly compute serve", + "deploy": "fastly compute publish" + } +} diff --git a/packages/sdk/fastly/example/src/cat.jpg b/packages/sdk/fastly/example/src/cat.jpg new file mode 100644 index 0000000000..2e53fbe4ec Binary files /dev/null and b/packages/sdk/fastly/example/src/cat.jpg differ diff --git a/packages/sdk/fastly/example/src/dog.jpg b/packages/sdk/fastly/example/src/dog.jpg new file mode 100644 index 0000000000..67caee2b53 Binary files /dev/null and b/packages/sdk/fastly/example/src/dog.jpg differ diff --git a/packages/sdk/fastly/example/src/index.ts b/packages/sdk/fastly/example/src/index.ts new file mode 100644 index 0000000000..f0e073c595 --- /dev/null +++ b/packages/sdk/fastly/example/src/index.ts @@ -0,0 +1,118 @@ +/* eslint-disable no-console, @typescript-eslint/no-use-before-define, no-restricted-globals */ +/// +import { env } from 'fastly:env'; +import { includeBytes } from 'fastly:experimental'; +import { KVStore } from 'fastly:kv-store'; + +import { init } from '@launchdarkly/fastly-server-sdk'; +import type { LDMultiKindContext } from '@launchdarkly/js-server-sdk-common'; + +// Set your LaunchDarkly client ID here +const LAUNCHDARKLY_CLIENT_ID = ''; +// Set the KV store name used to store the LaunchDarkly data here +const KV_STORE_NAME = 'launchdarkly'; +// Set the Fastly Backend name used to send LaunchDarkly events here +const EVENTS_BACKEND_NAME = 'launchdarkly'; + +const cat = includeBytes('./src/cat.jpg'); +const dog = includeBytes('./src/dog.jpg'); + +// The entry point for your application. +// +// Use this fetch event listener to define your main request handling logic. It +// could be used to route based on the request properties (such as method or +// path), send the request to a backend, make completely new requests, and/or +// generate synthetic responses. + +addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event: FetchEvent) { + // Log service version + console.log('FASTLY_SERVICE_VERSION:', env('FASTLY_SERVICE_VERSION') || 'local'); + + // Get the client request. + const req = event.request; + + // Filter requests that have unexpected methods. + if (!['HEAD', 'GET', 'PURGE'].includes(req.method)) { + return new Response('This method is not allowed', { + status: 405, + }); + } + + const isLocal = env('FASTLY_HOSTNAME') === 'localhost'; + const kvStoreName = isLocal ? 'launchdarkly_local' : KV_STORE_NAME; + const ldClientId = isLocal ? 'local' : LAUNCHDARKLY_CLIENT_ID; + + const store = new KVStore(kvStoreName); + const ldClient = init(ldClientId, store, { + sendEvents: true, + eventsBackendName: EVENTS_BACKEND_NAME, + }); + await ldClient.waitForInitialization(); + + const flagContext: LDMultiKindContext = { + kind: 'multi', + user: { + // In a real-world scenario, you would use get the user key from a cookie, header, or other source + key: 'test-user', + }, + 'fastly-request': { + key: env('FASTLY_TRACE_ID'), + fastly_service_version: env('FASTLY_SERVICE_VERSION'), + fastly_cache_generation: env('FASTLY_CACHE_GENERATION'), + fastly_hostname: env('FASTLY_HOSTNAME'), + fastly_pop: env('FASTLY_POP'), + fastly_region: env('FASTLY_REGION'), + fastly_service_id: env('FASTLY_SERVICE_ID'), + fastly_trace_id: env('FASTLY_TRACE_ID'), + }, + }; + + const url = new URL(req.url); + + if (url.pathname === '/') { + const flagKey = 'example-flag'; + const variationDetail = await ldClient.boolVariationDetail(flagKey, flagContext, false); + + const output = { + flagContext, + flagKey, + variationDetail, + }; + event.waitUntil(ldClient.flush()); + + return new Response(JSON.stringify(output, undefined, 2), { + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + } + + if (url.pathname === '/cat') { + return new Response(cat, { + status: 200, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + }); + } + if (url.pathname === '/dog') { + return new Response(dog, { + status: 200, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + }); + } + if (url.pathname === '/animal') { + const animal = await ldClient.stringVariation('animal', flagContext, 'cat'); + const image = animal === 'cat' ? cat : dog; + + event.waitUntil(ldClient.flush()); + return new Response(image, { + status: 200, + headers: new Headers({ 'Content-Type': 'image/jpeg' }), + }); + } + + // // Catch all other requests and return a 404. + return new Response('not found', { + status: 404, + }); +} diff --git a/packages/sdk/fastly/example/tsconfig.json b/packages/sdk/fastly/example/tsconfig.json new file mode 100644 index 0000000000..9f2c981cc9 --- /dev/null +++ b/packages/sdk/fastly/example/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "module": "ES2022", + "target": "ES2022", + "moduleResolution": "bundler", + "customConditions": ["fastly"], + "esModuleInterop": true, + "lib": ["ES2022"], + "rootDir": "src", + "outDir": "build", + "types": ["@fastly/js-compute"], + "skipLibCheck": true + }, + "include": ["./src/**/*.js", "./src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/fastly/jest.config.json b/packages/sdk/fastly/jest.config.json new file mode 100644 index 0000000000..6174807746 --- /dev/null +++ b/packages/sdk/fastly/jest.config.json @@ -0,0 +1,9 @@ +{ + "transform": { "^.+\\.ts?$": "ts-jest" }, + "testMatch": ["**/*.test.ts?(x)"], + "testPathIgnorePatterns": ["node_modules", "example", "dist"], + "modulePathIgnorePatterns": ["dist"], + "testEnvironment": "node", + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], + "collectCoverageFrom": ["src/**/*.ts"] +} diff --git a/packages/sdk/fastly/jsr.json b/packages/sdk/fastly/jsr.json new file mode 100644 index 0000000000..4aad242265 --- /dev/null +++ b/packages/sdk/fastly/jsr.json @@ -0,0 +1,9 @@ +{ + "name": "@launchdarkly/fastly-server-sdk", + "version": "0.0.1", + "exports": "./src/index.ts", + "publish": { + "include": ["LICENSE", "README.md", "package.json", "jsr.json", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] + } +} diff --git a/packages/sdk/fastly/package.json b/packages/sdk/fastly/package.json new file mode 100644 index 0000000000..c64f3ac5d9 --- /dev/null +++ b/packages/sdk/fastly/package.json @@ -0,0 +1,74 @@ +{ + "name": "@launchdarkly/fastly-server-sdk", + "version": "0.0.1", + "packageManager": "yarn@3.4.1", + "description": "Cloudflare LaunchDarkly SDK", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/sdk/fastly", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "license": "Apache-2.0", + "keywords": [ + "launchdarkly", + "fastly", + "edge", + "compute", + "kv" + ], + "type": "module", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "require": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "main": "../dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup && ../../../scripts/replace-version.sh .", + "clean": "rimraf dist", + "tsw": "yarn tsc --watch", + "start": "rimraf dist && yarn tsw", + "lint": "eslint . --ext .ts", + "test": "npx jest --runInBand", + "coverage": "yarn test --coverage", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", + "check": "yarn prettier && yarn lint && yarn build && yarn test" + }, + "dependencies": { + "@fastly/js-compute": "^3.30.1", + "@launchdarkly/js-server-sdk-common": "2.11.1", + "crypto-js": "^4.2.0" + }, + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@types/crypto-js": "^4.2.2", + "@types/jest": "^29.5.14", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^18.0.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jest": "^28.11.0", + "eslint-plugin-prettier": "^5.2.3", + "jest": "^29.7.0", + "prettier": "^3.4.2", + "rimraf": "^6.0.1", + "ts-jest": "^29.2.5", + "tsup": "^8.3.5", + "typedoc": "^0.27.4", + "typescript": "^5.7.2" + } +} diff --git a/packages/sdk/fastly/src/api/EdgeFeatureStore.ts b/packages/sdk/fastly/src/api/EdgeFeatureStore.ts new file mode 100644 index 0000000000..e639db1523 --- /dev/null +++ b/packages/sdk/fastly/src/api/EdgeFeatureStore.ts @@ -0,0 +1,129 @@ +import type { + DataKind, + LDFeatureStore, + LDFeatureStoreDataStorage, + LDFeatureStoreItem, + LDFeatureStoreKindData, + LDLogger, +} from '@launchdarkly/js-server-sdk-common'; +import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; + +export interface EdgeProvider { + get: (rootKey: string) => Promise; +} + +export class EdgeFeatureStore implements LDFeatureStore { + private readonly _rootKey: string; + private _kvData: string | null = null; + + constructor( + private readonly _edgeProvider: EdgeProvider, + sdkKey: string, + private readonly _description: string, + private _logger: LDLogger, + ) { + this._rootKey = `LD-Env-${sdkKey}`; + } + + /** + * This function is used to lazy load the KV data from the edge provider. This is necessary because Fastly Compute + * has a limit of 32 backend requests (including requests to fetch the KV data). + * https://docs.fastly.com/products/compute-resource-limits + */ + private async _getKVData(): Promise { + if (!this._kvData) { + this._kvData = await this._edgeProvider.get(this._rootKey); + } + return this._kvData; + } + + async get( + kind: DataKind, + dataKey: string, + callback: (res: LDFeatureStoreItem | null) => void, + ): Promise { + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); + + try { + const i = await this._getKVData(); + + if (!i) { + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); + } + + const item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${kindKey}`); + } + + switch (namespace) { + case 'features': + callback(item.flags[dataKey]); + break; + case 'segments': + callback(item.segments[dataKey]); + break; + default: + callback(null); + } + } catch (err) { + this._logger.error(err); + callback(null); + } + } + + async all(kind: DataKind, callback: (res: LDFeatureStoreKindData) => void = noop): Promise { + const { namespace } = kind; + const kindKey = namespace === 'features' ? 'flags' : namespace; + this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); + try { + const i = await this._getKVData(); + if (!i) { + throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); + } + + const item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${kindKey}`); + } + + switch (namespace) { + case 'features': + callback(item.flags); + break; + case 'segments': + callback(item.segments); + break; + default: + callback({}); + } + } catch (err) { + this._logger.error(err); + callback({}); + } + } + + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { + const config = await this._getKVData(); + const result = config !== null; + this._logger.debug(`Is ${this._rootKey} initialized? ${result}`); + callback(result); + } + + init(allData: LDFeatureStoreDataStorage, callback: () => void): void { + callback(); + } + + getDescription(): string { + return this._description; + } + + // unused + close = noop; + + delete = noop; + + upsert = noop; +} diff --git a/packages/sdk/fastly/src/api/LDClient.ts b/packages/sdk/fastly/src/api/LDClient.ts new file mode 100644 index 0000000000..443becd83c --- /dev/null +++ b/packages/sdk/fastly/src/api/LDClient.ts @@ -0,0 +1,31 @@ +import { Info, internal, LDClientImpl } from '@launchdarkly/js-server-sdk-common'; + +import EdgePlatform from '../platform'; +import { FastlySDKOptions } from '../utils/validateOptions'; +import createCallbacks from './createCallbacks'; +import createOptions from './createOptions'; + +export const DEFAULT_EVENTS_BACKEND_NAME = 'launchdarkly'; + +/** + * The LaunchDarkly SDK edge client object. + */ +export default class LDClient extends LDClientImpl { + // clientSideID is only used to query the edge key-value store and send analytics, not to initialize with LD servers + constructor(clientSideID: string, platformInfo: Info, options: FastlySDKOptions) { + const { eventsBackendName, ...ldOptions } = options; + const platform = new EdgePlatform( + platformInfo, + eventsBackendName || DEFAULT_EVENTS_BACKEND_NAME, + ); + const internalOptions: internal.LDInternalOptions = { + analyticsEventPath: `/events/bulk/${clientSideID}`, + diagnosticEventPath: `/events/diagnostic/${clientSideID}`, + includeAuthorizationHeader: false, + }; + + const finalOptions = createOptions(ldOptions); + + super(clientSideID, platform, finalOptions, createCallbacks(), internalOptions); + } +} diff --git a/packages/sdk/fastly/src/api/createCallbacks.ts b/packages/sdk/fastly/src/api/createCallbacks.ts new file mode 100644 index 0000000000..922a4b9e08 --- /dev/null +++ b/packages/sdk/fastly/src/api/createCallbacks.ts @@ -0,0 +1,9 @@ +const createCallbacks = () => ({ + onError: () => {}, + onFailed: () => {}, + onReady: () => {}, + onUpdate: () => {}, + hasEventListeners: () => false, +}); + +export default createCallbacks; diff --git a/packages/sdk/fastly/src/api/createOptions.ts b/packages/sdk/fastly/src/api/createOptions.ts new file mode 100644 index 0000000000..4dabafe34f --- /dev/null +++ b/packages/sdk/fastly/src/api/createOptions.ts @@ -0,0 +1,23 @@ +import { BasicLogger, LDOptions } from '@launchdarkly/js-server-sdk-common'; + +export const defaultOptions: LDOptions = { + stream: false, + sendEvents: true, + useLdd: true, + diagnosticOptOut: true, + logger: BasicLogger.get(), +}; + +const createOptions = (options: LDOptions) => { + const finalOptions = { ...defaultOptions, ...options }; + + // The Fastly SDK does not poll LaunchDarkly for updates, so a custom baseUri does not make sense. However, we need + // to set it to something when a custom eventsUri is specified in order to pass validation in sdk-server-common. + if (finalOptions.eventsUri) { + finalOptions.baseUri = finalOptions.eventsUri; + } + finalOptions.logger?.debug(`Using LD options: ${JSON.stringify(finalOptions)}`); + return finalOptions; +}; + +export default createOptions; diff --git a/packages/sdk/fastly/src/api/index.ts b/packages/sdk/fastly/src/api/index.ts new file mode 100644 index 0000000000..c4ae612f9d --- /dev/null +++ b/packages/sdk/fastly/src/api/index.ts @@ -0,0 +1,4 @@ +import LDClient from './LDClient'; + +export * from './EdgeFeatureStore'; +export { LDClient }; diff --git a/packages/sdk/fastly/src/createPlatformInfo.ts b/packages/sdk/fastly/src/createPlatformInfo.ts new file mode 100644 index 0000000000..79374b7dd6 --- /dev/null +++ b/packages/sdk/fastly/src/createPlatformInfo.ts @@ -0,0 +1,24 @@ +import { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; + +const name = '@launchdarkly/fastly-server-sdk'; +const version = '0.0.1'; // x-release-please-version + +class FastlyPlatformInfo implements Info { + platformData(): PlatformData { + return { + name: 'Fastly Compute', + }; + } + + sdkData(): SdkData { + return { + name, + version, + userAgentBase: 'FastlyEdgeSDK', + }; + } +} + +const createPlatformInfo = () => new FastlyPlatformInfo(); + +export default createPlatformInfo; diff --git a/packages/sdk/fastly/src/index.ts b/packages/sdk/fastly/src/index.ts new file mode 100644 index 0000000000..73ccdb96cc --- /dev/null +++ b/packages/sdk/fastly/src/index.ts @@ -0,0 +1,33 @@ +/// +import { KVStore } from 'fastly:kv-store'; + +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; + +import { EdgeFeatureStore, EdgeProvider, LDClient } from './api'; +import { DEFAULT_EVENTS_BACKEND_NAME } from './api/LDClient'; +import createPlatformInfo from './createPlatformInfo'; +import validateOptions, { FastlySDKOptions } from './utils/validateOptions'; + +export const init = ( + clientSideId: string, + kvStore: KVStore, + options: FastlySDKOptions = { eventsBackendName: DEFAULT_EVENTS_BACKEND_NAME }, +) => { + const logger = options.logger ?? BasicLogger.get(); + + const edgeProvider: EdgeProvider = { + get: async (rootKey: string) => { + const entry = await kvStore.get(rootKey); + return entry ? entry.text() : null; + }, + }; + + const finalOptions = { + featureStore: new EdgeFeatureStore(edgeProvider, clientSideId, 'Fastly', logger), + logger, + ...options, + }; + + validateOptions(clientSideId, finalOptions); + return new LDClient(clientSideId, createPlatformInfo(), finalOptions); +}; diff --git a/packages/sdk/fastly/src/platform/crypto/cryptoJSHasher.ts b/packages/sdk/fastly/src/platform/crypto/cryptoJSHasher.ts new file mode 100644 index 0000000000..4aec221057 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/cryptoJSHasher.ts @@ -0,0 +1,49 @@ +import CryptoJS from 'crypto-js'; + +import { Hasher as LDHasher } from '@launchdarkly/js-server-sdk-common'; + +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class CryptoJSHasher implements LDHasher { + private _cryptoJSHasher; + + constructor(algorithm: SupportedHashAlgorithm) { + let algo; + + switch (algorithm) { + case 'sha1': + algo = CryptoJS.algo.SHA1; + break; + case 'sha256': + algo = CryptoJS.algo.SHA256; + break; + default: + throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); + } + + this._cryptoJSHasher = algo.create(); + } + + digest(encoding: SupportedOutputEncoding): string { + const result = this._cryptoJSHasher.finalize(); + + let enc; + switch (encoding) { + case 'base64': + enc = CryptoJS.enc.Base64; + break; + case 'hex': + enc = CryptoJS.enc.Hex; + break; + default: + throw new Error('unsupported output encoding. Only base64 and hex are supported.'); + } + + return result.toString(enc); + } + + update(data: string): this { + this._cryptoJSHasher.update(data); + return this; + } +} diff --git a/packages/sdk/fastly/src/platform/crypto/cryptoJSHmac.ts b/packages/sdk/fastly/src/platform/crypto/cryptoJSHmac.ts new file mode 100644 index 0000000000..98e8976bb0 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/cryptoJSHmac.ts @@ -0,0 +1,45 @@ +import CryptoJS from 'crypto-js'; + +import { Hmac as LDHmac } from '@launchdarkly/js-server-sdk-common'; + +import { SupportedHashAlgorithm, SupportedOutputEncoding } from './types'; + +export default class CryptoJSHmac implements LDHmac { + private _cryptoJSHmac; + + constructor(algorithm: SupportedHashAlgorithm, key: string) { + let algo; + + switch (algorithm) { + case 'sha1': + algo = CryptoJS.algo.SHA1; + break; + case 'sha256': + algo = CryptoJS.algo.SHA256; + break; + default: + throw new Error('unsupported hash algorithm. Only sha1 and sha256 are supported.'); + } + + this._cryptoJSHmac = CryptoJS.algo.HMAC.create(algo, key); + } + + digest(encoding: SupportedOutputEncoding): string { + const result = this._cryptoJSHmac.finalize(); + + if (encoding === 'base64') { + return result.toString(CryptoJS.enc.Base64); + } + + if (encoding === 'hex') { + return result.toString(CryptoJS.enc.Hex); + } + + throw new Error('unsupported output encoding. Only base64 and hex are supported.'); + } + + update(data: string): this { + this._cryptoJSHmac.update(data); + return this; + } +} diff --git a/packages/sdk/fastly/src/platform/crypto/index.ts b/packages/sdk/fastly/src/platform/crypto/index.ts new file mode 100644 index 0000000000..7a25f59036 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/index.ts @@ -0,0 +1,24 @@ +import type { Crypto, Hasher, Hmac } from '@launchdarkly/js-server-sdk-common'; + +import CryptoJSHasher from './cryptoJSHasher'; +import CryptoJSHmac from './cryptoJSHmac'; +import { SupportedHashAlgorithm } from './types'; + +/** + * Uses crypto-js as substitute to node:crypto because the latter + * is not yet supported in some runtimes. + * https://cryptojs.gitbook.io/docs/ + */ +export default class EdgeCrypto implements Crypto { + createHash(algorithm: SupportedHashAlgorithm): Hasher { + return new CryptoJSHasher(algorithm); + } + + createHmac(algorithm: SupportedHashAlgorithm, key: string): Hmac { + return new CryptoJSHmac(algorithm, key); + } + + randomUUID(): string { + return crypto.randomUUID(); + } +} diff --git a/packages/sdk/fastly/src/platform/crypto/types.ts b/packages/sdk/fastly/src/platform/crypto/types.ts new file mode 100644 index 0000000000..3cf314d1f4 --- /dev/null +++ b/packages/sdk/fastly/src/platform/crypto/types.ts @@ -0,0 +1,2 @@ +export type SupportedHashAlgorithm = 'sha1' | 'sha256'; +export type SupportedOutputEncoding = 'base64' | 'hex'; diff --git a/packages/sdk/fastly/src/platform/index.ts b/packages/sdk/fastly/src/platform/index.ts new file mode 100644 index 0000000000..e4d34ea977 --- /dev/null +++ b/packages/sdk/fastly/src/platform/index.ts @@ -0,0 +1,17 @@ +import type { Crypto, Info, Platform, Requests } from '@launchdarkly/js-server-sdk-common'; + +import EdgeCrypto from './crypto'; +import EdgeRequests from './requests'; + +export default class EdgePlatform implements Platform { + info: Info; + + crypto: Crypto = new EdgeCrypto(); + + requests: Requests; + + constructor(info: Info, eventsBackend: string) { + this.info = info; + this.requests = new EdgeRequests(eventsBackend); + } +} diff --git a/packages/sdk/fastly/src/platform/requests.ts b/packages/sdk/fastly/src/platform/requests.ts new file mode 100644 index 0000000000..3315c3b078 --- /dev/null +++ b/packages/sdk/fastly/src/platform/requests.ts @@ -0,0 +1,34 @@ +/// +import { NullEventSource } from '@launchdarkly/js-server-sdk-common'; +import type { + EventSource, + EventSourceCapabilities, + EventSourceInitDict, + Options, + Requests, + Response, +} from '@launchdarkly/js-server-sdk-common'; + +export default class EdgeRequests implements Requests { + eventsBackend: string; + + constructor(eventsBackend: string) { + this.eventsBackend = eventsBackend; + } + + fetch(url: string, options: Options = {}): Promise { + return fetch(url, { ...options, backend: this.eventsBackend }); + } + + createEventSource(url: string, eventSourceInitDict: EventSourceInitDict): EventSource { + return new NullEventSource(url, eventSourceInitDict); + } + + getEventSourceCapabilities(): EventSourceCapabilities { + return { + readTimeout: false, + headers: false, + customMethod: false, + }; + } +} diff --git a/packages/sdk/fastly/src/utils/validateOptions.ts b/packages/sdk/fastly/src/utils/validateOptions.ts new file mode 100644 index 0000000000..9b0f324996 --- /dev/null +++ b/packages/sdk/fastly/src/utils/validateOptions.ts @@ -0,0 +1,46 @@ +import { LDOptions as LDOptionsCommon, TypeValidators } from '@launchdarkly/js-server-sdk-common'; + +/** + * The Launchdarkly Fastly Compute SDK configuration options. + */ +export type FastlySDKOptions = Pick & { + /** + * The Fastly Backend name to send LaunchDarkly events. Backends are configured using the Fastly service backend configuration. This option can be ignored if the `sendEvents` option is set to `false`. See [Fastly's Backend documentation](https://developer.fastly.com/reference/api/services/backend/) for more information. The default value is `launchdarkly`. + */ + eventsBackendName?: string; +}; + +/** + * The internal options include featureStore because that's how the LDClient + * implementation expects it. + */ +export type LDOptionsInternal = FastlySDKOptions & Pick; + +const validators = { + clientSideId: TypeValidators.String, + featureStore: TypeValidators.ObjectOrFactory, + logger: TypeValidators.Object, +}; + +const validateOptions = (clientSideId: string, options: LDOptionsInternal) => { + const { eventsBackendName, featureStore, logger, sendEvents, ...rest } = options; + if (!clientSideId || !validators.clientSideId.is(clientSideId)) { + throw new Error('You must configure the client with a client-side id'); + } + + if (!featureStore || !validators.featureStore.is(featureStore)) { + throw new Error('You must configure the client with a feature store'); + } + + if (!logger || !validators.logger.is(logger)) { + throw new Error('You must configure the client with a logger'); + } + + if (JSON.stringify(rest) !== '{}') { + throw new Error(`Invalid configuration: ${Object.keys(rest).toString()} not supported`); + } + + return true; +}; + +export default validateOptions; diff --git a/packages/sdk/fastly/tsconfig.eslint.json b/packages/sdk/fastly/tsconfig.eslint.json new file mode 100644 index 0000000000..56c9b38305 --- /dev/null +++ b/packages/sdk/fastly/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/sdk/fastly/tsconfig.json b/packages/sdk/fastly/tsconfig.json new file mode 100644 index 0000000000..7ff06dea45 --- /dev/null +++ b/packages/sdk/fastly/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + // Uses "." so it can load package.json. + "rootDir": ".", + "outDir": "dist", + "target": "es2017", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "resolveJsonModule": true, + "stripInternal": true, + "moduleResolution": "node", + "types": ["jest", "node"], + "skipLibCheck": true + }, + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example"] +} diff --git a/packages/sdk/fastly/tsconfig.ref.json b/packages/sdk/fastly/tsconfig.ref.json new file mode 100644 index 0000000000..832c1d8dd7 --- /dev/null +++ b/packages/sdk/fastly/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "package.json", "src/**/testData.json"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/sdk/fastly/tsup.config.ts b/packages/sdk/fastly/tsup.config.ts new file mode 100644 index 0000000000..7ec3b3486b --- /dev/null +++ b/packages/sdk/fastly/tsup.config.ts @@ -0,0 +1,26 @@ +// 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', + }, + minify: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: false, + clean: true, + noExternal: ['@launchdarkly/js-server-sdk-common'], + dts: true, + metafile: true, + esbuildOptions(opts) { + // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, + // so we need to craft something that works without it. + // So start of line followed by a character that isn't followed by m or underscore, but we + // want other things that do start with m, so we need to progressively handle more characters + // of meta with exclusions. + // eslint-disable-next-line no-param-reassign + opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; + }, +}); diff --git a/packages/sdk/fastly/typedoc.json b/packages/sdk/fastly/typedoc.json new file mode 100644 index 0000000000..7ac616b544 --- /dev/null +++ b/packages/sdk/fastly/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/release-please-config.json b/release-please-config.json index 039d191708..7623bb361a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -20,6 +20,22 @@ "src/createPlatformInfo.ts" ] }, + "packages/sdk/fastly": { + "extra-files": [ + { + "type": "json", + "path": "jsr.json", + "jsonpath": "$.version" + }, + { + "type": "json", + "path": "example/package.json", + "jsonpath": "$.dependencies['@launchdarkly/fastly-server-sdk']" + }, + "src/createPlatformInfo.ts", + "__tests__/createPlatformInfo.test.ts" + ] + }, "packages/sdk/react-native": {}, "packages/sdk/server-node": {}, "packages/sdk/vercel": { diff --git a/tsconfig.json b/tsconfig.json index 2059110636..f6ff5c4506 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,6 +69,9 @@ }, { "path": "./packages/telemetry/browser-telemetry/tsconfig.ref.json" + }, + { + "path": "./packages/sdk/fastly/tsconfig.ref.json" } ] }