From 1c8607aee33e879a227e6d9dc26d9aeab4ae7d8c Mon Sep 17 00:00:00 2001 From: Adam Wootton Date: Wed, 16 Oct 2024 14:38:30 -0400 Subject: [PATCH] feat: support self targeting in app router (#974) --- .../app/self-targeting/ClientComponent.tsx | 30 ++++++++++ .../ConditionalClientComponent.tsx | 9 +++ .../app/self-targeting/ServerComponent.tsx | 17 ++++++ .../app/app/self-targeting/devcycle.ts | 22 ++++++++ .../app/app/self-targeting/layout.tsx | 25 +++++++++ .../app/app/self-targeting/page.tsx | 19 +++++++ e2e/nextjs/app-router/app/yarn.lock | 46 +++++++-------- .../app-router/tests/app-router.spec.ts | 8 +++ .../InternalDevCycleClientsideProvider.tsx | 5 +- sdk/nextjs/src/common/invalidateConfig.ts | 8 ++- sdk/nextjs/src/server/bucketing.ts | 33 ++++++++--- sdk/nextjs/src/server/requests.ts | 56 +++++++++++++++++++ 12 files changed, 246 insertions(+), 32 deletions(-) create mode 100644 e2e/nextjs/app-router/app/app/self-targeting/ClientComponent.tsx create mode 100644 e2e/nextjs/app-router/app/app/self-targeting/ConditionalClientComponent.tsx create mode 100644 e2e/nextjs/app-router/app/app/self-targeting/ServerComponent.tsx create mode 100644 e2e/nextjs/app-router/app/app/self-targeting/devcycle.ts create mode 100644 e2e/nextjs/app-router/app/app/self-targeting/layout.tsx create mode 100644 e2e/nextjs/app-router/app/app/self-targeting/page.tsx diff --git a/e2e/nextjs/app-router/app/app/self-targeting/ClientComponent.tsx b/e2e/nextjs/app-router/app/app/self-targeting/ClientComponent.tsx new file mode 100644 index 000000000..2c8665bd9 --- /dev/null +++ b/e2e/nextjs/app-router/app/app/self-targeting/ClientComponent.tsx @@ -0,0 +1,30 @@ +'use client' +import { + useVariableValue, + useAllVariables, + useAllFeatures, + renderIfEnabled, +} from '@devcycle/nextjs-sdk' + +const ConditionalComponent = renderIfEnabled( + 'enabled-feature', + () => import('./ConditionalClientComponent'), +) + +export const ClientComponent = () => { + const enabledVar = useVariableValue('enabled-feature', false) + const disabledVar = useVariableValue('disabled-feature', false) + const allVariables = useAllVariables() + const allFeatures = useAllFeatures() + + return ( +
+

Client Component

+

Client Enabled Variable: {JSON.stringify(enabledVar)}

+

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

+

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

+

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

+ +
+ ) +} diff --git a/e2e/nextjs/app-router/app/app/self-targeting/ConditionalClientComponent.tsx b/e2e/nextjs/app-router/app/app/self-targeting/ConditionalClientComponent.tsx new file mode 100644 index 000000000..dc4e58442 --- /dev/null +++ b/e2e/nextjs/app-router/app/app/self-targeting/ConditionalClientComponent.tsx @@ -0,0 +1,9 @@ +'use client' + +export default function ConditionalClientComponent() { + return ( +
+

Client Component Conditionally Bundled

+
+ ) +} diff --git a/e2e/nextjs/app-router/app/app/self-targeting/ServerComponent.tsx b/e2e/nextjs/app-router/app/app/self-targeting/ServerComponent.tsx new file mode 100644 index 000000000..64d8084b8 --- /dev/null +++ b/e2e/nextjs/app-router/app/app/self-targeting/ServerComponent.tsx @@ -0,0 +1,17 @@ +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() + + return ( +
+

Server Component

+

Server Enabled Variable: {JSON.stringify(enabledVar)}

+

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

+

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

+

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

+
+ ) +} diff --git a/e2e/nextjs/app-router/app/app/self-targeting/devcycle.ts b/e2e/nextjs/app-router/app/app/self-targeting/devcycle.ts new file mode 100644 index 000000000..00c03b940 --- /dev/null +++ b/e2e/nextjs/app-router/app/app/self-targeting/devcycle.ts @@ -0,0 +1,22 @@ +import { setupDevCycle } from '@devcycle/nextjs-sdk/server' +import { headers } from 'next/headers' +export const { + getVariableValue, + getClientContext, + getAllVariables, + getAllFeatures, +} = setupDevCycle({ + clientSDKKey: process.env.NEXT_PUBLIC_E2E_NEXTJS_CLIENT_KEY ?? '', + serverSDKKey: process.env.E2E_NEXTJS_SERVER_KEY ?? '', + userGetter: async () => { + const reqHeaders = headers() + return { + user_id: 'self-targeting-user', + customData: { + // set a dummy field here so that the headers call stays in the build output + someKey: reqHeaders.get('some-key'), + }, + } + }, + options: { enableStreaming: false }, +}) diff --git a/e2e/nextjs/app-router/app/app/self-targeting/layout.tsx b/e2e/nextjs/app-router/app/app/self-targeting/layout.tsx new file mode 100644 index 000000000..cfbb2fd1a --- /dev/null +++ b/e2e/nextjs/app-router/app/app/self-targeting/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next' +import React from 'react' +import { DevCycleClientsideProvider } from '@devcycle/nextjs-sdk' +import { getClientContext } from './devcycle' + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +} + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + ) +} diff --git a/e2e/nextjs/app-router/app/app/self-targeting/page.tsx b/e2e/nextjs/app-router/app/app/self-targeting/page.tsx new file mode 100644 index 000000000..e72d9c85f --- /dev/null +++ b/e2e/nextjs/app-router/app/app/self-targeting/page.tsx @@ -0,0 +1,19 @@ +import { ClientComponent } from './ClientComponent' +import { ServerComponent } from './ServerComponent' +import React, { Suspense } from 'react' +import Link from 'next/link' + +const Home = async () => { + return ( +
+
Streaming Disabled
+ Loading...}> + + + + Go To page +
+ ) +} + +export default Home diff --git a/e2e/nextjs/app-router/app/yarn.lock b/e2e/nextjs/app-router/app/yarn.lock index 039085efc..c3a8fc790 100644 --- a/e2e/nextjs/app-router/app/yarn.lock +++ b/e2e/nextjs/app-router/app/yarn.lock @@ -6,67 +6,67 @@ __metadata: cacheKey: 10 "@devcycle/bucketing@file:../../../../dist/lib/shared/bucketing::locator=app%40workspace%3A.": - version: 1.22.2 - resolution: "@devcycle/bucketing@file:../../../../dist/lib/shared/bucketing#../../../../dist/lib/shared/bucketing::hash=bea185&locator=app%40workspace%3A." + version: 1.22.3 + resolution: "@devcycle/bucketing@file:../../../../dist/lib/shared/bucketing#../../../../dist/lib/shared/bucketing::hash=b38b6f&locator=app%40workspace%3A." dependencies: - "@devcycle/types": "npm:^1.17.2" + "@devcycle/types": "npm:^1.17.3" lodash: "npm:^4.17.21" murmurhash: "npm:^2.0.0" ua-parser-js: "npm:^1.0.36" - checksum: 58912b320dc25f9dfca153ab8318424f4f76b7e204988445a93440345fd96dd5d7e2985558d0489ec3700ac8df386a50616095ab0e53f04e18c480cefa222534 + checksum: 20d3f95c06bfede6c31dba858aa84eac84cd64b29de7bf650315baf362bde4881f2f63773c56aa51ac58b7f7276e7232ca6bf37529be2fcb8576035f5d0d620a languageName: node linkType: hard "@devcycle/js-client-sdk@file:../../../../dist/sdk/js::locator=app%40workspace%3A.": - version: 1.29.2 - resolution: "@devcycle/js-client-sdk@file:../../../../dist/sdk/js#../../../../dist/sdk/js::hash=2b74f4&locator=app%40workspace%3A." + version: 1.29.3 + resolution: "@devcycle/js-client-sdk@file:../../../../dist/sdk/js#../../../../dist/sdk/js::hash=d66dac&locator=app%40workspace%3A." dependencies: - "@devcycle/types": "npm:^1.17.2" + "@devcycle/types": "npm:^1.17.3" fetch-retry: "npm:^5.0.6" lodash: "npm:^4.17.21" ua-parser-js: "npm:^1.0.36" uuid: "npm:^8.3.2" - checksum: 30f412d7fbfe86d4a4f4e79f8ab2ea5271f9bff3240c44bb73675e7d7c8a7169f9a9648a91f43d6a5bb2e529fe5345907f6a8d1067071d5278f736f2dd0e2d4c + checksum: efdf46fd7bde6c8b94cbfe5a1c8b077085e9c8e3d1f37c929966b7521e348d055365ae20e293f50a38a54cacbc2780fed0fa2de0d3f8cd91de1777e15e4e4e0e languageName: node linkType: hard "@devcycle/nextjs-sdk@file:../../../../dist/sdk/nextjs::locator=app%40workspace%3A.": - version: 2.4.2 - resolution: "@devcycle/nextjs-sdk@file:../../../../dist/sdk/nextjs#../../../../dist/sdk/nextjs::hash=640fa7&locator=app%40workspace%3A." + version: 2.4.3 + resolution: "@devcycle/nextjs-sdk@file:../../../../dist/sdk/nextjs#../../../../dist/sdk/nextjs::hash=071ce6&locator=app%40workspace%3A." dependencies: - "@devcycle/bucketing": "npm:^1.22.2" - "@devcycle/js-client-sdk": "npm:^1.29.2" - "@devcycle/react-client-sdk": "npm:^1.27.2" - "@devcycle/types": "npm:^1.17.2" + "@devcycle/bucketing": "npm:^1.22.3" + "@devcycle/js-client-sdk": "npm:^1.29.3" + "@devcycle/react-client-sdk": "npm:^1.27.3" + "@devcycle/types": "npm:^1.17.3" hoist-non-react-statics: "npm:^3.3.2" server-only: "npm:^0.0.1" - checksum: c5a36f0e85c2770730a6d4afea9c039133ad4e1c0d27faa8990bfd73603199f3bb017c6f1a87897e2e7ee4d53090b6941b4f7cbd71f73203b158ccf4ac5c715f + checksum: 0d0d6d7786a14cd6b06a55a4235721aae9d6809984e13828439d55fc43ec2e67d0b784cab8d7a4ff73c6d524a86b8fc2b6727ec29e1b8d4b01ddae117db3dddd languageName: node linkType: hard "@devcycle/react-client-sdk@file:../../../../dist/sdk/react::locator=app%40workspace%3A.": - version: 1.27.2 - resolution: "@devcycle/react-client-sdk@file:../../../../dist/sdk/react#../../../../dist/sdk/react::hash=8d7170&locator=app%40workspace%3A." + version: 1.27.3 + resolution: "@devcycle/react-client-sdk@file:../../../../dist/sdk/react#../../../../dist/sdk/react::hash=7aea30&locator=app%40workspace%3A." dependencies: - "@devcycle/js-client-sdk": "npm:^1.29.2" - "@devcycle/types": "npm:^1.17.2" + "@devcycle/js-client-sdk": "npm:^1.29.3" + "@devcycle/types": "npm:^1.17.3" hoist-non-react-statics: "npm:^3.3.2" peerDependencies: react: ">=16.8.0" - checksum: 2b3828b891e513493af4a943e6529d647410a7195af2a6879d83eefc5ccff93cdb791cf2f1ebe79b15f9d57227e705966385015fe53dd47f95a24e2e8eb96050 + checksum: 1288cd4cb9eeffc530b55bd248bc1b9c2b6f30a37b98c9b119b19beee44b7b9e72da36353e82d1d18e13f865e6e2bc57f0a3cbb8f465e3f5a49bb4bef415ca34 languageName: node linkType: hard "@devcycle/types@file:../../../../dist/lib/shared/types::locator=app%40workspace%3A.": - version: 1.17.2 - resolution: "@devcycle/types@file:../../../../dist/lib/shared/types#../../../../dist/lib/shared/types::hash=fc04bd&locator=app%40workspace%3A." + version: 1.17.3 + resolution: "@devcycle/types@file:../../../../dist/lib/shared/types#../../../../dist/lib/shared/types::hash=14220c&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: 34f0632c6edd461dfd05c7ce16c33dde9ba7607bc3cd2856fa1eb722ad1520a289c88022f1200902f9def08ad4085d9573a5bb50d22e7db7c5d90fc0b58100d9 + checksum: 99767c647fee0bf8bfa18fead884dbd637a498d6e153791c88dbb8c1c8e06fd19a6738e3fa90517d4b48c1bbc85f284780185bfd25bccfcf4eac37a363ccdfff languageName: node linkType: hard diff --git a/e2e/nextjs/app-router/tests/app-router.spec.ts b/e2e/nextjs/app-router/tests/app-router.spec.ts index 62f7c31d7..6954258b5 100644 --- a/e2e/nextjs/app-router/tests/app-router.spec.ts +++ b/e2e/nextjs/app-router/tests/app-router.spec.ts @@ -151,3 +151,11 @@ test('has expected page elements when obfuscated', async ({ page }) => { page.getByText('Client Component Conditionally Bundled'), ).toBeVisible() }) + +test('self-targeting overrides the values', async ({ page }) => { + await page.goto('/self-targeting') + await expect(page.getByText('Server Enabled Variable: false')).toBeVisible() + await expect(page.getByText('Client Enabled Variable: false')).toBeVisible() + await expect(page.getByText('Server Disabled Variable: true')).toBeVisible() + await expect(page.getByText('Client Disabled Variable: true')).toBeVisible() +}) diff --git a/sdk/nextjs/src/client/internal/InternalDevCycleClientsideProvider.tsx b/sdk/nextjs/src/client/internal/InternalDevCycleClientsideProvider.tsx index f0ec8a767..91092731e 100644 --- a/sdk/nextjs/src/client/internal/InternalDevCycleClientsideProvider.tsx +++ b/sdk/nextjs/src/client/internal/InternalDevCycleClientsideProvider.tsx @@ -74,7 +74,10 @@ export const InternalDevCycleClientsideProvider = ({ ) } try { - await invalidateConfig(clientSDKKey) + await invalidateConfig( + clientSDKKey, + serverData?.user.user_id ?? null, + ) } catch { // do nothing on failure, this is best effort } diff --git a/sdk/nextjs/src/common/invalidateConfig.ts b/sdk/nextjs/src/common/invalidateConfig.ts index 15ea8652a..6c547f784 100644 --- a/sdk/nextjs/src/common/invalidateConfig.ts +++ b/sdk/nextjs/src/common/invalidateConfig.ts @@ -1,7 +1,10 @@ 'use server' import { revalidateTag } from 'next/cache' -export const invalidateConfig = async (sdkToken: string): Promise => { +export const invalidateConfig = async ( + sdkToken: string, + userId: string | null, +): Promise => { if (typeof window != 'undefined') { console.error( 'DevCycle realtime updates are only available in Next.js 14.0.5 and above. Please update your version ' + @@ -11,4 +14,7 @@ export const invalidateConfig = async (sdkToken: string): Promise => { } console.log('Invalidating old DevCycle cached config') revalidateTag(sdkToken) + if (userId) { + revalidateTag(userId) + } } diff --git a/sdk/nextjs/src/server/bucketing.ts b/sdk/nextjs/src/server/bucketing.ts index 05d83fb4f..4c4dfcec7 100644 --- a/sdk/nextjs/src/server/bucketing.ts +++ b/sdk/nextjs/src/server/bucketing.ts @@ -1,4 +1,4 @@ -import { fetchCDNConfig } from './requests' +import { fetchCDNConfig, sdkConfigAPI } from './requests' import { generateBucketedConfig } from '@devcycle/bucketing' import { cache } from 'react' import { DevCycleUser, DVCPopulatedUser } from '@devcycle/js-client-sdk' @@ -6,12 +6,12 @@ import { BucketedConfigWithAdditionalFields, DevCycleNextOptions, } from '../common/types' -import { ConfigBody, ConfigSource } from '@devcycle/types' +import { BucketedUserConfig, ConfigBody, ConfigSource } from '@devcycle/types' // wrap this function in react cache to avoid redoing work for the same user and config const generateBucketedConfigCached = cache( async ( - sdkKey: string, + obfuscated: boolean, user: DevCycleUser, config: ConfigBody, userAgent?: string, @@ -23,12 +23,31 @@ const generateBucketedConfigCached = cache( undefined, userAgent ?? undefined, ) + + // clientSDKKey is always defined for bootstrap config + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const clientSDKKey = config.clientSDKKey! + if (config.debugUsers?.includes(user.user_id ?? '')) { + const bucketedConfigResponse = await sdkConfigAPI( + clientSDKKey, + obfuscated, + populatedUser, + ) + const response = + (await bucketedConfigResponse.json()) as BucketedUserConfig + + return { + bucketedConfig: { + ...response, + clientSDKKey, + }, + } + } + return { bucketedConfig: { ...generateBucketedConfig({ user: populatedUser, config }), - // clientSDKKey is always defined for bootstrap config - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - clientSDKKey: config.clientSDKKey!, + clientSDKKey, sse: { url: config.sse ? `${config.sse.hostname}${config.sse.path}` @@ -92,7 +111,7 @@ export const getBucketedConfig = async ( ) const { bucketedConfig } = await generateBucketedConfigCached( - sdkKey, + !!options.enableObfuscation, user, config, userAgent, diff --git a/sdk/nextjs/src/server/requests.ts b/sdk/nextjs/src/server/requests.ts index a5448c2d8..ebe7b85b5 100644 --- a/sdk/nextjs/src/server/requests.ts +++ b/sdk/nextjs/src/server/requests.ts @@ -1,3 +1,6 @@ +import type { DVCClientAPIUser } from '@devcycle/types' +import { DVCPopulatedUser } from '@devcycle/js-client-sdk' + const getFetchUrl = (sdkKey: string, obfuscated: boolean) => `https://config-cdn.devcycle.com/config/v2/server/bootstrap/${ obfuscated ? 'obfuscated/' : '' @@ -19,3 +22,56 @@ export const fetchCDNConfig = async ( }, ) } + +const getSDKAPIUrl = ( + sdkKey: string, + obfuscated: boolean, + user: DVCPopulatedUser, +) => { + const searchParams = new URLSearchParams() + serializeUserSearchParams(user, searchParams) + searchParams.set('sdkKey', sdkKey) + if (obfuscated) { + searchParams.set('obfuscated', '1') + } + searchParams.set('sdkPlatform', 'nextjs') + searchParams.set('sse', '1') + return `https://sdk-api.devcycle.com/v1/sdkConfig?${searchParams.toString()}` +} + +export const sdkConfigAPI = async ( + sdkKey: string, + obfuscated: boolean, + user: DVCPopulatedUser, +): Promise => { + return await fetch(getSDKAPIUrl(sdkKey, obfuscated, user), { + next: { + revalidate: 60, + tags: [sdkKey, user.user_id], + }, + }) +} + +const convertToQueryFriendlyFormat = (property?: any): any => { + if (property instanceof Date) { + return property.getTime() + } + if (typeof property === 'object') { + return JSON.stringify(property) + } + return property +} + +export const serializeUserSearchParams = ( + user: DVCClientAPIUser, + queryParams: URLSearchParams, +): void => { + for (const key in user) { + const userProperty = convertToQueryFriendlyFormat( + user[key as keyof DVCClientAPIUser], + ) + if (userProperty !== null && userProperty !== undefined) { + queryParams.append(key, userProperty) + } + } +}