Add Payload Feature Flags plugin with custom endpoints and configurations

This commit is contained in:
2025-09-12 11:45:33 +02:00
commit 453b9eac7c
41 changed files with 20187 additions and 0 deletions

View 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>
)
}

View File

@@ -0,0 +1,5 @@
.wrapper {
display: flex;
gap: 5px;
flex-direction: column;
}

View 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>
)
}

View 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
View File

@@ -0,0 +1 @@
export { BeforeDashboardClient } from '../components/BeforeDashboardClient.js'

12
src/exports/rsc.ts Normal file
View 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
View 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
View 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
}