diff --git a/.github/workflows/sdk-server-edge.yml b/.github/workflows/sdk-server-edge.yml new file mode 100644 index 0000000000..67a1c22e02 --- /dev/null +++ b/.github/workflows/sdk-server-edge.yml @@ -0,0 +1,27 @@ +name: shared/sdk-server + +on: + push: + branches: [main] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main] + paths-ignore: + - '**.md' + +jobs: + build-test-sdk-server: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: 'https://registry.npmjs.org' + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/js-server-sdk-common-edge' + workspace_path: packages/shared/sdk-server-edge diff --git a/package.json b/package.json index 1e34212f20..e5138aa0a7 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "workspaces": [ "packages/shared/common", "packages/shared/sdk-server", + "packages/shared/sdk-server-edge", "packages/sdk/server-node", "packages/sdk/cloudflare" ], diff --git a/packages/sdk/cloudflare/package.json b/packages/sdk/cloudflare/package.json index 666e1bead9..993a4ccf4b 100644 --- a/packages/sdk/cloudflare/package.json +++ b/packages/sdk/cloudflare/package.json @@ -1,7 +1,7 @@ { "name": "@launchdarkly/cloudflare-server-sdk", "version": "0.0.1", - "description": "LaunchDarkly Server-Side SDK for Cloudflare workers", + "description": "Cloudflare LaunchDarkly SDK", "packageManager": "yarn@3.4.1", "keywords": [ "launchdarkly", @@ -11,22 +11,22 @@ ], "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", - "type": "module", "files": [ "/dist" ], "scripts": { + "doc": "../../../scripts/build-doc.sh .", "build": "rimraf dist && yarn tsc", "tsw": "yarn tsc --watch", "start": "rimraf dist && yarn tsw", "lint": "eslint . --ext .ts", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --ci --runInBand --coverage", - "check": "yarn prettier && yarn lint && yarn tsc && yarn test" + "check": "yarn prettier && yarn lint && yarn build && yarn test && yarn doc" }, "dependencies": { "@cloudflare/workers-types": "^4.20230321.0", - "@launchdarkly/js-server-sdk-common": "0.2.0", + "@launchdarkly/js-server-sdk-common-edge": "0.0.1", "crypto-js": "^4.1.1" }, "devDependencies": { diff --git a/packages/sdk/cloudflare/src/createLDClient/createFeatureStore.test.ts b/packages/sdk/cloudflare/src/createFeatureStore.test.ts similarity index 96% rename from packages/sdk/cloudflare/src/createLDClient/createFeatureStore.test.ts rename to packages/sdk/cloudflare/src/createFeatureStore.test.ts index 1ccd4d6159..cda7e2f9c8 100644 --- a/packages/sdk/cloudflare/src/createLDClient/createFeatureStore.test.ts +++ b/packages/sdk/cloudflare/src/createFeatureStore.test.ts @@ -1,8 +1,8 @@ -import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; +import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common-edge'; import createFeatureStore from './createFeatureStore'; -import mockKV from '../utils/mockKV'; -import * as testData from '../utils/testData.json'; +import mockKV from './utils/mockKV'; +import * as testData from './utils/testData.json'; describe('createFeatureStore', () => { const sdkKey = 'sdkKey'; diff --git a/packages/sdk/cloudflare/src/createLDClient/createFeatureStore.ts b/packages/sdk/cloudflare/src/createFeatureStore.ts similarity index 95% rename from packages/sdk/cloudflare/src/createLDClient/createFeatureStore.ts rename to packages/sdk/cloudflare/src/createFeatureStore.ts index 2cb112308b..87b19a615a 100644 --- a/packages/sdk/cloudflare/src/createLDClient/createFeatureStore.ts +++ b/packages/sdk/cloudflare/src/createFeatureStore.ts @@ -1,13 +1,13 @@ import type { KVNamespace } from '@cloudflare/workers-types'; -import type { +import { DataKind, LDLogger, LDFeatureStore, LDFeatureStoreDataStorage, LDFeatureStoreItem, LDFeatureStoreKindData, -} from '@launchdarkly/js-server-sdk-common'; -import noop from '../utils/noop'; + noop, +} from '@launchdarkly/js-server-sdk-common-edge'; const createFeatureStore = (kvNamespace: KVNamespace, sdkKey: string, logger: LDLogger) => { const key = `LD-Env-${sdkKey}`; diff --git a/packages/sdk/cloudflare/src/createLDClient/CloudflareImpl.ts b/packages/sdk/cloudflare/src/createLDClient/CloudflareImpl.ts deleted file mode 100644 index 7698c36bc3..0000000000 --- a/packages/sdk/cloudflare/src/createLDClient/CloudflareImpl.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KVNamespace } from '@cloudflare/workers-types'; -import { EventEmitter } from 'node:events'; -import { LDClientImpl, LDOptions } from '@launchdarkly/js-server-sdk-common'; -import CloudflarePlatform from '../platform'; -import createOptions from './createOptions'; -import createCallbacks from './createCallbacks'; - -export default class CloudflareImpl extends LDClientImpl { - emitter: EventEmitter; - - // sdkKey is only used to query the KV, not to initialize with LD servers - constructor(kvNamespace: KVNamespace, sdkKey: string, options: LDOptions = {}) { - const emitter = new EventEmitter(); - - super( - 'n/a', - new CloudflarePlatform(), - createOptions(kvNamespace, sdkKey, options), - createCallbacks(emitter) - ); - this.emitter = emitter; - } -} diff --git a/packages/sdk/cloudflare/src/createLDClient/createOptions.test.ts b/packages/sdk/cloudflare/src/createLDClient/createOptions.test.ts deleted file mode 100644 index 7216f4657c..0000000000 --- a/packages/sdk/cloudflare/src/createLDClient/createOptions.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import createOptions from './createOptions'; -import mockKV from '../utils/mockKV'; - -describe('createOptions', () => { - it('throws without SDK key', () => { - expect(() => { - createOptions(mockKV, ''); - }).toThrowError(/You must configure the client with a client key/); - }); -}); diff --git a/packages/sdk/cloudflare/src/createLDClient/createOptions.ts b/packages/sdk/cloudflare/src/createLDClient/createOptions.ts deleted file mode 100644 index 80bec9ac18..0000000000 --- a/packages/sdk/cloudflare/src/createLDClient/createOptions.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { KVNamespace } from '@cloudflare/workers-types'; -import { BasicLogger, LDLogger, LDOptions, SafeLogger } from '@launchdarkly/js-server-sdk-common'; -import { version } from '../../package.json'; -import createFeatureStore from './createFeatureStore'; - -type SupportedLDOptions = Pick; -const allowedOptions = ['logger', 'featureStore']; - -const defaults = { - stream: false, - sendEvents: false, - offline: false, - useLdd: true, - allAttributesPrivate: false, - privateAttributes: [], - contextKeysCapacity: 1000, - contextKeysFlushInterval: 300, - diagnosticOptOut: true, - diagnosticRecordingInterval: 900, - wrapperName: 'cloudflare', - wrapperVersion: version, -}; - -export const finalizeLogger = ({ logger }: SupportedLDOptions) => { - const fallbackLogger = new BasicLogger({ - level: 'info', - // eslint-disable-next-line no-console - destination: console.error, - }); - - return logger ? new SafeLogger(logger, fallbackLogger) : fallbackLogger; -}; - -export const finalizeFeatureStore = ( - kvNamespace: KVNamespace, - sdkKey: string, - { featureStore }: SupportedLDOptions, - logger: LDLogger -) => featureStore ?? createFeatureStore(kvNamespace, sdkKey, logger); - -const createOptions = ( - kvNamespace: KVNamespace, - sdkKey: string, - options: SupportedLDOptions = {} -) => { - if (!sdkKey) { - throw new Error('You must configure the client with a client key'); - } - - if (!kvNamespace || typeof kvNamespace !== 'object' || !kvNamespace.get) { - throw new Error('You must configure the client with a Cloudflare KV Store namespace binding'); - } - - Object.entries(options).forEach(([key]) => { - if (!allowedOptions.includes(key)) { - throw new Error(`Configuration option: ${key} not supported`); - } - }); - - const logger = finalizeLogger(options); - const featureStore = finalizeFeatureStore(kvNamespace, sdkKey, options, logger); - const finalOptions = { ...defaults, ...options, logger, featureStore }; - logger.debug(`Using LD options: ${JSON.stringify(finalOptions)}`); - return finalOptions; -}; - -export default createOptions; diff --git a/packages/sdk/cloudflare/src/createLDClient/index.ts b/packages/sdk/cloudflare/src/createLDClient/index.ts deleted file mode 100644 index 01c3add0f8..0000000000 --- a/packages/sdk/cloudflare/src/createLDClient/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { KVNamespace } from '@cloudflare/workers-types'; -import type { LDOptions } from '@launchdarkly/js-server-sdk-common'; -import CloudflareImpl from './CloudflareImpl'; - -const createLDClient = (kvNamespace: KVNamespace, sdkKey: string, options: LDOptions = {}) => - new CloudflareImpl(kvNamespace, sdkKey, options); - -export default createLDClient; diff --git a/packages/sdk/cloudflare/src/platform/info.ts b/packages/sdk/cloudflare/src/createPlatformInfo.ts similarity index 50% rename from packages/sdk/cloudflare/src/platform/info.ts rename to packages/sdk/cloudflare/src/createPlatformInfo.ts index a50901a5a7..37ce6b5ca5 100644 --- a/packages/sdk/cloudflare/src/platform/info.ts +++ b/packages/sdk/cloudflare/src/createPlatformInfo.ts @@ -1,8 +1,8 @@ -import type { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common'; +import type { Info, PlatformData, SdkData } from '@launchdarkly/js-server-sdk-common-edge'; -import { name, version } from '../../package.json'; +import { name, version } from '../package.json'; -export default class CloudflareInfo implements Info { +class CloudflarePlatformInfo implements Info { platformData(): PlatformData { return { name: 'Cloudflare worker', @@ -16,3 +16,7 @@ export default class CloudflareInfo implements Info { }; } } + +const createPlatformInfo = () => new CloudflarePlatformInfo(); + +export default createPlatformInfo; diff --git a/packages/sdk/cloudflare/src/index.test.ts b/packages/sdk/cloudflare/src/index.test.ts index a59a9b1c0e..4db9f790a1 100644 --- a/packages/sdk/cloudflare/src/index.test.ts +++ b/packages/sdk/cloudflare/src/index.test.ts @@ -1,6 +1,7 @@ import type { KVNamespace } from '@cloudflare/workers-types'; +import { LDClient } from '@launchdarkly/js-server-sdk-common-edge'; import { Miniflare } from 'miniflare'; -import { init, LDClient } from './index'; +import { init } from './index'; import * as allFlagsSegments from './utils/testData.json'; const mf = new Miniflare({ @@ -15,17 +16,21 @@ const context = { kind: 'user', key: 'test-user-key-1' }; const namespace = 'LD_KV'; const rootEnvKey = `LD-Env-${sdkKey}`; -describe('worker', () => { +describe('init', () => { let kv: KVNamespace; let ldClient: LDClient; beforeAll(async () => { kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace; await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments)); - ldClient = init(kv, sdkKey); + ldClient = init(sdkKey, kv); await ldClient.waitForInitialization(); }); + afterAll(() => { + ldClient.close(); + }); + test('variation', async () => { const flagDetail = await ldClient.variation(flagKey, context, false); expect(flagDetail).toBeTruthy(); diff --git a/packages/sdk/cloudflare/src/index.ts b/packages/sdk/cloudflare/src/index.ts index bfff8b9370..89ac2e168f 100644 --- a/packages/sdk/cloudflare/src/index.ts +++ b/packages/sdk/cloudflare/src/index.ts @@ -1,5 +1,5 @@ /** - * This is the API reference for the LaunchDarkly Server-Side SDK for Cloudflare. + * This is the API reference for the Cloudflare LaunchDarkly SDK. * * In typical usage, you will call {@link init} once at startup time to obtain an instance of * {@link LDClient}, which provides access to all of the SDK's functionality. @@ -9,39 +9,18 @@ * @packageDocumentation */ import type { KVNamespace } from '@cloudflare/workers-types'; -import type { - LDClient as LDClientCommon, - LDFlagsState, - LDFlagsStateOptions, - LDOptions as LDOptionsCommon, - LDContext, - LDEvaluationDetail, - LDFlagValue, -} from '@launchdarkly/js-server-sdk-common'; -import createLDClient from './createLDClient'; +import { + BasicLogger, + init as initEdge, + LDClient, + LDOptions, +} from '@launchdarkly/js-server-sdk-common-edge'; +import createFeatureStore from './createFeatureStore'; +import createPlatformInfo from './createPlatformInfo'; -export * from '@launchdarkly/js-server-sdk-common'; +export * from '@launchdarkly/js-server-sdk-common-edge'; -/** - * The Cloudflare SDK only supports these functions: - * - waitForInitialization - * - variation - * - variationDetail - * - allFlagsState - */ -export type LDClient = Pick< - Omit, - 'variation' | 'variationDetail' | 'allFlagsState' -> & { - waitForInitialization: () => Promise; -}; - -/** - * The Cloudflare SDK only supports these options: - * - logger - * - featureStore - */ -export type LDOptions = Pick; +export type { LDClient }; /** * Creates an instance of the Cloudflare LaunchDarkly client. @@ -55,48 +34,20 @@ export type LDOptions = Pick; * this. * * @param kvNamespace - * The Cloudflare KV configured with LaunchDarkly. + * The Cloudflare KV configured for LaunchDarkly. * @param sdkKey - * The client side SDK key. This is only used to query the kvNamespace above, - * not to connect with LD servers. + * The client side SDK key. This is only used to query the kvNamespace above, + * not to connect with LaunchDarkly servers. * @param options - * Optional configuration settings. The only supported options for the Cloudflare SDK - * are 'logger' and 'featureStore'. + * Optional configuration settings. The only supported option is logger. * @return - * The new {@link LDClient} instance. + * The new {@link LDClient} instance. */ -export const init = ( - kvNamespace: KVNamespace, - sdkKey: string, - options: LDOptions = {} -): LDClient => { - const client = createLDClient(kvNamespace, sdkKey, options); - return { - variation( - key: string, - context: LDContext, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDFlagValue) => void - ): Promise { - return client.variation(key, context, defaultValue, callback); - }, - variationDetail( - key: string, - context: LDContext, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDEvaluationDetail) => void - ): Promise { - return client.variationDetail(key, context, defaultValue, callback); - }, - allFlagsState( - context: LDContext, - o?: LDFlagsStateOptions, - callback?: (err: Error | null, res: LDFlagsState | null) => void - ): Promise { - return client.allFlagsState(context, o, callback); - }, - waitForInitialization(): Promise { - return client.waitForInitialization(); - }, - }; +export const init = (sdkKey: string, kvNamespace: KVNamespace, options: LDOptions = {}) => { + const logger = options.logger ?? BasicLogger.get(); + return initEdge(sdkKey, createPlatformInfo(), { + featureStore: createFeatureStore(kvNamespace, sdkKey, logger), + logger, + ...options, + }); }; diff --git a/packages/sdk/cloudflare/src/platform/index.ts b/packages/sdk/cloudflare/src/platform/index.ts deleted file mode 100644 index e9a3f504a8..0000000000 --- a/packages/sdk/cloudflare/src/platform/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Crypto, Info, Platform, Requests } from '@launchdarkly/js-server-sdk-common'; -import CloudflareCrypto from './crypto'; -import CloudflareInfo from './info'; -import CloudflareRequests from './requests'; - -export default class CloudflarePlatform implements Platform { - info: Info = new CloudflareInfo(); - - crypto: Crypto = new CloudflareCrypto(); - - requests: Requests = new CloudflareRequests(); -} diff --git a/packages/sdk/cloudflare/tsconfig.json b/packages/sdk/cloudflare/tsconfig.json index bbeebea69c..ba2b0ea352 100644 --- a/packages/sdk/cloudflare/tsconfig.json +++ b/packages/sdk/cloudflare/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "dist", "target": "es6", "lib": ["es6"], - "module": "es2022", + "module": "commonjs", "strict": true, "noImplicitOverride": true, "allowSyntheticDefaultImports": true, diff --git a/packages/shared/common/src/index.ts b/packages/shared/common/src/index.ts index b64b4984b3..53118276ab 100644 --- a/packages/shared/common/src/index.ts +++ b/packages/shared/common/src/index.ts @@ -6,6 +6,7 @@ export * from './api'; export * from './validators'; export * from './logging'; export * from './options'; +export * from './utils'; export * as internal from './internal'; diff --git a/packages/shared/common/src/logging/BasicLogger.ts b/packages/shared/common/src/logging/BasicLogger.ts index 945b5d79a6..e78bba6297 100644 --- a/packages/shared/common/src/logging/BasicLogger.ts +++ b/packages/shared/common/src/logging/BasicLogger.ts @@ -31,6 +31,15 @@ export default class BasicLogger implements LDLogger { private formatter?: (...args: any[]) => string; + /** + * This should only be used as a default fallback and not as a convenient + * solution. In most cases you should construct a new instance with the + * appropriate options for your specific needs. + */ + static get() { + return new BasicLogger({}); + } + constructor(options: BasicLoggerOptions) { this.logLevel = LogPriority[options.level ?? 'info'] ?? LogPriority.info; this.name = options.name ?? 'LaunchDarkly'; diff --git a/packages/shared/common/src/utils/index.ts b/packages/shared/common/src/utils/index.ts new file mode 100644 index 0000000000..fb04a0d2f6 --- /dev/null +++ b/packages/shared/common/src/utils/index.ts @@ -0,0 +1,4 @@ +import noop from './noop'; + +// eslint-disable-next-line import/prefer-default-export +export { noop }; diff --git a/packages/sdk/cloudflare/src/utils/noop.ts b/packages/shared/common/src/utils/noop.ts similarity index 100% rename from packages/sdk/cloudflare/src/utils/noop.ts rename to packages/shared/common/src/utils/noop.ts diff --git a/packages/shared/sdk-server-edge/CHANGELOG.md b/packages/shared/sdk-server-edge/CHANGELOG.md new file mode 100644 index 0000000000..055e614b5e --- /dev/null +++ b/packages/shared/sdk-server-edge/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `@launchdarkly/js-server-sdk-common-edge` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). diff --git a/packages/shared/sdk-server-edge/README.md b/packages/shared/sdk-server-edge/README.md new file mode 100644 index 0000000000..df274ee5f5 --- /dev/null +++ b/packages/shared/sdk-server-edge/README.md @@ -0,0 +1,32 @@ +# LaunchDarkly SDK JavaScript Common Server Edge Code + +[![NPM][js-server-sdk-common-edge-badge]][js-server-sdk-common-edge-link] +[![Actions Status][shared-sdk-server-edge-ci-badge]][shared-sdk-server-edge-ci] +[![Documentation](https://launchdarkly.github.io/js-core/packages/shared/common/docs/) + +This project contains Typescript classes and interfaces that are applicable to server-side edge SDKs. + +This library is a beta version and should not be considered ready for production use while this message is visible. + +## Contributing + +See [Contributing](../CONTRIBUTING.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 + +[shared-sdk-server-edge-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/sdk-server-edge.yml/badge.svg +[shared-sdk-server-edge-ci]: https://github.com/launchdarkly/js-core/actions/workflows/sdk-server-edge.yml +[js-server-sdk-common-edge-badge]: https://img.shields.io/npm/v/@launchdarkly/js-server-sdk-common-edge.svg?style=flat-square +[js-server-sdk-common-edge-link]: https://www.npmjs.com/package/@launchdarkly/js-server-sdk-common-edge diff --git a/packages/shared/sdk-server-edge/jest.config.json b/packages/shared/sdk-server-edge/jest.config.json new file mode 100644 index 0000000000..6174807746 --- /dev/null +++ b/packages/shared/sdk-server-edge/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/shared/sdk-server-edge/package.json b/packages/shared/sdk-server-edge/package.json new file mode 100644 index 0000000000..291bd0d5e8 --- /dev/null +++ b/packages/shared/sdk-server-edge/package.json @@ -0,0 +1,50 @@ +{ + "name": "@launchdarkly/js-server-sdk-common-edge", + "version": "0.0.1", + "description": "LaunchDarkly Server SDK for JavaScript - common edge code", + "packageManager": "yarn@3.4.1", + "keywords": [ + "launchdarkly", + "edge", + "function", + "worker" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "doc": "../../../scripts/build-doc.sh .", + "build": "rimraf dist && yarn tsc", + "tsw": "yarn tsc --watch", + "start": "rimraf dist && yarn tsw", + "lint": "eslint . --ext .ts", + "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", + "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --ci --runInBand --coverage", + "check": "yarn prettier && yarn lint && yarn build && yarn test && yarn doc" + }, + "dependencies": { + "@launchdarkly/js-server-sdk-common": "0.2.0", + "crypto-js": "^4.1.1" + }, + "devDependencies": { + "@types/crypto-js": "^4.1.1", + "@types/jest": "^29.5.0", + "@typescript-eslint/eslint-plugin": "^5.57.0", + "@typescript-eslint/parser": "^5.57.0", + "eslint": "^8.37.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^29.5.0", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^2.8.7", + "rimraf": "^5.0.0", + "ts-jest": "^29.1.0", + "typedoc": "0.23.26", + "typescript": "^5.0.3" + } +} diff --git a/packages/shared/sdk-server-edge/src/api/LDClient.ts b/packages/shared/sdk-server-edge/src/api/LDClient.ts new file mode 100644 index 0000000000..7e6baebec8 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/LDClient.ts @@ -0,0 +1,22 @@ +import { EventEmitter } from 'node:events'; +import { Info, LDClientImpl, LDOptions } from '@launchdarkly/js-server-sdk-common'; +import createOptions from './createOptions'; +import createCallbacks from './createCallbacks'; +import EdgePlatform from '../platform'; + +/** + * The LaunchDarkly SDK edge client object. + */ +export class LDClient extends LDClientImpl { + emitter: EventEmitter; + + // sdkKey is only used to query featureStore, not to initialize with LD servers + constructor(sdkKey: string, platformInfo: Info, options: LDOptions) { + const em = new EventEmitter(); + const platform = new EdgePlatform(platformInfo); + super('n/a', platform, createOptions(options), createCallbacks(em)); + this.emitter = em; + } +} + +export default LDClient; diff --git a/packages/sdk/cloudflare/src/createLDClient/createCallbacks.test.ts b/packages/shared/sdk-server-edge/src/api/createCallbacks.test.ts similarity index 95% rename from packages/sdk/cloudflare/src/createLDClient/createCallbacks.test.ts rename to packages/shared/sdk-server-edge/src/api/createCallbacks.test.ts index aaf5d94088..a64eea2940 100644 --- a/packages/sdk/cloudflare/src/createLDClient/createCallbacks.test.ts +++ b/packages/shared/sdk-server-edge/src/api/createCallbacks.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; +import { noop } from '@launchdarkly/js-server-sdk-common'; import createCallbacks from './createCallbacks'; -import noop from '../utils/noop'; describe('createCallbacks', () => { let emitter: EventEmitter; diff --git a/packages/sdk/cloudflare/src/createLDClient/createCallbacks.ts b/packages/shared/sdk-server-edge/src/api/createCallbacks.ts similarity index 87% rename from packages/sdk/cloudflare/src/createLDClient/createCallbacks.ts rename to packages/shared/sdk-server-edge/src/api/createCallbacks.ts index 06e3eb0548..dfb369295c 100644 --- a/packages/sdk/cloudflare/src/createLDClient/createCallbacks.ts +++ b/packages/shared/sdk-server-edge/src/api/createCallbacks.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events'; -import noop from '../utils/noop'; +import { noop } from '@launchdarkly/js-server-sdk-common'; const createCallbacks = (emitter: EventEmitter) => ({ onError: (err: Error) => { diff --git a/packages/shared/sdk-server-edge/src/api/createOptions.test.ts b/packages/shared/sdk-server-edge/src/api/createOptions.test.ts new file mode 100644 index 0000000000..b0464dcf04 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/createOptions.test.ts @@ -0,0 +1,16 @@ +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; +import createOptions, { defaultOptions } from './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/shared/sdk-server-edge/src/api/createOptions.ts b/packages/shared/sdk-server-edge/src/api/createOptions.ts new file mode 100644 index 0000000000..df71218df9 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/createOptions.ts @@ -0,0 +1,23 @@ +import { BasicLogger, LDOptions } from '@launchdarkly/js-server-sdk-common'; + +export const defaultOptions: LDOptions = { + stream: false, + sendEvents: false, + offline: false, + useLdd: true, + allAttributesPrivate: false, + privateAttributes: [], + contextKeysCapacity: 1000, + contextKeysFlushInterval: 300, + diagnosticOptOut: true, + diagnosticRecordingInterval: 900, + logger: BasicLogger.get(), +}; + +const createOptions = (options: LDOptions) => { + const finalOptions = { ...defaultOptions, ...options }; + finalOptions.logger?.debug(`Using LD options: ${JSON.stringify(finalOptions)}`); + return finalOptions; +}; + +export default createOptions; diff --git a/packages/shared/sdk-server-edge/src/api/index.ts b/packages/shared/sdk-server-edge/src/api/index.ts new file mode 100644 index 0000000000..52fd69f1d2 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/api/index.ts @@ -0,0 +1 @@ +export * from './LDClient'; diff --git a/packages/shared/sdk-server-edge/src/index.ts b/packages/shared/sdk-server-edge/src/index.ts new file mode 100644 index 0000000000..62680ca043 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/index.ts @@ -0,0 +1,25 @@ +/** + * This is the internal API reference for the common code shared between + * LaunchDarkly Edge SDKs. + * + * Do not use this package directly in a cloud worker. + * + */ +import type { Info } from '@launchdarkly/js-server-sdk-common'; +import { LDClient } from './api'; +import validateOptions, { LDOptions, LDOptionsInternal } from './utils/validateOptions'; + +export * from '@launchdarkly/js-server-sdk-common'; +export type { LDClient, LDOptions }; + +/** + * Do not use this function directly. + * + * This is an internal API to be used directly only by LaunchDarkly Edge SDKs. + */ +export const init = (sdkKey: string, platformInfo: Info, options: LDOptionsInternal) => { + // this throws if options are invalid + validateOptions(sdkKey, options); + + return new LDClient(sdkKey, platformInfo, options); +}; diff --git a/packages/sdk/cloudflare/src/platform/crypto/cryptoJSHasher.ts b/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHasher.ts similarity index 100% rename from packages/sdk/cloudflare/src/platform/crypto/cryptoJSHasher.ts rename to packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHasher.ts diff --git a/packages/sdk/cloudflare/src/platform/crypto/cryptoJSHmac.ts b/packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHmac.ts similarity index 100% rename from packages/sdk/cloudflare/src/platform/crypto/cryptoJSHmac.ts rename to packages/shared/sdk-server-edge/src/platform/crypto/cryptoJSHmac.ts diff --git a/packages/sdk/cloudflare/src/platform/crypto/index.ts b/packages/shared/sdk-server-edge/src/platform/crypto/index.ts similarity index 86% rename from packages/sdk/cloudflare/src/platform/crypto/index.ts rename to packages/shared/sdk-server-edge/src/platform/crypto/index.ts index b1fe2991ae..62f9dc03fc 100644 --- a/packages/sdk/cloudflare/src/platform/crypto/index.ts +++ b/packages/shared/sdk-server-edge/src/platform/crypto/index.ts @@ -5,10 +5,10 @@ import { SupportedHashAlgorithm } from './types'; /** * Uses crypto-js as substitute to node:crypto because the latter - * is not yet supported in cloudflare. + * is not yet supported in some runtimes. * https://cryptojs.gitbook.io/docs/ */ -export default class CloudflareCrypto implements Crypto { +export default class EdgeCrypto implements Crypto { createHash(algorithm: SupportedHashAlgorithm): Hasher { return new CryptoJSHasher(algorithm); } diff --git a/packages/sdk/cloudflare/src/platform/crypto/types.ts b/packages/shared/sdk-server-edge/src/platform/crypto/types.ts similarity index 100% rename from packages/sdk/cloudflare/src/platform/crypto/types.ts rename to packages/shared/sdk-server-edge/src/platform/crypto/types.ts diff --git a/packages/shared/sdk-server-edge/src/platform/index.ts b/packages/shared/sdk-server-edge/src/platform/index.ts new file mode 100644 index 0000000000..1581069995 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/platform/index.ts @@ -0,0 +1,15 @@ +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 = new EdgeRequests(); + + constructor(info: Info) { + this.info = info; + } +} diff --git a/packages/sdk/cloudflare/src/platform/requests.ts b/packages/shared/sdk-server-edge/src/platform/requests.ts similarity index 86% rename from packages/sdk/cloudflare/src/platform/requests.ts rename to packages/shared/sdk-server-edge/src/platform/requests.ts index 339774fb13..d61e9e4230 100644 --- a/packages/sdk/cloudflare/src/platform/requests.ts +++ b/packages/shared/sdk-server-edge/src/platform/requests.ts @@ -7,8 +7,9 @@ import type { Requests, } from '@launchdarkly/js-server-sdk-common'; -export default class CloudflareRequests implements Requests { +export default class EdgeRequests implements Requests { fetch(url: string, options: Options = {}): Promise { + // @ts-ignore return fetch(url, options); } diff --git a/packages/shared/sdk-server-edge/src/utils/mockFeatureStore.ts b/packages/shared/sdk-server-edge/src/utils/mockFeatureStore.ts new file mode 100644 index 0000000000..037bed69ec --- /dev/null +++ b/packages/shared/sdk-server-edge/src/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/shared/sdk-server-edge/src/utils/validateOptions.test.ts b/packages/shared/sdk-server-edge/src/utils/validateOptions.test.ts new file mode 100644 index 0000000000..0a10d21dec --- /dev/null +++ b/packages/shared/sdk-server-edge/src/utils/validateOptions.test.ts @@ -0,0 +1,45 @@ +import { BasicLogger } from '@launchdarkly/js-server-sdk-common'; +import validateOptions from './validateOptions'; +import mockFeatureStore from './mockFeatureStore'; + +describe('validateOptions', () => { + test('throws without SDK key', () => { + expect(() => { + validateOptions('', {}); + }).toThrowError(/You must configure the client with a client key/); + }); + + test('throws without featureStore', () => { + expect(() => { + validateOptions('test-sdk-key', {}); + }).toThrowError(/You must configure the client with a feature store/); + }); + + test('throws without logger', () => { + expect(() => { + validateOptions('test-sdk-key', { featureStore: mockFeatureStore }); + }).toThrowError(/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', + }); + }).toThrowError(/Invalid configuration: streamUri,proxyOptions not supported/); + }); +}); diff --git a/packages/shared/sdk-server-edge/src/utils/validateOptions.ts b/packages/shared/sdk-server-edge/src/utils/validateOptions.ts new file mode 100644 index 0000000000..b1b799ad71 --- /dev/null +++ b/packages/shared/sdk-server-edge/src/utils/validateOptions.ts @@ -0,0 +1,37 @@ +import { LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common/dist/api/options/LDOptions'; + +/** + * The Launchdarkly Edge SDKs configuration options. Only logger is officially + * supported. sendEvents is unsupported and is only included as a beta + * preview. + */ +export type LDOptions = Pick; + +/** + * The internal options include featureStore because that's how the LDClient + * implementation expects it. + */ +export type LDOptionsInternal = LDOptions & Pick; + +const validateOptions = (sdkKey: string, options: LDOptionsInternal) => { + const { featureStore, logger, sendEvents, ...rest } = options; + if (!sdkKey) { + throw new Error('You must configure the client with a client key'); + } + + if (!featureStore || typeof featureStore !== 'object' || !featureStore.get) { + throw new Error('You must configure the client with a feature store'); + } + + if (!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/shared/sdk-server-edge/tsconfig.eslint.json b/packages/shared/sdk-server-edge/tsconfig.eslint.json new file mode 100644 index 0000000000..56c9b38305 --- /dev/null +++ b/packages/shared/sdk-server-edge/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/shared/sdk-server-edge/tsconfig.json b/packages/shared/sdk-server-edge/tsconfig.json new file mode 100644 index 0000000000..1a75360798 --- /dev/null +++ b/packages/shared/sdk-server-edge/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "es6", + "lib": ["es6", "webworker"], + "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__"] +} diff --git a/packages/shared/sdk-server-edge/tsconfig.ref.json b/packages/shared/sdk-server-edge/tsconfig.ref.json new file mode 100644 index 0000000000..0c86b2c554 --- /dev/null +++ b/packages/shared/sdk-server-edge/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 698d7b6fe0..dccf5ceb5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,9 @@ { "path": "./packages/shared/sdk-server/tsconfig.ref.json" }, + { + "path": "./packages/shared/sdk-server-edge/tsconfig.ref.json" + }, { "path": "./packages/sdk/server-node/tsconfig.ref.json" },