Skip to content

Commit 072c55b

Browse files
ubugeeeiCopilot
andauthored
feat: feature flags improvement (#970)
* wip * save * feat: feature flags improvement * save * save * Update modules/00.feature-flags.ts Co-authored-by: Copilot <[email protected]> * Update modules/00.feature-flags.ts Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]>
1 parent 402dcbe commit 072c55b

File tree

9 files changed

+347
-197
lines changed

9 files changed

+347
-197
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
- name: Check Building
3737
run: pnpm build
3838
env:
39+
NODE_OPTIONS: '--max-old-space-size=4096'
3940
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
4041
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
4142
CLOUDFLARE_R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,80 @@ pnpm install
2121
pnpm dev
2222
```
2323

24+
## Feature Flags Module
25+
26+
This project includes a custom Nuxt module for managing feature flags with full TypeScript support.
27+
28+
### Features
29+
30+
- **Type-safe**: Auto-generates TypeScript definitions for all feature flags
31+
- **Universal**: Works seamlessly in both client and server environments
32+
- **Build-time resolution**: All feature flags are resolved during build step, not at runtime
33+
- **Zero runtime overhead**: Flags are replaced at build time with constants
34+
- **Tree-shaking friendly**: Unused code paths are eliminated from the final bundle
35+
36+
### Configuration
37+
38+
Configure feature flags in `nuxt.config.ts`:
39+
40+
```ts
41+
export default defineNuxtConfig({
42+
featureFlags: {
43+
timetable: true,
44+
soldOutAfterParty: false,
45+
}
46+
})
47+
```
48+
49+
### Usage
50+
51+
#### Dynamic Component Import
52+
53+
Use feature flags with dynamic imports to conditionally load components:
54+
55+
```vue
56+
<script setup lang="ts">
57+
// Conditionally import component based on feature flag
58+
const BetaFeature = import.meta.vfFeatures.betaFeature
59+
? defineAsyncComponent(() => import('~/components/BetaFeature.vue'))
60+
: null;
61+
</script>
62+
63+
<template>
64+
<div>
65+
<BetaFeature v-if="BetaFeature" />
66+
</div>
67+
</template>
68+
```
69+
70+
#### Page-level Feature Flags
71+
72+
Control page generation with Nuxt's `ignore` option:
73+
74+
This approach completely excludes pages from the build, resulting in smaller bundle sizes and true 404s when features are disabled.
75+
76+
See: https://nuxt.com/docs/4.x/api/nuxt-config#ignore
77+
78+
#### Server API Routes
79+
80+
Control API endpoints with feature flags:
81+
82+
```ts
83+
// server/api/timetable.get.ts
84+
export default defineEventHandler(async (event) => {
85+
// Block API if feature is disabled
86+
if (!import.meta.vfFeatures.timetable) {
87+
throw createError({
88+
statusCode: 404,
89+
statusMessage: 'Endpoint not available'
90+
});
91+
}
92+
93+
// Return timetable data
94+
return await getTimetableData();
95+
});
96+
```
97+
2498
## Plans Overview
2599

26100
### Features

app/layouts/default.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const menuItems = computed<MenuItemProps[]>(() =>
3939
label: "Timetable",
4040
// TODO:
4141
routeName: localeRoute({ name: "index" }).name,
42-
disabled: !__FEATURE_TIMETABLE__,
42+
disabled: !import.meta.vfFeatures.timetable,
4343
},
4444
{
4545
id: HOME_HEADING_ID.speaker,

app/pages/ticket/index.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ const { signIn, status, data } = useAuth();
3030
const { t, locale } = useI18n();
3131
const localeRoute = useLocaleRoute();
3232
33-
const isSoldOutAfterParty = __FEATURE_SOLD_OUT_AFTER_PARTY__;
34-
const isSoldOutEarlyBirdAfterParty = __FEATURE_SOLD_OUT_EARLY_BIRD_AFTER_PARTY__;
33+
const isSoldOutAfterParty = import.meta.vfFeatures.soldOutAfterParty;
34+
const isSoldOutEarlyBirdAfterParty = import.meta.vfFeatures.soldOutEarlyBirdAfterParty;
3535
3636
const isLoading = ref(false);
3737

features.d.ts

Lines changed: 0 additions & 8 deletions
This file was deleted.

modules/00.feature-flags.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { defineNuxtModule, addTemplate } from "nuxt/kit";
2+
3+
export interface FeatureFlags {
4+
[key: string]: boolean;
5+
}
6+
7+
export type ModuleOptions = FeatureFlags;
8+
9+
export default defineNuxtModule<ModuleOptions>({
10+
meta: {
11+
name: "feature-flags",
12+
configKey: "featureFlags",
13+
},
14+
defaults: {},
15+
async setup(options, nuxt) {
16+
const featureFlags = options || {};
17+
const defines: Record<string, string> = {};
18+
for (const [key, value] of Object.entries(featureFlags)) {
19+
defines[`import.meta.vfFeatures.${key}`] = JSON.stringify(value);
20+
}
21+
22+
const typeContent = generateTypeDeclarations(featureFlags);
23+
24+
/**
25+
* For client (Nuxt Vite)
26+
---------------------------------------------------------------------------- */
27+
28+
nuxt.options.vite = nuxt.options.vite || {};
29+
nuxt.options.vite.define = {
30+
...nuxt.options.vite.define,
31+
...defines,
32+
};
33+
34+
addTemplate({
35+
filename: "types/feature-flags.d.ts",
36+
getContents: () => typeContent,
37+
write: true,
38+
});
39+
40+
nuxt.hook("prepare:types", async ({ references }) => {
41+
references.push({ path: "./types/feature-flags.d.ts" });
42+
});
43+
44+
/**
45+
* For server (Nitro)
46+
---------------------------------------------------------------------------- */
47+
48+
nuxt.options.nitro = nuxt.options.nitro || {};
49+
nuxt.options.nitro.esbuild = nuxt.options.nitro.esbuild || {};
50+
nuxt.options.nitro.esbuild.options = nuxt.options.nitro.esbuild.options || {};
51+
nuxt.options.nitro.esbuild.options.define = { ...nuxt.options.nitro.esbuild.options.define, ...defines };
52+
nuxt.options.nitro.replace = { ...nuxt.options.nitro.replace, ...defines };
53+
54+
addTemplate({
55+
filename: "types/nitro-feature-flags.d.ts",
56+
getContents: () => typeContent,
57+
write: true,
58+
});
59+
60+
nuxt.hook("nitro:config", (nitroConfig) => {
61+
nitroConfig.typescript = nitroConfig.typescript || {};
62+
nitroConfig.typescript.tsConfig = nitroConfig.typescript.tsConfig || {};
63+
nitroConfig.typescript.tsConfig.compilerOptions = nitroConfig.typescript.tsConfig.compilerOptions || {};
64+
nitroConfig.typescript.tsConfig.compilerOptions.types = nitroConfig.typescript.tsConfig.compilerOptions.types || [];
65+
if (Array.isArray(nitroConfig.typescript.tsConfig.compilerOptions.types)) {
66+
nitroConfig.typescript.tsConfig.compilerOptions.types.push(".nuxt/types/nitro-feature-flags");
67+
}
68+
});
69+
70+
nuxt.hook("prepare:types", async ({ references }) => {
71+
references.push({ path: "./types/nitro-feature-flags.d.ts" });
72+
});
73+
},
74+
});
75+
76+
function generateTypeDeclarations(featureFlags: FeatureFlags): string {
77+
const flagTypes = Object.entries(featureFlags)
78+
.map(([key, value]) => ` readonly ${key}: ${typeof value};`)
79+
.join("\n");
80+
81+
return `// Auto-generated feature flags type definitions
82+
// Do not edit this file directly
83+
84+
declare global {
85+
interface ImportMetaFeatureFlags {
86+
${flagTypes}
87+
}
88+
89+
interface ImportMeta {
90+
readonly vfFeatures: ImportMetaFeatureFlags;
91+
}
92+
}
93+
94+
export {};
95+
`;
96+
}

nuxt.config.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { NuxtPage } from "nuxt/schema";
66
// https://nuxt.com/docs/api/configuration/nuxt-config
77
export default defineNuxtConfig({
88
modules: [
9+
"./modules/00.feature-flags.ts",
910
"@nuxt/eslint",
1011
"@nuxt/scripts",
1112
"@nuxtjs/i18n",
@@ -57,9 +58,6 @@ export default defineNuxtConfig({
5758
peatixApiSecret: process.env.PEATIX_API_SECRET,
5859
peatixEventId: process.env.PEATIX_EVENT_ID,
5960

60-
// for server
61-
__FEATURE_TIMETABLE__: !["0", undefined].includes(process.env.FEATURE_TIMETABLE), // ?
62-
6361
siteUrl: process.env.NODE_ENV === "production"
6462
? process.env.CONTEXT === "production"
6563
? "https://vuefes.jp/2025/"
@@ -106,11 +104,6 @@ export default defineNuxtConfig({
106104
},
107105

108106
vite: {
109-
define: {
110-
__FEATURE_TIMETABLE__: process.env.FEATURE_TIMETABLE || false, // ?
111-
__FEATURE_SOLD_OUT_AFTER_PARTY__: !["0", undefined].includes(process.env.FEATURE_SOLD_OUT_AFTER_PARTY),
112-
__FEATURE_SOLD_OUT_EARLY_BIRD_AFTER_PARTY__: !["0", undefined].includes(process.env.FEATURE_SOLD_OUT_EARLY_BIRD_AFTER_PARTY),
113-
},
114107
css: {
115108
transformer: "lightningcss",
116109
lightningcss: {
@@ -132,6 +125,13 @@ export default defineNuxtConfig({
132125
],
133126
},
134127

128+
// Use `process.env.CONTEXT !== "production"` for dev only features
129+
featureFlags: {
130+
timetable: false,
131+
soldOutAfterParty: true,
132+
soldOutEarlyBirdAfterParty: true,
133+
},
134+
135135
eslint: {
136136
config: {
137137
stylistic: {

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
]
3232
},
3333
"scripts": {
34-
"build": "cross-env NODE_OPTIONS='--max-old-space-size=4096' FEATURE_SOLD_OUT_AFTER_PARTY=1 FEATURE_SOLD_OUT_EARLY_BIRD_AFTER_PARTY=1 nuxt build",
35-
"dev": "cross-env FEATURE_TIMETABLE=0 FEATURE_SOLD_OUT_AFTER_PARTY=1 FEATURE_SOLD_OUT_EARLY_BIRD_AFTER_PARTY=1 nuxt dev",
34+
"build": "nuxt build",
35+
"dev": "nuxt dev",
3636
"generate": "nuxt generate",
3737
"preview": "nuxt preview",
3838
"check": "nuxi typecheck",
@@ -70,7 +70,6 @@
7070
"@types/three": "^0.175.0",
7171
"@unhead/vue": "^2.0.5",
7272
"@vueuse/core": "^13.2.0",
73-
"cross-env": "^7.0.3",
7473
"cspell": "^8.17.5",
7574
"d1-driver": "^2.0.2",
7675
"dotenv": "^17.0.1",

0 commit comments

Comments
 (0)