mirror of
https://github.com/xtr-dev/payload-feature-flags.git
synced 2025-12-10 02:43:25 +00:00
Add Payload Feature Flags plugin with custom endpoints and configurations
This commit is contained in:
29
src/components/BeforeDashboardClient.tsx
Normal file
29
src/components/BeforeDashboardClient.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import { useConfig } from '@payloadcms/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const BeforeDashboardClient = () => {
|
||||
const { config } = useConfig()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMessage = async () => {
|
||||
const response = await fetch(`${config.serverURL}${config.routes.api}/my-plugin-endpoint`)
|
||||
const result = await response.json()
|
||||
setMessage(result.message)
|
||||
}
|
||||
|
||||
void fetchMessage()
|
||||
}, [config.serverURL, config.routes.api])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Added by the plugin: Before Dashboard Client</h1>
|
||||
<div>
|
||||
Message from the endpoint:
|
||||
<div>{message || 'Loading...'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/components/BeforeDashboardServer.module.css
Normal file
5
src/components/BeforeDashboardServer.module.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-direction: column;
|
||||
}
|
||||
19
src/components/BeforeDashboardServer.tsx
Normal file
19
src/components/BeforeDashboardServer.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ServerComponentProps } from 'payload'
|
||||
|
||||
import styles from './BeforeDashboardServer.module.css'
|
||||
|
||||
export const BeforeDashboardServer = async (props: ServerComponentProps) => {
|
||||
const { payload } = props
|
||||
|
||||
const { docs } = await payload.find({ collection: 'plugin-collection' })
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<h1>Added by the plugin: Before Dashboard Server</h1>
|
||||
Docs from Local API:
|
||||
{docs.map((doc) => (
|
||||
<div key={doc.id}>{doc.id}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
src/endpoints/customEndpointHandler.ts
Normal file
78
src/endpoints/customEndpointHandler.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { PayloadHandler } from 'payload'
|
||||
|
||||
export const customEndpointHandler = (collectionSlug: string): PayloadHandler =>
|
||||
async (req) => {
|
||||
const { payload } = req
|
||||
const url = new URL(req.url)
|
||||
const pathParts = url.pathname.split('/').filter(Boolean)
|
||||
const flagName = pathParts[pathParts.length - 1]
|
||||
|
||||
// Check if we're fetching a specific flag
|
||||
if (flagName && flagName !== 'feature-flags') {
|
||||
try {
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
name: {
|
||||
equals: flagName,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (result.docs.length === 0) {
|
||||
return Response.json(
|
||||
{ error: 'Feature flag not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const flag = result.docs[0]
|
||||
|
||||
// Return simplified flag data
|
||||
return Response.json({
|
||||
name: flag.name,
|
||||
enabled: flag.enabled,
|
||||
rolloutPercentage: flag.rolloutPercentage,
|
||||
variants: flag.variants,
|
||||
metadata: flag.metadata,
|
||||
})
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: 'Failed to fetch feature flag' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all feature flags
|
||||
try {
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
limit: 1000, // Adjust as needed
|
||||
where: {
|
||||
enabled: {
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Return simplified flag data
|
||||
const flags = result.docs.reduce((acc: any, flag: any) => {
|
||||
acc[flag.name] = {
|
||||
enabled: flag.enabled,
|
||||
rolloutPercentage: flag.rolloutPercentage,
|
||||
variants: flag.variants,
|
||||
metadata: flag.metadata,
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return Response.json(flags)
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: 'Failed to fetch feature flags' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
1
src/exports/client.ts
Normal file
1
src/exports/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'
|
||||
12
src/exports/rsc.ts
Normal file
12
src/exports/rsc.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { BeforeDashboardServer } from '../components/BeforeDashboardServer.js'
|
||||
|
||||
// Server-side hooks for React Server Components
|
||||
export {
|
||||
getFeatureFlag,
|
||||
isFeatureEnabled,
|
||||
getAllFeatureFlags,
|
||||
isUserInRollout,
|
||||
getUserVariant,
|
||||
getFeatureFlagsByTag,
|
||||
type FeatureFlag,
|
||||
} from '../hooks/server.js'
|
||||
198
src/hooks/server.ts
Normal file
198
src/hooks/server.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { getPayload } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export interface FeatureFlag {
|
||||
name: string
|
||||
enabled: boolean
|
||||
rolloutPercentage?: number
|
||||
variants?: Array<{
|
||||
name: string
|
||||
weight: number
|
||||
metadata?: any
|
||||
}>
|
||||
metadata?: any
|
||||
}
|
||||
|
||||
// Helper to get the collection slug from config
|
||||
async function getCollectionSlug(): Promise<string> {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
// Look for the feature flags collection - it should have a 'name' field with unique constraint
|
||||
const collection = payload.config.collections?.find(col =>
|
||||
col.fields.some(field =>
|
||||
field.name === 'name' &&
|
||||
field.type === 'text' &&
|
||||
field.unique === true
|
||||
) &&
|
||||
col.fields.some(field => field.name === 'enabled' && field.type === 'checkbox')
|
||||
)
|
||||
return collection?.slug || 'feature-flags'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific feature flag by name (for use in React Server Components)
|
||||
*/
|
||||
export async function getFeatureFlag(flagName: string): Promise<FeatureFlag | null> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
name: {
|
||||
equals: flagName,
|
||||
},
|
||||
},
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
if (result.docs.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const flag = result.docs[0]
|
||||
|
||||
return {
|
||||
name: flag.name as string,
|
||||
enabled: flag.enabled as boolean,
|
||||
rolloutPercentage: flag.rolloutPercentage as number | undefined,
|
||||
variants: flag.variants as any,
|
||||
metadata: flag.metadata,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch feature flag ${flagName}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled (for use in React Server Components)
|
||||
*/
|
||||
export async function isFeatureEnabled(flagName: string): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
return flag?.enabled ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active feature flags (for use in React Server Components)
|
||||
*/
|
||||
export async function getAllFeatureFlags(): Promise<Record<string, FeatureFlag>> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
enabled: {
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const flags: Record<string, FeatureFlag> = {}
|
||||
|
||||
for (const doc of result.docs) {
|
||||
flags[doc.name as string] = {
|
||||
name: doc.name as string,
|
||||
enabled: doc.enabled as boolean,
|
||||
rolloutPercentage: doc.rolloutPercentage as number | undefined,
|
||||
variants: doc.variants as any,
|
||||
metadata: doc.metadata,
|
||||
}
|
||||
}
|
||||
|
||||
return flags
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch feature flags:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is in a feature rollout (for use in React Server Components)
|
||||
*/
|
||||
export async function isUserInRollout(
|
||||
flagName: string,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
|
||||
if (!flag?.enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!flag.rolloutPercentage || flag.rolloutPercentage === 100) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Simple hash function for consistent user bucketing
|
||||
const hash = userId.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0)
|
||||
}, 0)
|
||||
|
||||
return (Math.abs(hash) % 100) < flag.rolloutPercentage
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the variant for a user in an A/B test (for use in React Server Components)
|
||||
*/
|
||||
export async function getUserVariant(
|
||||
flagName: string,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
const flag = await getFeatureFlag(flagName)
|
||||
|
||||
if (!flag?.enabled || !flag.variants || flag.variants.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Hash the user ID for consistent variant assignment
|
||||
const hash = Math.abs(userId.split('').reduce((acc, char) => {
|
||||
return ((acc << 5) - acc) + char.charCodeAt(0)
|
||||
}, 0))
|
||||
|
||||
const bucket = hash % 100
|
||||
let cumulative = 0
|
||||
|
||||
for (const variant of flag.variants) {
|
||||
cumulative += variant.weight
|
||||
if (bucket < cumulative) {
|
||||
return variant.name
|
||||
}
|
||||
}
|
||||
|
||||
return flag.variants[0]?.name || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature flags by tags (for use in React Server Components)
|
||||
*/
|
||||
export async function getFeatureFlagsByTag(tag: string): Promise<FeatureFlag[]> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const collectionSlug = await getCollectionSlug()
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: {
|
||||
'tags.tag': {
|
||||
equals: tag,
|
||||
},
|
||||
},
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
return result.docs.map(doc => ({
|
||||
name: doc.name as string,
|
||||
enabled: doc.enabled as boolean,
|
||||
rolloutPercentage: doc.rolloutPercentage as number | undefined,
|
||||
variants: doc.variants as any,
|
||||
metadata: doc.metadata,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch feature flags with tag ${tag}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
234
src/index.ts
Normal file
234
src/index.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import type { Config, CollectionConfig, Field } from 'payload'
|
||||
|
||||
import { customEndpointHandler } from './endpoints/customEndpointHandler.js'
|
||||
|
||||
export type CollectionOverrides = Partial<
|
||||
Omit<CollectionConfig, 'fields'>
|
||||
> & {
|
||||
fields?: (args: { defaultFields: Field[] }) => Field[]
|
||||
}
|
||||
|
||||
export type PayloadFeatureFlagsConfig = {
|
||||
/**
|
||||
* Enable/disable the plugin
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Default value for new feature flags
|
||||
* @default false
|
||||
*/
|
||||
defaultValue?: boolean
|
||||
/**
|
||||
* Enable percentage-based rollouts
|
||||
* @default true
|
||||
*/
|
||||
enableRollouts?: boolean
|
||||
/**
|
||||
* Enable variant/experiment support (A/B testing)
|
||||
* @default true
|
||||
*/
|
||||
enableVariants?: boolean
|
||||
/**
|
||||
* Enable REST API endpoints for feature flags
|
||||
* @default false
|
||||
*/
|
||||
enableApi?: boolean
|
||||
/**
|
||||
* Override collection configuration
|
||||
*/
|
||||
collectionOverrides?: CollectionOverrides
|
||||
}
|
||||
|
||||
export const payloadFeatureFlags =
|
||||
(pluginOptions: PayloadFeatureFlagsConfig = {}) =>
|
||||
(config: Config): Config => {
|
||||
const {
|
||||
disabled = false,
|
||||
defaultValue = false,
|
||||
enableRollouts = true,
|
||||
enableVariants = true,
|
||||
enableApi = false,
|
||||
collectionOverrides = {},
|
||||
} = pluginOptions
|
||||
|
||||
// Get collection slug from overrides or use default
|
||||
const collectionSlug = collectionOverrides.slug || 'feature-flags'
|
||||
|
||||
if (!config.collections) {
|
||||
config.collections = []
|
||||
}
|
||||
|
||||
// Define default fields
|
||||
const defaultFields: Field[] = [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: 'Unique identifier for the feature flag',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Describe what this feature flag controls',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'enabled',
|
||||
type: 'checkbox',
|
||||
defaultValue: defaultValue,
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Toggle this feature flag on or off',
|
||||
},
|
||||
},
|
||||
...(enableRollouts ? [
|
||||
{
|
||||
name: 'rolloutPercentage',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
defaultValue: 100,
|
||||
admin: {
|
||||
description: 'Percentage of users who will see this feature (0-100)',
|
||||
condition: (data: any) => data?.enabled === true,
|
||||
},
|
||||
},
|
||||
] : []),
|
||||
...(enableVariants ? [
|
||||
{
|
||||
name: 'variants',
|
||||
type: 'array',
|
||||
admin: {
|
||||
description: 'Define variants for A/B testing',
|
||||
condition: (data: any) => data?.enabled === true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Variant identifier (e.g., control, variant-a)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'weight',
|
||||
type: 'number',
|
||||
min: 0,
|
||||
max: 100,
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Weight for this variant (all weights should sum to 100)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional data for this variant',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] : []),
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'tag',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
admin: {
|
||||
description: 'Tags for organizing feature flags',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'metadata',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: 'Additional metadata for this feature flag',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Apply field overrides if provided
|
||||
const fields = collectionOverrides.fields
|
||||
? collectionOverrides.fields({ defaultFields })
|
||||
: defaultFields
|
||||
|
||||
// Extract field overrides from collectionOverrides
|
||||
const { fields: _fieldsOverride, ...otherOverrides } = collectionOverrides
|
||||
|
||||
// Create the feature flags collection with overrides
|
||||
const featureFlagsCollection: CollectionConfig = {
|
||||
slug: collectionSlug,
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
group: 'Configuration',
|
||||
description: 'Manage feature flags for your application',
|
||||
...(otherOverrides.admin || {}),
|
||||
},
|
||||
fields,
|
||||
// Apply any other collection overrides
|
||||
...otherOverrides,
|
||||
}
|
||||
|
||||
config.collections.push(featureFlagsCollection)
|
||||
|
||||
/**
|
||||
* If the plugin is disabled, we still want to keep the collection
|
||||
* so the database schema is consistent which is important for migrations.
|
||||
*/
|
||||
if (disabled) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (!config.endpoints) {
|
||||
config.endpoints = []
|
||||
}
|
||||
|
||||
if (!config.admin) {
|
||||
config.admin = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components) {
|
||||
config.admin.components = {}
|
||||
}
|
||||
|
||||
if (!config.admin.components.beforeDashboard) {
|
||||
config.admin.components.beforeDashboard = []
|
||||
}
|
||||
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`payload-feature-flags/client#BeforeDashboardClient`,
|
||||
)
|
||||
config.admin.components.beforeDashboard.push(
|
||||
`payload-feature-flags/rsc#BeforeDashboardServer`,
|
||||
)
|
||||
|
||||
// Add API endpoints if enabled
|
||||
if (enableApi) {
|
||||
// Add API endpoint for fetching feature flags
|
||||
config.endpoints.push({
|
||||
handler: customEndpointHandler(collectionSlug),
|
||||
method: 'get',
|
||||
path: '/feature-flags',
|
||||
})
|
||||
|
||||
// Add endpoint for checking a specific feature flag
|
||||
config.endpoints.push({
|
||||
handler: customEndpointHandler(collectionSlug),
|
||||
method: 'get',
|
||||
path: '/feature-flags/:flag',
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
Reference in New Issue
Block a user