diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..d1f7ffc --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "partial-prerendering", + "summer-sale", + "svelte-example", + "next-13", + "next-14", + "next-15" + ], + "snapshot": { + "useCalculatedVersion": true + } +} diff --git a/.changeset/olive-chairs-grab.md b/.changeset/olive-chairs-grab.md new file mode 100644 index 0000000..416688c --- /dev/null +++ b/.changeset/olive-chairs-grab.md @@ -0,0 +1,46 @@ +--- +'@vercel/flags': minor +--- + +@vercel/flags/next: Export `dedupe` function + +`dedupe` is a middleware-friendly version of `React.cache`. It allows ensuring a function only ever runs once for a request. + +```ts +import { dedupe } from '@vercel/flags/next'; + +let i = 0; +const runOnce = dedupe(async () => { + return i++; +}); + +await runOnce(); // returns 0 +await runOnce(); // still returns 0 +``` + +This function is useful when you want to deduplicate work within each feature flag's `decide` function. For example if multiple flags need to check auth you can dedupe the auth function so it only runs once per request. + +`dedupe` is also useful to optimistically generate a random visitor id to be set in a cookie, while also allowing each feature flag to access the id. You can call a dedupe'd function to generate the random id within your Edge Middleware and also within your feature flag's `decide` functions. The function will return a consistent id. + +```ts +import { nanoid } from 'nanoid'; +import { cookies, headers } from 'next/headers'; +import { dedupe } from '@vercel/flags/next'; + +/** + * Reads the visitor id from a cookie or returns a new visitor id + */ +export const getOrGenerateVisitorId = dedupe( + async (): Promise<{ value: string; fresh: boolean }> => { + const visitorIdCookie = (await cookies()).get('visitor-id')?.value; + + return visitorIdCookie + ? { value: visitorIdCookie, fresh: false } + : { value: nanoid(), fresh: true }; + }, +); +``` + +> Note: "once per request" is an imprecise description. A `dedupe`d function actually runs once per request, per compute instance. If a dedupe'd function is used in Edge Middleware and in a React Server Component it will run twice, as there are two separate compute instances handling this request. + +> Note: This function acts as a sort of polyfill until similar functionality lands in Next.js directly. diff --git a/.changeset/poor-rules-film.md b/.changeset/poor-rules-film.md new file mode 100644 index 0000000..2d205fd --- /dev/null +++ b/.changeset/poor-rules-film.md @@ -0,0 +1,5 @@ +--- +'@vercel/flags': major +--- + +add identify, drop getPrecomputationContext, change .run({}) diff --git a/.changeset/ten-eagles-build.md b/.changeset/ten-eagles-build.md new file mode 100644 index 0000000..acedac4 --- /dev/null +++ b/.changeset/ten-eagles-build.md @@ -0,0 +1,5 @@ +--- +'@vercel/flags': major +--- + +remove unstable\_ prefixes diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..bcac0cd --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,20 @@ +const { resolve } = require('node:path'); + +module.exports = { + root: true, + // This tells ESLint to load the config from the package `eslint-config-custom` + extends: ['custom'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + 'import/no-default-export': 'off', + '@typescript-eslint/no-confusing-void-expression': 'off', + }, + parserOptions: { + project: [ + resolve(__dirname, './packages/*/tsconfig.json'), + resolve(__dirname, './tooling/*/tsconfig.json'), + resolve(__dirname, './examples/*/tsconfig.json'), + resolve(__dirname, './tests/*/tsconfig.json'), + ], + }, +}; diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 0000000..3e8b32b --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,34 @@ +name: Playwright Tests +on: + push: + branches: [main] + pull_request: + branches: ['*'] +jobs: + test: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install Playwright Browsers + run: pnpm exec playwright install chromium + - name: Install Playwright System Dependencies + run: pnpm exec playwright install-deps chromium + - name: Run Playwright tests + run: pnpm test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..4d4856c --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,96 @@ +name: Quality + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + prettier: + name: 'Prettier' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run Prettier check + run: pnpm prettier-check + + eslint: + name: 'ESLint' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run ESLint + run: pnpm run lint + + types: + name: 'TypeScript' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run TypeScript type check + run: pnpm run type-check + + publint: + name: 'publint' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run Publint + run: pnpm run publint diff --git a/.github/workflows/release-snapshot.yaml b/.github/workflows/release-snapshot.yaml new file mode 100644 index 0000000..944059d --- /dev/null +++ b/.github/workflows/release-snapshot.yaml @@ -0,0 +1,95 @@ +# This workflow lets you manually create snapshot relases +# +# A snapshot release is useful when you want to try out changes on a pull request +# before making a full release and without making a pre release mode. +# +# Problem +# +# This is useful as changesets force pre release mode across all packages in our +# mono repo. When using pre releases then stable releases of all packages are +# blocked until pre release is exited. +# +# What are snapshot releases +# +# To work around this issue we have this workflow. It lets you create a +# once-off release for a specific branch. We call those snapshot releases. +# Snapshot releases are published under the `snapshot` dist-tag and have +# versions like 0.4.0-579bd13f016c7de43a2830340634b3948db358b6-20230913164912, +# which consist of the version that would be generated, the commit hash and +# the timestamp. +# +# How to create a snapshot release +# +# Make sure you have a branch pushed to GitHub, and make sure that branch has +# a changeset committed. You can generate a changeset with "pnpm changeset". +# +# Then open github.com/vercel/variants and click on Actions > Release Snapshot +# Then click "Run workflow" on the right and select the branch you want to +# create a snapshot release for and click the "Run workflow" button. +# +# Then wait for the run to kick off and open the details. +# Find the "Create Snapshot Release" step in the logs. +# The last line of that log should contain something like +# 🦋 success packages published successfully: +# 🦋 @vercel/edge-config@0.4.0-579bd13f016c7de43a2830340634b3948db358b6-20230913164912 +# This shows the version which was generated. + +name: Release Snapshot + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + +on: + workflow_dispatch: + +jobs: + release-snapshot: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Add npm auth token to pnpm + run: pnpm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN_ELEVATED}" + env: + NPM_TOKEN_ELEVATED: ${{secrets.NPM_TOKEN_ELEVATED}} + + - name: Install Dependencies + run: pnpm install + + - name: Add SHORT_SHA env property with commit short sha + run: echo "SHORT_SHA=`echo ${{ github.sha }} | cut -c1-8`" >> $GITHUB_ENV + + - name: Version Packages + run: pnpm changeset version --snapshot ${SHORT_SHA} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} + + - name: Build + run: pnpm build + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} + EDGE_CONFIG: ${{ secrets.EDGE_CONFIG }} + FLAGS_SECRET: ${{ secrets.FLAGS_SECRET }} + FLAGS: ${{ secrets.FLAGS }} + LAUNCHDARKLY_API_TOKEN: ${{ secrets.LAUNCHDARKLY_API_TOKEN }} + HAPPYKIT_API_TOKEN: ${{ secrets.HAPPYKIT_API_TOKEN }} + HAPPYKIT_ENV_KEY: ${{ secrets.HAPPYKIT_ENV_KEY }} + + - name: Publish Snapshot Release + run: pnpm changeset publish --no-git-tag --tag snapshot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c638449 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + # This expects you to have a script called release which does a build for your packages and calls changeset publish + publish: pnpm release + version: pnpm version-packages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN_ELEVATED }} + EDGE_CONFIG: ${{ secrets.EDGE_CONFIG }} + FLAGS_SECRET: ${{ secrets.FLAGS_SECRET }} + LAUNCHDARKLY_API_TOKEN: ${{ secrets.LAUNCHDARKLY_API_TOKEN }} + HAPPYKIT_API_TOKEN: ${{ secrets.HAPPYKIT_API_TOKEN }} + HAPPYKIT_ENV_KEY: ${{ secrets.HAPPYKIT_ENV_KEY }} diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..48da4ac --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,37 @@ +name: Unit Tests + +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: 'Test' + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c34c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# build +dist/ + +# dependencies +node_modules/ + +# logs +npm-debug.log + +.turbo +.DS_Store +.vscode +.vercel +.env*.local +.svelte-kit +**/test-results/ +**/playwright-report/ +**/blob-report/ +**/playwright/.cache/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..922f10a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +20.x diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..83584d4 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact = true +strict-peer-dependencies=false \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..47529f9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +pnpm-lock.yaml +.next +.vercel +.vscode diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ec9dcd9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2ba293 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# `@vercel/flags` + +These packages allow working with feature flags. + +## Constraints + +- each framework's `flag` function takes the same declaration as `flag(declaration)` +- adapters are independent of frameworks and can be used as `flag(adapter(declaration))` diff --git a/examples/docs/.eslintrc.json b/examples/docs/.eslintrc.json new file mode 100644 index 0000000..3bdd5cc --- /dev/null +++ b/examples/docs/.eslintrc.json @@ -0,0 +1,11 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "import/order": "off", + "no-console": "off", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/restrict-template-expressions": "off" + } +} diff --git a/examples/docs/.gitignore b/examples/docs/.gitignore new file mode 100644 index 0000000..de7a9fc --- /dev/null +++ b/examples/docs/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +.env*.local diff --git a/examples/docs/README.md b/examples/docs/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/examples/docs/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/examples/docs/app/.well-known/vercel/flags/route.ts b/examples/docs/app/.well-known/vercel/flags/route.ts new file mode 100644 index 0000000..e51a8a7 --- /dev/null +++ b/examples/docs/app/.well-known/vercel/flags/route.ts @@ -0,0 +1,25 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { verifyAccess, type ApiData } from '@vercel/flags'; +import { getProviderData } from '@vercel/flags/next'; +// The @/ import is not working in the ".well-known" folder due do the dot in the path. +// We need to use relative paths instead. This seems like a TypeScript issue. +import * as marketingFlags from '../../../examples/marketing-pages/flags'; +import * as dashboardFlags from '../../../examples/dashboard-pages/flags'; +import * as adapterFlags from '../../../concepts/adapters/flags'; +import * as topLevelFlags from '../../../../flags'; +import * as basicEdgeMiddlewareFlags from '../../../examples/feature-flags-in-edge-middleware/flags'; + +export async function GET(request: NextRequest) { + const access = await verifyAccess(request.headers.get('Authorization')); + if (!access) return NextResponse.json(null, { status: 401 }); + + return NextResponse.json( + getProviderData({ + ...marketingFlags, + ...dashboardFlags, + ...topLevelFlags, + ...adapterFlags, + ...basicEdgeMiddlewareFlags, + }), + ); +} diff --git a/examples/docs/app/api-reference/page.tsx b/examples/docs/app/api-reference/page.tsx new file mode 100644 index 0000000..35bfc01 --- /dev/null +++ b/examples/docs/app/api-reference/page.tsx @@ -0,0 +1,46 @@ +import { Content } from '@/components/content'; +import Link from 'next/link'; + +export default function ExamplesPage() { + return ( + +

API Reference

+
Shows the APIs available in the Flags SDK
+

@vercel/flags

+

APIs for integrating flags with Vercel.

+

+ + Learn more + {' '} + about these methods. +

+

@vercel/flags/next

+

APIs for working with feature flags in Next.js.

+

flag

+

Declares a new feature flag.

+

precompute

+

Precomputes the value of a group of feature flags.

+

combine

+

Calculates the combinations of flags.

+

dedupe

+

Deduplicates work.

+

deserialize

+

Deserializes a feature flag.

+

evaluate

+

Evaluates a feature flag.

+

generatePermutations

+

Generates permutations of all options of a group of flags.

+

getPrecomputed

+

Gets precomputed values of a group of flags.

+

getProviderData

+

Turns flag definitions into provider data Vercel understands.

+

serialize

+

Serializes a feature flag.

+

setTracerProvider

+

Set an OpenTelemetry tracer provider.

+
+ ); +} diff --git a/examples/docs/app/concepts/adapters/edge-config-adapter.ts b/examples/docs/app/concepts/adapters/edge-config-adapter.ts new file mode 100644 index 0000000..1c2e1bc --- /dev/null +++ b/examples/docs/app/concepts/adapters/edge-config-adapter.ts @@ -0,0 +1,55 @@ +import type { Adapter } from '@vercel/flags'; +import { createClient, type EdgeConfigClient } from '@vercel/edge-config'; + +/** + * Allows creating a custom Edge Config adapter for feature flags + */ +export function createEdgeConfigAdapter( + connectionString: string | EdgeConfigClient, + options?: { + edgeConfigItemKey?: string; + teamSlug?: string; + }, +) { + if (!connectionString) { + throw new Error('Edge Config Adapter: Missing connection string'); + } + const edgeConfigClient = + typeof connectionString === 'string' + ? createClient(connectionString) + : connectionString; + + const edgeConfigItemKey = options?.edgeConfigItemKey ?? 'flags'; + + return function edgeConfigAdapter(): Adapter< + ValueType, + EntitiesType + > { + return { + origin: options?.teamSlug + ? `https://vercel.com/${options.teamSlug}/~/stores/edge-config/${edgeConfigClient.connection.id}/items#item=${edgeConfigItemKey}` + : undefined, + async decide({ key }): Promise { + const definitions = + await edgeConfigClient.get>( + edgeConfigItemKey, + ); + + // if a defaultValue was provided this error will be caught and the defaultValue will be used + if (!definitions) { + throw new Error( + `Edge Config Adapter: Edge Config item "${edgeConfigItemKey}" not found`, + ); + } + + // if a defaultValue was provided this error will be caught and the defaultValue will be used + if (!(key in definitions)) { + throw new Error( + `Edge Config Adapter: Flag "${key}" not found in Edge Config item "${edgeConfigItemKey}"`, + ); + } + return definitions[key] as ValueType; + }, + }; + }; +} diff --git a/examples/docs/app/concepts/adapters/flags.tsx b/examples/docs/app/concepts/adapters/flags.tsx new file mode 100644 index 0000000..636fce7 --- /dev/null +++ b/examples/docs/app/concepts/adapters/flags.tsx @@ -0,0 +1,10 @@ +import { flag } from '@vercel/flags/next'; +import { createEdgeConfigAdapter } from './edge-config-adapter'; + +const edgeConfigAdapter = createEdgeConfigAdapter(process.env.EDGE_CONFIG!); + +export const customAdapterFlag = flag({ + key: 'custom-adapter-flag', + description: 'Shows how to use a custom flags adapter', + adapter: edgeConfigAdapter(), +}); diff --git a/examples/docs/app/concepts/adapters/page.tsx b/examples/docs/app/concepts/adapters/page.tsx new file mode 100644 index 0000000..78856a8 --- /dev/null +++ b/examples/docs/app/concepts/adapters/page.tsx @@ -0,0 +1,156 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import { DemoFlag } from '@/components/demo-flag'; +import { customAdapterFlag } from './flags'; +import { SelfDocumentingExampleAlert } from '@/components/self-documenting-example-alert'; +import Link from 'next/link'; + +export default async function Page() { + const customAdapter = await customAdapterFlag(); + return ( + +

Adapters

+

Integrate any flag provider.

+

+ It is possible to integrate any feature flag provider with the Flags SDK + using an adapter. We publish adapters for the most common providers, but + it is also possible to write a custom adapter in case we don't list + your provider or in case you have an in-house solution for feature + flags. +

+

+ Adapters conceptually replace the decide and{' '} + origin parts of a flag declaration. +

+

How to use an existing adapter

+

+ Adapters are still a work-in-progress. We have not published any offical + adapters yet, but it is already possible to create your own as described + below. +

+ + {` + // THIS IS A PREVIEW + // The @flags-sdk/* adapters are not available yet + import { flag } from '@vercel/flags/next'; + import { statsig } from "@flags-sdk/statsig"; + + export const exampleFlag = flag({ + key: "example-flag", + adapter: statsig(), + });`} + +

How to write a custom adapter

+

+ Creating custom adapters is possible by creating an adapter factory. +

+ {` + import type { Adapter } from '@vercel/flags'; + import { createClient, EdgeConfigClient } from '@vercel/edge-config'; + + /** + * A factory function for your adapter + */ + export function createExampleAdapter(/* options */) { + // create the client for your provider here, or reuse the one + // passed in through options + + return function exampleAdapter(): Adapter< + ValueType, + EntitiesType + > { + return { + origin(key) { + // link to the flag in the provider's dashboard + return \`https://example.com/flags/\${key}\`; + }, + async decide({ key }): Promise { + // use the SDK instance created earlier to evaluate flags here + return false as ValueType; + }, + }; + }; + } + `} +

This allows passing the provider in the flag declaration.

+ + {` + import { flag } from '@vercel/flags/next'; + import { createExampleAdapter } from "./example-adapter" + + // create an instance of the adapter + const exampleAdapter = createExampleAdapter(); + + export const exampleFlag = flag({ + key: "example-flag", + // use the adapter for many feature flags + adapter: exampleAdapter(), + });`} + + +

Example

+

Below is an example of an Flags SDK adapter reading Edge Config.

+ + + + + Inspect the source code + {' '} + to see the actual usage of the feature flag. + + +

Exposing default adapters

+

+ In the example above, as a user of the adapter, we first needed to + create an instance of the adapter. It is possible to simplify usage + further by exposing a default adapter. +

+

+ Usage with a default adapter, where we can import a fully configured{' '} + exampleAdapter. +

+ + {` + import { flag } from '@vercel/flags/next'; + import { exampleAdapter } from "./example-adapter" + + export const exampleFlag = flag({ + key: "example-flag", + // use the adapter for many feature flags + adapter: exampleAdapter(), + });`} + +

+ Many @flags-sdk/* adapters will implement this pattern. The + default adapter will get created lazily on first usage, and can + initialize itself based on known environment variables. +

+ {` + // extend the adapter definition to expose a default adapter + let defaultEdgeConfigAdapter: + | ReturnType + | undefined; + + /** + * A default Vercel adapter for Edge Config + * + */ + export function edgeConfigAdapter(): Adapter< + ValueType, + EntitiesType + > { + // Initialized lazily to avoid warning when it is not actually used and env vars are missing. + if (!defaultEdgeConfigAdapter) { + if (!process.env.EDGE_CONFIG) { + throw new Error('Edge Config Adapter: Missing EDGE_CONFIG env var'); + } + + defaultEdgeConfigAdapter = createEdgeConfigAdapter(process.env.EDGE_CONFIG); + } + + return defaultEdgeConfigAdapter(); + } + `} +
+ ); +} diff --git a/examples/docs/app/concepts/dedupe/page.tsx b/examples/docs/app/concepts/dedupe/page.tsx new file mode 100644 index 0000000..11634d6 --- /dev/null +++ b/examples/docs/app/concepts/dedupe/page.tsx @@ -0,0 +1,81 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import { dedupe } from '@vercel/flags/next'; +import Link from 'next/link'; + +const dedupeExample = dedupe(() => { + return Math.random().toString().substring(0, 8); +}); + +export default async function Page() { + const random1 = await dedupeExample(); + const random2 = await dedupeExample(); + const random3 = await dedupeExample(); + + return ( + +

Dedupe

+

Prevent duplicate work.

+

+ Any function wrapped in dedupe will only ever run once for + the same request within the same runtime and given the same arguments. +

+

+ The dedupe function is an integral piece when working with + the Flags SDK.{' '} +

+

Example

+ {` + import { dedupe } from '@vercel/flags/next'; + + const dedupeExample = dedupe(() => { + return Math.random(); + }); + + export default async function Page() { + const random1 = await dedupeExample(); + const random2 = await dedupeExample(); + const random3 = await dedupeExample(); + + // these will all be the same random number + return
{random1} {random2} {random3}
; + } + `}
+

Example of output:

+
+ {random1} {random2} {random3} +
+

Use case: Avoiding duplicate work

+

+ This helper is extremly useful in combination with the{' '} + identify function, as it allows the identification to only + happen once per request. This is useful in preventing overhead when + passing the same identify function to multiple feature + flags. +

+

Use case: Generating consistent random IDs

+

+ When experimenting on anonymous visitors it is common to set a cookie + containing a random id from Edge Middleware. This random id is later + used to consistently assign users to specific groups of A/B tests. +

+

+ For this use case the function generating the random id can be wrapped + in dedupe. The deduplicated function is then called in Edge + Middleware to produce the random id, and from a flag's{' '} + identify function to identify the user even on the first + page visit when no cookie is present yet. +

+

+ As the function is guaranteed to generate the same id the Edge + Middleware can set a cookie containing the generated id in a response, + and the feature flag can already use the generated id even if the + original request did not contain the id. +

+

+ Learn more about this + approach in the Marketing Pages example. +

+
+ ); +} diff --git a/examples/docs/app/concepts/identify/flags.ts b/examples/docs/app/concepts/identify/flags.ts new file mode 100644 index 0000000..cabf6d4 --- /dev/null +++ b/examples/docs/app/concepts/identify/flags.ts @@ -0,0 +1,35 @@ +import type { ReadonlyRequestCookies } from '@vercel/flags'; +import { dedupe, flag } from '@vercel/flags/next'; + +interface Entities { + user?: { id: string }; +} + +const identify = dedupe( + ({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => { + const userId = cookies.get('identify-example-user-id')?.value; + return { user: userId ? { id: userId } : undefined }; + }, +); + +export const basicIdentifyExampleFlag = flag({ + key: 'basic-identify-example-flag', + identify, + description: 'Basic identify example', + decide({ entities }) { + console.log(entities); + if (!entities?.user) return false; + return entities.user.id === 'user1'; + }, +}); + +export const fullIdentifyExampleFlag = flag({ + key: 'full-identify-example-flag', + identify, + description: 'Full identify example', + decide({ entities }) { + console.log(entities); + if (!entities?.user) return false; + return entities.user.id === 'user1'; + }, +}); diff --git a/examples/docs/app/concepts/identify/page.tsx b/examples/docs/app/concepts/identify/page.tsx new file mode 100644 index 0000000..5613271 --- /dev/null +++ b/examples/docs/app/concepts/identify/page.tsx @@ -0,0 +1,221 @@ +import { Content } from '@/components/content'; +import { basicIdentifyExampleFlag, fullIdentifyExampleFlag } from './flags'; +import { DemoFlag } from '@/components/demo-flag'; +import { Button } from '@/components/ui/button'; +import { cookies } from 'next/headers'; +import { CodeBlock } from '@/components/code-block'; +import Link from 'next/link'; +import { SelfDocumentingExampleAlert } from '@/components/self-documenting-example-alert'; + +export default async function Page() { + const basic = await basicIdentifyExampleFlag(); + const full = await fullIdentifyExampleFlag(); + + return ( + +

Identify

+

Establishing evaluation context.

+

+ It is common for features to be on for some users, but off for others. + For example a feature might be on for team members but off for everyone + else. +

+

+ The flag declaration accepts an identify{' '} + function. The entities returned from the identify function + are passed as an argument to the decide function. +

+ +

Basic example

+

A trivial case to illustrate the concept

+ {` + import { dedupe, flag } from '@vercel/flags/next'; + + export const exampleFlag = flag({ + key: 'identify-example-flag', + identify() { + return { user: { id: 'user1' } }; + }, + decide({ entities }) { + return entities?.user?.id === 'user1'; + }, + }); + `} + + + + + + Inspect the source code + {' '} + to see the actual usage of the feature flag. + + +

+ Having first-class support for an evaluation context allows decoupling + the identifying step from the decision making step. +

+ +

Type safety

+

+ The entities can be typed using the flag function. +

+ + {` + import { dedupe, flag } from '@vercel/flags/next'; + + interface Entities { + user?: { id: string }; + } + + export const exampleFlag = flag({ + key: 'identify-example-flag', + identify() { + return { user: { id: 'user1' } }; + }, + decide({ entities }) { + return entities?.user?.id === 'user1'; + }, + }); + `} + +

Headers and Cookies

+

+ The identify function is called with headers{' '} + and cookies arguments, which is useful when dealing with + anonymous or authenticated users. +

+

+ The arguments are normalized to a common format so the same flag to be + used in Edge Middleware, App Router and Pages Router without having to + worry about the differences in how headers and{' '} + cookies are represented there. +

+ {` + import { flag } from '@vercel/flags/next'; + + export const exampleFlag = flag({ + // ... + identify({ headers, cookies }) { + // access to normalized headers and cookies here + headers.get("auth") + cookies.get("auth")?.value + // ... + }, + // ... + }); + `} + +

Deduplication

+

+ The dedupe function is a helper to prevent duplicate work. +

+

+ Any function wrapped in dedupe will only ever run once for + the same request within the same runtime and given the same arguments. +

+

+ This helper is extremly useful in combination with the{' '} + identify function, as it allows the identification to only + happen once per request. This is useful in preventing overhead when + passing the same identify function to multiple feature + flags. +

+

+ Learn more about{' '} + dedupe. +

+ +

Custom evaluation context

+

+ While it is best practice to let the identify function + determine the evaluation context, it is possible to provide a custom + evaluation context. +

+ {` + // pass a custom evaluation context from the call side + await exampleFlag.run({ identify: { user: { id: 'user1' } } }); + + // pass a custom evaluation context function from the call side + await exampleFlag.run({ identify: () => ({ user: { id: 'user1' } }) }); + `} +

+ This should be used sparsely, as custom evaluation context can make + feature flags less predictable across your code base. +

+ +

Full example

+

+ The example below shows how to use the identify function to + display different content to different users. +

+ +
+ + + +
+ + + + Inspect the source code + {' '} + to see the actual usage of the feature flag. + + +

The above example is implemented using this feature flag:

+ {` + import type { ReadonlyRequestCookies } from '@vercel/flags'; + import { dedupe, flag } from '@vercel/flags/next'; + + interface Entities { + user?: { id: string }; + } + + const identify = dedupe( + ({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => { + // This could read a JWT instead + const userId = cookies.get('identify-example-user-id')?.value; + return { user: userId ? { id: userId } : undefined }; + }, + ); + + export const identifyExampleFlag = flag({ + key: 'identify-example-flag', + identify, + decide({ entities }) { + if (!entities?.user) return false; + return entities.user.id === 'user1'; + }, + }); + `} +
+ ); +} diff --git a/examples/docs/app/concepts/page.tsx b/examples/docs/app/concepts/page.tsx new file mode 100644 index 0000000..efe4134 --- /dev/null +++ b/examples/docs/app/concepts/page.tsx @@ -0,0 +1,55 @@ +import { Content } from '@/components/content'; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import Link from 'next/link'; + +export default function Page() { + // This page should talk about the need to declare options upfront. + return ( + +

Concepts

+
+ + + + Identify + Establishing evaluation context + + + + + + + Dedupe + Preventing duplicate work + + + + + + + Precompute + + Using feature flags on static pages + + + + + + + + Adapters + + Using adapters to integrate providers + + + + +
+
+ ); +} diff --git a/examples/docs/app/concepts/precompute/page.tsx b/examples/docs/app/concepts/precompute/page.tsx new file mode 100644 index 0000000..ac038c1 --- /dev/null +++ b/examples/docs/app/concepts/precompute/page.tsx @@ -0,0 +1,251 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import Image from 'next/image'; +import Link from 'next/link'; + +export default function Page() { + // This page should talk about the need to declare options upfront. + return ( + +

Precompute

+

Using feature flags on static pages.

+

+ Precomputing describes a pattern where Edge Middleware uses feature + flags to decide which variant of a page to show. This allows to keep the + page itself static, which leads to incredibly low latency globally as + the page can be served by an Edge Network. +

+ Precompute manual +

Manual approach

+

+ In its most simple form this pattern is implemented creating two + versions of the home page in app/home-a/page.tsx and{' '} + app/home-b/page.tsx. Then, use Edge Middleare to rewrite + the request either to /home-a or /home-b. +

+ {` + // flags.ts + import { flag } from '@vercel/flags/next'; + + export const homeFlag = flag({ + key: 'home', + decide: () => Math.random() > 0.5, + }); + `} + + {` + // middleware.ts + import { NextResponse, type NextRequest } from 'next/server'; + import { homeFlag } from './flags.ts'; + + export const config = { matcher: ['/'] }; + + export async function middleware(request: NextRequest) { + const home = await homeFlag(); + + // Determine which version to show based on the feature flag + const version = home ? '/home-b' : '/home-a'; + + // Rewrite the request to the appropriate version + const nextUrl = new URL(version, request.url); + return NextResponse.rewrite(nextUrl); + } + `} + +

+ This approach works well for simple cases, but has a few downsides. It + can be cumbersome having to maintain both /home-a or{' '} + /home-b. The approach also doesn't scale well when a + feature flag is used on more than one page, or when multiple feature + flags are used on a single page. +

+

Precomputing

+

+ This is an extension to the previously described pattern. It allows + combining multiple feature flags on a single, static page. +

+ +

+ This pattern is useful for experimentation on static pages, as it allows + middleware to make routing decisions, while being able to keep the + different variants of the underlying flags static. +

+ +

+ It further allows generating a page for each combination of feature + flags either at build time or lazily the first time it is accessed. It + can then be cached using ISR so it does not need to be regenerated. +

+ +

+ Technically this works by using dynamic route segments to transport an + encoded version of the feature flags computed within Edge Middleware. + Encoding the values within the URL allows the page itself to access the + precomputed values, and also ensures there is a unique URL for each + combination of feature flags on a page. Because the system works using + rewrites, the visitor will never see the URL containing the flags. They + will only see the clean, original URL. +

+ +

Export flags to be precomputed

+

+ You can export one or multiple arrays of flags to be precomputed. This + by itself does not do anything yet, but you will use the exported array + in the next step: +

+ + {` + // flags.ts + import { flag } from '@vercel/flags/next'; + + export const showSummerSale = flag({ + key: 'summer-sale', + decide: () => false, + }); + + export const showBanner = flag({ + key: 'banner', + decide: () => false, + }); + + // a group of feature flags to be precomputed + export const marketingFlags = [showSummerSale, showBanner] as const; + `} + +

Precompute flags in middleware

+

+ In this step, import marketingFlags from the flags{' '} + file that you created in the previous step. Then, call{' '} + precompute with the list of flags to be precomputed. + You'll then forward the precomputation result to the underlying + page using an URL rewrite: +

+ {` + // middleware.ts + import { type NextRequest, NextResponse } from 'next/server'; + import { precompute } from '@vercel/flags/next'; + import { marketingFlags } from './flags'; + + // Note that we're running this middleware for / only, but + // you could extend it to further pages you're experimenting on + export const config = { matcher: ['/'] }; + + export async function middleware(request: NextRequest) { + // precompute returns a string encoding each flag's returned value + const code = await precompute(marketingFlags); + + // rewrites the request to include the precomputed code for this flag combination + const nextUrl = new URL( + \`/\${code}\${request.nextUrl.pathname}\${request.nextUrl.search}\`, + request.url, + ); + + return NextResponse.rewrite(nextUrl, { request }); + } + `} + +

Accessing the precomputation result from a page

+ +

+ Next, import the feature flags you created earlier, such as{' '} + showBanner, while providing the code from the URL and the{' '} + marketingFlags list of flags used in the precomputation. +

+

+ When the showBanner flag is called within this component it + reads the result from the precomputation, and it does not invoke the + flag's decide function again: +

+ + {` + // app/[code]/page.tsx + import { marketingFlags, showSummerSale, showBanner } from '../../flags'; + type Params = Promise<{ code: string }>; + + export default async function Page({ params }: { params: Params }) { + const { code } = await params; + // access the precomputed result by passing params.code and the group of + // flags used during precomputation of this route segment + const summerSale = await showSummerSale(code, marketingFlags); + const banner = await showBanner(code, marketingFlags); + + return ( +
+ {banner ?

welcome

: null} + + {summerSale ? ( +

summer sale live now

+ ) : ( +

summer sale starting soon

+ )} +
+ ); + } + `}
+ +

+ This approach allows middleware to decide the value of feature flags and + to pass the precomputation result down to the page. This approach also + works with API Routes. +

+ +

Enabling ISR (optional)

+

+ So far you've set up middleware to decide the value of each feature + flag to be precomputed and to pass the value down. In this step you can + enable ISR to cache generated pages after their initial render: +

+ + {` + // app/[code]/layout.tsx + import type { ReactNode } from 'react'; + + export async function generateStaticParams() { + // returning an empty array is enough to enable ISR + return []; + } + + export default async function Layout({ children }: { children: ReactNode }) { + return children; + } + `} + +

Opting into build-time rendering (optional)

+ +

+ The @vercel/flags/next submodule exposes a helper function + for generating pages for different combinations of flags at build time. + This function is called generatePermutations and takes a + list of flags and returns an array of strings representing each + combination of flags: +

+ {` + // app/[code]/page.tsx + import type { ReactNode } from 'react'; + import { generatePermutations } from '@vercel/flags/next'; + + export async function generateStaticParams() { + const codes = await generatePermutations(marketingFlags); + return codes.map((code) => ({ code })); + } + + export default function Page() { /* ... */} + `} +

+ You can further customize which specific combinations you want render by + passing a filter function as the second argument of{' '} + generatePermutations. +

+

Example

+

+ See the Marketing Pages{' '} + example which implements this pattern. +

+
+ ); +} diff --git a/examples/docs/app/examples/dashboard-pages/flags.ts b/examples/docs/app/examples/dashboard-pages/flags.ts new file mode 100644 index 0000000..c7e9bda --- /dev/null +++ b/examples/docs/app/examples/dashboard-pages/flags.ts @@ -0,0 +1,26 @@ +import type { ReadonlyRequestCookies } from '@vercel/flags'; +import { flag, dedupe } from '@vercel/flags/next'; + +interface Entities { + user?: { id: string }; +} + +const identify = dedupe( + ({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => { + const userId = cookies.get('dashboard-user-id')?.value; + return { user: userId ? { id: userId } : undefined }; + }, +); + +export const dashboardFlag = flag({ + key: 'dashboard-flag', + identify, + description: 'Flag used on the Dashboard Pages example', + decide({ entities }) { + if (!entities?.user) return false; + // Allowed users could be loaded from Edge Config or elsewhere + const allowedUsers = ['user1']; + + return allowedUsers.includes(entities.user.id); + }, +}); diff --git a/examples/docs/app/examples/dashboard-pages/page.tsx b/examples/docs/app/examples/dashboard-pages/page.tsx new file mode 100644 index 0000000..4958a11 --- /dev/null +++ b/examples/docs/app/examples/dashboard-pages/page.tsx @@ -0,0 +1,156 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import { dashboardFlag } from './flags'; +import { DemoFlag } from '@/components/demo-flag'; +import { Button } from '@/components/ui/button'; +import { cookies } from 'next/headers'; +import Link from 'next/link'; +import { SelfDocumentingExampleAlert } from '@/components/self-documenting-example-alert'; + +export default async function Page() { + const dashboard = await dashboardFlag(); + + return ( + +

Dashboard Pages

+

+ This example shows how to use feature flags for dashboard pages. + Dashboard pages are typically rendered at request time, and dashboard + pages typically require an authentiacted user. +

+

Example

+

+ The example below shows how to use feature flags to show a feature to a + specific users on a dashboard page. They are flagged in based on their + user id. The buttons below allow you to either act as a flagged in user + or as a regular user. +

+
+ + + +
+ + + + Inspect the source code + {' '} + to see the actual usage of the feature flag. + +

Definition

+

The example above works by first defining a feature flag.

+ + {` +import type { ReadonlyRequestCookies } from '@vercel/flags'; +import { flag, dedupe } from '@vercel/flags/next'; + +interface Entities { + user?: { id: string }; +} + +const identify = dedupe( + ({ cookies }: { cookies: ReadonlyRequestCookies }): Entities => { + const userId = cookies.get('dashboard-user-id')?.value; + return { user: userId ? { id: userId } : undefined }; + }, +); + +export const dashboardFlag = flag({ + key: 'dashboard-flag', + identify, + decide({ entities }) { + if (!entities?.user) return false; + // Allowed users could be loaded from Edge Config or elsewhere + const allowedUsers = ['user1']; + + return allowedUsers.includes(entities.user.id); + }, +}); + `} + +

+ The definition includes an identify function. The + identify function is used to establish the evaluation + context. +

+

+ The example reads the user id directly from the cookie. In a real + dashboard you would likely read a signed JWT instead. +

+

Usage

+

Any server-side code can evaluate the feature flag by calling it.

+ + {` + export default async function DashboardPage() { + const dashboard = await dashboardFlag(); + // do something with the flag + return
Dashboard
; + } + `} +
+

+ Since dashboard pages are typically dynamic anyhow the async call to + evaluate the feature flag should fit right in. +

+

Identifying

+

+ The example flag calls identify to establish the evaluation + context. This function returns the entities that are used to evaluate + the feature flag. +

+

+ The decide function then later gets access to the{' '} + entities returned from the identify function. +

+

+ Learn more about{' '} + identify. +

+

Evaluation Context

+

+ Feature Flags used on dashboard will usually run in the Serverless + Function Region, close to the database. This means it is accetable for a + feature flag's decide function to read the database + when establishing the evaluation context. However, ideally, it would + only read from the JWT as this will lead to lower overall latency. +

+

Deduplication

+

+ The identify call uses dedupe to avoid + duplicate work when multiple feature flags depend on the same evaluation + context. +

+

+ Learn more about{' '} + dedupe. +

+
+ ); +} diff --git a/examples/docs/app/examples/edge-config/page.tsx b/examples/docs/app/examples/edge-config/page.tsx new file mode 100644 index 0000000..d9397d6 --- /dev/null +++ b/examples/docs/app/examples/edge-config/page.tsx @@ -0,0 +1,13 @@ +import { Content } from '@/components/content'; + +export default function EdgeConfigPage() { + return ( + +

Edge Config

+

+ Shows feature flags backed by Edge Config, without any third-party + provider. +

+
+ ); +} diff --git a/examples/docs/app/examples/feature-flags-in-edge-middleware/flags.ts b/examples/docs/app/examples/feature-flags-in-edge-middleware/flags.ts new file mode 100644 index 0000000..d4605ff --- /dev/null +++ b/examples/docs/app/examples/feature-flags-in-edge-middleware/flags.ts @@ -0,0 +1,8 @@ +import { flag } from '@vercel/flags/next'; + +export const basicEdgeMiddlewareFlag = flag({ + key: 'basic-edge-middleware-flag', + decide({ cookies }) { + return cookies.get('basic-edge-middleware-flag')?.value === '1'; + }, +}); diff --git a/examples/docs/app/examples/feature-flags-in-edge-middleware/handlers.ts b/examples/docs/app/examples/feature-flags-in-edge-middleware/handlers.ts new file mode 100644 index 0000000..3d649a2 --- /dev/null +++ b/examples/docs/app/examples/feature-flags-in-edge-middleware/handlers.ts @@ -0,0 +1,17 @@ +'use client'; + +export function actAsFlaggedInUser() { + document.cookie = 'basic-edge-middleware-flag=1; Path=/'; + window.location.reload(); +} + +export function actAsFlaggedOutUser() { + document.cookie = 'basic-edge-middleware-flag=0; Path=/'; + window.location.reload(); +} + +export function clear() { + document.cookie = + 'basic-edge-middleware-flag=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT'; + window.location.reload(); +} diff --git a/examples/docs/app/examples/feature-flags-in-edge-middleware/middleware.ts b/examples/docs/app/examples/feature-flags-in-edge-middleware/middleware.ts new file mode 100644 index 0000000..f2fd4c5 --- /dev/null +++ b/examples/docs/app/examples/feature-flags-in-edge-middleware/middleware.ts @@ -0,0 +1,14 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { basicEdgeMiddlewareFlag } from './flags'; + +export async function featureFlagsInEdgeMiddleware(request: NextRequest) { + const active = await basicEdgeMiddlewareFlag(); + const variant = active ? 'variant-on' : 'variant-off'; + + return NextResponse.rewrite( + new URL( + `/examples/feature-flags-in-edge-middleware/${variant}`, + request.url, + ), + ); +} diff --git a/examples/docs/app/examples/feature-flags-in-edge-middleware/shared.tsx b/examples/docs/app/examples/feature-flags-in-edge-middleware/shared.tsx new file mode 100644 index 0000000..ce5b905 --- /dev/null +++ b/examples/docs/app/examples/feature-flags-in-edge-middleware/shared.tsx @@ -0,0 +1,81 @@ +import { Content } from '@/components/content'; +import { DemoFlag } from '@/components/demo-flag'; +import { basicEdgeMiddlewareFlag } from './flags'; +import { Button } from '@/components/ui/button'; +import { actAsFlaggedInUser, actAsFlaggedOutUser, clear } from './handlers'; +import Link from 'next/link'; +import { CodeBlock } from '@/components/code-block'; +import { SelfDocumentingExampleAlert } from '@/components/self-documenting-example-alert'; + +// This component does not actually use the feature flag, but the +// variant-on and variant-off pages know about the value statically. +export function Shared({ variant }: { variant: 'on' | 'off' }) { + return ( + +

Feature Flags in Edge Middleware

+

+ Shows how to use feature flags in Edge Middleware to serve different + static variants of a page. +

+

Example

+

+ This example works by using a feature flag in Edge Middleware to then + rewrite the request to a different page. Rewriting the request means the + user-facing URL shown in the browser stays the same, while different + content is served for different visitors. As the underlying{' '} + variant-on and variant-off pages are static, + the Edge Network can serve these at the edge. +

+ + {` + import { type NextRequest, NextResponse } from 'next/server'; + import { basicEdgeMiddlewareFlag } from './flags'; + + + export const config = { + matcher: ['/examples/feature-flags-in-edge-middleware'], + }; + + export async function middleware(request: NextRequest) { + const active = await basicEdgeMiddlewareFlag(); + const variant = active ? 'variant-on' : 'variant-off'; + + return NextResponse.rewrite( + new URL( + \`/examples/feature-flags-in-edge-middleware/\${variant}\`, + request.url, + ), + ); + } + `} +
+ + + +
+ + + + Inspect the source code + {' '} + to see the actual usage of the feature flag. + + +

Advanced examples

+

+ Using feature flags in Edge Middleware as shown in this example is very + basic. This approach does not scale well when you have are using + multiple feature flags on the same page or when you are using the same + feature flag on multiple pages. We recommend using{' '} + precompute for more advanced + use cases, which solves these challenges. +

+
+ ); +} diff --git a/examples/docs/app/examples/feature-flags-in-edge-middleware/variant-off/page.tsx b/examples/docs/app/examples/feature-flags-in-edge-middleware/variant-off/page.tsx new file mode 100644 index 0000000..4bbe92f --- /dev/null +++ b/examples/docs/app/examples/feature-flags-in-edge-middleware/variant-off/page.tsx @@ -0,0 +1,6 @@ +import { Shared } from '../shared'; + +export default async function Page() { + // we statically know that the flag is false as this is the variant-off page + return ; +} diff --git a/examples/docs/app/examples/feature-flags-in-edge-middleware/variant-on/page.tsx b/examples/docs/app/examples/feature-flags-in-edge-middleware/variant-on/page.tsx new file mode 100644 index 0000000..e78102c --- /dev/null +++ b/examples/docs/app/examples/feature-flags-in-edge-middleware/variant-on/page.tsx @@ -0,0 +1,6 @@ +import { Shared } from '../shared'; + +export default async function Page() { + // we statically know that the flag is true as this is the variant-on page + return ; +} diff --git a/examples/docs/app/examples/marketing-pages/[code]/page.tsx b/examples/docs/app/examples/marketing-pages/[code]/page.tsx new file mode 100644 index 0000000..ea59bca --- /dev/null +++ b/examples/docs/app/examples/marketing-pages/[code]/page.tsx @@ -0,0 +1,67 @@ +import { Content } from '@/components/content'; +import { + marketingAbTest, + marketingFlags, + secondMarketingAbTest, +} from '../flags'; +import { DemoFlag } from '@/components/demo-flag'; +import { RegenerateIdButton } from '../regenerate-id-button'; +import { generatePermutations } from '@vercel/flags/next'; +import { SelfDocumentingExampleAlert } from '@/components/self-documenting-example-alert'; +import Link from 'next/link'; + +// Ensure the page is static +export const dynamic = 'error'; + +// Generate all permutations (all combinations of flag 1 and flag 2). +export async function generateStaticParams() { + const permutations = await generatePermutations(marketingFlags); + return permutations.map((code) => ({ code })); +} + +export default async function Page({ + params, +}: { + params: Promise<{ code: string }>; +}) { + const awaitedParams = await params; + const abTest = await marketingAbTest(awaitedParams.code, marketingFlags); + const secondAbTest = await secondMarketingAbTest( + awaitedParams.code, + marketingFlags, + ); + + return ( + +

Marketing Pages

+

+ This example shows how to use feature flags for marketing pages. + Dashboard pages are typically static, and served from the CDN at the + edge. +

+

+ When A/B testing on marketing pages it's important to avoid layout + shift and jank, and to keep the pages static. This example shows how to + keep a page static and serveable from the CDN even when running multiple + A/B tests on the page. +

+

Example

+

+ The example below shows the usage of two feature flags on a static page. + These flags represent two A/B tests which you could be running + simulatenously. +

+
+ +
+ + + + + Inspect the source code + {' '} + to see the actual usage of the feature flag. + +
+ ); +} diff --git a/examples/docs/app/examples/marketing-pages/flags.tsx b/examples/docs/app/examples/marketing-pages/flags.tsx new file mode 100644 index 0000000..f593767 --- /dev/null +++ b/examples/docs/app/examples/marketing-pages/flags.tsx @@ -0,0 +1,42 @@ +import type { ReadonlyRequestCookies } from '@vercel/flags'; +import { dedupe, flag } from '@vercel/flags/next'; +import { getOrGenerateVisitorId } from './get-or-generate-visitor-id'; + +interface Entities { + visitor?: { id: string }; +} + +const identify = dedupe( + async ({ + cookies, + }: { + cookies: ReadonlyRequestCookies; + }): Promise => { + const visitorId = await getOrGenerateVisitorId(cookies); + return { visitor: visitorId ? { id: visitorId } : undefined }; + }, +); + +export const marketingAbTest = flag({ + key: 'marketing-ab-test-flag', + identify, + description: 'A/B test flag used on the Marketing Pages example', + decide({ entities }) { + if (!entities?.visitor) return false; + // TODO use hashing algorithm? + return /^[a-n0-5]/i.test(entities.visitor.id); + }, +}); + +export const secondMarketingAbTest = flag({ + key: 'second-marketing-ab-test-flag', + identify, + description: 'A/B test flag used on the Marketing Pages example', + decide({ entities }) { + if (!entities?.visitor) return false; + // TODO use hashing algorithm? + return /[a-n0-5]$/i.test(entities.visitor.id); + }, +}); + +export const marketingFlags = [marketingAbTest, secondMarketingAbTest]; diff --git a/examples/docs/app/examples/marketing-pages/get-or-generate-visitor-id.tsx b/examples/docs/app/examples/marketing-pages/get-or-generate-visitor-id.tsx new file mode 100644 index 0000000..891db61 --- /dev/null +++ b/examples/docs/app/examples/marketing-pages/get-or-generate-visitor-id.tsx @@ -0,0 +1,18 @@ +import { nanoid } from 'nanoid'; +import { dedupe } from '@vercel/flags/next'; +import type { ReadonlyRequestCookies } from '@vercel/flags'; +import type { NextRequest } from 'next/server'; + +const generateId = dedupe(async () => nanoid()); + +// This function is not deduplicated, as it is called with +// two different cookies objects, so it can not be deduplicated. +// +// However, the generateId function will always generate the same id of the +// same request, so it is safe to call it multiple times within the same runtime. +export const getOrGenerateVisitorId = async ( + cookies: ReadonlyRequestCookies | NextRequest['cookies'], +) => { + const visitorId = cookies.get('marketing-visitor-id')?.value; + return visitorId ?? generateId(); +}; diff --git a/examples/docs/app/examples/marketing-pages/middleware.tsx b/examples/docs/app/examples/marketing-pages/middleware.tsx new file mode 100644 index 0000000..0b3b0a7 --- /dev/null +++ b/examples/docs/app/examples/marketing-pages/middleware.tsx @@ -0,0 +1,18 @@ +import { precompute } from '@vercel/flags/next'; +import { type NextRequest, NextResponse } from 'next/server'; +import { marketingFlags } from './flags'; +import { getOrGenerateVisitorId } from './get-or-generate-visitor-id'; + +export async function marketingMiddleware(request: NextRequest) { + // assign a cookie to the visitor + const visitorId = await getOrGenerateVisitorId(request.cookies); + + // precompute the flags + const code = await precompute(marketingFlags); + + // rewrite the page with the code and set the cookie + return NextResponse.rewrite( + new URL(`/examples/marketing-pages/${code}`, request.url), + { headers: { 'Set-Cookie': `marketing-visitor-id=${visitorId}; Path=/` } }, + ); +} diff --git a/examples/docs/app/examples/marketing-pages/regenerate-id-button.tsx b/examples/docs/app/examples/marketing-pages/regenerate-id-button.tsx new file mode 100644 index 0000000..a44f1d0 --- /dev/null +++ b/examples/docs/app/examples/marketing-pages/regenerate-id-button.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Button } from '@/components/ui/button'; + +export function RegenerateIdButton() { + return ( + + ); +} diff --git a/examples/docs/app/examples/page.tsx b/examples/docs/app/examples/page.tsx new file mode 100644 index 0000000..6209813 --- /dev/null +++ b/examples/docs/app/examples/page.tsx @@ -0,0 +1,132 @@ +import { Content } from '@/components/content'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import Link from 'next/link'; + +export default function ExamplesPage() { + return ( + +

Examples

+

This section shows common use cases for feature flags.

+
+ + + + Dashboard Pages + + Using feature flags on dynamic pages. + + + + + + Marketing Pages + + Using feature flags on static pages. + + +

Concept examples

+

+ These concept pages are self-documenting examples. Each of these pages + is built using the concept it describes, so you can inspect the source + code to see how they work. +

+ + + + Identify + + Using evaluation contexts. + + + + + + Dedupe + + Avoiding redundant work. + + + + + + Precompute + + Using feature flags on static pages. + + + + + + Adapters + + + Integrate any flag provider, or even your custom setup. + + + +

More examples

+ + + + Using feature flags in Edge Middleware + + + Shows how to use feature flags with a static page at the edge. + + + + {/* + + + Consent Management + Get GDPR compliant + + To be GDRP compliant... + + + + + + Dashboard vs Auth Buttons + + Landing pages without layout shift + + + + Shows how to create a landing page which either contains a{' '} + Dashboard button or Sign Up and Sign In{' '} + buttons. + + + + + + + Edge Config + Ultra-low latency + + + Shows feature flags backed by Edge Config, without any third-party + provider. + + + + + + + Consent Management + Get GDPR compliant + + To be GDRP compliant... + + */} +
+
+ ); +} diff --git a/examples/docs/app/favicon.ico b/examples/docs/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/examples/docs/app/favicon.ico differ diff --git a/examples/docs/app/fonts/GeistMonoVF.woff b/examples/docs/app/fonts/GeistMonoVF.woff new file mode 100644 index 0000000..f2ae185 Binary files /dev/null and b/examples/docs/app/fonts/GeistMonoVF.woff differ diff --git a/examples/docs/app/fonts/GeistVF.woff b/examples/docs/app/fonts/GeistVF.woff new file mode 100644 index 0000000..1b62daa Binary files /dev/null and b/examples/docs/app/fonts/GeistVF.woff differ diff --git a/examples/docs/app/getting-started/quickstart/page.tsx b/examples/docs/app/getting-started/quickstart/page.tsx new file mode 100644 index 0000000..dc1d7c6 --- /dev/null +++ b/examples/docs/app/getting-started/quickstart/page.tsx @@ -0,0 +1,73 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import Link from 'next/link'; + +export default function Page() { + return ( + +

Quickstart

+

Installation

+

Install using your package manager.

+ {`npm install @vercel/flags`} +

+ Then create an environment variable called FLAGS_SECRET. +

+

+ The FLAGS_SECRET value must have a specific length (32 + random bytes encoded in base64) to work as an encryption key. Create one + using node: +

+ + {`node -e "console.log(crypto.randomBytes(32).toString('base64url'))"`} + +

+ This secret is required to use the SDK. It is used to read overrides and + to encrypt flag values in case they are sent to the client and should + stay secret. +

+

+ This secret will also be used in case you are using the Flags Explorer + in the Vercel Toolbar. +

+

Declaring a feature flag

+

+ Create a file called flags.ts in your project. +
+ Then declare your first feature flag there. +

+ + {` + import { flag } from '@vercel/flags/next'; + + export const exampleFlag = flag({ + key: "example-flag", + decide() { + return false; + } + });`} + +

+ The feature flags declared here should only ever be used server-side. +

+

Using your first feature flag

+

Using a feature flag in a React Server Component.

+ + {` + import { exampleFlag } from '../../flags'; + + export default async function Page() { + const example = await exampleFlag(); + return
{example ? 'Example' : 'No Example'}
; + }`} +
+

Feature Flags can also be used in Edge Middleware or API Routes.

+

This was just the beginning

+

+ There is way more to the Flags SDK than shown in this quickstart. Make + sure to explore the Concepts to learn how + to target inidivual users, how to use feature flags for static pages, + how to integrate feature flag providers using adapters and much more. +

+
+ ); +} diff --git a/examples/docs/app/globals.css b/examples/docs/app/globals.css new file mode 100644 index 0000000..a8144b6 --- /dev/null +++ b/examples/docs/app/globals.css @@ -0,0 +1,88 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/examples/docs/app/layout.tsx b/examples/docs/app/layout.tsx new file mode 100644 index 0000000..2f86776 --- /dev/null +++ b/examples/docs/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; +import localFont from 'next/font/local'; +import './globals.css'; +import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/app-sidebar'; +import { VercelToolbar } from '@vercel/toolbar/next'; + +const geistSans = localFont({ + src: './fonts/GeistVF.woff', + variable: '--font-geist-sans', + weight: '100 900', +}); +const geistMono = localFont({ + src: './fonts/GeistMonoVF.woff', + variable: '--font-geist-mono', + weight: '100 900', +}); + +export const metadata: Metadata = { + title: 'Flags SDK by Vercel', + description: 'The feature flags SDK by Vercel for Next.js and SvelteKit', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const shouldInjectToolbar = process.env.NODE_ENV === 'development'; + return ( + + + + + {children} + + {shouldInjectToolbar && } + + + ); +} diff --git a/examples/docs/app/nav-items.ts b/examples/docs/app/nav-items.ts new file mode 100644 index 0000000..5514fd5 --- /dev/null +++ b/examples/docs/app/nav-items.ts @@ -0,0 +1,214 @@ +interface Item { + title: string; + slug: string; + url: string; + nav?: 'hidden'; + items?: Item[]; +} + +export const navItems: Item[] = [ + { + title: 'Getting Started', + slug: 'getting-started', + url: '/', + items: [ + { + title: 'Overview', + slug: 'overview', + url: '/', + }, + { + title: 'Quickstart', + slug: 'quickstart', + url: '/getting-started/quickstart', + }, + ], + }, + { + title: 'Philosophy', + slug: 'philosophy', + url: '/philosophy', + items: [ + { + title: 'Why Feature Flags?', + slug: 'why-feature-flags', + url: '/philosophy/why-feature-flags', + }, + { + title: 'Why Experimentation?', + slug: 'why-experimentation', + url: '/philosophy/why-experimentation', + }, + { + title: 'Flags as Code', + slug: 'flags-as-code', + url: '/philosophy/flags-as-code', + }, + { + title: 'Server-side vs Client-side', + slug: 'server-side-vs-client-side', + url: '/philosophy/server-side-vs-client-side', + }, + { + title: 'Data locality', + slug: 'data-locality', + url: '/philosophy/data-locality', + }, + ], + }, + { + title: 'Concepts', + url: '/concepts', + slug: 'concepts', + items: [ + { + title: 'Identify', + slug: 'identify', + url: '/concepts/identify', + }, + { + title: 'Dedupe', + slug: 'dedupe', + url: '/concepts/dedupe', + }, + { + title: 'Precompute', + slug: 'precompute', + url: '/concepts/precompute', + }, + { + title: 'Adapters', + slug: 'adapters', + url: '/concepts/adapters', + }, + ], + }, + { + title: 'Examples', + url: '/examples', + slug: 'examples', + items: [ + { + title: 'Dashboard Pages', + url: '/examples/dashboard-pages', + slug: 'dashboard-pages', + }, + { + title: 'Marketing Pages', + url: '/examples/marketing-pages', + slug: 'marketing-pages', + }, + { + title: 'Marketing Pages', + url: '/examples/feature-flags-in-edge-middleware', + slug: 'feature-flags-in-edge-middleware', + nav: 'hidden', + }, + ], + }, + // { + // title: 'Usage Patterns', + // url: '/usage-patterns', + // slug: 'usage-patterns', + // items: [ + // { + // title: 'Server-side', + // url: '/usage-patterns/server-side', + // slug: 'server-side', + // items: [ + // { + // title: 'React Server Components', + // url: '/usage-patterns/server-side/react-server-components', + // slug: 'react-server-components', + // }, + // ], + // // Server-side usage + // // - React Server Components + // // - getServerSideProps + // // - API Routes (App Router; Pages Router) + + // // Middleware usage + // // - Middleware (separate pages) # section on build time + // // - Middleware (precomputed; ISR, dynamic route segment) # section on build time + // // - next.config.js rewrites + + // // Client-side usage + // // - Context # any pattern + // // - Datafile Injection # not flags SDK + // }, + // { + // title: 'Middleware', + // slug: 'middleware', + // url: '/usage-patterns/middleware', + // }, + // { + // title: 'Client-side', + // slug: 'client-side', + // url: '/usage-patterns/client-side', + // }, + // // { + // // title: 'React Server Components', + // // url: '/usage-patterns/react-server-components', + // // }, + // // { + // // title: 'ISR', + // // url: '/app-router/isr', + // // }, + // ], + // }, + // { + // title: 'Middleware', + // url: '#', + // items: [ + // { + // title: 'Usage in Middleware', + // url: '#', + // }, + // { + // title: 'Precomputing', + // url: '#', + // }, + // ], + // }, + // { + // title: 'Pages Router', + // url: '#', + // items: [ + // { + // title: 'getServerSideProps', + // url: '#', + // }, + // { + // title: 'getStaticProps', + // url: '#', + // }, + // ], + // }, + // { + // title: 'Client-Side Usage', + // url: '#', + // items: [ + // { + // title: 'Middleware', + // url: '#', + // }, + // { + // title: 'API Endpoints', + // url: '#', + // }, + // ], + // }, + { + title: 'API Reference', + slug: 'api-reference', + url: '/api-reference', + items: [], + }, + + { + title: 'Vercel', + slug: 'vercel', + url: '/vercel', + items: [], + }, +]; diff --git a/examples/docs/app/page.tsx b/examples/docs/app/page.tsx new file mode 100644 index 0000000..4518357 --- /dev/null +++ b/examples/docs/app/page.tsx @@ -0,0 +1,39 @@ +import { Content } from '@/components/content'; + +export default function Page() { + return ( + +

Overview

+

The feature flags SDK by Vercel for Next.js.

+

+ This package provides a simple way to use feature flags in your Next.js + applications. It can be used no matter if your application is hosted on + Vercel or not. It works with App Router, Pages Router and Edge + Middleware. It also works with any feature flag provider. +

+

+ This package encodes the best practices when using feature flags in + Next.js. We also understand that you sometimes need to deviate from the + golden path, and have examples for those cases as well. +

+

+ While this package is called @vercel/flags it does not + require using Vercel. It is also not limited to feature flags, and can + be used for experimentation, A/B testing and any other dynamic flagging + of code. +

+

Rendering strategies

+

+ In Next.js, there are several ways to render a page. This SDK works with + all of them, no matter if you are using server-side rendering, static + site generation, partial prerendering or Edge Middleware. +

+

Feature Flag Providers

+

+ The Flags SDK works with any feature flag provider. We offer adapters + for commonly used providers, but it is also possible to write a custom + adapter in case you have an in-house solution for feature flags. +

+
+ ); +} diff --git a/examples/docs/app/philosophy/data-locality/page.tsx b/examples/docs/app/philosophy/data-locality/page.tsx new file mode 100644 index 0000000..0c30727 --- /dev/null +++ b/examples/docs/app/philosophy/data-locality/page.tsx @@ -0,0 +1,164 @@ +import { Content } from '@/components/content'; +import Link from 'next/link'; + +export default function Page() { + return ( + +

Data Locality

+

+ Where feature flags are evaluated, the data they need to do so, and the + consequences this has on latency. +

+

In order to evaluate feature flags two types of data are needed:

+
    +
  • + The definition contains the rules for evaluating the feature, + such as who the feature flag should be enabled for. This is typcially + loaded from a feature flag provided. +
  • +
  • + The evaluation context is data about the user or entity the + feature flags are evaluated for. +
  • +
+ +

A feature flag evaluation can be thought of like this

+
evaluate(definition, evaluation context) = value
+

+ Evaluating a feature flag requires the definition and the evaluation + context. +

+

+ A simple feature flag which is on or off for all users does not need an + evaluation context. An advanced feature flag which is only on for some + users needs an evaluation context. +

+

Where feature flags can be evaluated

+
    +
  • + Server-side runs in the{' '} + + Serverless Function Region + {' '} + configured for the project and is used for React Server Components, + API Routes, and Server Actions. +
  • +
  • + Edge Middleware runs globally, in our Edge Network. +
  • +
  • + Client-side runs in your browser. +
  • +
+

+ There are different considerations for how the defintions and the + evaluation context are loaded or established. For example, it has + disasterous consequences if an application needs to make a network + request from Edge Middleware in order to establish the current user for + the evalaution context. +

+

How feature flag definitions are loaded

+

+ Feature flag SDKs initially need to bootstrap the feature flag + definitions. Typically this happens using a network request. They then + typically establish a websocket connection to get notified about any + changes to the feature flag configuration in the flag provider. +

+

+ This model works well with long-running servers, but is not a great fit + for the serverless world. Serverless functions have a much shorter + lifetime than long-running servers, especially at the edge. This means + applications need to pay the latency cost of bootstrapping feature flags + more frequently. Having multiple websocket connections to the same flag + provider also increases load on the provider. +

+

Vercel Edge Config

+

+ Vercel offers a solution called Edge Config to this problem. It is + specifically designed for storing feature flag definitions. Edge Config + can be read in under 1ms at p90 and under 15ms at p95, including the + network latency from your Serverless Function or Edge Middleware. +

+

+ To put this into perspective,{' '} + + according to this benchmark + + , an AWS S3 bucket does not even send the first byte by the time an Edge + Config is fully read. +

+

+ Using Edge Config is optional, but highly recommended, when using the + Flags SDK. +

+

Evaluating on the server

+

+ Feature flags can be evaluated on the server using the Flags SDK. This + is the most common way to evaluate feature flags, and is the easiest to + implement. +

+

+ The serverless function region is typically close to your + application's database, so it is somewhat okay to make a network + request to establish the evaluation context. +

+

Evaluating at the edge

+

+ In order to evaluate feature flags at the edge it's necessary to + have the definition and the evaluation context available at the edge. +

+

+ Using Edge Config allows storing definitions at the Edge as shown in the + previous section. This means feature flags can be used in Edge + Middleware at ultra low latency. +

+

+ However, some feature flags migth need an evaluation context in order to + evaluate. Since the evaluation context depends on the application it is + up to the application to provide it at low latency. +

+

+ Making a network request or reading a database to get the evaluation + context inside of Edge Middleware should be avoided at all cost. +

+

+ Instead, it is wise to store the information necessary to evaluate + feature flags in a cookie when users sign into an application. The + browser will then forward the necessary information when making + requests, such that Edge Middlware can establish the evaluation context + based on the provided cookie. Where necessary, the cookie stored on the + client can be sigend or even encrypted to avoid manipulation or leaking + information. +

+

Deduplicating effort

+

+ No matter whether feature flags are evaluated in Serverless Functions or + in Edge Middleware it is wise to deduplicate the effort of establishing + the evaluation context. +

+

+ Learn more about{' '} + dedupe. +

+

Evaluating on the client

+

+ Feature flags can also be evaluated on the client. The Flags SDK does + not have a built-in pattern for doing so currently. +

+

+ It is however possible to evalaute feature flags on the server and pass + the evaluated value down to the client. +

+

+ There is also a pattern, independent of the Flags SDK, which is + recommended in case you must absolutely use client-side feature flags. +

+

+ Learn more about bootstrapping datafiles. +

+
+ ); +} diff --git a/examples/docs/app/philosophy/flags-as-code/page.tsx b/examples/docs/app/philosophy/flags-as-code/page.tsx new file mode 100644 index 0000000..6fee184 --- /dev/null +++ b/examples/docs/app/philosophy/flags-as-code/page.tsx @@ -0,0 +1,151 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import Link from 'next/link'; + +export default function Page() { + return ( + +

Flags as Code

+

+ The Vercel Flags SDK is conceptually different from the SDKs of most + feature flag providers. +

+

Consistently simple call sides

+

+ Using the SDK of a typical feature flag provider looks similar to the + code example below, where the SDK is called with the name of the feature + flag as a string. +

+ + {` + // a typical feature flag SDK, such as OpenFeature in this example + const exampleValue = await client.getBooleanValue( + 'exampleFlag', + false + );`} + +

+ Compare this to what it looks like when using a feature flag in the + Vercel Flags SDK. +

+ + {` + // the Vercel Flags SDK + const exampleValue = await exampleFlag();`} + +

+ Being able to use a feature flag of course requires first declaring it + like so: +

+ + {` + import { flag } from '@vercel/flags/next'; + + export const exampleFlag = flag({ + key: "example-flag", + defaultValue: false, + decide() { + return false; + } + });`} + +

Feature Flags are functions

+

+ Turning each feature flag into its own function means the implementation + can change without having to touch the call side. It also allows you to + use your well-known editor aids like “Find All References” to see if a + flag is still in use. +

+

Feature Flags declare their default value

+

+ Each feature flag's declaration can contain the default value. This + value is used in case the feature flag can not be evaluated. Containing + the default value on the declaration means it will be consistent across + all evaluations. +

+

+ Learn more about defaultValue. +

+

Feature Flags declare how their context is established

+

+ Traditional feature flag SDKs require passing in the context on the call + side, as shown below. +

+ + {` + // a typical feature flag SDK, such as OpenFeature in this example + + // add a value to the invocation context + const context = { + user: { id: '123' }, + }; + + const boolValue = await client.getBooleanValue( + 'boolFlag', + false, + context + );`} + +

+ The big downside of this approach is that every call side needs to + recreate the evaluation context. If the evaluation context is created + differently or not provided the feature flag may evaluate differently + across the codebase. +

+

+ The Vercel Flags SDK does not require the context to be passed in on + each invocation. Instead, the context is established on the declaration + of the feature flag. +

+ + {` + import { flag } from '@vercel/flags/next'; + + export const exampleFlag = flag({ + key: "example-flag", + identify() { + return { user: { id: '123' } }; + }, + decide({ entities }) { + return entities.user.id === '123'; + } + });`} + +

+ Learn more about{' '} + identify. +

+

Avoid vendor lock-in

+

+ A downside of using the SDK of a specific provider is that it makes it + hard to switch to a different feature flag provider down the road. Often + the provider's SDK becomes deeply integrated into the codebase over + time. +

+

+ The Vercel Flags SDK does not lock you into a specific provider. You can + easily switch to a different provider by changing the definition of the + feature flag. Switching providers is possible without changing where + your feature flag is used. +

+

+ The Vercel Flags SDK further specifically contains an adapter pattern + for this, which makes it even easier to swap providers. +

+ + {` + import { flag } from '@vercel/flags/next'; + import { statsig } from "@flags-sdk/statsig"; + + export const exampleFlag = flag({ + key: "example-flag", + // simply replace the adapter with a different one + adapter: statsig(), + });`} + +

+ Learn more about adapters. +

+
+ ); +} diff --git a/examples/docs/app/philosophy/page.tsx b/examples/docs/app/philosophy/page.tsx new file mode 100644 index 0000000..704fa1d --- /dev/null +++ b/examples/docs/app/philosophy/page.tsx @@ -0,0 +1,19 @@ +import { Content } from '@/components/content'; + +export default function Page() { + return ( + +

Philosophy

+

+ We believe the best way to use feature flags is to use them server-side. + Using feature flags server-side prevents common problems that come with + client-side usage, like layout shift or flashing the wrong content. +

+

+ This approach fits extremely well with the App Router in Next.js. With + React Server Components, there is always a way to load feature flags on + the server. +

+
+ ); +} diff --git a/examples/docs/app/philosophy/server-side-vs-client-side/page.tsx b/examples/docs/app/philosophy/server-side-vs-client-side/page.tsx new file mode 100644 index 0000000..b481ff1 --- /dev/null +++ b/examples/docs/app/philosophy/server-side-vs-client-side/page.tsx @@ -0,0 +1,70 @@ +import { Content } from '@/components/content'; +import Link from 'next/link'; + +export default function Page() { + return ( + +

Server-side vs Client-side

+

+ At Vercel we strongly believe feature flags should be used server-side. +

+

Avoid layout shift and jank

+

+ When a feature flag is used on the client side, it can cause layout + shifts and jank. The application needs to wait until the feature flags + are bootstrapped over the network. In the meantime it has to take one of + two bad choices. +

+
    +
  • Show a loading spinner
  • +
  • Speculatively show one version of the page
  • +
+

+ But if the feature flag turns out to have a different value the page + needs to be swapped out leading to jank. +

+

+ With server-side usage of feature flags this problem is avoided. The + server will only send the version of the page that matches the feature + flag. No layout shift or jank. +

+

Keeping pages static

+

+ A big benefit of client-side usage of feature flags is that the page + itself can stay fully static. Having static pages is great, as they can + be served by the CDN around the world which has incredibly low latency + globally. +

+

+ A common misconception is that server-side usage of feature flags means + that the page can no longer be static. This is not the case. The Vercel + Flags SDK comes with multiple patterns which allow keeping the page + static without falling back to client-side usage. +

+

+ These patterns are made possible by using Edge Middleare. One or + multiple feature flags can be evaluted in Edge Middleware, and the + request can then be rewritten to serve a statically generated version of + the page. This combines extremely well with Incremental Static + Regeneration (ISR). +

+

+ Learn more about keeping pages static. +

+

Confidentiality

+

+ Using feature flags on the client typically means the name of the + feature flag is sent to the client. Often times teams then fall back to + using cryptic alias for their feature flags in order to avoid leaking + features. +

+

Code size

+

+ When feature flags are used server-side only the necessary code is sent + to the client. In contrast, when using feature flags client-side it is + common that both versions of the page are sent to the client, which + leads to an increased bundle size. +

+
+ ); +} diff --git a/examples/docs/app/philosophy/why-experimentation/page.tsx b/examples/docs/app/philosophy/why-experimentation/page.tsx new file mode 100644 index 0000000..436a5d6 --- /dev/null +++ b/examples/docs/app/philosophy/why-experimentation/page.tsx @@ -0,0 +1,56 @@ +import { Content } from '@/components/content'; + +export default function Page() { + return ( + +

Why Experimentation?

+

+ Experimentation and A/B testing are vital practices in modern software + development, enabling data-driven decision-making and product + optimization. +

+

Key Aspects:

+
    +
  1. + Hypothesis Testing: Validate assumptions about user behavior + and business metrics +
  2. +
  3. + Controlled Comparisons: Evaluate multiple variants across user + groups +
  4. +
  5. + Statistical Significance: Ensure reliable conclusions through + adequate data data +
  6. +
  7. + Risk Mitigation: Identify issues before full deployment +
  8. +
  9. + User-Centric Design: Align development with actual user + preferences +
  10. +
  11. + Quantifiable Impact: Measure effects on key business metrics +
  12. +
+ +

Implementation Essentials:

+
    +
  • Robust infrastructure for concurrent experiments
  • +
  • Clear success metrics
  • +
  • Cross-functional collaboration
  • +
  • Ethical considerations (user privacy, equity)
  • +
+

+ Synergy with feature flags enables rapid deployment and rollback of test + variants, accelerating the pace of product improvement. +

+

+ In summary, experimentation and A/B testing empower teams to make + empirically-driven decisions, optimize user experiences, and drive + efficient innovation. +

+
+ ); +} diff --git a/examples/docs/app/philosophy/why-feature-flags/page.tsx b/examples/docs/app/philosophy/why-feature-flags/page.tsx new file mode 100644 index 0000000..89ec4c5 --- /dev/null +++ b/examples/docs/app/philosophy/why-feature-flags/page.tsx @@ -0,0 +1,69 @@ +import { Content } from '@/components/content'; + +export default function Page() { + return ( + +

Why Feature Flags?

+

+ Feature flags, also known as feature toggles, significantly enhance the + software development lifecycle. They offer developers granular control + over feature visibility and functionality, providing numerous + advantages. +

+

Key benefits include:

+
    +
  1. + Releasing without stress: Feature flags allow merging features + to production without showing them to users yet. Safely enable new + features for your team members only to ensure they work correctly, in + production, before releasing them to all your users. Stress free. +
  2. +
  3. + Trunk based development: Feature flags allow developers to + merge unfinished features to the main branch, while keeping them + hidden for users. This avoids merge conflicts once your feature is + ready to be released to the public, as it will already be in the main + branch. +
  4. +
  5. + Controlled Rollouts: Gradually release features to a percentage + of your visitors while monitoring health metrics, to identify and + address issues before full-scale deployment. +
  6. +
  7. + Quick Rollbacks: Being able to quickly disable a feature + without having to wait for a timely revert or redeployment massively + reduces risk. +
  8. +
  9. + User Segmentation: Features can be selectively enabled for + specific user groups or traffic percentages, allowing targeted + releases and personalized experiences. +
  10. +
  11. + Technical Debt Management: Flags can help manage and remove + outdated code paths, facilitating cleaner codebases over time. +
  12. +
  13. + Compliance and Regulations: They assist in meeting regulatory + requirements by enabling or disabling features based on geographical + or legal constraints. +
  14. +
+

+ In essence, feature flags empower development teams to deploy + frequently, and respond quickly to issues, ultimately leading to more + robust, user-centric software development practices. Their integration + throughout the development lifecycle promotes agility, reduces risk, and + enhances overall product quality. +

+

+ + Feature Toggles + {' '} + by Pete Hodgson is highly recommended reading around the advantages of + feature flags. +

+
+ ); +} diff --git a/examples/docs/app/vercel/page.tsx b/examples/docs/app/vercel/page.tsx new file mode 100644 index 0000000..f360c7c --- /dev/null +++ b/examples/docs/app/vercel/page.tsx @@ -0,0 +1,69 @@ +import { CodeBlock } from '@/components/code-block'; +import { Content } from '@/components/content'; +import Link from 'next/link'; + +export default function ExamplesPage() { + return ( + +

Vercel

+

Integrate your flags with Vercel.

+

Flags Explorer

+

+ The Flags Explorer lives in the Vercel Toolbar and allows overriding any + feature flags for your session only, without affecting your team + members.{' '} +

+

+ The Flags SDK will automatically respect overrides set by the Flags + Explorer. +

+

Logs

+

+ Requests will automatically be annotated with the evaluated feature + flags. The flag values can then be seen in the Log details view. +

+

Analytics

+

+ The Flags SDK can annotate requests with the evaluated feature flags. + The flag values can then be seen in the Analytics view. +

+

Edge Config

+

+ Edge Config is a globally replicated, ultra-low latency, highly + available system designed to store feature flags. Edge Config reads are + extremely fast, as Vercel co-locates the data stored in Edge Config with + the Vercel Functions. This allows your functions to read Edge Config + without going over the network in most cases. +

+

+ This extremely low latency is perfect for feature flags. Vercel has + integrations with many providers to sync their feature flags into Edge + Config, from which your application can then bootstrap them when + handling requests. +

+

OpenTelemetry

+

+ The Flags SDK can be used with OpenTelemetry to annotate requests with + the evaluated feature flags. +

+ {` + // instrumentation.ts + import { registerOTel } from '@vercel/otel' + import { setTracerProvider } from '@vercel/flags' + import { trace } from '@opentelemetry/api' + + export function register(): void { + if (process.env.NODE_ENV === 'development') return + registerOTel({ serviceName: 'example-app' }) + setTracerProvider(trace) + } + `} +

+ + Learn more + {' '} + about using the Vercel OpenTelemetry Collector. +

+
+ ); +} diff --git a/examples/docs/components.json b/examples/docs/components.json new file mode 100644 index 0000000..639cbe5 --- /dev/null +++ b/examples/docs/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/examples/docs/components/app-sidebar.tsx b/examples/docs/components/app-sidebar.tsx new file mode 100644 index 0000000..bab4d71 --- /dev/null +++ b/examples/docs/components/app-sidebar.tsx @@ -0,0 +1,93 @@ +'use client'; +import * as React from 'react'; +import { ToggleRight } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from '@/components/ui/sidebar'; +import { navItems } from '@/app/nav-items'; + +export function AppSidebar({ ...props }: React.ComponentProps) { + const pathname = usePathname(); + return ( + + + + + + +
+ +
+
+

Flags SDK

+
+ +
+
+
+
+ + + + {navItems.map((navItem) => { + const visibleSubItems = navItem.items?.filter( + (item) => item.nav !== 'hidden', + ); + return ( + + item.url !== pathname, + )) || + // highlight the parent when a hidden item is active + (navItem.url !== '/' && + pathname.startsWith(navItem.url) && + navItem.items?.some( + (item) => + item.nav === 'hidden' && item.url === pathname, + )) + } + > + + {navItem.title} + + + {Array.isArray(visibleSubItems) && + visibleSubItems.length > 0 ? ( + + {visibleSubItems.map((subItem) => ( + + + {subItem.title} + + + ))} + + ) : null} + + ); + })} + + + +
+ ); +} diff --git a/examples/docs/components/badges.tsx b/examples/docs/components/badges.tsx new file mode 100644 index 0000000..12c2baa --- /dev/null +++ b/examples/docs/components/badges.tsx @@ -0,0 +1,16 @@ +import { Badge } from './ui/badge'; + +export function Badges({ + appRouter, + pagesRouter, +}: { + appRouter?: boolean; + pagesRouter?: boolean; +}) { + return ( +
+ {appRouter ? App Router : null} + {pagesRouter ? Pages Router : null} +
+ ); +} diff --git a/examples/docs/components/code-block.tsx b/examples/docs/components/code-block.tsx new file mode 100644 index 0000000..88f4918 --- /dev/null +++ b/examples/docs/components/code-block.tsx @@ -0,0 +1,47 @@ +import { File } from 'lucide-react'; +import { + type BundledTheme, + type CodeToHastOptions, + codeToHtml, + type StringLiteralUnion, + type ThemeRegistrationAny, +} from 'shiki'; +import stripIndent from 'strip-indent'; + +export async function CodeBlock({ + children, + plain, + lang = 'tsx', + theme = 'nord', + fileName, +}: { + children: string; + plain?: boolean; + lang?: CodeToHastOptions['lang']; + theme?: ThemeRegistrationAny | StringLiteralUnion; + fileName?: string; +}) { + const __html = await codeToHtml( + plain + ? children + : stripIndent( + children.startsWith('\n') ? children.substring(1) : children, + ), + { + lang, + theme, + }, + ); + + return ( +
+
+ {fileName ? ( +
+ {' '} + {fileName} +
+ ) : null} +
+ ); +} diff --git a/examples/docs/components/content.tsx b/examples/docs/components/content.tsx new file mode 100644 index 0000000..c98bf26 --- /dev/null +++ b/examples/docs/components/content.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react'; +import { Header } from './header'; +import { navItems } from '@/app/nav-items'; + +export function Content({ + children, + crumbs, +}: { + children: React.ReactNode; + crumbs: string[]; +}) { + const resolvedCrumbs = useMemo(() => { + const breadcrumbs = []; + let current = navItems; + for (const crumb of crumbs) { + // @ts-expect-error crumb is a valid key. + current = current.find((item) => item.slug === crumb); + + breadcrumbs.push({ + // @ts-expect-error crumb is a valid key. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ok + name: current.title, + // @ts-expect-error crumb is a valid key. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ok + href: current.url, + }); + // @ts-expect-error crumb is a valid key. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ok + current = current.items; + } + return breadcrumbs; + }, [crumbs]); + + return ( + <> +
+
{children}
+ + ); +} diff --git a/examples/docs/components/demo-flag.tsx b/examples/docs/components/demo-flag.tsx new file mode 100644 index 0000000..9bab8b4 --- /dev/null +++ b/examples/docs/components/demo-flag.tsx @@ -0,0 +1,14 @@ +export function DemoFlag({ value, name }: { value: boolean; name: string }) { + return ( +
+

+ The feature flag {name} evaluated + to {JSON.stringify(value)}. +

+
+ ); +} diff --git a/examples/docs/components/header.tsx b/examples/docs/components/header.tsx new file mode 100644 index 0000000..a713826 --- /dev/null +++ b/examples/docs/components/header.tsx @@ -0,0 +1,50 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { Separator } from '@/components/ui/separator'; +import { SidebarTrigger } from '@/components/ui/sidebar'; +import { Fragment } from 'react'; + +export interface HeaderProps { + crumbs: { name: string; href?: string }[]; +} + +export function Header({ crumbs }: HeaderProps) { + return ( +
+ + + + + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + const content = isLast ? ( + {crumb.name} + ) : ( + crumb.name + ); + return ( + + {crumb.href ? ( + + {content} + + ) : ( + {content} + )} + {isLast ? null : ( + + )} + + ); + })} + + +
+ ); +} diff --git a/examples/docs/components/self-documenting-example-alert.tsx b/examples/docs/components/self-documenting-example-alert.tsx new file mode 100644 index 0000000..c9ffd07 --- /dev/null +++ b/examples/docs/components/self-documenting-example-alert.tsx @@ -0,0 +1,16 @@ +import { Code } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; + +export function SelfDocumentingExampleAlert({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + Self document example + {children} + + ); +} diff --git a/examples/docs/components/ui/alert.tsx b/examples/docs/components/ui/alert.tsx new file mode 100644 index 0000000..b135c52 --- /dev/null +++ b/examples/docs/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/examples/docs/components/ui/badge.tsx b/examples/docs/components/ui/badge.tsx new file mode 100644 index 0000000..b1371b6 --- /dev/null +++ b/examples/docs/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/examples/docs/components/ui/breadcrumb.tsx b/examples/docs/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..0ff237f --- /dev/null +++ b/examples/docs/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>