Added feature flags system (#803)

* added feature flag system

* Update packages/web/src/core/services/featureFlags.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove process.env

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-08-22 13:47:24 -04:00
committed by GitHub
parent 1de92cd2e9
commit 449fb3ac36
5 changed files with 138 additions and 3 deletions

View File

@@ -12,6 +12,8 @@ import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { ErrorBoundary } from "react-error-boundary";
import { MapProvider } from "react-map-gl/maplibre";
// Import feature flags and dev overrides
import "@core/services/dev-overrides.ts";
export function App() {
const { getDevice } = useDeviceStore();
@@ -31,7 +33,6 @@ export function App() {
setConnectDialogOpen(open);
}}
/>
{/* <Toaster /> */}
<TanStackRouterDevtools position="bottom-right" />
<DeviceWrapper device={device}>
<div

View File

@@ -1,4 +1,5 @@
import { cn } from "@core/utils/cn.ts";
import React from "react";
import { Trans } from "react-i18next";
type FooterProps = {
@@ -6,6 +7,18 @@ type FooterProps = {
};
const Footer = ({ className, ...props }: FooterProps) => {
const version = React.useMemo(
() => String(import.meta.env.VITE_VERSION)?.toUpperCase() || "",
[],
);
const commitHash = React.useMemo(
() =>
String(import.meta.env.VITE_COMMIT_HASH)
?.toUpperCase()
.slice(0, 7) || "unk",
[],
);
return (
<footer
className={cn(
@@ -16,13 +29,13 @@ const Footer = ({ className, ...props }: FooterProps) => {
>
<div className="px-2">
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
{String(import.meta.env.VITE_VERSION)?.toUpperCase()}
{version}
</span>
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40 mx-2">
-
</span>
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
{`#${String(import.meta.env.VITE_COMMIT_HASH)?.toUpperCase()}`}
{`#${commitHash}`}
</span>
</div>
<p className="ml-auto mr-auto text-gray-500 dark:text-gray-400">

View File

@@ -0,0 +1,22 @@
import {
type FlagKey,
type Flags,
featureFlags,
} from "@core/services/featureFlags.ts";
import * as React from "react";
export function useFeatureFlags(): Flags {
return React.useSyncExternalStore(
(cb) => featureFlags.subscribe(cb),
() => featureFlags.all(),
() => featureFlags.all(),
);
}
export function useFeatureFlag(key: FlagKey): boolean {
return React.useSyncExternalStore(
(cb) => featureFlags.subscribe(cb),
() => featureFlags.get(key),
() => featureFlags.get(key),
);
}

View File

@@ -0,0 +1,10 @@
import { featureFlags } from "@core/services/featureFlags";
const isDev = typeof import.meta !== "undefined" && import.meta.env?.DEV;
console.log(`Dev mode: ${isDev}`);
if (isDev) {
featureFlags.setOverrides({
persistNodeDB: true,
});
}

View File

@@ -0,0 +1,89 @@
import { z } from "zod";
/** Map feature keys -> env var names (Vite exposes only VITE_*). */
export const FLAG_ENV = {
persistNodeDB: "VITE_PERSIST_NODE_DB",
persistMessages: "VITE_PERSIST_MESSAGES",
} as const;
export type FlagKey = keyof typeof FLAG_ENV;
export type Flags = Record<FlagKey, boolean>;
type Listener = () => void;
const present = z
.string()
.optional()
.transform((v) => v !== undefined);
const mutableShape: Record<string, z.ZodTypeAny> = {};
for (const envName of Object.values(FLAG_ENV)) {
mutableShape[envName] = present;
}
const EnvSchema = z.object(mutableShape);
class FeatureFlags {
private base: Flags;
private overrides: Partial<Flags> = {};
private listeners = new Set<Listener>();
constructor(base: Flags) {
this.base = base;
}
get(key: FlagKey): boolean {
return this.overrides[key] ?? this.base[key];
}
/** Get all flags */
all(): Flags {
return { ...this.base, ...this.overrides };
}
/** Optional dev/test override. Pass null to clear. */
setOverride(key: FlagKey, val: boolean | null) {
if (val === null) {
delete this.overrides[key];
} else {
this.overrides[key] = val;
}
this.emit();
}
/** Set many at once */
setOverrides(partial: Partial<Flags>) {
for (const [k, v] of Object.entries(partial)) {
this.setOverride(k as FlagKey, v as boolean);
if (v === null) {
delete this.overrides[k as FlagKey];
} else {
this.overrides[k as FlagKey] = v as boolean;
}
}
this.emit();
}
subscribe(fn: Listener) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit() {
for (const listener of this.listeners) {
listener();
}
}
}
export function createFeatureFlags(env: Record<string, unknown>): FeatureFlags {
const parsed = EnvSchema.parse(env);
const base = Object.fromEntries(
(Object.keys(FLAG_ENV) as FlagKey[]).map((k) => [
k,
parsed[FLAG_ENV[k]] as boolean,
]),
) as Flags;
return new FeatureFlags(base);
}
export const featureFlags = createFeatureFlags(import.meta.env);