Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export default defineEventHandler((event) => {
})
```


Or guard entire pages via `definePageMeta`:

```html
<script lang="ts">
definePageMeta({
featureFlag: 'training-portal',
featureFallback: '/404',
featureNotifyOnBlock: true,
})
</script>
```

## Contribution

<details>
Expand Down
19 changes: 19 additions & 0 deletions playground/pages/new-protected.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<template>
<div class="min-h-screen p-12 text-white bg-neutral-950">
<h2 class="text-2xl font-bold mb-4">
🔐 Protected Feature Page
</h2>
<p>
This page is protected by feature flag:
<code class="text-emerald-400">newSystem</code>
</p>
</div>
</template>

<script setup lang="ts">
definePageMeta({
featureFlag: 'newSystem',
featureFallback: '/404',
featureNotifyOnBlock: true,
})
</script>
6 changes: 6 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createResolver,
addPlugin,
addImportsDir,
addRouteMiddleware,

} from '@nuxt/kit'
import type { Resolver } from '@nuxt/kit'
Expand Down Expand Up @@ -37,6 +38,11 @@ export default defineNuxtModule<FeatureFlagsConfig>({
addImportsDir(resolver.resolve('runtime/composables'))
addPlugin(resolver.resolve('runtime/plugin'))
addImportsDir(resolver.resolve('runtime/middleware'))
addRouteMiddleware({
name: 'feature-flag',
path: resolver.resolve('runtime/middleware/featureFlag'),
global: true,
})

// Run validation at `ready` (after Nuxt merges config)
nuxt.hook('ready', async (): Promise<void> => {
Expand Down
51 changes: 51 additions & 0 deletions src/runtime/middleware/featureFlag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useFeatureFlag } from '../composables/useFeatureFlag'
import { defineNuxtRouteMiddleware, navigateTo, showError } from '#app'

// Defines optional route metadata for feature-flag-based access control.
interface FeatureMeta {
// Name of the feature flag required to access the route.
featureFlag?: string

// Path to navigate to when the feature flag is disabled.
featureFallback?: string

// Whether to log a console warning when the route is blocked.
featureNotifyOnBlock?: boolean
}

/**
* Nuxt route middleware that restricts access to routes based on feature flags.
*
* @param to - The target route object.
* @returns
* - `undefined` if access is allowed.
* - A `navigateTo()` redirect if a fallback route is defined.
* - A `showError()` result with a 404 status if blocked without fallback.
*
* @example
* ```ts
* definePageMeta({
* featureFlag: 'beta-dashboard',
* featureFallback: '/dashboard',
* featureNotifyOnBlock: true
* })
* ```
*/
export default defineNuxtRouteMiddleware((to) => {
const meta: FeatureMeta = to.meta as FeatureMeta
const flag: string | undefined = meta.featureFlag
if (!flag) return

const { isEnabled } = useFeatureFlag()
if (isEnabled(flag)) return

if (meta.featureNotifyOnBlock) {
console.warn(`[nuxt-feature-flags] Feature "${flag}" is disabled`)
}

if (meta.featureFallback) {
return navigateTo(meta.featureFallback)
}

return showError({ statusCode: 404, statusMessage: 'Feature not available' })
})
64 changes: 64 additions & 0 deletions test/unit/featureFlagMiddleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// test/unit/featureFlagMiddleware.test.ts
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import middleware from '../../src/runtime/middleware/featureFlag'
import type { RouteLocationNormalized } from 'vue-router'

const { navigateTo, showError } = vi.hoisted(() => ({
navigateTo: vi.fn(),
showError: vi.fn(),
}))

vi.mock('#app', () => ({
defineNuxtRouteMiddleware: (fn: (to: RouteLocationNormalized, from: RouteLocationNormalized) => unknown) => fn,
navigateTo,
showError,
}))

type IsEnabledFn = (flagName: string) => boolean
const isEnabled = vi.fn<IsEnabledFn>()
vi.mock('../../src/runtime/composables/useFeatureFlag', () => ({
useFeatureFlag: () => ({ isEnabled }),
}))

/**
* Helper to create a minimal, strictly typed RouteLocationNormalized for tests.
*/
const makeRoute = (meta: Partial<RouteLocationNormalized['meta']>): RouteLocationNormalized =>
({ meta } as unknown as RouteLocationNormalized)

describe('featureFlag middleware', (): void => {
beforeEach((): void => {
isEnabled.mockReset()
navigateTo.mockReset()
showError.mockReset()
})

afterEach((): void => {
vi.restoreAllMocks()
})

it('redirects to fallback when flag disabled', (): void => {
isEnabled.mockReturnValue(false)
const to: RouteLocationNormalized = makeRoute({ featureFlag: 'test', featureFallback: '/404' } as Record<string, unknown>)
const from: RouteLocationNormalized = makeRoute({})
middleware(to, from)
expect(navigateTo).toHaveBeenCalledWith('/404')
})

it('shows error when flag disabled and no fallback', (): void => {
isEnabled.mockReturnValue(false)
const to: RouteLocationNormalized = makeRoute({ featureFlag: 'test' } as Record<string, unknown>)
const from: RouteLocationNormalized = makeRoute({})
middleware(to, from)
expect(showError).toHaveBeenCalledWith({ statusCode: 404, statusMessage: 'Feature not available' })
})

it('does nothing when flag enabled', (): void => {
isEnabled.mockReturnValue(true)
const to: RouteLocationNormalized = makeRoute({ featureFlag: 'test' } as Record<string, unknown>)
const from: RouteLocationNormalized = makeRoute({})
middleware(to, from)
expect(navigateTo).not.toHaveBeenCalled()
expect(showError).not.toHaveBeenCalled()
})
})
6 changes: 6 additions & 0 deletions types/feature-flags.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,10 @@ declare module 'nuxt/schema' {
interface PublicRuntimeConfig {
featureFlags: FeatureFlagsConfig
}

interface PageMeta {
featureFlag?: string
featureFallback?: string
featureNotifyOnBlock?: boolean
}
}