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..c8d9686cf 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 @@ -12,14 +12,6 @@ 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 - } -} - export class EnvironmentConfigManager { private readonly logger: DVCLogger private readonly sdkKey: string 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/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/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..05fb21655 --- /dev/null +++ b/lib/shared/server-request/src/index.ts @@ -0,0 +1,2 @@ +export * from './request' +export * from './userError' diff --git a/lib/shared/server-request/src/request.ts b/lib/shared/server-request/src/request.ts new file mode 100644 index 000000000..072b7e9a3 --- /dev/null +++ b/lib/shared/server-request/src/request.ts @@ -0,0 +1,134 @@ +import fetchWithRetry, { RequestInitWithRetry } from 'fetch-retry' + +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/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/js-cloud-server/src/cloudClient.ts b/sdk/js-cloud-server/src/cloudClient.ts index 9a172db70..1a75f1271 100644 --- a/sdk/js-cloud-server/src/cloudClient.ts +++ b/sdk/js-cloud-server/src/cloudClient.ts @@ -22,9 +22,9 @@ import { getAllVariables, getVariable, postTrack, - ResponseError, } from './request' import { DevCycleUser } from './models/user' +import { ResponseError } from '@devcycle/server-request' const castIncomingUser = (user: DevCycleUser) => { if (!(user instanceof DevCycleUser)) { diff --git a/sdk/js-cloud-server/src/request.ts b/sdk/js-cloud-server/src/request.ts index 92cfa2ad3..cce830ca5 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 { post } 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/nodejs/src/client.ts b/sdk/nodejs/src/client.ts index 27842e0e3..cbdb86709 100644 --- a/sdk/nodejs/src/client.ts +++ b/sdk/nodejs/src/client.ts @@ -17,7 +17,6 @@ import { VariableTypeAlias, } from '@devcycle/types' import os from 'os' -import { UserError } from './utils/userError' import { DevCycleUser, DVCVariable, @@ -31,6 +30,7 @@ import { DevCycleEvent, } from '@devcycle/js-cloud-server-sdk' import { DVCPopulatedUserFromDevCycleUser } from './models/populatedUserHelpers' +import { UserError } from '@devcycle/server-request' interface IPlatformData { platform: string diff --git a/sdk/nodejs/src/request.ts b/sdk/nodejs/src/request.ts index a7c0829b2..9d5046802 100644 --- a/sdk/nodejs/src/request.ts +++ b/sdk/nodejs/src/request.ts @@ -1,5 +1,4 @@ -import { RequestInitWithRetry } from 'fetch-retry' -import { get, post } from '@devcycle/js-cloud-server-sdk' +import { post } from '@devcycle/server-request' import { DVCLogger, SDKEventBatchRequestBody } from '@devcycle/types' export const HOST = '.devcycle.com' @@ -23,20 +22,3 @@ export async function publishEvents( 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..a0a3ea75a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4997,6 +4997,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"