Skip to content

Commit

Permalink
feat: Add Fastly Edge SDK (#723)
Browse files Browse the repository at this point in the history
**Requirements**

- [x] I have added test coverage for new or changed functionality
- [x] I have followed the repository's [pull request submission
guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests)
- [x] I have validated my changes against all supported platform
versions

**Describe the solution you've provided**

This PR adds a new Fastly Compute SDK. This SDK must be used in
conjunction with our upcoming Fastly KV integration. The SDK is
essentially the same as the other edge integrations (Akamai, Cloudflare,
Vercel), with the following changes:
- `node:events` is not compatible with Fastly's runtime. As a result, we
cannot use `@launchdarkly/sdk-server-edge`. Instead, I copied the
contents of `@launchdarkly/sdk-server-edge` into the fastly package and
replaced `createCallbacks.ts` with an empty implementation.
- Enabled `sendEvents` by default.
- Added the `eventsUri` option to allow for sending events to a custom
endpoint.
- Added a new optional `eventsBackendName` option. A [Fastly
Backend](https://developer.fastly.com/reference/api/services/backend/)
configured to `https://events.launchdarkly.com` is required for sending
events. The default value is `launchdarkly`. This option is passed to
[Fastly's customized
fetch()](https://js-compute-reference-docs.edgecompute.app/docs/globals/fetch#explicit-backends).


I added an example app that demonstrates using the SDK to evaluate a
feature flag edge to control the static image served.

I published an beta version to npm
[here](https://www.npmjs.com/package/@launchdarkly/fastly-server-sdk?activeTab=readme).
  • Loading branch information
ldhenry authored Mar 10, 2025
1 parent 465e60c commit 02e0eee
Show file tree
Hide file tree
Showing 54 changed files with 1,927 additions and 0 deletions.
24 changes: 24 additions & 0 deletions .github/workflows/fastly.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/manual-publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/manual-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }}
Expand Down Expand Up @@ -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']
Expand Down
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions .sdk_metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions packages/sdk/fastly/README.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions packages/sdk/fastly/__mocks__/fastly:kv-store.ts
Original file line number Diff line number Diff line change
@@ -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(),
}));
130 changes: 130 additions & 0 deletions packages/sdk/fastly/__tests__/api/EdgeFeatureStore.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
68 changes: 68 additions & 0 deletions packages/sdk/fastly/__tests__/api/LDClient.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
});
});
17 changes: 17 additions & 0 deletions packages/sdk/fastly/__tests__/api/createOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading

0 comments on commit 02e0eee

Please sign in to comment.