diff --git a/lib/shared/bucketing/src/bucketing.ts b/lib/shared/bucketing/src/bucketing.ts index 94c7415d5..7d7f6303b 100644 --- a/lib/shared/bucketing/src/bucketing.ts +++ b/lib/shared/bucketing/src/bucketing.ts @@ -238,7 +238,7 @@ export const generateBucketedConfig = ({ } segmentedFeatures.forEach(({ feature, target }) => { - const { _id, key, type, variations, settings } = feature + const { variations } = feature const { rolloutHash, bucketingHash } = generateBoundedHashes( user.user_id, target._id, diff --git a/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts b/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts index 5dbc0b812..a3f4d92b3 100644 --- a/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts +++ b/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts @@ -8,10 +8,10 @@ import { Response } from 'cross-fetch' import { DevCycleOptions, dvcDefaultLogger, - ResponseError, } from '@devcycle/js-cloud-server-sdk' import { DVCLogger } from '@devcycle/types' import { getEnvironmentConfig } from '../src/request' +import { ResponseError } from '@devcycle/server-request' const setInterval_mock = mocked(setInterval) const getEnvironmentConfig_mock = mocked(getEnvironmentConfig) diff --git a/lib/shared/config-manager/src/index.ts b/lib/shared/config-manager/src/index.ts index 617be268a..ac3cff0fc 100644 --- a/lib/shared/config-manager/src/index.ts +++ b/lib/shared/config-manager/src/index.ts @@ -1,7 +1,7 @@ import { DVCLogger } from '@devcycle/types' -// import { UserError } from './utils/userError' import { getEnvironmentConfig } from './request' -import { ResponseError, DevCycleOptions } from '@devcycle/js-cloud-server-sdk' +import { DevCycleOptions } from '@devcycle/js-cloud-server-sdk' +import { ResponseError, UserError } from '@devcycle/server-request' type ConfigPollingOptions = DevCycleOptions & { cdnURI?: string @@ -10,15 +10,7 @@ type ConfigPollingOptions = DevCycleOptions & { type SetIntervalInterface = (handler: () => void, timeout?: number) => any type ClearIntervalInterface = (intervalTimeout: any) => void -type SetConfigBuffer = (sdkKey: string, projectConfig: string) => void - -export class UserError extends Error { - constructor(error: Error | string) { - super(error instanceof Error ? error.message : error) - this.name = 'UserError' - this.stack = error instanceof Error ? error.stack : undefined - } -} +type SetConfigBuffer = (sdkKey: string, projectConfigStr: string) => void export class EnvironmentConfigManager { private readonly logger: DVCLogger diff --git a/lib/shared/config-manager/src/request.ts b/lib/shared/config-manager/src/request.ts index 84e621f7e..5211827a2 100644 --- a/lib/shared/config-manager/src/request.ts +++ b/lib/shared/config-manager/src/request.ts @@ -1,5 +1,4 @@ -import { RequestInitWithRetry } from 'fetch-retry' -import { get } from '@devcycle/js-cloud-server-sdk' +import { getWithTimeout } from '@devcycle/server-request' export async function getEnvironmentConfig( url: string, @@ -19,20 +18,3 @@ export async function getEnvironmentConfig( requestTimeout, ) } - -async function getWithTimeout( - url: string, - requestConfig: RequestInit | RequestInitWithRetry, - timeout: number, -): Promise { - const controller = new AbortController() - const id = setTimeout(() => { - controller.abort() - }, timeout) - const response = await get(url, { - ...requestConfig, - signal: controller.signal, - }) - clearTimeout(id) - return response -} diff --git a/lib/shared/server-request/.eslintrc.json b/lib/shared/server-request/.eslintrc.json new file mode 100644 index 000000000..9761c5638 --- /dev/null +++ b/lib/shared/server-request/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/lib/shared/server-request/README.md b/lib/shared/server-request/README.md new file mode 100644 index 000000000..678c71269 --- /dev/null +++ b/lib/shared/server-request/README.md @@ -0,0 +1,11 @@ +# server-request + +This library contains common code for making requests to the DevCycle server from server SDKs. + +## Building + +Run `nx build server-request` to build the library. + +## Running unit tests + +Run `nx test server-request` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/lib/shared/server-request/__mocks__/cross-fetch.ts b/lib/shared/server-request/__mocks__/cross-fetch.ts new file mode 100644 index 000000000..ef8b92b3d --- /dev/null +++ b/lib/shared/server-request/__mocks__/cross-fetch.ts @@ -0,0 +1,6 @@ +const { Request, Response } = jest.requireActual('cross-fetch') + +const fetch = jest.fn() + +export { Request, Response } +export default fetch diff --git a/lib/shared/server-request/__mocks__/fetch-retry.ts b/lib/shared/server-request/__mocks__/fetch-retry.ts new file mode 100644 index 000000000..6fb1926a0 --- /dev/null +++ b/lib/shared/server-request/__mocks__/fetch-retry.ts @@ -0,0 +1,2 @@ +export const fetchWithRetry = (_fetch: unknown): unknown => _fetch +export default fetchWithRetry diff --git a/lib/shared/server-request/__tests__/models/requestEvent.spec.ts b/lib/shared/server-request/__tests__/models/requestEvent.spec.ts new file mode 100644 index 000000000..cc5d1ef2f --- /dev/null +++ b/lib/shared/server-request/__tests__/models/requestEvent.spec.ts @@ -0,0 +1,63 @@ +import { DVCRequestEvent, EventTypes } from '../../src' + +describe('DVCRequestEvent Unit Tests', () => { + it('should construct custom DVCRequestEvent from DVCEvent', () => { + const date = Date.now() + const requestEvent = new DVCRequestEvent( + { + type: 'type', + date, + target: 'target', + value: 610, + metaData: { meta: 'data' }, + }, + 'user_id', + { feature: 'vars' }, + ) + + expect(requestEvent).toEqual({ + type: 'customEvent', + customType: 'type', + user_id: 'user_id', + clientDate: date, + target: 'target', + value: 610, + featureVars: { feature: 'vars' }, + metaData: { meta: 'data' }, + }) + }) + + it('should construct an event for an internal DVC Event', () => { + const requestEvent = new DVCRequestEvent( + { + type: EventTypes.variableEvaluated, + }, + 'user_id', + ) + expect(requestEvent).toEqual( + expect.objectContaining({ + type: EventTypes.variableEvaluated, + user_id: 'user_id', + clientDate: expect.any(Number), + }), + ) + }) + + it('should check that type is defined as a string', () => { + expect(() => new (DVCRequestEvent as any)({})).toThrow( + 'Missing parameter: type', + ) + expect(() => new (DVCRequestEvent as any)({ type: 6 })).toThrow( + 'type is not of type: string', + ) + }) + + it('should check that user_id is defined as a string', () => { + expect(() => new (DVCRequestEvent as any)({ type: 'type' })).toThrow( + 'Missing parameter: user_id', + ) + expect(() => new (DVCRequestEvent as any)({ type: 'type' }, 6)).toThrow( + 'user_id is not of type: string', + ) + }) +}) diff --git a/sdk/js-cloud-server/__tests__/request.spec.ts b/lib/shared/server-request/__tests__/request.spec.ts similarity index 98% rename from sdk/js-cloud-server/__tests__/request.spec.ts rename to lib/shared/server-request/__tests__/request.spec.ts index 7db68a935..ea66abaec 100644 --- a/sdk/js-cloud-server/__tests__/request.spec.ts +++ b/lib/shared/server-request/__tests__/request.spec.ts @@ -5,7 +5,7 @@ global.fetch = fetch const fetchRequestMock = fetch as jest.MockedFn -import { post, get } from '../src/request' +import { post, get } from '../src/' describe('request.ts Unit Tests', () => { beforeEach(() => { diff --git a/sdk/js-cloud-server/__tests__/utils/paramUtils.spec.ts b/lib/shared/server-request/__tests__/utils/paramUtils.spec.ts similarity index 95% rename from sdk/js-cloud-server/__tests__/utils/paramUtils.spec.ts rename to lib/shared/server-request/__tests__/utils/paramUtils.spec.ts index 89d483d30..3577a2206 100644 --- a/sdk/js-cloud-server/__tests__/utils/paramUtils.spec.ts +++ b/lib/shared/server-request/__tests__/utils/paramUtils.spec.ts @@ -1,8 +1,4 @@ -import { - checkParamDefined, - checkParamType, - typeEnum, -} from '../../src/utils/paramUtils' +import { checkParamDefined, checkParamType, typeEnum } from '../../src' describe('paramUtils Unit Tests', () => { describe('checkParamDefined', () => { diff --git a/lib/shared/server-request/jest.config.ts b/lib/shared/server-request/jest.config.ts new file mode 100644 index 000000000..4c176337d --- /dev/null +++ b/lib/shared/server-request/jest.config.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +export default { + displayName: 'server-request', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/lib/shared/server-request', +} diff --git a/lib/shared/server-request/package.json b/lib/shared/server-request/package.json new file mode 100644 index 000000000..71ed80a86 --- /dev/null +++ b/lib/shared/server-request/package.json @@ -0,0 +1,9 @@ +{ + "name": "@devcycle/server-request", + "version": "1.0.0", + "type": "commonjs", + "private": true, + "dependencies": { + "fetch-retry": "^5.0.3" + } +} diff --git a/lib/shared/server-request/project.json b/lib/shared/server-request/project.json new file mode 100644 index 000000000..b14eb1076 --- /dev/null +++ b/lib/shared/server-request/project.json @@ -0,0 +1,40 @@ +{ + "name": "server-request", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "lib/shared/server-request/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/lib/shared/server-request", + "main": "lib/shared/server-request/src/index.ts", + "tsConfig": "lib/shared/server-request/tsconfig.lib.json", + "assets": ["lib/shared/server-request/*.md"] + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["lib/shared/server-request/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "lib/shared/server-request/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/lib/shared/server-request/src/index.ts b/lib/shared/server-request/src/index.ts new file mode 100644 index 000000000..d00d2ef02 --- /dev/null +++ b/lib/shared/server-request/src/index.ts @@ -0,0 +1,5 @@ +export * from './request' +export * from './userError' +export * from './utils/paramUtils' +export * from './models/devcycleEvent' +export * from './models/requestEvent' diff --git a/lib/shared/server-request/src/models/devcycleEvent.ts b/lib/shared/server-request/src/models/devcycleEvent.ts new file mode 100644 index 000000000..893859c97 --- /dev/null +++ b/lib/shared/server-request/src/models/devcycleEvent.ts @@ -0,0 +1,26 @@ +export interface DevCycleEvent { + /** + * type of the event + */ + type: string + + /** + * date event occurred according to client stored as time since epoch + */ + date?: number + + /** + * target / subject of event. Contextual to event type + */ + target?: string + + /** + * value for numerical events. Contextual to event type + */ + value?: number + + /** + * extra metadata for event. Contextual to event type + */ + metaData?: Record +} diff --git a/sdk/nodejs/src/models/requestEvent.ts b/lib/shared/server-request/src/models/requestEvent.ts similarity index 67% rename from sdk/nodejs/src/models/requestEvent.ts rename to lib/shared/server-request/src/models/requestEvent.ts index 4d375a527..f984e1d1c 100644 --- a/sdk/nodejs/src/models/requestEvent.ts +++ b/lib/shared/server-request/src/models/requestEvent.ts @@ -1,9 +1,16 @@ -import { EventTypes } from '../eventQueue' -import { - DevCycleEvent, - checkParamDefined, - checkParamString, -} from '@devcycle/js-cloud-server-sdk' +import { checkParamDefined, checkParamString } from '../utils/paramUtils' +import { DevCycleEvent } from './devcycleEvent' + +export const AggregateEventTypes: Record = { + variableEvaluated: 'variableEvaluated', + aggVariableEvaluated: 'aggVariableEvaluated', + variableDefaulted: 'variableDefaulted', + aggVariableDefaulted: 'aggVariableDefaulted', +} + +export const EventTypes: Record = { + ...AggregateEventTypes, +} export class DVCRequestEvent { type: string diff --git a/lib/shared/server-request/src/request.ts b/lib/shared/server-request/src/request.ts new file mode 100644 index 000000000..1447e4520 --- /dev/null +++ b/lib/shared/server-request/src/request.ts @@ -0,0 +1,157 @@ +import fetchWithRetry, { RequestInitWithRetry } from 'fetch-retry' +import { DVCLogger, SDKEventBatchRequestBody } from '@devcycle/types' + +export const HOST = '.devcycle.com' +export const EVENT_URL = 'https://events' +export const EVENTS_PATH = '/v1/events/batch' + +export async function publishEvents( + logger: DVCLogger, + sdkKey: string, + eventsBatch: SDKEventBatchRequestBody, + eventsBaseURLOverride?: string, +): Promise { + const url = eventsBaseURLOverride + ? `${eventsBaseURLOverride}${EVENTS_PATH}` + : `${EVENT_URL}${HOST}${EVENTS_PATH}` + return await post( + url, + { + body: JSON.stringify({ batch: eventsBatch }), + }, + sdkKey, + ) +} + +export class ResponseError extends Error { + constructor(message: string) { + super(message) + this.name = 'ResponseError' + } + + status: number +} + +const exponentialBackoff: RequestInitWithRetry['retryDelay'] = (attempt) => { + const delay = Math.pow(2, attempt) * 100 + const randomSum = delay * 0.2 * Math.random() + return delay + randomSum +} + +type retryOnRequestErrorFunc = ( + retries: number, +) => RequestInitWithRetry['retryOn'] + +const retryOnRequestError: retryOnRequestErrorFunc = (retries) => { + return (attempt, error, response) => { + if (attempt >= retries) { + return false + } else if (response && response?.status < 500) { + return false + } + + return true + } +} + +const handleResponse = async (res: Response) => { + // res.ok only checks for 200-299 status codes + if (!res.ok && res.status >= 400) { + let error + try { + const response: any = await res.clone().json() + error = new ResponseError( + response.message || 'Something went wrong', + ) + } catch (e) { + error = new ResponseError('Something went wrong') + } + error.status = res.status + throw error + } + + return res +} + +export async function getWithTimeout( + url: string, + requestConfig: RequestInit | RequestInitWithRetry, + timeout: number, +): Promise { + const controller = new AbortController() + const id = setTimeout(() => { + controller.abort() + }, timeout) + const response = await get(url, { + ...requestConfig, + signal: controller.signal, + }) + clearTimeout(id) + return response +} + +export async function post( + url: string, + requestConfig: RequestInit | RequestInitWithRetry, + sdkKey: string, +): Promise { + const [_fetch, config] = await getFetchAndConfig(requestConfig) + const postHeaders = { + ...config.headers, + Authorization: sdkKey, + 'Content-Type': 'application/json', + } + const res = await _fetch(url, { + ...config, + headers: postHeaders, + method: 'POST', + }) + + return handleResponse(res) +} + +export async function get( + url: string, + requestConfig: RequestInit | RequestInitWithRetry, +): Promise { + const [_fetch, config] = await getFetchAndConfig(requestConfig) + const headers = { ...config.headers, 'Content-Type': 'application/json' } + + const res = await _fetch(url, { + ...config, + headers, + method: 'GET', + }) + + return handleResponse(res) +} + +async function getFetch() { + if (typeof fetch !== 'undefined') { + return fetch + } + + return (await import('cross-fetch')).default +} + +async function getFetchWithRetry() { + const fetch = await getFetch() + return fetchWithRetry(fetch) +} + +type FetchClient = Awaited> +type FetchAndConfig = [FetchClient, RequestInit] + +async function getFetchAndConfig( + requestConfig: RequestInit | RequestInitWithRetry, +): Promise { + const useRetries = 'retries' in requestConfig + if (useRetries && requestConfig.retries) { + const newConfig: RequestInitWithRetry = { ...requestConfig } + newConfig.retryOn = retryOnRequestError(requestConfig.retries) + newConfig.retryDelay = exponentialBackoff + return [await getFetchWithRetry(), newConfig] + } + + return [await getFetch(), requestConfig] +} diff --git a/sdk/nodejs/src/utils/userError.ts b/lib/shared/server-request/src/userError.ts similarity index 100% rename from sdk/nodejs/src/utils/userError.ts rename to lib/shared/server-request/src/userError.ts diff --git a/sdk/js-cloud-server/src/utils/paramUtils.ts b/lib/shared/server-request/src/utils/paramUtils.ts similarity index 100% rename from sdk/js-cloud-server/src/utils/paramUtils.ts rename to lib/shared/server-request/src/utils/paramUtils.ts diff --git a/lib/shared/server-request/tsconfig.json b/lib/shared/server-request/tsconfig.json new file mode 100644 index 000000000..b8b7b3ce4 --- /dev/null +++ b/lib/shared/server-request/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/lib/shared/server-request/tsconfig.lib.json b/lib/shared/server-request/tsconfig.lib.json new file mode 100644 index 000000000..fcf1bcdb3 --- /dev/null +++ b/lib/shared/server-request/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/lib/shared/server-request/tsconfig.spec.json b/lib/shared/server-request/tsconfig.spec.json new file mode 100644 index 000000000..29067780c --- /dev/null +++ b/lib/shared/server-request/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2019", + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/sdk/edge-worker-server/.babelrc b/sdk/edge-worker-server/.babelrc new file mode 100644 index 000000000..b34c8a529 --- /dev/null +++ b/sdk/edge-worker-server/.babelrc @@ -0,0 +1,10 @@ +{ + "presets": [ + [ + "@nx/js/babel", + { + "useBuiltIns": "usage" + } + ] + ] +} diff --git a/sdk/edge-worker-server/.eslintrc.json b/sdk/edge-worker-server/.eslintrc.json new file mode 100644 index 000000000..8fee0b748 --- /dev/null +++ b/sdk/edge-worker-server/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*", "node_modules/*", "src/pb-types/compiled*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/sdk/edge-worker-server/.gitignore b/sdk/edge-worker-server/.gitignore new file mode 100644 index 000000000..92053055c --- /dev/null +++ b/sdk/edge-worker-server/.gitignore @@ -0,0 +1,4 @@ +built/* +dist/* +node_modules/* +.npmrc diff --git a/sdk/edge-worker-server/README.md b/sdk/edge-worker-server/README.md new file mode 100644 index 000000000..b1905c351 --- /dev/null +++ b/sdk/edge-worker-server/README.md @@ -0,0 +1,19 @@ +# DevCycle EdgeWorker Server SDK + +This SDK supports EdgeWorker environments like CloudFlare Worker WebWorker environments. +It acts as a server-side SDK for EdgeWorker environments, either in local or cloud bucketing mode. +Local bucketing mode will make all variable evaluations locally in the SDK after fetching the latest +project configuration from our CDN. Whereas cloud bucketing mode will make a request to the DevCycle bucketing API, +hosted on a Cloudflare Worker, for all variable evaluations. See the docs for more information. + +### Installation + +Our library can be found on npm and installed by the following: + +``` +npm install @devcycle/edge-worker-server-sdk +``` + +### Usage + +To find usage documentation, visit our [docs](https://docs.devcycle.com/docs/sdk/server-side-sdks/node#usage). diff --git a/sdk/edge-worker-server/__tests__/client.spec.ts b/sdk/edge-worker-server/__tests__/client.spec.ts new file mode 100644 index 000000000..bd13e2956 --- /dev/null +++ b/sdk/edge-worker-server/__tests__/client.spec.ts @@ -0,0 +1,250 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +const testVariable = { + _id: 'test-id', + value: true, + type: 'Boolean', + key: 'test-key', + evalReason: null, + defaultValue: false, +} +const mockGenerateBucketedConfig = jest.fn().mockReturnValue({ + variables: { 'test-key': testVariable }, + features: { + 'test-feature': { + _id: 'test-id', + _variation: 'variation-id', + variationKey: 'variationKey', + variationName: 'Variation Name', + key: 'test-feature', + type: 'release', + }, + }, + variableVariationMap: { + 'test-key': { + _feature: 'test-id', + _variation: 'variation-id', + }, + }, +}) +jest.mock('@devcycle/bucketing', () => { + return { + generateBucketedConfig: mockGenerateBucketedConfig, + } +}) + +const mockFetchConfigPromise = Promise.resolve() +jest.mock('@devcycle/config-manager', () => { + return { + EnvironmentConfigManager: jest.fn().mockImplementation(() => { + return { fetchConfigPromise: mockFetchConfigPromise } + }), + } +}) + +import { DevCycleEdgeClient, initializeDevCycle } from '../src' +import { DevCycleUser } from '@devcycle/js-cloud-server-sdk' + +describe('DevCycleEdgeClient Tests', () => { + const user = { + user_id: 'node_sdk_test', + country: 'CA', + customData: { + test: 'test', + canBeNull: null, + }, + privateCustomData: { + private: 'private', + }, + } + const emptyUser = { user_id: 'empty' } + + let client: DevCycleEdgeClient + + beforeAll(async () => { + client = initializeDevCycle('server_token', {}) + await client.onClientInitialized() + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + describe('variable', () => { + it('returns a valid variable object for a variable that is in the config', async () => { + const variable = await client.variable(user, 'test-key', false) + expect(variable.value).toEqual(true) + expect(variable.isDefaulted).toEqual(false) + expect(variable.type).toEqual('Boolean') + + expect(await client.variableValue(user, 'test-key', false)).toEqual( + true, + ) + }) + + it('returns a valid variable object for a variable that is in the config with a DVCUser instance', async () => { + const dvcUser = new DevCycleUser(user) + const variable = await client.variable(dvcUser, 'test-key', false) + expect(variable.value).toEqual(true) + expect(variable.isDefaulted).toEqual(false) + expect(variable.type).toEqual('Boolean') + + expect( + await client.variableValue(dvcUser, 'test-key', false), + ).toEqual(true) + }) + + it('returns a valid variable object for a variable that is not in the config', async () => { + mockGenerateBucketedConfig.mockReturnValueOnce({}) + const variable = await client.variable(user, 'test-key2', false) + expect(variable.value).toEqual(false) + expect(variable.isDefaulted).toEqual(true) + expect(variable.type).toEqual('Boolean') + + mockGenerateBucketedConfig.mockReturnValueOnce({}) + expect( + await client.variableValue(user, 'test-key2', false), + ).toEqual(false) + }) + + it('returns a defaulted variable object for a variable that is in the config but the wrong type', async () => { + mockGenerateBucketedConfig.mockReturnValueOnce({}) + const variable = await client.variable(user, 'test-key', 'test') + expect(variable.value).toEqual('test') + expect(variable.isDefaulted).toEqual(true) + expect(variable.type).toEqual('String') + + expect( + await client.variableValue(user, 'test-key', 'test'), + ).toEqual('test') + }) + + it('returns a variable with the correct type for string', async () => { + const variable = await client.variable(user, 'test-key', 'test') + expect(typeof variable.value).toEqual('string') + expect(variable.type).toBe('String') + // this will be a type error for non-strings + variable.value.concat() + // should allow assignment to different string + variable.value = 'test2' + expect(variable.type).toEqual('String') + + expect( + await client.variableValue(user, 'test-key', 'test'), + ).toEqual('test') + }) + + it('returns a variable with the correct type for number', async () => { + const variable = await client.variable(user, 'test-key', 1) + expect(typeof variable.value).toEqual('number') + expect(variable.type).toBe('Number') + // this will be a type error for non-numbers + variable.value.toFixed() + + expect(await client.variableValue(user, 'test-key', 1)).toEqual(1) + }) + + it('returns a variable with the correct type for JSON', async () => { + const defaultVal = { key: 'test' } + const variable = await client.variable(user, 'test-key', defaultVal) + expect(variable.value).toBeInstanceOf(Object) + expect(variable.type).toBe('JSON') + expect(variable.value).toEqual({ key: 'test' }) + + expect( + await client.variableValue(user, 'test-key', { key: 'test' }), + ).toEqual(defaultVal) + }) + + it('to throw an error if key is not defined', async () => { + try { + await client.variable( + user, + undefined as unknown as string, + false, + ) + } catch (ex) { + expect(ex.message).toBe('Missing parameter: key') + } + + try { + await client.variableValue( + user, + undefined as unknown as string, + false, + ) + } catch (ex) { + expect(ex.message).toBe('Missing parameter: key') + } + }) + + it('to throw an error if defaultValue is not defined', async () => { + try { + await client.variable( + user, + 'test-key', + undefined as unknown as string, + ) + } catch (ex) { + expect(ex.message).toBe( + 'The default value for variable test-key is not of type Boolean, Number, String, or JSON', + ) + } + + try { + await client.variableValue( + user, + 'test-key', + undefined as unknown as string, + ) + } catch (ex) { + expect(ex.message).toBe( + 'The default value for variable test-key is not of type Boolean, Number, String, or JSON', + ) + } + }) + }) + + describe('allVariables', () => { + it('to return all variables', async () => { + const res = await client.allVariables(user) + expect(res).toEqual({ + 'test-key': { + _id: 'test-id', + key: 'test-key', + value: true, + type: 'Boolean', + defaultValue: false, + evalReason: null, + }, + }) + }) + + it('to return no variables when allVariables call succeeds but user has no variables', async () => { + mockGenerateBucketedConfig.mockReturnValueOnce({}) + const res = await client.allVariables(emptyUser) + expect(res).toEqual({}) + }) + }) + + describe('allFeatures', () => { + it('to return all features', async () => { + const res = await client.allFeatures(user) + expect(res).toEqual({ + 'test-feature': { + _id: 'test-id', + _variation: 'variation-id', + variationKey: 'variationKey', + variationName: 'Variation Name', + key: 'test-feature', + type: 'release', + }, + }) + }) + + it('to return no features when allFeatures call succeeds but user has no features', async () => { + mockGenerateBucketedConfig.mockReturnValueOnce({}) + const res = await client.allFeatures(emptyUser) + expect(res).toEqual({}) + }) + }) +}) diff --git a/sdk/edge-worker-server/__tests__/initialize.spec.ts b/sdk/edge-worker-server/__tests__/initialize.spec.ts new file mode 100644 index 000000000..8d6795294 --- /dev/null +++ b/sdk/edge-worker-server/__tests__/initialize.spec.ts @@ -0,0 +1,57 @@ +import { + DevCycleEdgeClient, + DevCycleCloudClient, + initializeDevCycle, +} from '../src' + +const mockFetchConfigPromise = Promise.resolve() +jest.mock('@devcycle/config-manager', () => { + return { + EnvironmentConfigManager: jest.fn().mockImplementation(() => { + return { fetchConfigPromise: mockFetchConfigPromise } + }), + } +}) + +describe('EdgeWorker SDK Initialize', () => { + afterAll(() => { + jest.clearAllMocks() + }) + + it('sucessfully calls initialize with no options', async () => { + const client: DevCycleEdgeClient = await initializeDevCycle( + 'dvc_server_token', + ).onClientInitialized() + expect(client).toBeDefined() + }) + + it('fails to initialize in Local Bucketing mode when no token is provided', () => { + expect(() => + initializeDevCycle(undefined as unknown as string), + ).toThrow('Missing SDK key! Call initialize with a valid SDK key') + }) + + it('fails to initialize in Local Bucketing mode when client token is provided', () => { + expect(() => initializeDevCycle('dvc_client_token')).toThrow( + 'Invalid SDK key provided. Please call initialize with a valid server SDK key', + ) + }) + + it('sucessfully calls initialize with enableCloudBucketing set to true', () => { + const client: DevCycleCloudClient = initializeDevCycle( + 'dvc_server_token', + { + enableCloudBucketing: true, + }, + ) + expect(client).toBeDefined() + }) + + it('fails to initialize in Cloud Bucketing mode when no token is provided', () => { + expect(() => + initializeDevCycle(undefined as unknown as string, { + enableCloudBucketing: true, + }), + ).toThrow('Missing SDK key! Call initialize with a valid SDK key') + }) +}) diff --git a/sdk/edge-worker-server/__tests__/models/populatedUserHelpers.spec.ts b/sdk/edge-worker-server/__tests__/models/populatedUserHelpers.spec.ts new file mode 100644 index 000000000..ae71f7ef4 --- /dev/null +++ b/sdk/edge-worker-server/__tests__/models/populatedUserHelpers.spec.ts @@ -0,0 +1,79 @@ +import { DVCPopulatedUserFromDevCycleUser } from '../../src/models/populatedUserHelpers' +import { DevCycleUser } from '@devcycle/js-cloud-server-sdk' + +describe('DVCPopulatedPBUser Unit Tests', () => { + it('should construct DVCPopulatedPBUser from UserParam', () => { + const requestUser = DVCPopulatedUserFromDevCycleUser( + new DevCycleUser({ + user_id: 'user_id', + email: 'email', + name: 'name', + language: 'en', + country: 'ca', + appVersion: 'appVersion', + appBuild: 1, + customData: { custom: 'data' }, + privateCustomData: { private: 'customData' }, + }), + { + platformVersion: 'platformVersion', + hostname: 'hostname', + }, + ) + expect(requestUser).toEqual( + expect.objectContaining({ + user_id: 'user_id', + email: 'email', + name: 'name', + language: 'en', + country: 'ca', + appVersion: 'appVersion', + appBuild: 1, + customData: { custom: 'data' }, + privateCustomData: { private: 'customData' }, + lastSeenDate: expect.any(Date), + createdDate: expect.any(Date), + platform: 'EdgeWorker', + platformVersion: 'platformVersion', + sdkType: 'server', + sdkVersion: expect.any(String), + hostname: 'hostname', + }), + ) + }) + + it('should construct DVCPopulatedPBUser from DVCUser', () => { + const requestUser = new DevCycleUser({ + user_id: 'user_id', + email: 'email', + name: 'name', + language: 'en', + country: 'ca', + appVersion: 'appVersion', + appBuild: 1, + customData: { custom: 'data' }, + privateCustomData: { private: 'customData' }, + }) + const populatedUser = DVCPopulatedUserFromDevCycleUser(requestUser, {}) + expect(populatedUser).toEqual( + expect.objectContaining({ + user_id: 'user_id', + email: 'email', + name: 'name', + language: 'en', + country: 'ca', + appVersion: 'appVersion', + appBuild: 1, + customData: { custom: 'data' }, + privateCustomData: { private: 'customData' }, + lastSeenDate: expect.any(Date), + createdDate: expect.any(Date), + platform: 'EdgeWorker', + platformVersion: '', + sdkType: 'server', + sdkVersion: expect.any(String), + hostname: '', + }), + ) + }) +}) diff --git a/sdk/edge-worker-server/jest.config.ts b/sdk/edge-worker-server/jest.config.ts new file mode 100644 index 000000000..145530a6f --- /dev/null +++ b/sdk/edge-worker-server/jest.config.ts @@ -0,0 +1,33 @@ +/* eslint-disable */ +export default { + displayName: 'edge-worker-server-sdk', + globals: {}, + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + collectCoverage: true, + collectCoverageFrom: [ + '/src/**/*.{ts,js}', + '!/**/*.{spec,test,mock}.{ts,js}', + ], + coverageDirectory: '../../coverage/sdk/edge-worker-server-sdk', + preset: '../../jest.preset.js', +} + +module.exports.reporters = [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'test-results', + outputName: `${module.exports.displayName}.xml`, + }, + ], +] diff --git a/sdk/edge-worker-server/package.json b/sdk/edge-worker-server/package.json new file mode 100644 index 000000000..6cf361dd7 --- /dev/null +++ b/sdk/edge-worker-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "@devcycle/edge-worker-server-sdk", + "version": "1.15.4", + "description": "The DevCycle Edge Worker Server SDK used for feature management.", + "license": "MIT", + "devDependencies": { + "@devcycle/bucketing-test-data": "^1.2.21" + }, + "dependencies": { + "@devcycle/bucketing": "^1.4.2", + "@devcycle/js-cloud-server-sdk": "^1.0.0", + "@devcycle/types": "^1.1.15", + "fetch-retry": "^5.0.3" + }, + "engines": { + "node": ">=14.0.0" + } +} diff --git a/sdk/edge-worker-server/project.json b/sdk/edge-worker-server/project.json new file mode 100644 index 000000000..260e177c9 --- /dev/null +++ b/sdk/edge-worker-server/project.json @@ -0,0 +1,72 @@ +{ + "name": "edge-worker", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "sdk/edge-worker-server/src", + "targets": { + "check-types": { + "executor": "nx:run-commands", + "options": { + "command": "yarn run -T tsc -b --incremental", + "cwd": "sdk/edge-worker-server" + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["sdk/edge-worker-server/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/sdk/edge-worker-server"], + "options": { + "jestConfig": "sdk/edge-worker-server/jest.config.ts", + "passWithNoTests": false, + "codeCoverage": true + }, + "dependsOn": [ + { + "target": "build" + }, + { + "target": "build:json", + "dependencies": true + } + ] + }, + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/sdk/edge-worker-server", + "tsConfig": "sdk/edge-worker-server/tsconfig.lib.json", + "packageJson": "sdk/edge-worker-server/package.json", + "buildableProjectDepsInPackageJsonType": "dependencies", + "main": "sdk/edge-worker-server/src/index.ts", + "assets": ["sdk/edge-worker-server/*.md"], + "external": [ + "shared-types", + "shared-bucketing-test-data", + "shared-bucketing", + "js-cloud-server-sdk" + ] + }, + "dependsOn": [ + { + "target": "build", + "dependencies": true + } + ] + }, + "npm-publish": { + "executor": "nx:run-commands", + "options": { + "command": "../../../scripts/npm-safe-publish.sh \"@devcycle/edge-worker-server-sdk\"", + "cwd": "dist/sdk/edge-worker-server", + "forwardAllArgs": true + } + } + }, + "tags": [] +} diff --git a/sdk/edge-worker-server/src/client.ts b/sdk/edge-worker-server/src/client.ts new file mode 100644 index 000000000..311dd239d --- /dev/null +++ b/sdk/edge-worker-server/src/client.ts @@ -0,0 +1,247 @@ +import { EnvironmentConfigManager } from '@devcycle/config-manager' +import { + ConfigBody, + DVCLogger, + getVariableTypeFromValue, + VariableTypeAlias, +} from '@devcycle/types' +import { + DevCycleUser, + DVCVariable, + VariableParam, + dvcDefaultLogger, + DVCVariableValue, + DVCVariableSet, + DVCFeatureSet, + DevCycleCloudOptions, +} from '@devcycle/js-cloud-server-sdk' +import { DVCPopulatedUserFromDevCycleUser } from './models/populatedUserHelpers' +import { + UserError, + DevCycleEvent, + checkParamDefined, + EventTypes, +} from '@devcycle/server-request' +import { generateBucketedConfig } from '@devcycle/bucketing' +import { generateAggEvent, publishDevCycleEvents } from './eventsPublisher' +import isArray from 'lodash/isArray' + +const castIncomingUser = (user: DevCycleUser) => { + if (!(user instanceof DevCycleUser)) { + return new DevCycleUser(user) + } + return user +} + +export class DevCycleEdgeClient { + private sdkKey: string + private options: DevCycleCloudOptions + private configHelper: EnvironmentConfigManager + private onInitialized: Promise + private logger: DVCLogger + private initialized = false + private configData: ConfigBody + + constructor(sdkKey: string, options: DevCycleCloudOptions = {}) { + this.sdkKey = sdkKey + this.options = options + this.logger = + options?.logger || dvcDefaultLogger({ level: options?.logLevel }) + + if (options?.enableEdgeDB) { + this.logger.info( + 'EdgeDB can only be enabled for the DVC Cloud Client.', + ) + } + + this.configHelper = new EnvironmentConfigManager( + this.logger, + sdkKey, + this.setConfigData.bind(this), + setInterval, + clearInterval, + options || {}, + ) + + this.onInitialized = this.configHelper.fetchConfigPromise + .then(() => { + this.logger.info('DevCycle initialized') + this.initialized = true + return this + }) + .catch((err) => { + this.logger.error(`Error initializing DevCycle: ${err}`) + if (err instanceof UserError) { + throw err + } + return this + }) + } + + setConfigData(sdkKey: string, projectConfigStr: string): void { + this.configData = JSON.parse(projectConfigStr) as ConfigBody + } + + /** + * Notify the user when Features have been loaded from the server. + * An optional callback can be passed in, and will return a promise if no callback has been passed in. + * + * @param onInitialized + */ + async onClientInitialized( + onInitialized?: (err?: Error) => void, + ): Promise { + if (onInitialized && typeof onInitialized === 'function') { + this.onInitialized + .then(() => onInitialized()) + .catch((err) => onInitialized(err)) + } + return this.onInitialized + } + + async variable( + user: DevCycleUser, + key: string, + defaultValue: T, + ): Promise> { + await this.onInitialized + + const incomingUser = castIncomingUser(user) + // this will throw if type is invalid + const type = getVariableTypeFromValue( + defaultValue, + key, + this.logger, + true, + ) + const populatedUser = DVCPopulatedUserFromDevCycleUser( + incomingUser, + this.options, + ) + const bucketedConfig = generateBucketedConfig({ + config: this.configData, + user: populatedUser, + }) + const configVariable = bucketedConfig?.variables?.[key] + + const options: VariableParam = { + key, + type, + defaultValue, + } + if (configVariable) { + if (type === configVariable.type) { + options.value = configVariable.value as VariableTypeAlias + options.evalReason = configVariable.evalReason + } else { + this.logger.error( + `Type mismatch for variable ${key}. Expected ${type}, got ${configVariable.type}`, + ) + } + } + + const dvcVariable = new DVCVariable(options) + + const aggEvent = generateAggEvent( + populatedUser.user_id, + dvcVariable.isDefaulted + ? EventTypes.aggVariableDefaulted + : EventTypes.aggVariableEvaluated, + key, + bucketedConfig.variableVariationMap, + ) + // Don't await for the event to be published to the Events API + publishDevCycleEvents( + this.logger, + this.sdkKey, + populatedUser, + [aggEvent], + bucketedConfig.featureVariationMap, + ) + + return dvcVariable + } + + async variableValue( + user: DevCycleUser, + key: string, + defaultValue: T, + ): Promise> { + return (await this.variable(user, key, defaultValue)).value + } + + async allVariables(user: DevCycleUser): Promise { + await this.onInitialized + + const incomingUser = castIncomingUser(user) + const populatedUser = DVCPopulatedUserFromDevCycleUser( + incomingUser, + this.options, + ) + const bucketedConfig = generateBucketedConfig({ + config: this.configData, + user: populatedUser, + }) + return bucketedConfig?.variables || {} + } + + async allFeatures(user: DevCycleUser): Promise { + await this.onInitialized + + const incomingUser = castIncomingUser(user) + const populatedUser = DVCPopulatedUserFromDevCycleUser( + incomingUser, + this.options, + ) + const bucketedConfig = generateBucketedConfig({ + config: this.configData, + user: populatedUser, + }) + return bucketedConfig?.features || {} + } + + private static checkDevCycleEvent(event: DevCycleEvent): void { + checkParamDefined('type', event.type) + } + + async track( + user: DevCycleUser, + event: DevCycleEvent | DevCycleEvent[], + ): Promise { + const incomingUser = castIncomingUser(user) + + if (!this.initialized) { + this.logger.warn( + 'track called before DevCycleClient initialized, event will not be tracked', + ) + return + } + + if (isArray(event)) { + event.forEach(DevCycleEdgeClient.checkDevCycleEvent) + } else { + DevCycleEdgeClient.checkDevCycleEvent(event) + } + const populatedUser = DVCPopulatedUserFromDevCycleUser( + incomingUser, + this.options, + ) + const bucketedConfig = generateBucketedConfig({ + config: this.configData, + user: populatedUser, + }) + + await publishDevCycleEvents( + this.logger, + this.sdkKey, + populatedUser, + isArray(event) ? event : [event], + bucketedConfig.featureVariationMap, + ) + } + + async close(): Promise { + await this.onInitialized + this.configHelper.cleanup() + } +} diff --git a/sdk/edge-worker-server/src/eventsPublisher.ts b/sdk/edge-worker-server/src/eventsPublisher.ts new file mode 100644 index 000000000..3dc228843 --- /dev/null +++ b/sdk/edge-worker-server/src/eventsPublisher.ts @@ -0,0 +1,64 @@ +import { DVCPopulatedUser } from '@devcycle/js-cloud-server-sdk' +import { + DevCycleEvent, + DVCRequestEvent, + publishEvents, +} from '@devcycle/server-request' +import { + BucketedUserConfig, + DVCLogger, + SDKEventBatchRequestBody, +} from '@devcycle/types' + +export function generateAggEvent( + user_id: string, + type: string, + variableKey: string, + variableVariationMap?: BucketedUserConfig['variableVariationMap'], +): DVCRequestEvent { + return new DVCRequestEvent( + { + type, + target: variableKey, + value: 1, + metaData: variableVariationMap + ? variableVariationMap[variableKey] + : undefined, + }, + user_id, + ) +} + +export async function publishDevCycleEvents( + logger: DVCLogger, + sdkKey: string, + user: DVCPopulatedUser, + events: DevCycleEvent[], + featureVars: Record, +): Promise { + const requestEvents = events.map((event) => { + return new DVCRequestEvent(event, user.user_id, featureVars) + }) + const requestBody: SDKEventBatchRequestBody = [ + { + user, + events: requestEvents, + }, + ] + try { + const res = await publishEvents(logger, sdkKey, requestBody) + if (res.status !== 201) { + this.logger.error( + `Error publishing events, status: ${ + res.status + }, body: ${await res.text()}`, + ) + } else { + this.logger.debug(`DevCycle Flushed ${requestEvents.length} Events`) + } + } catch (ex) { + this.logger.error( + `DevCycle Error Flushing Events response message: ${ex.message}`, + ) + } +} diff --git a/sdk/edge-worker-server/src/index.ts b/sdk/edge-worker-server/src/index.ts new file mode 100644 index 000000000..2be9c1cf7 --- /dev/null +++ b/sdk/edge-worker-server/src/index.ts @@ -0,0 +1,82 @@ +import { DevCycleEdgeClient } from './client' +import { + DevCycleUser, + DevCycleCloudClient, + dvcDefaultLogger, + DevCycleOptions, + DVCVariableValue, + JSON, + DVCJSON, + DVCCustomDataJSON, + DVCVariable, + DVCVariableSet, + DVCVariableInterface, + DVCFeature, + DVCFeatureSet, + DevCycleCloudOptions, +} from '@devcycle/js-cloud-server-sdk' +import { isValidServerSDKKey, DevCycleEvent } from '@devcycle/server-request' + +export { + DevCycleEdgeClient, + DevCycleCloudClient, + DevCycleUser, + DevCycleOptions, + DevCycleEvent, + DVCVariableValue, + JSON, + DVCJSON, + DVCCustomDataJSON, + DVCVariable, + DVCVariableSet, + DVCVariableInterface, + DVCFeature, + DVCFeatureSet, +} +export { dvcDefaultLogger } + +type DevCycleEdgeOptions = DevCycleCloudOptions & { + enableCloudBucketing?: boolean +} + +type DevCycleOptionsCloudEnabled = DevCycleEdgeOptions & { + enableCloudBucketing: true +} +type DevCycleOptionsLocalEnabled = DevCycleEdgeOptions & { + enableCloudBucketing?: false +} + +export function initializeDevCycle( + sdkKey: string, + options?: DevCycleOptionsLocalEnabled, +): DevCycleEdgeClient +export function initializeDevCycle( + sdkKey: string, + options: DevCycleOptionsCloudEnabled, +): DevCycleCloudClient +export function initializeDevCycle( + sdkKey: string, + options?: DevCycleEdgeOptions, +): DevCycleEdgeClient | DevCycleCloudClient +export function initializeDevCycle( + sdkKey: string, + options: DevCycleEdgeOptions = {}, +): DevCycleEdgeClient | DevCycleCloudClient { + if (!sdkKey) { + throw new Error('Missing SDK key! Call initialize with a valid SDK key') + } else if (!isValidServerSDKKey(sdkKey)) { + throw new Error( + 'Invalid SDK key provided. Please call initialize with a valid server SDK key', + ) + } + + if (options.enableCloudBucketing) { + return new DevCycleCloudClient(sdkKey, options, options) + } + return new DevCycleEdgeClient(sdkKey, options) +} + +/** + * @deprecated Use initializeDevCycle instead + */ +export const initialize = initializeDevCycle diff --git a/sdk/edge-worker-server/src/models/populatedUserHelpers.ts b/sdk/edge-worker-server/src/models/populatedUserHelpers.ts new file mode 100644 index 000000000..80389f1f9 --- /dev/null +++ b/sdk/edge-worker-server/src/models/populatedUserHelpers.ts @@ -0,0 +1,26 @@ +import { + DVCPopulatedUser, + DevCycleUser, + DevCyclePlatformDetails, + DevCycleCloudOptions, +} from '@devcycle/js-cloud-server-sdk' +import * as packageJson from '../../package.json' + +export function getPlatformDetails( + options: DevCycleCloudOptions, +): DevCyclePlatformDetails { + return { + platform: 'EdgeWorker', + platformVersion: options.platformVersion || '', + sdkType: 'server', + sdkVersion: packageJson.version, + hostname: options.hostname || '', + } +} + +export function DVCPopulatedUserFromDevCycleUser( + user: DevCycleUser, + options: DevCycleCloudOptions, +): DVCPopulatedUser { + return new DVCPopulatedUser(user, getPlatformDetails(options)) +} diff --git a/sdk/edge-worker-server/tsconfig.json b/sdk/edge-worker-server/tsconfig.json new file mode 100644 index 000000000..1e5701a22 --- /dev/null +++ b/sdk/edge-worker-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/sdk/edge-worker-server/tsconfig.lib.json b/sdk/edge-worker-server/tsconfig.lib.json new file mode 100644 index 000000000..c7d1f3f78 --- /dev/null +++ b/sdk/edge-worker-server/tsconfig.lib.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "declaration": true, + "lib": ["es2021", "WebWorker"], + "types": ["@cloudflare/workers-types"] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/__mocks__/**/*.ts", + "**/__tests__/**/*.ts", + "jest.config.ts" + ], + "include": ["**/*.ts"] +} diff --git a/sdk/edge-worker-server/tsconfig.spec.json b/sdk/edge-worker-server/tsconfig.spec.json new file mode 100644 index 000000000..1fad60626 --- /dev/null +++ b/sdk/edge-worker-server/tsconfig.spec.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2019", + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/__mocks__/**/*.ts", + "**/*.d.ts", + "jest.config.ts" + ] +} diff --git a/sdk/js-cloud-server/__tests__/cloudClient.spec.ts b/sdk/js-cloud-server/__tests__/cloudClient.spec.ts index 71dc0e2dd..08f10c26f 100644 --- a/sdk/js-cloud-server/__tests__/cloudClient.spec.ts +++ b/sdk/js-cloud-server/__tests__/cloudClient.spec.ts @@ -1,7 +1,7 @@ jest.unmock('cross-fetch') import fetch from 'cross-fetch' global.fetch = fetch -import { DevCycleEvent } from '../src/types' +import { DevCycleEvent } from '@devcycle/server-request' import * as DVC from '../src' import { server } from '../src/__mocks__/server' diff --git a/sdk/js-cloud-server/src/cloudClient.ts b/sdk/js-cloud-server/src/cloudClient.ts index 9a172db70..7d1d74827 100644 --- a/sdk/js-cloud-server/src/cloudClient.ts +++ b/sdk/js-cloud-server/src/cloudClient.ts @@ -3,10 +3,8 @@ import { DVCVariableValue, DVCVariableSet, DVCFeatureSet, - DevCycleEvent, } from './types' import { DVCVariable } from './models/variable' -import { checkParamDefined } from './utils/paramUtils' import { dvcDefaultLogger } from './utils/logger' import { DVCPopulatedUser, @@ -22,9 +20,13 @@ import { getAllVariables, getVariable, postTrack, - ResponseError, } from './request' import { DevCycleUser } from './models/user' +import { + ResponseError, + DevCycleEvent, + checkParamDefined, +} from '@devcycle/server-request' const castIncomingUser = (user: DevCycleUser) => { if (!(user instanceof DevCycleUser)) { diff --git a/sdk/js-cloud-server/src/index.ts b/sdk/js-cloud-server/src/index.ts index d4098d801..15f4bba7d 100644 --- a/sdk/js-cloud-server/src/index.ts +++ b/sdk/js-cloud-server/src/index.ts @@ -1,6 +1,6 @@ import { DevCycleOptions } from './types' import { DevCycleCloudClient } from './cloudClient' -import { isValidServerSDKKey } from './utils/paramUtils' +import { isValidServerSDKKey } from '@devcycle/server-request' import { DevCycleUser } from './models/user' export { DevCycleCloudClient, DevCycleUser } @@ -10,9 +10,8 @@ export * from './models/variable' export * from './types' export * from './request' export * from './utils/logger' -export * from './utils/paramUtils' -type DevCycleCloudOptions = Pick< +export type DevCycleCloudOptions = Pick< DevCycleOptions, 'logger' | 'logLevel' | 'enableEdgeDB' | 'bucketingAPIURI' > & { diff --git a/sdk/js-cloud-server/src/models/user.ts b/sdk/js-cloud-server/src/models/user.ts index 6b7d0befa..95344edde 100644 --- a/sdk/js-cloud-server/src/models/user.ts +++ b/sdk/js-cloud-server/src/models/user.ts @@ -1,5 +1,5 @@ import { DVCCustomDataJSON } from '@devcycle/types' -import { checkParamType, typeEnum } from '../utils/paramUtils' +import { checkParamType, typeEnum } from '@devcycle/server-request' type DevCycleUserData = { user_id: string diff --git a/sdk/js-cloud-server/src/models/variable.ts b/sdk/js-cloud-server/src/models/variable.ts index abbe2fe81..06cfd0374 100644 --- a/sdk/js-cloud-server/src/models/variable.ts +++ b/sdk/js-cloud-server/src/models/variable.ts @@ -4,7 +4,7 @@ import { checkParamDefined, checkParamType, typeEnum, -} from '../utils/paramUtils' +} from '@devcycle/server-request' export type VariableParam = { key: string diff --git a/sdk/js-cloud-server/src/request.ts b/sdk/js-cloud-server/src/request.ts index 92cfa2ad3..6eb223014 100644 --- a/sdk/js-cloud-server/src/request.ts +++ b/sdk/js-cloud-server/src/request.ts @@ -1,6 +1,6 @@ import { DVCPopulatedUser } from './models/populatedUser' -import { DevCycleEvent, DevCycleOptions } from './types' -import fetchWithRetry, { RequestInitWithRetry } from 'fetch-retry' +import { DevCycleOptions } from './types' +import { post, DevCycleEvent } from '@devcycle/server-request' export const HOST = '.devcycle.com' @@ -11,56 +11,6 @@ const TRACK_PATH = '/v1/track' const BUCKETING_URL = `${BUCKETING_BASE}${HOST}` const EDGE_DB_QUERY_PARAM = '?enableEdgeDB=' -export class ResponseError extends Error { - constructor(message: string) { - super(message) - this.name = 'ResponseError' - } - - status: number -} - -const exponentialBackoff: RequestInitWithRetry['retryDelay'] = (attempt) => { - const delay = Math.pow(2, attempt) * 100 - const randomSum = delay * 0.2 * Math.random() - return delay + randomSum -} - -type retryOnRequestErrorFunc = ( - retries: number, -) => RequestInitWithRetry['retryOn'] - -const retryOnRequestError: retryOnRequestErrorFunc = (retries) => { - return (attempt, error, response) => { - if (attempt >= retries) { - return false - } else if (response && response?.status < 500) { - return false - } - - return true - } -} - -const handleResponse = async (res: Response) => { - // res.ok only checks for 200-299 status codes - if (!res.ok && res.status >= 400) { - let error - try { - const response: any = await res.clone().json() - error = new ResponseError( - response.message || 'Something went wrong', - ) - } catch (e) { - error = new ResponseError('Something went wrong') - } - error.status = res.status - throw error - } - - return res -} - export async function getAllFeatures( user: DVCPopulatedUser, sdkKey: string, @@ -148,69 +98,3 @@ export async function postTrack( sdkKey, ) } - -export async function post( - url: string, - requestConfig: RequestInit | RequestInitWithRetry, - sdkKey: string, -): Promise { - const [_fetch, config] = await getFetchAndConfig(requestConfig) - const postHeaders = { - ...config.headers, - Authorization: sdkKey, - 'Content-Type': 'application/json', - } - const res = await _fetch(url, { - ...config, - headers: postHeaders, - method: 'POST', - }) - - return handleResponse(res) -} - -export async function get( - url: string, - requestConfig: RequestInit | RequestInitWithRetry, -): Promise { - const [_fetch, config] = await getFetchAndConfig(requestConfig) - const headers = { ...config.headers, 'Content-Type': 'application/json' } - - const res = await _fetch(url, { - ...config, - headers, - method: 'GET', - }) - - return handleResponse(res) -} - -async function getFetch() { - if (typeof fetch !== 'undefined') { - return fetch - } - - return (await import('cross-fetch')).default -} - -async function getFetchWithRetry() { - const fetch = await getFetch() - return fetchWithRetry(fetch) -} - -type FetchClient = Awaited> -type FetchAndConfig = [FetchClient, RequestInit] - -async function getFetchAndConfig( - requestConfig: RequestInit | RequestInitWithRetry, -): Promise { - const useRetries = 'retries' in requestConfig - if (useRetries && requestConfig.retries) { - const newConfig: RequestInitWithRetry = { ...requestConfig } - newConfig.retryOn = retryOnRequestError(requestConfig.retries) - newConfig.retryDelay = exponentialBackoff - return [await getFetchWithRetry(), newConfig] - } - - return [await getFetch(), requestConfig] -} diff --git a/sdk/js-cloud-server/src/types.ts b/sdk/js-cloud-server/src/types.ts index f4605e4f4..b9882a605 100644 --- a/sdk/js-cloud-server/src/types.ts +++ b/sdk/js-cloud-server/src/types.ts @@ -135,33 +135,6 @@ export interface DVCVariableInterface { readonly evalReason?: unknown } -export interface DevCycleEvent { - /** - * type of the event - */ - type: string - - /** - * date event occurred according to client stored as time since epoch - */ - date?: number - - /** - * target / subject of event. Contextual to event type - */ - target?: string - - /** - * value for numerical events. Contextual to event type - */ - value?: number - - /** - * extra metadata for event. Contextual to event type - */ - metaData?: Record -} - export interface DVCFeature { readonly _id: string diff --git a/sdk/nodejs/__tests__/eventQueue.test.ts b/sdk/nodejs/__tests__/eventQueue.test.ts index 73411bf3f..57e3592aa 100644 --- a/sdk/nodejs/__tests__/eventQueue.test.ts +++ b/sdk/nodejs/__tests__/eventQueue.test.ts @@ -1,6 +1,6 @@ -jest.mock('../src/request') +jest.mock('@devcycle/server-request') + import { EventQueue, EventQueueOptions } from '../src/eventQueue' -import { EventTypes } from '../src/eventQueue' import { BucketedUserConfig, DVCReporter, PublicProject } from '@devcycle/types' import { mocked } from 'jest-mock' import { @@ -12,7 +12,7 @@ import { setPlatformDataJSON } from './utils/setPlatformData' import { Response } from 'cross-fetch' import { dvcDefaultLogger } from '@devcycle/js-cloud-server-sdk' import { DVCPopulatedUserFromDevCycleUser } from '../src/models/populatedUserHelpers' -import { publishEvents } from '../src/request' +import { EventTypes, publishEvents } from '@devcycle/server-request' import testData from '@devcycle/bucketing-test-data/json-data/testData.json' const { config } = testData diff --git a/sdk/nodejs/__tests__/models/requestEvent.spec.ts b/sdk/nodejs/__tests__/models/requestEvent.spec.ts index 2cbb71673..b0e2ab5af 100644 --- a/sdk/nodejs/__tests__/models/requestEvent.spec.ts +++ b/sdk/nodejs/__tests__/models/requestEvent.spec.ts @@ -1,5 +1,4 @@ -import { DVCRequestEvent } from '../../src/models/requestEvent' -import { EventTypes } from '../../src/eventQueue' +import { EventTypes, DVCRequestEvent } from '@devcycle/server-request' describe('DVCRequestEvent Unit Tests', () => { it('should construct custom DVCRequestEvent from DVCEvent', () => { diff --git a/sdk/nodejs/src/client.ts b/sdk/nodejs/src/client.ts index 27842e0e3..d18533a3b 100644 --- a/sdk/nodejs/src/client.ts +++ b/sdk/nodejs/src/client.ts @@ -4,7 +4,7 @@ import { getVariableTypeCode, variableForUser_PB, } from './utils/userBucketingHelper' -import { EventQueue, EventTypes } from './eventQueue' +import { EventQueue } from './eventQueue' import * as packageJson from '../package.json' import { importBucketingLib, @@ -17,20 +17,23 @@ import { VariableTypeAlias, } from '@devcycle/types' import os from 'os' -import { UserError } from './utils/userError' import { DevCycleUser, DVCVariable, VariableParam, - checkParamDefined, dvcDefaultLogger, DevCycleOptions, DVCVariableValue, DVCVariableSet, DVCFeatureSet, - DevCycleEvent, } from '@devcycle/js-cloud-server-sdk' import { DVCPopulatedUserFromDevCycleUser } from './models/populatedUserHelpers' +import { + UserError, + EventTypes, + DevCycleEvent, + checkParamDefined, +} from '@devcycle/server-request' interface IPlatformData { platform: string diff --git a/sdk/nodejs/src/eventQueue.ts b/sdk/nodejs/src/eventQueue.ts index bbf60eacd..44249b804 100644 --- a/sdk/nodejs/src/eventQueue.ts +++ b/sdk/nodejs/src/eventQueue.ts @@ -1,4 +1,3 @@ -import { DVCRequestEvent } from './models/requestEvent' import { BucketedUserConfig, DVCLogger, @@ -6,19 +5,12 @@ import { FlushResults, } from '@devcycle/types' import { getBucketingLib } from './bucketing' -import { publishEvents } from './request' -import { DevCycleEvent, DVCPopulatedUser } from '@devcycle/js-cloud-server-sdk' - -export const AggregateEventTypes: Record = { - variableEvaluated: 'variableEvaluated', - aggVariableEvaluated: 'aggVariableEvaluated', - variableDefaulted: 'variableDefaulted', - aggVariableDefaulted: 'aggVariableDefaulted', -} - -export const EventTypes: Record = { - ...AggregateEventTypes, -} +import { DVCPopulatedUser } from '@devcycle/js-cloud-server-sdk' +import { + DevCycleEvent, + DVCRequestEvent, + publishEvents, +} from '@devcycle/server-request' type UserEventsBatchRecord = { user: DVCPopulatedUser @@ -130,7 +122,7 @@ export class EventQueue { metricTags, ) } catch (ex) { - this.logger.error(`DVC Error Flushing Events: ${ex.message}`) + this.logger.error(`DevCycle Error Flushing Events: ${ex.message}`) } const results: FlushResults = { @@ -157,7 +149,7 @@ export class EventQueue { val + batches.eventCount const eventCount = flushPayloads.reduce(reducer, 0) this.logger.debug( - `DVC Flush ${eventCount} Events, for ${flushPayloads.length} Users`, + `DevCycle Flush ${eventCount} Events, for ${flushPayloads.length} Users`, ) const startTimeRequests = Date.now() @@ -194,7 +186,7 @@ export class EventQueue { } } else { this.logger.debug( - `DVC Flushed ${eventCount} Events, for ${flushPayload.records.length} Users`, + `DevCycle Flushed ${eventCount} Events, for ${flushPayload.records.length} Users`, ) getBucketingLib().onPayloadSuccess( this.sdkKey, @@ -204,7 +196,7 @@ export class EventQueue { } } catch (ex) { this.logger.error( - `DVC Error Flushing Events response message: ${ex.message}`, + `DevCycle Error Flushing Events response message: ${ex.message}`, ) getBucketingLib().onPayloadFailure( this.sdkKey, @@ -248,7 +240,7 @@ export class EventQueue { try { await this._flushEvents() } catch (e) { - this.logger.error(`DVC Error Flushing Events`, e) + this.logger.error(`DevCycle Error Flushing Events`, e) } this.flushInProgress = false diff --git a/sdk/nodejs/src/index.ts b/sdk/nodejs/src/index.ts index 1d02a65ce..b3583f608 100644 --- a/sdk/nodejs/src/index.ts +++ b/sdk/nodejs/src/index.ts @@ -3,9 +3,7 @@ import { DevCycleUser, DevCycleCloudClient, dvcDefaultLogger, - isValidServerSDKKey, DevCycleOptions, - DevCycleEvent, DVCVariableValue, JSON, DVCJSON, @@ -17,6 +15,7 @@ import { DVCFeatureSet, } from '@devcycle/js-cloud-server-sdk' import { getNodeJSPlatformDetails } from './utils/platformDetails' +import { isValidServerSDKKey, DevCycleEvent } from '@devcycle/server-request' export { DevCycleClient, diff --git a/sdk/nodejs/src/request.ts b/sdk/nodejs/src/request.ts deleted file mode 100644 index a7c0829b2..000000000 --- a/sdk/nodejs/src/request.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { RequestInitWithRetry } from 'fetch-retry' -import { get, post } from '@devcycle/js-cloud-server-sdk' -import { DVCLogger, SDKEventBatchRequestBody } from '@devcycle/types' - -export const HOST = '.devcycle.com' -export const EVENT_URL = 'https://events' -export const EVENTS_PATH = '/v1/events/batch' - -export async function publishEvents( - logger: DVCLogger, - sdkKey: string, - eventsBatch: SDKEventBatchRequestBody, - eventsBaseURLOverride?: string, -): Promise { - const url = eventsBaseURLOverride - ? `${eventsBaseURLOverride}${EVENTS_PATH}` - : `${EVENT_URL}${HOST}${EVENTS_PATH}` - return await post( - url, - { - body: JSON.stringify({ batch: eventsBatch }), - }, - sdkKey, - ) -} - -async function getWithTimeout( - url: string, - requestConfig: RequestInit | RequestInitWithRetry, - timeout: number, -): Promise { - const controller = new AbortController() - const id = setTimeout(() => { - controller.abort() - }, timeout) - const response = await get(url, { - ...requestConfig, - signal: controller.signal, - }) - clearTimeout(id) - return response -} diff --git a/tsconfig.base.json b/tsconfig.base.json index 917528ac8..ac02c20d6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,6 +36,9 @@ "@devcycle/openfeature-nodejs-provider": [ "sdk/openfeature-nodejs-provider/src/index.ts" ], + "@devcycle/server-request": [ + "lib/shared/server-request/src/index.ts" + ], "@devcycle/react-client-sdk": ["sdk/react/src/index.ts"], "@devcycle/react-native-client-sdk": [ "sdk/react-native/src/index.ts" diff --git a/yarn.lock b/yarn.lock index 586aab80d..c23627558 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4871,7 +4871,7 @@ __metadata: languageName: unknown linkType: soft -"@devcycle/bucketing@workspace:lib/shared/bucketing": +"@devcycle/bucketing@^1.4.2, @devcycle/bucketing@workspace:lib/shared/bucketing": version: 0.0.0-use.local resolution: "@devcycle/bucketing@workspace:lib/shared/bucketing" dependencies: @@ -4888,6 +4888,18 @@ __metadata: languageName: unknown linkType: soft +"@devcycle/edge-worker-server-sdk@workspace:sdk/edge-worker-server": + version: 0.0.0-use.local + resolution: "@devcycle/edge-worker-server-sdk@workspace:sdk/edge-worker-server" + dependencies: + "@devcycle/bucketing": ^1.4.2 + "@devcycle/bucketing-test-data": ^1.2.21 + "@devcycle/js-cloud-server-sdk": ^1.0.0 + "@devcycle/types": ^1.1.15 + fetch-retry: ^5.0.3 + languageName: unknown + linkType: soft + "@devcycle/js-client-sdk-web-example@workspace:examples/js/web-elements-app": version: 0.0.0-use.local resolution: "@devcycle/js-client-sdk-web-example@workspace:examples/js/web-elements-app" @@ -4997,6 +5009,14 @@ __metadata: languageName: unknown linkType: soft +"@devcycle/server-request@workspace:lib/shared/server-request": + version: 0.0.0-use.local + resolution: "@devcycle/server-request@workspace:lib/shared/server-request" + dependencies: + fetch-retry: ^5.0.3 + languageName: unknown + linkType: soft + "@devcycle/types@^1.1.15, @devcycle/types@workspace:lib/shared/types": version: 0.0.0-use.local resolution: "@devcycle/types@workspace:lib/shared/types"