diff --git a/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts b/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts index 202cb0aab..aa091bead 100644 --- a/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts +++ b/lib/shared/config-manager/__tests__/environmentConfigManager.spec.ts @@ -172,7 +172,7 @@ describe('EnvironmentConfigManager Unit Tests', () => { expect(trackSDKConfigEvent_mock).toBeCalledWith( 'https://config-cdn.devcycle.com/config/v1/server/sdkKey.json', expect.any(Number), - expect.objectContaining({ status: 200 }), + expect.objectContaining({ resStatus: 200 }), undefined, undefined, undefined, @@ -211,7 +211,7 @@ describe('EnvironmentConfigManager Unit Tests', () => { const envConfig = getConfigManager(logger, 'sdkKey', {}) expect(envConfig.fetchConfigPromise).rejects.toThrow( - 'Invalid SDK key provided:', + 'Invalid SDK key provided', ) expect(setInterval_mock).toHaveBeenCalledTimes(0) }) diff --git a/lib/shared/config-manager/src/CDNConfigSource.ts b/lib/shared/config-manager/src/CDNConfigSource.ts new file mode 100644 index 000000000..79b25f9d6 --- /dev/null +++ b/lib/shared/config-manager/src/CDNConfigSource.ts @@ -0,0 +1,78 @@ +import { ConfigBody, DVCLogger } from '@devcycle/types' +import { ConfigSource } from './ConfigSource' +import { getEnvironmentConfig } from './request' +import { ResponseError, UserError } from '@devcycle/server-request' + +export class CDNConfigSource extends ConfigSource { + constructor( + private cdnURI: string, + private logger: DVCLogger, + private requestTimeoutMS: number, + ) { + super() + } + + async getConfig( + sdkKey: string, + kind: 'server' | 'bootstrap', + lastModifiedThreshold?: string, + ): Promise<[ConfigBody | null, Record]> { + let res: Response + try { + res = await getEnvironmentConfig({ + logger: this.logger, + url: this.getConfigURL(sdkKey, kind), + requestTimeout: this.requestTimeoutMS, + currentEtag: this.configEtag, + currentLastModified: this.configLastModified, + sseLastModified: lastModifiedThreshold, + }) + } catch (e) { + if (e instanceof ResponseError && e.status === 403) { + throw new UserError(`Invalid SDK key provided: ${sdkKey}`) + } + throw e + } + + const metadata = { + resEtag: res.headers.get('etag') ?? undefined, + resLastModified: res.headers.get('last-modified') ?? undefined, + resRayId: res.headers.get('cf-ray') ?? undefined, + resStatus: res.status ?? undefined, + } + + const projectConfig = (await res.json()) as unknown + + this.logger.debug( + `Downloaded config, status: ${ + res?.status + }, etag: ${res?.headers.get('etag')}`, + ) + + if (res.status === 304) { + this.logger.debug( + `Config not modified, using cache, etag: ${this.configEtag}` + + `, last-modified: ${this.configLastModified}`, + ) + } else if (res.status === 200 && projectConfig) { + const lastModifiedHeader = res.headers.get('last-modified') + if (this.isLastModifiedHeaderOld(lastModifiedHeader ?? null)) { + this.logger.debug( + 'Skipping saving config, existing last modified date is newer.', + ) + return [null, metadata] + } + this.configEtag = res.headers.get('etag') || '' + this.configLastModified = lastModifiedHeader || '' + return [projectConfig as ConfigBody, metadata] + } + return [null, metadata] + } + + getConfigURL(sdkKey: string, kind: 'server' | 'bootstrap'): string { + if (kind === 'bootstrap') { + return `${this.cdnURI}/config/v1/server/bootstrap/${sdkKey}.json` + } + return `${this.cdnURI}/config/v1/server/${sdkKey}.json` + } +} diff --git a/lib/shared/config-manager/src/ConfigSource.ts b/lib/shared/config-manager/src/ConfigSource.ts new file mode 100644 index 000000000..7e1ef70de --- /dev/null +++ b/lib/shared/config-manager/src/ConfigSource.ts @@ -0,0 +1,44 @@ +import { ConfigBody } from '@devcycle/types' +import { isValidDate } from './request' + +export abstract class ConfigSource { + configEtag?: string + configLastModified?: string + + /** + * Method to get the config from the source. + * Should return null if the config has not changed, and throw an error if it could not be retrieved. + * @param sdkKey + * @param kind + * @param lastModifiedThreshold + */ + abstract getConfig( + sdkKey: string, + kind: 'server' | 'bootstrap', + lastModifiedThreshold?: string, + ): Promise<[ConfigBody | null, Record]> + + /** + * Return the URL (or path or storage key etc.) that will be used to retrieve the config for the given SDK key + * @param sdkKey + * @param kind + */ + abstract getConfigURL(sdkKey: string, kind: 'server' | 'bootstrap'): string + + protected isLastModifiedHeaderOld( + lastModifiedHeader: string | null, + ): boolean { + const lastModifiedHeaderDate = lastModifiedHeader + ? new Date(lastModifiedHeader) + : null + const configLastModifiedDate = this.configLastModified + ? new Date(this.configLastModified) + : null + + return ( + isValidDate(configLastModifiedDate) && + isValidDate(lastModifiedHeaderDate) && + lastModifiedHeaderDate <= configLastModifiedDate + ) + } +} diff --git a/lib/shared/config-manager/src/index.ts b/lib/shared/config-manager/src/index.ts index 529abb5e9..2e92ad122 100644 --- a/lib/shared/config-manager/src/index.ts +++ b/lib/shared/config-manager/src/index.ts @@ -1,14 +1,17 @@ import { ConfigBody, DVCLogger } from '@devcycle/types' -import { getEnvironmentConfig, isValidDate } from './request' import { ResponseError, UserError } from '@devcycle/server-request' import { SSEConnection } from '@devcycle/sse-connection' +import { CDNConfigSource } from './CDNConfigSource' +import { isValidDate } from './request' +import { ConfigSource } from './ConfigSource' + +export * from './ConfigSource' type ConfigPollingOptions = { configPollingIntervalMS?: number sseConfigPollingIntervalMS?: number configPollingTimeoutMS?: number configCDNURI?: string - cdnURI?: string clientMode?: boolean enableBetaRealTimeUpdates?: boolean } @@ -19,7 +22,7 @@ type SetConfigBufferInterface = (sdkKey: string, projectConfig: string) => void type TrackSDKConfigEventInterface = ( url: string, responseTimeMS: number, - res?: Response, + retrievalMetadata?: Record, err?: ResponseError, reqEtag?: string, reqLastModified?: string, @@ -28,21 +31,19 @@ type TrackSDKConfigEventInterface = ( export class EnvironmentConfigManager { private _hasConfig = false - configEtag?: string - configLastModified?: string configSSE?: ConfigBody['sse'] private currentPollingInterval: number private readonly configPollingIntervalMS: number private readonly sseConfigPollingIntervalMS: number - private readonly requestTimeoutMS: number - private readonly cdnURI: string private readonly enableRealtimeUpdates: boolean fetchConfigPromise: Promise private intervalTimeout?: any private clientMode: boolean private sseConnection?: SSEConnection + private readonly requestTimeoutMS: number + private configSource: ConfigSource constructor( private readonly logger: DVCLogger, @@ -55,11 +56,11 @@ export class EnvironmentConfigManager { configPollingIntervalMS = 10000, sseConfigPollingIntervalMS = 10 * 60 * 1000, // 10 minutes configPollingTimeoutMS = 5000, - configCDNURI, - cdnURI = 'https://config-cdn.devcycle.com', + configCDNURI = 'https://config-cdn.devcycle.com', clientMode = false, enableBetaRealTimeUpdates = false, }: ConfigPollingOptions, + configSource?: ConfigSource, ) { this.clientMode = clientMode this.enableRealtimeUpdates = enableBetaRealTimeUpdates @@ -74,7 +75,10 @@ export class EnvironmentConfigManager { configPollingTimeoutMS >= this.configPollingIntervalMS ? this.configPollingIntervalMS : configPollingTimeoutMS - this.cdnURI = configCDNURI || cdnURI + + this.configSource = + configSource ?? + new CDNConfigSource(configCDNURI, logger, this.requestTimeoutMS) this.fetchConfigPromise = this._fetchConfig() .then(() => { @@ -145,7 +149,7 @@ export class EnvironmentConfigManager { .then(() => { this.logger.debug('Config re-fetched from SSE message') }) - .catch((e) => { + .catch((e: unknown) => { this.logger.warn( `Failed to re-fetch config from SSE Message: ${e}`, ) @@ -187,6 +191,10 @@ export class EnvironmentConfigManager { return this._hasConfig } + get configEtag(): string | undefined { + return this.configSource.configEtag + } + private stopPolling(): void { this.clearInterval(this.intervalTimeout) this.intervalTimeout = null @@ -197,22 +205,19 @@ export class EnvironmentConfigManager { this.stopSSE() } - getConfigURL(): string { - if (this.clientMode) { - return `${this.cdnURI}/config/v1/server/bootstrap/${this.sdkKey}.json` - } - return `${this.cdnURI}/config/v1/server/${this.sdkKey}.json` - } - async _fetchConfig(sseLastModified?: string): Promise { - const url = this.getConfigURL() - let res: Response | null - let projectConfig: string | null = null - let responseError: ResponseError | null = null + const url = this.configSource.getConfigURL( + this.sdkKey, + this.clientMode ? 'bootstrap' : 'server', + ) + let projectConfig: ConfigBody | null = null + let retrievalMetadata: Record + let userError: UserError | null = null const startTime = Date.now() let responseTimeMS = 0 - const currentEtag = this.configEtag - const currentLastModified = this.configLastModified + + const currentEtag = this.configSource.configEtag + const currentLastModified = this.configSource.configLastModified const logError = (error: any) => { const errMsg = @@ -226,11 +231,11 @@ export class EnvironmentConfigManager { } const trackEvent = (err?: ResponseError) => { - if ((res && res?.status !== 304) || err) { + if (projectConfig || err) { this.trackSDKConfigEvent( url, responseTimeMS, - res || undefined, + retrievalMetadata, err, currentEtag, currentLastModified, @@ -241,24 +246,19 @@ export class EnvironmentConfigManager { try { this.logger.debug( - `Requesting new config for ${url}, etag: ${this.configEtag}` + - `, last-modified: ${this.configLastModified}`, + `Requesting new config for ${url}, etag: ${this.configSource.configEtag}` + + `, last-modified: ${this.configSource.configLastModified}`, ) - res = await getEnvironmentConfig({ - logger: this.logger, - url, - requestTimeout: this.requestTimeoutMS, - currentEtag, - currentLastModified, - sseLastModified, - }) + ;[projectConfig, retrievalMetadata] = + await this.configSource.getConfig( + this.sdkKey, + this.clientMode ? 'bootstrap' : 'server', + sseLastModified, + ) responseTimeMS = Date.now() - startTime - projectConfig = await res.text() - this.logger.debug( - `Downloaded config, status: ${ - res?.status - }, etag: ${res?.headers.get('etag')}`, - ) + // if no errors occurred, the projectConfig is either new or null (meaning cached version is used) + // either way, trigger the SSE config handler to see if we need to reconnect + this.handleSSEConfig(projectConfig ?? undefined) } catch (ex) { if (this.hasConfig) { // TODO currently event queue in WASM requires a valid config @@ -266,42 +266,21 @@ export class EnvironmentConfigManager { trackEvent(ex) } logError(ex) - res = null - if (ex instanceof ResponseError) { - responseError = ex + if (ex instanceof UserError) { + userError = ex } } - if (res?.status === 304) { - this.logger.debug( - `Config not modified, using cache, etag: ${this.configEtag}` + - `, last-modified: ${this.configLastModified}`, - ) - this.handleSSEConfig() - return - } else if (res?.status === 200 && projectConfig) { - const lastModifiedHeader = res?.headers.get('last-modified') - if (this.isLastModifiedHeaderOld(lastModifiedHeader)) { - this.logger.debug( - 'Skipping saving config, existing last modified date is newer.', - ) - return - } - + if (projectConfig) { try { - this.handleSSEConfig(projectConfig) - this.setConfigBuffer( `${this.sdkKey}${this.clientMode ? '_client' : ''}`, - projectConfig, + JSON.stringify(projectConfig), ) this._hasConfig = true - this.configEtag = res?.headers.get('etag') || '' - this.configLastModified = lastModifiedHeader || '' return } catch (e) { logError(new Error('Invalid config JSON.')) - res = null } finally { trackEvent() } @@ -311,9 +290,9 @@ export class EnvironmentConfigManager { this.logger.warn( `Failed to download config, using cached version. url: ${url}.`, ) - } else if (responseError?.status === 403) { + } else if (userError) { this.cleanup() - throw new UserError(`Invalid SDK key provided: ${this.sdkKey}`) + throw userError } else { throw new Error('Failed to download DevCycle config.') } @@ -323,8 +302,8 @@ export class EnvironmentConfigManager { const lastModifiedHeaderDate = lastModifiedHeader ? new Date(lastModifiedHeader) : null - const configLastModifiedDate = this.configLastModified - ? new Date(this.configLastModified) + const configLastModifiedDate = this.configSource.configLastModified + ? new Date(this.configSource.configLastModified) : null return ( @@ -334,13 +313,10 @@ export class EnvironmentConfigManager { ) } - private handleSSEConfig(projectConfig?: string) { + private handleSSEConfig(configBody?: ConfigBody) { if (this.enableRealtimeUpdates) { const originalConfigSSE = this.configSSE - if (projectConfig) { - const configBody = JSON.parse( - projectConfig, - ) as ConfigBody + if (configBody) { this.configSSE = configBody.sse } diff --git a/lib/shared/vercel-edge-config/.eslintrc.json b/lib/shared/vercel-edge-config/.eslintrc.json new file mode 100644 index 000000000..27a78cf05 --- /dev/null +++ b/lib/shared/vercel-edge-config/.eslintrc.json @@ -0,0 +1,25 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/lib/shared/vercel-edge-config/README.md b/lib/shared/vercel-edge-config/README.md new file mode 100644 index 000000000..c486d7471 --- /dev/null +++ b/lib/shared/vercel-edge-config/README.md @@ -0,0 +1,9 @@ +# DevCycle Vercel Edge Config Adapter + +This library provides an adapter for DevCycle Node.js and Next.js SDKs to retrieve configuration data from Vercel +Edge Config. To use this adapter, you must have the Vercel integration set up. + +Vercel Edge Config provides a much faster configuration retrieval for services deployed to Vercel's cloud infrastructure. +With the DevCycel Edge Config Adapter, you can significantly improve the speed flags are retrieved during user requests. + +See the [docs](https://docs.devcycle.com/integrations/vercel-edge-config) for more information. diff --git a/lib/shared/vercel-edge-config/jest.config.ts b/lib/shared/vercel-edge-config/jest.config.ts new file mode 100644 index 000000000..33040bd3b --- /dev/null +++ b/lib/shared/vercel-edge-config/jest.config.ts @@ -0,0 +1,14 @@ +/* eslint-disable */ +export default { + displayName: 'shared-vercel-edge-config', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/shared/vercel-edge-config', +} diff --git a/lib/shared/vercel-edge-config/package.json b/lib/shared/vercel-edge-config/package.json new file mode 100644 index 000000000..c24ae523b --- /dev/null +++ b/lib/shared/vercel-edge-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@devcycle/vercel-edge-config", + "version": "0.0.1", + "peerDependencies": { + "@devcycle/nodejs-server-sdk": "*", + "@devcycle/types": "*", + "@vercel/edge-config": "^1.2.0" + }, + "type": "commonjs", + "main": "./src/index.js", + "typings": "./src/index.d.ts" +} diff --git a/lib/shared/vercel-edge-config/project.json b/lib/shared/vercel-edge-config/project.json new file mode 100644 index 000000000..72be4a8d3 --- /dev/null +++ b/lib/shared/vercel-edge-config/project.json @@ -0,0 +1,58 @@ +{ + "name": "shared-vercel-edge-config", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "lib/shared/vercel-edge-config/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/lib/shared/vercel-edge-config", + "main": "lib/shared/vercel-edge-config/src/index.ts", + "tsConfig": "lib/shared/vercel-edge-config/tsconfig.lib.json", + "assets": ["lib/shared/vercel-edge-config/*.md"] + } + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": [ + "lib/shared/vercel-edge-config/**/*.ts", + "lib/shared/vercel-edge-config/package.json" + ] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "lib/shared/vercel-edge-config/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "check-types": { + "executor": "nx:run-commands", + "options": { + "command": "yarn run -T tsc -b --incremental", + "cwd": "lib/shared/vercel-edge-config" + } + }, + "npm-publish": { + "executor": "nx:run-commands", + "options": { + "command": "../../../scripts/npm-safe-publish.sh \"@devcycle/vercel-edge-config\"", + "cwd": "dist/lib/shared/vercel-edge-config", + "forwardAllArgs": true + } + } + }, + "tags": [] +} diff --git a/lib/shared/vercel-edge-config/src/edge-config.spec.ts b/lib/shared/vercel-edge-config/src/edge-config.spec.ts new file mode 100644 index 000000000..890e8222f --- /dev/null +++ b/lib/shared/vercel-edge-config/src/edge-config.spec.ts @@ -0,0 +1,71 @@ +import { EdgeConfigSource } from './edge-config' +import { fromPartial } from '@total-typescript/shoehorn' + +describe('EdgeConfigSource', () => { + it('sends a request to edge config with the correct key', async () => { + const get = jest.fn() + const edgeConfigSource = new EdgeConfigSource( + fromPartial({ + get, + }), + ) + + get.mockResolvedValue({ + key: 'value', + lastModified: 'some date', + }) + + const result = await edgeConfigSource.getConfig('sdk-key', 'server') + + expect(get).toHaveBeenCalledWith('devcycle-config-v1-server-sdk-key') + + expect(result).toEqual([ + { key: 'value', lastModified: 'some date' }, + { resLastModified: 'some date' }, + ]) + }) + + it('returns null when the existing config date is newer', async () => { + const get = jest.fn() + const edgeConfigSource = new EdgeConfigSource( + fromPartial({ + get, + }), + ) + + get.mockResolvedValueOnce({ + key: 'value', + lastModified: '2024-07-18T14:55:32.720Z', + }) + + get.mockResolvedValueOnce({ + key: 'value', + lastModified: '2024-07-18T14:12:32.720Z', + }) + + const result = await edgeConfigSource.getConfig('sdk-key', 'server') + expect(result[0]).not.toBeNull() + const result2 = await edgeConfigSource.getConfig('sdk-key', 'server') + expect(result2[0]).toBeNull() + }) + + it('requests the bootstrap config', async () => { + const get = jest.fn() + const edgeConfigSource = new EdgeConfigSource( + fromPartial({ + get, + }), + ) + + get.mockResolvedValue({ + key: 'value', + lastModified: 'some date', + }) + + await edgeConfigSource.getConfig('sdk-key', 'bootstrap') + + expect(get).toHaveBeenCalledWith( + 'devcycle-config-v1-server-bootstrap-sdk-key', + ) + }) +}) diff --git a/lib/shared/vercel-edge-config/src/edge-config.ts b/lib/shared/vercel-edge-config/src/edge-config.ts new file mode 100644 index 000000000..aaf45ed20 --- /dev/null +++ b/lib/shared/vercel-edge-config/src/edge-config.ts @@ -0,0 +1,41 @@ +import { ConfigSource, UserError } from '@devcycle/nodejs-server-sdk' +import { EdgeConfigClient, EdgeConfigValue } from '@vercel/edge-config' +import { ConfigBody } from '@devcycle/types' + +export class EdgeConfigSource extends ConfigSource { + constructor(private edgeConfigClient: EdgeConfigClient) { + super() + } + + async getConfig( + sdkKey: string, + kind: 'server' | 'bootstrap', + ): Promise<[ConfigBody | null, Record]> { + const configPath = this.getConfigURL(sdkKey, kind) + const config = await this.edgeConfigClient.get<{ + [x: string]: EdgeConfigValue + }>(configPath) + + if (!config) { + throw new UserError(`Invalid SDK key provided: ${sdkKey}`) + } + + const lastModified = config['lastModified'] as string + + if (this.isLastModifiedHeaderOld(lastModified)) { + return [null, { resLastModified: lastModified }] + } + + this.configLastModified = config['lastModified'] as string + return [ + config as unknown as ConfigBody, + { resLastModified: this.configLastModified }, + ] + } + + getConfigURL(sdkKey: string, kind: 'server' | 'bootstrap'): string { + return kind == 'bootstrap' + ? `devcycle-config-v1-server-bootstrap-${sdkKey}` + : `devcycle-config-v1-server-${sdkKey}` + } +} diff --git a/lib/shared/vercel-edge-config/src/index.ts b/lib/shared/vercel-edge-config/src/index.ts new file mode 100644 index 000000000..2ba1d657f --- /dev/null +++ b/lib/shared/vercel-edge-config/src/index.ts @@ -0,0 +1 @@ +export * from './edge-config' diff --git a/lib/shared/vercel-edge-config/tsconfig.json b/lib/shared/vercel-edge-config/tsconfig.json new file mode 100644 index 000000000..b8b7b3ce4 --- /dev/null +++ b/lib/shared/vercel-edge-config/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/vercel-edge-config/tsconfig.lib.json b/lib/shared/vercel-edge-config/tsconfig.lib.json new file mode 100644 index 000000000..00ed4b712 --- /dev/null +++ b/lib/shared/vercel-edge-config/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "node16", + "moduleResolution": "node16", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/lib/shared/vercel-edge-config/tsconfig.spec.json b/lib/shared/vercel-edge-config/tsconfig.spec.json new file mode 100644 index 000000000..4101da4ec --- /dev/null +++ b/lib/shared/vercel-edge-config/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package.json b/package.json index 45cb6b326..65b1c6e69 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@openfeature/web-sdk": "^1.0.3", "@swc/helpers": "~0.5.2", "@types/express": "^4.17.17", + "@vercel/edge-config": "^1.2.0", "async": "^3.2.1", "bootstrap": "5.1.3", "class-transformer": "0.5.1", @@ -117,6 +118,7 @@ "@testing-library/jest-native": "5.4.3", "@testing-library/react": "14.0.0", "@testing-library/react-native": "12.3.2", + "@total-typescript/shoehorn": "^0.1.2", "@types/async": "^3.2.8", "@types/eventsource": "^1.1.15", "@types/hoist-non-react-statics": "^3.3.1", diff --git a/project.json b/project.json new file mode 100644 index 000000000..fa9cfe176 --- /dev/null +++ b/project.json @@ -0,0 +1,14 @@ +{ + "name": "devcycle-js-sdks", + "$schema": "node_modules/nx/schemas/project-schema.json", + "targets": { + "local-registry": { + "executor": "@nx/js:verdaccio", + "options": { + "port": 4873, + "config": ".verdaccio/config.yml", + "storage": "tmp/local-registry/storage" + } + } + } +} diff --git a/sdk/nodejs/src/client.ts b/sdk/nodejs/src/client.ts index ed2022ba6..701c0da03 100644 --- a/sdk/nodejs/src/client.ts +++ b/sdk/nodejs/src/client.ts @@ -31,6 +31,7 @@ import { } from '@devcycle/js-cloud-server-sdk' import { DVCPopulatedUserFromDevCycleUser } from './models/populatedUserHelpers' import { randomUUID } from 'crypto' +import { DevCycleOptionsLocalEnabled } from './index' import { WASMBucketingExports } from '@devcycle/bucketing-assembly-script' interface IPlatformData { @@ -72,7 +73,7 @@ export class DevCycleClient { return this._isInitialized } - constructor(sdkKey: string, options?: DevCycleServerSDKOptions) { + constructor(sdkKey: string, options?: DevCycleOptionsLocalEnabled) { this.clientUUID = randomUUID() this.hostname = os.hostname() this.sdkKey = sdkKey @@ -102,6 +103,7 @@ export class DevCycleClient { clearInterval, this.trackSDKConfigEvent.bind(this), options || {}, + options?.configSource, ) if (options?.enableClientBootstrapping) { this.clientConfigHelper = new EnvironmentConfigManager( @@ -117,6 +119,7 @@ export class DevCycleClient { clearInterval, this.trackSDKConfigEvent.bind(this), { ...options, clientMode: true }, + options?.configSource, ) } @@ -359,7 +362,7 @@ export class DevCycleClient { private trackSDKConfigEvent( url: string, responseTimeMS: number, - res?: Response, + metaData?: Record, err?: ResponseError, reqEtag?: string, reqLastModified?: string, @@ -377,10 +380,8 @@ export class DevCycleClient { clientUUID: this.clientUUID, reqEtag, reqLastModified, - resEtag: res?.headers.get('etag') ?? undefined, - resLastModified: res?.headers.get('last-modified') ?? undefined, - resRayId: res?.headers.get('cf-ray') ?? undefined, - resStatus: (err?.status || res?.status) ?? undefined, + ...metaData, + resStatus: metaData?.resStatus ?? err?.status ?? undefined, errMsg: err?.message ?? undefined, sseConnected: sseConnected ?? undefined, }, diff --git a/sdk/nodejs/src/index.ts b/sdk/nodejs/src/index.ts index 49bc6f799..9ea98ed3f 100644 --- a/sdk/nodejs/src/index.ts +++ b/sdk/nodejs/src/index.ts @@ -98,11 +98,23 @@ export type DVCEvent = DevCycleEvent */ export type DVCOptions = DevCycleServerSDKOptions +import { ConfigSource } from '@devcycle/config-manager' + +export { ConfigSource } + +export { UserError } from '@devcycle/server-request' + type DevCycleOptionsCloudEnabled = DevCycleServerSDKOptions & { enableCloudBucketing: true } -type DevCycleOptionsLocalEnabled = DevCycleServerSDKOptions & { + +export type DevCycleOptionsLocalEnabled = DevCycleServerSDKOptions & { enableCloudBucketing?: false + + /** + * Override the source to retrieve configuration from. Defaults to the DevCycle CDN + */ + configSource?: ConfigSource } export function initializeDevCycle( @@ -136,7 +148,7 @@ export function initializeDevCycle( getNodeJSPlatformDetails(), ) } - return new DevCycleClient(sdkKey, options) + return new DevCycleClient(sdkKey, options as DevCycleOptionsLocalEnabled) } /** diff --git a/tsconfig.base.json b/tsconfig.base.json index 37651efef..4b7cae0d2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -62,6 +62,9 @@ "lib/shared/sse-connection/src/index.ts" ], "@devcycle/types": ["lib/shared/types/src/index.ts"], + "@devcycle/vercel-edge-config": [ + "shared/vercel-edge-config/src/index.ts" + ], "@devcycle/web-debugger": ["lib/web-debugger/index.ts"], "@devcycle/web-debugger/react": ["lib/web-debugger/react.tsx"] } diff --git a/yarn.lock b/yarn.lock index a5f433933..5edeeff2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4841,6 +4841,16 @@ __metadata: languageName: unknown linkType: soft +"@devcycle/vercel-edge-config@workspace:lib/shared/vercel-edge-config": + version: 0.0.0-use.local + resolution: "@devcycle/vercel-edge-config@workspace:lib/shared/vercel-edge-config" + peerDependencies: + "@devcycle/nodejs-server-sdk": "*" + "@devcycle/types": "*" + "@vercel/edge-config": ^1.2.0 + languageName: unknown + linkType: soft + "@devcycle/web-debugger@workspace:lib/web-debugger": version: 0.0.0-use.local resolution: "@devcycle/web-debugger@workspace:lib/web-debugger" @@ -9876,6 +9886,13 @@ __metadata: languageName: node linkType: hard +"@total-typescript/shoehorn@npm:^0.1.2": + version: 0.1.2 + resolution: "@total-typescript/shoehorn@npm:0.1.2" + checksum: eb02c8fae1b8a219d6dceee4f582f478fb280c92fc33c444799a6cb65a73f7f172505a03ffcf19e53a021e27b5ce965af90dd85e561e597bbe021006f38a30ea + languageName: node + linkType: hard + "@trysound/sax@npm:0.2.0": version: 0.2.0 resolution: "@trysound/sax@npm:0.2.0" @@ -10844,6 +10861,27 @@ __metadata: languageName: node linkType: hard +"@vercel/edge-config-fs@npm:0.1.0": + version: 0.1.0 + resolution: "@vercel/edge-config-fs@npm:0.1.0" + checksum: 4eb27927a1c6a9d77cee4457424e208c3ff7f01e1cd0249b133804d12fd7099bd1aed6a9d9574b122d3bd59289b294a913b4df05bb69aca6091c8a020cbc2121 + languageName: node + linkType: hard + +"@vercel/edge-config@npm:^1.2.0": + version: 1.2.0 + resolution: "@vercel/edge-config@npm:1.2.0" + dependencies: + "@vercel/edge-config-fs": 0.1.0 + peerDependencies: + "@opentelemetry/api": ^1.7.0 + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + checksum: 98b7c512308106b21b7c8c95e9659a37c2dda56b10b3d062bae0964b1d5500f092db297aeb6aaa3af8ebf2059f6804ba2647736b48bd8d047c9e24943549ebe4 + languageName: node + linkType: hard + "@verdaccio/auth@npm:7.0.0-next-7.15": version: 7.0.0-next-7.15 resolution: "@verdaccio/auth@npm:7.0.0-next-7.15" @@ -15330,6 +15368,7 @@ __metadata: "@testing-library/jest-native": 5.4.3 "@testing-library/react": 14.0.0 "@testing-library/react-native": 12.3.2 + "@total-typescript/shoehorn": ^0.1.2 "@types/async": ^3.2.8 "@types/eventsource": ^1.1.15 "@types/express": ^4.17.17 @@ -15343,6 +15382,7 @@ __metadata: "@types/uuid": ^8.3.1 "@typescript-eslint/eslint-plugin": 5.62.0 "@typescript-eslint/parser": 5.62.0 + "@vercel/edge-config": ^1.2.0 as-proto: ^1.3.0 as-proto-gen: ^1.3.0 as-uuid: ^0.0.4