Skip to content

Commit

Permalink
feat: support self targeting in app router (#974)
Browse files Browse the repository at this point in the history
  • Loading branch information
ajwootto authored Oct 16, 2024
1 parent 66259c2 commit 1c8607a
Show file tree
Hide file tree
Showing 12 changed files with 246 additions and 32 deletions.
30 changes: 30 additions & 0 deletions e2e/nextjs/app-router/app/app/self-targeting/ClientComponent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Client Component</h1>
<p>Client Enabled Variable: {JSON.stringify(enabledVar)}</p>
<p>Client Disabled Variable: {JSON.stringify(disabledVar)}</p>
<p>Client All Variables: {JSON.stringify(allVariables)}</p>
<p>Client All Features: {JSON.stringify(allFeatures)}</p>
<ConditionalComponent />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
'use client'

export default function ConditionalClientComponent() {
return (
<div>
<h1>Client Component Conditionally Bundled</h1>
</div>
)
}
17 changes: 17 additions & 0 deletions e2e/nextjs/app-router/app/app/self-targeting/ServerComponent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1>Server Component</h1>
<p>Server Enabled Variable: {JSON.stringify(enabledVar)}</p>
<p>Server Disabled Variable: {JSON.stringify(disabledVar)}</p>
<p>Server All Variables: {JSON.stringify(allVariables)}</p>
<p>Server All Features: {JSON.stringify(allFeatures)}</p>
</div>
)
}
22 changes: 22 additions & 0 deletions e2e/nextjs/app-router/app/app/self-targeting/devcycle.ts
Original file line number Diff line number Diff line change
@@ -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 },
})
25 changes: 25 additions & 0 deletions e2e/nextjs/app-router/app/app/self-targeting/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<body>
<DevCycleClientsideProvider context={getClientContext()}>
{children}
</DevCycleClientsideProvider>
</body>
</html>
)
}
19 changes: 19 additions & 0 deletions e2e/nextjs/app-router/app/app/self-targeting/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<div>Streaming Disabled</div>
<Suspense fallback={<div>Loading...</div>}>
<ClientComponent />
<ServerComponent />
</Suspense>
<Link href="/normal/test">Go To page</Link>
</main>
)
}

export default Home
46 changes: 23 additions & 23 deletions e2e/nextjs/app-router/app/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions e2e/nextjs/app-router/tests/app-router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 7 additions & 1 deletion sdk/nextjs/src/common/invalidateConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
'use server'
import { revalidateTag } from 'next/cache'

export const invalidateConfig = async (sdkToken: string): Promise<void> => {
export const invalidateConfig = async (
sdkToken: string,
userId: string | null,
): Promise<void> => {
if (typeof window != 'undefined') {
console.error(
'DevCycle realtime updates are only available in Next.js 14.0.5 and above. Please update your version ' +
Expand All @@ -11,4 +14,7 @@ export const invalidateConfig = async (sdkToken: string): Promise<void> => {
}
console.log('Invalidating old DevCycle cached config')
revalidateTag(sdkToken)
if (userId) {
revalidateTag(userId)
}
}
33 changes: 26 additions & 7 deletions sdk/nextjs/src/server/bucketing.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
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'
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,
Expand All @@ -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}`
Expand Down Expand Up @@ -92,7 +111,7 @@ export const getBucketedConfig = async (
)

const { bucketedConfig } = await generateBucketedConfigCached(
sdkKey,
!!options.enableObfuscation,
user,
config,
userAgent,
Expand Down
56 changes: 56 additions & 0 deletions sdk/nextjs/src/server/requests.ts
Original file line number Diff line number Diff line change
@@ -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/' : ''
Expand All @@ -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<Response> => {
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)
}
}
}

0 comments on commit 1c8607a

Please sign in to comment.