diff --git a/e2e/nextjs/app-router/app/app/normal/ClientComponent.tsx b/e2e/nextjs/app-router/app/app/normal/ClientComponent.tsx index 2c8665bd9..88de183c2 100644 --- a/e2e/nextjs/app-router/app/app/normal/ClientComponent.tsx +++ b/e2e/nextjs/app-router/app/app/normal/ClientComponent.tsx @@ -5,6 +5,8 @@ import { useAllFeatures, renderIfEnabled, } from '@devcycle/nextjs-sdk' +import { useState } from 'react' +import { testAction } from './action' const ConditionalComponent = renderIfEnabled( 'enabled-feature', @@ -16,6 +18,7 @@ export const ClientComponent = () => { const disabledVar = useVariableValue('disabled-feature', false) const allVariables = useAllVariables() const allFeatures = useAllFeatures() + const [actionResult, setActionResult] = useState(null) return (
@@ -24,6 +27,14 @@ export const ClientComponent = () => {

Client Disabled Variable: {JSON.stringify(disabledVar)}

Client All Variables: {JSON.stringify(allVariables)}

Client All Features: {JSON.stringify(allFeatures)}

+

Server Function Result: {JSON.stringify(actionResult)}

+
) diff --git a/e2e/nextjs/app-router/app/app/normal/ServerComponent.tsx b/e2e/nextjs/app-router/app/app/normal/ServerComponent.tsx index 64d8084b8..2756bb92a 100644 --- a/e2e/nextjs/app-router/app/app/normal/ServerComponent.tsx +++ b/e2e/nextjs/app-router/app/app/normal/ServerComponent.tsx @@ -1,9 +1,11 @@ +import { headers } from 'next/headers' import { getAllFeatures, getAllVariables, getVariableValue } from './devcycle' export const ServerComponent = async () => { const enabledVar = await getVariableValue('enabled-feature', false) const disabledVar = await getVariableValue('disabled-feature', false) const allVariables = await getAllVariables() const allFeatures = await getAllFeatures() + const reqHeaders = await headers() return (
@@ -12,6 +14,10 @@ export const ServerComponent = async () => {

Server Disabled Variable: {JSON.stringify(disabledVar)}

Server All Variables: {JSON.stringify(allVariables)}

Server All Features: {JSON.stringify(allFeatures)}

+

+ Middleware Enabled Feature:{' '} + {reqHeaders.get('Middleware-Enabled-Feature')} +

) } diff --git a/e2e/nextjs/app-router/app/app/normal/action.ts b/e2e/nextjs/app-router/app/app/normal/action.ts new file mode 100644 index 000000000..155bf553f --- /dev/null +++ b/e2e/nextjs/app-router/app/app/normal/action.ts @@ -0,0 +1,8 @@ +'use server' + +import { getVariableValue } from './devcycle' + +export const testAction = async () => { + const result = await getVariableValue('enabled-feature', false) + return result +} diff --git a/e2e/nextjs/app-router/app/app/normal/devcycle.ts b/e2e/nextjs/app-router/app/app/normal/devcycle.ts index 3b54e7e9e..4627b87e1 100644 --- a/e2e/nextjs/app-router/app/app/normal/devcycle.ts +++ b/e2e/nextjs/app-router/app/app/normal/devcycle.ts @@ -11,7 +11,7 @@ export const { userGetter: async () => { const reqHeaders = await headers() return { - user_id: '123', + user_id: 'normal-user', customData: { // set a dummy field here so that the headers call stays in the build output someKey: reqHeaders.get('some-key'), diff --git a/e2e/nextjs/app-router/app/middleware.ts b/e2e/nextjs/app-router/app/middleware.ts new file mode 100644 index 000000000..a9278203f --- /dev/null +++ b/e2e/nextjs/app-router/app/middleware.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server' +import { getVariableValue } from './app/normal/devcycle' + +export const middleware = async () => { + const response = NextResponse.next() + const variableValue = await getVariableValue('enabled-feature', false) + + // Add custom header + response.headers.set( + 'Middleware-Enabled-Feature', + JSON.stringify(variableValue), + ) + + return response +} + +export const config = { + matcher: '/normal', +} diff --git a/e2e/nextjs/app-router/app/yarn.lock b/e2e/nextjs/app-router/app/yarn.lock index ff45f8651..1b8b13868 100644 --- a/e2e/nextjs/app-router/app/yarn.lock +++ b/e2e/nextjs/app-router/app/yarn.lock @@ -6,67 +6,68 @@ __metadata: cacheKey: 10 "@devcycle/bucketing@file:../../../../dist/lib/shared/bucketing::locator=app%40workspace%3A.": - version: 1.23.0 - resolution: "@devcycle/bucketing@file:../../../../dist/lib/shared/bucketing#../../../../dist/lib/shared/bucketing::hash=3760ab&locator=app%40workspace%3A." + version: 1.25.0 + resolution: "@devcycle/bucketing@file:../../../../dist/lib/shared/bucketing#../../../../dist/lib/shared/bucketing::hash=f620ff&locator=app%40workspace%3A." dependencies: - "@devcycle/types": "npm:^1.18.0" + "@devcycle/types": "npm:^1.20.0" lodash: "npm:^4.17.21" murmurhash: "npm:^2.0.0" ua-parser-js: "npm:^1.0.36" - checksum: 44395904d0770308ce33a41e7bf5aa82523b98ddf6a2e44d0a66e62a01ffbe76b0e931ad28def059afd260f296ca0f93c36e3960cc5f0a7f3d85b7a91b5fccd0 + checksum: 26b227546ed21ed61ccbf917eb5f6d60e7c5a31fd0b61d015f92dd95b4c51b1f79182ef0cf9bf54ddbd423f4eda496a79af77d75460126ef59b543a8dbeab358 languageName: node linkType: hard "@devcycle/js-client-sdk@file:../../../../dist/sdk/js::locator=app%40workspace%3A.": - version: 1.31.0 - resolution: "@devcycle/js-client-sdk@file:../../../../dist/sdk/js#../../../../dist/sdk/js::hash=40e7b0&locator=app%40workspace%3A." + version: 1.33.0 + resolution: "@devcycle/js-client-sdk@file:../../../../dist/sdk/js#../../../../dist/sdk/js::hash=5d8b0d&locator=app%40workspace%3A." dependencies: - "@devcycle/types": "npm:^1.18.0" + "@devcycle/types": "npm:^1.20.0" fetch-retry: "npm:^5.0.6" lodash: "npm:^4.17.21" ua-parser-js: "npm:^1.0.36" uuid: "npm:^8.3.2" - checksum: 3acdcbf8b351c08cf6b482e7a3e56aa9560a7e3589cfaf9fcd330624e101c3001479b68c457fa3a54384d1248b572f3ddb71364e6bf5ffbb385cd2f02dabb915 + checksum: d27d6f6952f363ea71a3eb6865021d1af175d696a81284f36dd6e7dd134c00996f8506cf5c8c35cc7b9c5044dc30edbf9308d580c330ed6bad7623c98dc91e7a languageName: node linkType: hard "@devcycle/nextjs-sdk@file:../../../../dist/sdk/nextjs::locator=app%40workspace%3A.": - version: 2.6.0 - resolution: "@devcycle/nextjs-sdk@file:../../../../dist/sdk/nextjs#../../../../dist/sdk/nextjs::hash=f2c38c&locator=app%40workspace%3A." + version: 2.8.0 + resolution: "@devcycle/nextjs-sdk@file:../../../../dist/sdk/nextjs#../../../../dist/sdk/nextjs::hash=7ea780&locator=app%40workspace%3A." dependencies: - "@devcycle/bucketing": "npm:^1.23.0" - "@devcycle/js-client-sdk": "npm:^1.31.0" - "@devcycle/react-client-sdk": "npm:^1.29.0" - "@devcycle/types": "npm:^1.18.0" + "@devcycle/bucketing": "npm:^1.25.0" + "@devcycle/js-client-sdk": "npm:^1.33.0" + "@devcycle/react-client-sdk": "npm:^1.31.0" + "@devcycle/types": "npm:^1.20.0" + class-transformer: "npm:^0.5.1" hoist-non-react-statics: "npm:^3.3.2" server-only: "npm:^0.0.1" - checksum: 9d905c89316e3e87fa1ec663ef29298abca0d40d7603bb156f4942568a322cf45e581d84481d7f0c2957f73f5fb72fbb5818307042ebd1a6d4ba796317f5ce2a + checksum: a97ec2cd27c27cdef68d9b87bd99e32a4babe7a8bdbdb33941463a7651402b95864202c1a028870a471925a3aa5644d7fdfacdd730072a76cf2a429df8e89329 languageName: node linkType: hard "@devcycle/react-client-sdk@file:../../../../dist/sdk/react::locator=app%40workspace%3A.": - version: 1.29.0 - resolution: "@devcycle/react-client-sdk@file:../../../../dist/sdk/react#../../../../dist/sdk/react::hash=53291e&locator=app%40workspace%3A." + version: 1.31.0 + resolution: "@devcycle/react-client-sdk@file:../../../../dist/sdk/react#../../../../dist/sdk/react::hash=3e2fc6&locator=app%40workspace%3A." dependencies: - "@devcycle/js-client-sdk": "npm:^1.31.0" - "@devcycle/types": "npm:^1.18.0" + "@devcycle/js-client-sdk": "npm:^1.33.0" + "@devcycle/types": "npm:^1.20.0" hoist-non-react-statics: "npm:^3.3.2" peerDependencies: react: ">=16.8.0" - checksum: bc8140acca4d07fd4c5adba4e3b750b52314eaf2f2b8794b073d5878dc99a6904594ef1c10702420a29e3712ea3d0238b72a511e3d731adc1fe7fc0c053cba67 + checksum: f022ea564ad4ef722b33afc295ccdd61eb44c03dec21f47a368d418297da3ba7ad398b175016d3ad9bc32f051159fc8c5d6a1875c4bca7bca01176cf133d3a2f languageName: node linkType: hard "@devcycle/types@file:../../../../dist/lib/shared/types::locator=app%40workspace%3A.": - version: 1.18.0 - resolution: "@devcycle/types@file:../../../../dist/lib/shared/types#../../../../dist/lib/shared/types::hash=4eb545&locator=app%40workspace%3A." + version: 1.20.0 + resolution: "@devcycle/types@file:../../../../dist/lib/shared/types#../../../../dist/lib/shared/types::hash=49328d&locator=app%40workspace%3A." dependencies: class-transformer: "npm:0.5.1" class-validator: "npm:0.14.1" iso-639-1: "npm:^2.1.13" lodash: "npm:^4.17.21" reflect-metadata: "npm:^0.1.13" - checksum: 9422caa1a8dd9cdd356ac213b968faca1e656381a195efdc12118bf65a672f84930d0866716c0717507ea9b54b031c779d3d1427fd608a4f511b5ee4a93ceb0c + checksum: 37a261b629edf04b9225ac3a29001c4c7694269d78221533b401ceb351ebfcf16602e18049797fe0af3baab17e84ffe1d7834227eeb4850f67d00925bc001437 languageName: node linkType: hard @@ -378,7 +379,7 @@ __metadata: languageName: node linkType: hard -"class-transformer@npm:0.5.1": +"class-transformer@npm:0.5.1, class-transformer@npm:^0.5.1": version: 0.5.1 resolution: "class-transformer@npm:0.5.1" checksum: 750327e3e9a5cf233c5234252f4caf6b06c437bf68a24acbdcfb06c8e0bfff7aa97c30428184813e38e08111b42871f20c5cf669ea4490f8ae837c09f08b31e7 diff --git a/e2e/nextjs/app-router/tests/app-router.spec.ts b/e2e/nextjs/app-router/tests/app-router.spec.ts index 6954258b5..bb462001d 100644 --- a/e2e/nextjs/app-router/tests/app-router.spec.ts +++ b/e2e/nextjs/app-router/tests/app-router.spec.ts @@ -95,6 +95,15 @@ test('has expected page elements', async ({ page }) => { await expect( page.getByText('Client Component Conditionally Bundled'), ).toBeVisible() + + // test server action flagging + await page.getByText('Test Action').click() + await expect(page.getByText('Server Function Result: true')).toBeVisible() + + // test middleware flagging + await expect( + page.getByText('Middleware Enabled Feature: true'), + ).toBeVisible() }) test('works after a client side navigation', async ({ page }) => { diff --git a/sdk/nextjs/src/server/allFeatures.ts b/sdk/nextjs/src/server/allFeatures.ts deleted file mode 100644 index 5f92a38c7..000000000 --- a/sdk/nextjs/src/server/allFeatures.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getClient } from './requestContext' -import { DVCFeatureSet } from '@devcycle/js-client-sdk' - -export async function getAllFeatures(): Promise { - const client = getClient() - if (!client) { - console.error( - 'React cache API is not working as expected. Please contact DevCycle support.', - ) - return {} - } - - return client.allFeatures() -} diff --git a/sdk/nextjs/src/server/getAllVariables.ts b/sdk/nextjs/src/server/getAllVariables.ts deleted file mode 100644 index aa81c2656..000000000 --- a/sdk/nextjs/src/server/getAllVariables.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { getClient } from './requestContext' -import { DVCVariableSet } from '@devcycle/js-client-sdk' - -export async function getAllVariables(): Promise { - const client = getClient() - if (!client) { - console.error( - 'React cache API is not working as expected. Please contact DevCycle support.', - ) - return {} - } - - return client.allVariables() -} diff --git a/sdk/nextjs/src/server/getVariableValue.ts b/sdk/nextjs/src/server/getVariableValue.ts deleted file mode 100644 index 031d2a57a..000000000 --- a/sdk/nextjs/src/server/getVariableValue.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getClient } from './requestContext' -import { - InferredVariableType, - VariableDefinitions, - VariableKey, - VariableTypeAlias, -} from '@devcycle/types' - -export async function getVariableValue< - K extends VariableKey, - ValueType extends VariableDefinitions[K], ->( - key: K, - defaultValue: ValueType, -): Promise> { - const client = getClient() - if (!client) { - console.error( - 'React cache API is not working as expected. Please contact DevCycle support.', - ) - return defaultValue as VariableTypeAlias - } - - const variable = client.variable(key, defaultValue) - - return variable.value -} diff --git a/sdk/nextjs/src/server/initialize.ts b/sdk/nextjs/src/server/initialize.ts index d34c522dd..ab78fde36 100644 --- a/sdk/nextjs/src/server/initialize.ts +++ b/sdk/nextjs/src/server/initialize.ts @@ -1,5 +1,8 @@ -import { DevCycleUser, initializeDevCycle } from '@devcycle/js-client-sdk' -import { getClient, setClient } from './requestContext' +import { + DevCycleClient, + DevCycleUser, + initializeDevCycle, +} from '@devcycle/js-client-sdk' import { getUserAgent } from './userAgent' import { DevCycleNextOptions, DevCycleServerData } from '../common/types' import { cache } from 'react' @@ -23,61 +26,47 @@ const cachedUserGetter = cache( }, ) -export const initialize = async ( - sdkKey: string, - clientSDKKey: string, - userGetter: () => DevCycleUser | Promise, - options: DevCycleNextOptions = {}, -): Promise => { - const [userAgent, user, configData] = await Promise.all([ - getUserAgent(options), - cachedUserGetter(userGetter), - getConfigFromSource(sdkKey, clientSDKKey, options), - ]) - - if (!user || typeof user.user_id !== 'string') { - throw new Error('DevCycle user getter must return a user') - } - - const initializeAlreadyCalled = !!getClient() - - if (!initializeAlreadyCalled) { - setClient( - initializeDevCycle(sdkKey, user, { - ...options, - deferInitialization: true, - ...jsClientOptions, - }), - ) - } +export const initialize = cache( + async ( + sdkKey: string, + clientSDKKey: string, + userGetter: () => DevCycleUser | Promise, + options: DevCycleNextOptions = {}, + ): Promise => { + const [userAgent, user, configData] = await Promise.all([ + getUserAgent(options), + cachedUserGetter(userGetter), + getConfigFromSource(sdkKey, clientSDKKey, options), + ]) - let config = null - try { - config = await getBucketedConfig( - configData.config, - configData.lastModified, - user, - options, - userAgent, - ) - } catch (e) { - console.error('Error fetching DevCycle config', e) - } + if (!user || typeof user.user_id !== 'string') { + throw new Error('DevCycle user getter must return a user') + } - const client = getClient() + const client = initializeDevCycle(sdkKey, user, { + ...options, + deferInitialization: true, + ...jsClientOptions, + }) - if (!client) { - throw new Error( - "React 'cache' function not working as expected. Please contact DevCycle support.", - ) - } + let config = null + try { + config = await getBucketedConfig( + configData.config, + configData.lastModified, + user, + options, + userAgent, + ) + } catch (e) { + console.error('Error fetching DevCycle config', e) + } - if (!initializeAlreadyCalled) { client.synchronizeBootstrapData(config, user, userAgent) - } - return { config, user, userAgent } -} + return { config, user, userAgent, client } + }, +) export const validateSDKKey = ( sdkKey: string, diff --git a/sdk/nextjs/src/server/requestContext.ts b/sdk/nextjs/src/server/requestContext.ts deleted file mode 100644 index 78f02097f..000000000 --- a/sdk/nextjs/src/server/requestContext.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DevCycleClient } from '@devcycle/js-client-sdk' -import { cache } from 'react' - -export const requestContext = ( - defaultValue: T, -): [() => T, (v: T) => void] => { - const getRef = cache(() => ({ current: defaultValue })) - - const getValue = (): T => getRef().current - - const setValue = (value: T) => { - getRef().current = value - } - - return [getValue, setValue] -} - -export const [getClient, setClient] = requestContext< - DevCycleClient | undefined ->(undefined) - -export const cacheStorageError = (): Error => { - return new Error( - 'React cache API is not working as expected. Please contact DevCycle support.', - ) -} diff --git a/sdk/nextjs/src/server/setupDevCycle.tsx b/sdk/nextjs/src/server/setupDevCycle.tsx index 7bc336955..f426a308e 100644 --- a/sdk/nextjs/src/server/setupDevCycle.tsx +++ b/sdk/nextjs/src/server/setupDevCycle.tsx @@ -1,10 +1,12 @@ import 'server-only' -import { getVariableValue } from './getVariableValue' import { initialize, validateSDKKey } from './initialize' -import { DevCycleUser, DVCCustomDataJSON } from '@devcycle/js-client-sdk' -import { getAllVariables } from './getAllVariables' -import { getAllFeatures } from './allFeatures' +import { + DevCycleUser, + DVCCustomDataJSON, + VariableDefinitions, +} from '@devcycle/js-client-sdk' import { DevCycleNextOptions } from '../common/types' +import { InferredVariableType, VariableKey } from '@devcycle/types' // server-side users must always be "identified" with a user id type ServerUser = @@ -12,6 +14,14 @@ type ServerUser = user_id: string } +type GetVariableValue = < + K extends VariableKey, + ValueType extends VariableDefinitions[K], +>( + key: K, + defaultValue: ValueType, +) => Promise> + // allow return type inference // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const setupDevCycle = < @@ -30,22 +40,34 @@ export const setupDevCycle = < validateSDKKey(serverSDKKey, 'server') validateSDKKey(clientSDKKey, 'client') - const _getVariableValue: typeof getVariableValue = async ( - key, - defaultValue, - ) => { - await initialize(serverSDKKey, clientSDKKey, userGetter, options) - return getVariableValue(key, defaultValue) + const _getVariableValue: GetVariableValue = async (key, defaultValue) => { + const { client } = await initialize( + serverSDKKey, + clientSDKKey, + userGetter, + options, + ) + return client.variableValue(key, defaultValue) } - const _getAllVariables: typeof getAllVariables = async () => { - await initialize(serverSDKKey, clientSDKKey, userGetter, options) - return getAllVariables() + const _getAllVariables = async () => { + const { client } = await initialize( + serverSDKKey, + clientSDKKey, + userGetter, + options, + ) + return client.allVariables() } - const _getAllFeatures: typeof getAllFeatures = async () => { - await initialize(serverSDKKey, clientSDKKey, userGetter, options) - return getAllFeatures() + const _getAllFeatures = async () => { + const { client } = await initialize( + serverSDKKey, + clientSDKKey, + userGetter, + options, + ) + return client.allFeatures() } const _getClientContext = () => { @@ -54,7 +76,10 @@ export const setupDevCycle = < clientSDKKey, userGetter, options, - ) + ).then((result) => { + const { client, ...serverData } = result + return serverData + }) const { enableStreaming, enableObfuscation, ...otherOptions } = options