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:
@@ -12,6 +12,8 @@ import { Outlet } from "@tanstack/react-router";
|
|||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { MapProvider } from "react-map-gl/maplibre";
|
import { MapProvider } from "react-map-gl/maplibre";
|
||||||
|
// Import feature flags and dev overrides
|
||||||
|
import "@core/services/dev-overrides.ts";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { getDevice } = useDeviceStore();
|
const { getDevice } = useDeviceStore();
|
||||||
@@ -31,7 +33,6 @@ export function App() {
|
|||||||
setConnectDialogOpen(open);
|
setConnectDialogOpen(open);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* <Toaster /> */}
|
|
||||||
<TanStackRouterDevtools position="bottom-right" />
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
<DeviceWrapper device={device}>
|
<DeviceWrapper device={device}>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import React from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
|
|
||||||
type FooterProps = {
|
type FooterProps = {
|
||||||
@@ -6,6 +7,18 @@ type FooterProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Footer = ({ className, ...props }: 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 (
|
return (
|
||||||
<footer
|
<footer
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -16,13 +29,13 @@ const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
>
|
>
|
||||||
<div className="px-2">
|
<div className="px-2">
|
||||||
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
|
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
|
||||||
{String(import.meta.env.VITE_VERSION)?.toUpperCase()}
|
{version}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40 mx-2">
|
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40 mx-2">
|
||||||
-
|
-
|
||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
|
<span className="font-semibold text-gray-500/40 dark:text-gray-400/40">
|
||||||
{`#${String(import.meta.env.VITE_COMMIT_HASH)?.toUpperCase()}`}
|
{`#${commitHash}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="ml-auto mr-auto text-gray-500 dark:text-gray-400">
|
<p className="ml-auto mr-auto text-gray-500 dark:text-gray-400">
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user