Skip to content

Commit

Permalink
feat: nodejs bootstrapping mode (#749)
Browse files Browse the repository at this point in the history
  • Loading branch information
ajwootto authored May 3, 2024
1 parent d220ba7 commit 5e868a4
Show file tree
Hide file tree
Showing 12 changed files with 132 additions and 6 deletions.
10 changes: 10 additions & 0 deletions lib/shared/bucketing-assembly-script/assembly/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,16 @@ export function setClientCustomDataUTF8(
_setClientCustomData(sdkKey, parsed as JSON.Obj)
}

/**
* return the SDK key that is stored in the config. Normally this would be the same as the passed-in SDK key, but for
* bootstrapping configs it's actually the client SDK key, while the config is stored under the server SDK key
* @param sdkKey
*/
export function getSDKKeyFromConfig(sdkKey: string): string | null {
const config = _getConfigData(sdkKey)
return config.sdkKey
}

export * from './managers/eventQueueManager'

export * from './testHelpers'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
jsonObjFromMap,
isValidString,
getJSONObjFromJSONOptional,
getStringFromJSONOptional,
} from '../helpers/jsonHelpers'
import { Feature } from './feature'
import { Audience } from './target'
Expand Down Expand Up @@ -84,6 +85,7 @@ export class ConfigBody {
readonly features: Feature[]
readonly variables: Variable[]
readonly etag: string | null
readonly sdkKey: string | null

private readonly _variableKeyMap: Map<string, Variable>
private readonly _variableIdMap: Map<string, Variable>
Expand Down Expand Up @@ -119,6 +121,7 @@ export class ConfigBody {

constructor(configJSONObj: JSON.Obj, etag: string | null = null) {
this.etag = etag
this.sdkKey = getStringFromJSONOptional(configJSONObj, 'sdkKey')

this.project = new PublicProject(
getJSONObjFromJSON(configJSONObj, 'project'),
Expand Down Expand Up @@ -206,6 +209,7 @@ export class ConfigBody {
json.set('audiences', jsonObjFromMap(this.audiences))
json.set('features', jsonArrFromValueArray(this.features))
json.set('variables', jsonArrFromValueArray(this.variables))
json.set('sdkKey', this.sdkKey)
return json.stringify()
}

Expand Down
3 changes: 3 additions & 0 deletions lib/shared/bucketing-test-data/src/data/testData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,7 @@ export const config: ConfigBody = {
],
variables,
variableHashes,
sdkKey: 'test',
}

export const barrenConfig: ConfigBody = {
Expand Down Expand Up @@ -641,6 +642,7 @@ export const barrenConfig: ConfigBody = {
],
variables: [],
variableHashes: {},
sdkKey: 'test',
}

export const configWithNullCustomData: ConfigBody = {
Expand Down Expand Up @@ -693,4 +695,5 @@ export const configWithNullCustomData: ConfigBody = {
],
variables,
variableHashes,
sdkKey: 'test',
}
12 changes: 11 additions & 1 deletion lib/shared/config-manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type ConfigPollingOptions = {
configPollingTimeoutMS?: number
configCDNURI?: string
cdnURI?: string
clientMode?: boolean
}

type SetIntervalInterface = (handler: () => void, timeout?: number) => any
Expand All @@ -29,6 +30,7 @@ export class EnvironmentConfigManager {
private readonly setConfigBuffer: SetConfigBuffer
private readonly setInterval: SetIntervalInterface
private readonly clearInterval: ClearIntervalInterface
private clientMode: boolean

constructor(
logger: DVCLogger,
Expand All @@ -41,6 +43,7 @@ export class EnvironmentConfigManager {
configPollingTimeoutMS = 5000,
configCDNURI,
cdnURI = 'https://config-cdn.devcycle.com',
clientMode = false,
}: ConfigPollingOptions,
) {
this.logger = logger
Expand All @@ -49,6 +52,7 @@ export class EnvironmentConfigManager {
this.setConfigBuffer = setConfigBuffer
this.setInterval = setInterval
this.clearInterval = clearInterval
this.clientMode = clientMode

this.pollingIntervalMS =
configPollingIntervalMS >= 1000 ? configPollingIntervalMS : 1000
Expand Down Expand Up @@ -86,6 +90,9 @@ export class EnvironmentConfigManager {
}

getConfigURL(): string {
if (this.clientMode) {
return `${this.cdnURI}/config/v1/server/bootstrap/${this.sdkKey}.json`
}
return `${this.cdnURI}/config/v1/server/${this.sdkKey}.json`
}

Expand Down Expand Up @@ -141,7 +148,10 @@ export class EnvironmentConfigManager {
try {
const etag = res?.headers.get('etag') || ''
const lastModified = res?.headers.get('last-modified') || ''
this.setConfigBuffer(this.sdkKey, projectConfig)
this.setConfigBuffer(
`${this.sdkKey}${this.clientMode ? '_client' : ''}`,
projectConfig,
)
this.hasConfig = true
this.configEtag = etag
this.configLastModified = lastModified
Expand Down
7 changes: 7 additions & 0 deletions lib/shared/types/src/types/apis/sdk/serverSDKTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,11 @@ export interface DevCycleServerSDKOptions {
* Overrides the default URL for the DVC Config CDN when using local bucketing.
*/
configCDNURI?: string

/**
* Enable the ability to create a client configuration for use as a bootstrapping config
* Useful for serverside-rendering usecases where the config can be obtained on the server
* and provided to the client
*/
enableClientBootstrapping?: boolean
}
5 changes: 5 additions & 0 deletions lib/shared/types/src/types/config/configBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,9 @@ export class ConfigBody<IdType = string> {
ably?: {
apiKey: string
}

/**
* The SDK key corresponding to this config. Used when a client config is being retrieved via a server SDK key
*/
sdkKey: string
}
7 changes: 4 additions & 3 deletions sdk/js-cloud-server/src/models/populatedUser.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { DVCCustomDataJSON } from '../types'
import * as packageJson from '../../package.json'
import { DevCycleUser } from './user'
import { SDKTypes } from '@devcycle/types'

export type DevCyclePlatformDetails = {
platform?: string
platformVersion?: string
sdkType?: 'server'
sdkType?: SDKTypes
sdkVersion?: string
hostname?: string
}
Expand All @@ -24,9 +25,9 @@ export class DVCPopulatedUser implements DevCycleUser {
readonly createdDate: Date
readonly platform: string
readonly platformVersion: string
readonly sdkType: 'server'
readonly sdkType: SDKTypes
readonly sdkVersion: string
readonly hostname: string
readonly hostname?: string

constructor(user: DevCycleUser, platformDetails: DevCyclePlatformDetails) {
this.user_id = user.user_id
Expand Down
1 change: 1 addition & 0 deletions sdk/nodejs/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@devcycle/server-request",
"@devcycle/config-manager",
"@devcycle/bucketing-assembly-script",
"@devcycle/js-client-sdk",
"@openfeature/core",
"fetch-retry",
"cross-fetch"
Expand Down
3 changes: 2 additions & 1 deletion sdk/nodejs/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@
"shared-types",
"shared-bucketing-as",
"shared-bucketing-test-data",
"js-cloud-server-sdk"
"js-cloud-server-sdk",
"js"
]
},
"dependsOn": [
Expand Down
68 changes: 67 additions & 1 deletion sdk/nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { EnvironmentConfigManager } from '@devcycle/config-manager'
import { UserError } from '@devcycle/server-request'
import {
bucketUserForConfig,
getSDKKeyFromConfig,
getVariableTypeCode,
variableForUser_PB,
} from './utils/userBucketingHelper'
Expand All @@ -13,6 +14,7 @@ import {
setConfigDataUTF8,
} from './bucketing'
import {
BucketedUserConfig,
DevCycleServerSDKOptions,
DVCLogger,
getVariableTypeFromValue,
Expand Down Expand Up @@ -55,6 +57,7 @@ type DevCycleProvider = InstanceType<DevCycleProviderConstructor>
export class DevCycleClient {
private sdkKey: string
private configHelper: EnvironmentConfigManager
private clientConfigHelper?: EnvironmentConfigManager
private eventQueue: EventQueue
private onInitialized: Promise<DevCycleClient>
private logger: DVCLogger
Expand Down Expand Up @@ -92,6 +95,16 @@ export class DevCycleClient {
clearInterval,
options || {},
)
if (options?.enableClientBootstrapping) {
this.clientConfigHelper = new EnvironmentConfigManager(
this.logger,
sdkKey,
setConfigDataUTF8,
setInterval,
clearInterval,
{ ...options, clientMode: true },
)
}
this.eventQueue = new EventQueue(sdkKey, {
...options,
logger: this.logger,
Expand All @@ -107,7 +120,10 @@ export class DevCycleClient {

getBucketingLib().setPlatformData(JSON.stringify(platformData))

return this.configHelper.fetchConfigPromise
return Promise.all([
this.configHelper.fetchConfigPromise,
this.clientConfigHelper?.fetchConfigPromise,
])
})

this.onInitialized = initializePromise
Expand Down Expand Up @@ -280,6 +296,56 @@ export class DevCycleClient {
this.eventQueue.queueEvent(populatedUser, event)
}

/**
* Call this to obtain a config that is suitable for use in the "bootstrapConfig" option of client-side JS SDKs
* Useful for serverside-rendering use cases where the server performs the initial rendering pass, and provides it
* to the client along with the DevCycle config to allow hydration
* @param user
* @param userAgent
*/
async getClientBootstrapConfig(
user: DevCycleUser,
userAgent: string,
): Promise<BucketedUserConfig & { sdkKey: string }> {
const incomingUser = castIncomingUser(user)

await this.onInitialized

if (!this.clientConfigHelper) {
throw new Error(
'enableClientBootstrapping option must be set to true to use getClientBootstrapConfig',
)
}

const sdkKey = getSDKKeyFromConfig(`${this.sdkKey}_client`)

if (!sdkKey) {
throw new Error(
'Client bootstrapping config is malformed. Please contact DevCycle support.',
)
}

try {
const { generateClientPopulatedUser } = await import(
'./clientUser.js'
)
const populatedUser = generateClientPopulatedUser(
incomingUser,
userAgent,
)
return {
...bucketUserForConfig(populatedUser, `${this.sdkKey}_client`),
sdkKey,
}
} catch (e) {
throw new Error(
'@devcycle/js-client-sdk package could not be found. ' +
'Please install it to use client boostrapping. Error: ' +
e.message,
)
}
}

async flushEvents(callback?: () => void): Promise<void> {
return this.eventQueue.flushEvents().then(callback)
}
Expand Down
14 changes: 14 additions & 0 deletions sdk/nodejs/src/clientUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { DVCPopulatedUser, DevCycleUser } from '@devcycle/js-client-sdk'

export const generateClientPopulatedUser = (
user: DevCycleUser,
userAgent: string,
): DVCPopulatedUser => {
return new DVCPopulatedUser(
user,
{},
undefined,
undefined,
userAgent ?? undefined,
)
}
4 changes: 4 additions & 0 deletions sdk/nodejs/src/utils/userBucketingHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export function bucketUserForConfig(
) as BucketedUserConfig
}

export function getSDKKeyFromConfig(sdkKey: string): string | null {
return getBucketingLib().getSDKKeyFromConfig(sdkKey)
}

export function getVariableTypeCode(type: VariableType): number {
const Bucketing = getBucketingLib()
switch (type) {
Expand Down

0 comments on commit 5e868a4

Please sign in to comment.