feat: feature flag for image proxy (#534)
This commit is contained in:
commit
f262e1162e
|
|
@ -0,0 +1,37 @@
|
|||
import { useAtomValue } from "jotai"
|
||||
import PostHog from "posthog-js"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
import type { FeatureKeys } from "~/modules/ab/atoms"
|
||||
import { debugFeaturesAtom, enableDebugOverrideAtom, IS_DEBUG_ENV } from "~/modules/ab/atoms"
|
||||
import { abPayloadFallback } from "~/modules/ab/fallback"
|
||||
|
||||
export const useAb = (feature: FeatureKeys) => {
|
||||
const isEnableDebugOverrides = useAtomValue(enableDebugOverrideAtom)
|
||||
const debugFeatureOverrides = useAtomValue(debugFeaturesAtom)
|
||||
|
||||
const isEnabled = useFeatureFlagEnabled(feature)
|
||||
|
||||
if (IS_DEBUG_ENV && isEnableDebugOverrides) return debugFeatureOverrides[feature]
|
||||
|
||||
return isEnabled
|
||||
}
|
||||
|
||||
export const isAbEnabled = (feature: FeatureKeys) => {
|
||||
const featureFlag = PostHog.getFeatureFlag(feature)
|
||||
const enabled = typeof featureFlag === "boolean" ? featureFlag : featureFlag === "enabled"
|
||||
const debugOverride = jotaiStore.get(debugFeaturesAtom)
|
||||
|
||||
const isEnableOverride = jotaiStore.get(enableDebugOverrideAtom)
|
||||
|
||||
if (isEnableOverride) {
|
||||
return debugOverride[feature]
|
||||
}
|
||||
|
||||
return enabled
|
||||
}
|
||||
|
||||
export const getAbValue = (feature: FeatureKeys) => {
|
||||
return PostHog.getFeatureFlagPayload(feature) || abPayloadFallback[feature]
|
||||
}
|
||||
|
|
@ -97,13 +97,12 @@ export const initializeApp = async () => {
|
|||
})
|
||||
|
||||
// should after hydrateSettings
|
||||
const { dataPersist: enabledDataPersist, sendAnonymousData } = getGeneralSettings()
|
||||
const { dataPersist: enabledDataPersist } = getGeneralSettings()
|
||||
|
||||
initSentry()
|
||||
initPostHog()
|
||||
await apm("i18n", initI18n)
|
||||
|
||||
if (sendAnonymousData) initPostHog()
|
||||
|
||||
let dataHydratedTime: undefined | number
|
||||
// Initialize the database
|
||||
if (enabledDataPersist) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { env } from "@follow/shared/env"
|
||||
import type { CaptureOptions, Properties } from "posthog-js"
|
||||
import { posthog } from "posthog-js"
|
||||
|
||||
import { getGeneralSettings } from "~/atoms/settings/general"
|
||||
import { whoami } from "~/atoms/user"
|
||||
|
||||
declare global {
|
||||
|
|
@ -12,9 +14,6 @@ declare global {
|
|||
}
|
||||
}
|
||||
export const initPostHog = async () => {
|
||||
if (import.meta.env.DEV) return
|
||||
const { default: posthog } = await import("posthog-js")
|
||||
|
||||
if (env.VITE_POSTHOG_KEY === undefined) return
|
||||
posthog.init(env.VITE_POSTHOG_KEY, {
|
||||
person_profiles: "identified_only",
|
||||
|
|
@ -25,6 +24,10 @@ export const initPostHog = async () => {
|
|||
window.posthog = {
|
||||
reset,
|
||||
capture(event_name: string, properties?: Properties | null, options?: CaptureOptions) {
|
||||
if (import.meta.env.DEV) return
|
||||
if (!getGeneralSettings().sendAnonymousData) {
|
||||
return
|
||||
}
|
||||
return capture.apply(posthog, [
|
||||
event_name,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { env } from "@follow/shared/env"
|
||||
import { imageRefererMatches } from "@follow/shared/image"
|
||||
|
||||
import { getAbValue, isAbEnabled } from "~/hooks/biz/useAb"
|
||||
|
||||
export const getImageProxyUrl = ({
|
||||
url,
|
||||
width,
|
||||
|
|
@ -9,7 +10,14 @@ export const getImageProxyUrl = ({
|
|||
url: string
|
||||
width: number
|
||||
height: number
|
||||
}) => `${env.VITE_IMGPROXY_URL}/unsafe/fit-in/${width}x${height}/${encodeURIComponent(url)}`
|
||||
}) => {
|
||||
const abValue = getAbValue("Image_Proxy_V2")
|
||||
if (isAbEnabled("Image_Proxy_V2")) {
|
||||
return `${abValue}?url=${encodeURIComponent(url)}&width=${width}&height=${height}`
|
||||
} else {
|
||||
return `${abValue}/unsafe/fit-in/${width}x${height}/${encodeURIComponent(url)}`
|
||||
}
|
||||
}
|
||||
|
||||
export const replaceImgUrlIfNeed = (url: string) => {
|
||||
for (const rule of imageRefererMatches) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import FEATURES from "@constants/flags.json"
|
||||
import { atom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
import { getStorageNS } from "~/lib/ns"
|
||||
|
||||
export type FeatureKeys = keyof typeof FEATURES
|
||||
|
||||
export const debugFeaturesAtom = atomWithStorage(getStorageNS("ab"), FEATURES, undefined, {
|
||||
getOnInit: true,
|
||||
})
|
||||
|
||||
export const IS_DEBUG_ENV = import.meta.env.DEV || import.meta.env["PREVIEW_MODE"]
|
||||
|
||||
export const enableDebugOverrideAtom = atom(IS_DEBUG_ENV)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { FeatureKeys } from "./atoms"
|
||||
|
||||
export const abPayloadFallback: Record<FeatureKeys, string> = {
|
||||
Image_Proxy_V2: "https://thumbor.follow.is",
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { FC } from "react"
|
||||
import { forwardRef } from "react"
|
||||
|
||||
import { useAb } from "~/hooks/biz/useAb"
|
||||
|
||||
import type { FeatureKeys } from "./atoms"
|
||||
|
||||
const Noop = () => null
|
||||
export const withFeature =
|
||||
(feature: FeatureKeys) =>
|
||||
<T extends object>(
|
||||
Component: FC<T>,
|
||||
|
||||
FallbackComponent: any = Noop,
|
||||
) => {
|
||||
// @ts-expect-error
|
||||
const WithFeature = forwardRef((props: T, ref: any) => {
|
||||
const isEnabled = useAb(feature)
|
||||
|
||||
if (isEnabled === undefined) return null
|
||||
|
||||
return isEnabled ? (
|
||||
<Component {...props} ref={ref} />
|
||||
) : (
|
||||
<FallbackComponent {...props} ref={ref} />
|
||||
)
|
||||
})
|
||||
|
||||
return WithFeature
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
|
||||
import { Divider } from "~/components/ui/divider"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import { useModalStack } from "~/components/ui/modal"
|
||||
import { RootPortal } from "~/components/ui/portal"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
|
||||
import type { FeatureKeys } from "./atoms"
|
||||
import { debugFeaturesAtom, enableDebugOverrideAtom, IS_DEBUG_ENV } from "./atoms"
|
||||
|
||||
export const FeatureFlagDebugger = () => {
|
||||
if (IS_DEBUG_ENV) return <DebugToggle />
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const DebugToggle = () => {
|
||||
const { present } = useModalStack()
|
||||
return (
|
||||
<RootPortal>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
present({
|
||||
title: "A/B",
|
||||
content: ABModalContent,
|
||||
})
|
||||
}}
|
||||
className="fixed bottom-5 right-0 flex size-5 items-center justify-center opacity-40 duration-200 hover:opacity-100"
|
||||
>
|
||||
<i className="i-mingcute-switch-line" />
|
||||
</div>
|
||||
</RootPortal>
|
||||
)
|
||||
}
|
||||
|
||||
const SwitchInternal = ({ Key }: { Key: FeatureKeys }) => {
|
||||
const enabled = useAtomValue(debugFeaturesAtom)[Key]
|
||||
const setDebugFeatures = useSetAtom(debugFeaturesAtom)
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setDebugFeatures((prev) => ({ ...prev, [Key]: checked }))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ABModalContent = () => {
|
||||
const features = useAtomValue(debugFeaturesAtom)
|
||||
|
||||
const enableOverride = useAtomValue(enableDebugOverrideAtom)
|
||||
const setEnableDebugOverride = useSetAtom(enableDebugOverrideAtom)
|
||||
return (
|
||||
<div>
|
||||
<Label className="flex items-center justify-between">
|
||||
Enable Override A/B
|
||||
<Switch
|
||||
checked={enableOverride}
|
||||
onCheckedChange={(checked) => {
|
||||
setEnableDebugOverride(checked)
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className={enableOverride ? "opacity-100" : "pointer-events-none opacity-40"}>
|
||||
{Object.keys(features).map((key) => {
|
||||
return (
|
||||
<div key={key} className="flex w-full items-center justify-between">
|
||||
<Label className="flex w-full items-center justify-between text-sm">
|
||||
{key}
|
||||
<SwitchInternal Key={key as any} />
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { Toaster } from "~/components/ui/sonner"
|
|||
import { HotKeyScopeMap } from "~/constants"
|
||||
import { jotaiStore } from "~/lib/jotai"
|
||||
import { persistConfig, queryClient } from "~/lib/query-client"
|
||||
import { FeatureFlagDebugger } from "~/modules/ab/providers"
|
||||
|
||||
import { ContextMenuProvider } from "./context-menu-provider"
|
||||
import { EventProvider } from "./event-provider"
|
||||
|
|
@ -39,6 +40,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
|
|||
<ModalStackProvider />
|
||||
<ContextMenuProvider />
|
||||
<StableRouterProvider />
|
||||
<FeatureFlagDebugger />
|
||||
{import.meta.env.DEV && <Devtools />}
|
||||
{children}
|
||||
</I18nProvider>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
"paths": {
|
||||
"~/*": ["src/*"],
|
||||
"@pkg": ["../../package.json"],
|
||||
"@locales/*": ["../../locales/*"]
|
||||
"@locales/*": ["../../locales/*"],
|
||||
"@constants/*": ["../../constants/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export const viteRenderBaseConfig = {
|
|||
"@pkg": resolve("package.json"),
|
||||
"@locales": resolve("locales"),
|
||||
"@follow/electron-main": resolve("apps/main/src"),
|
||||
"@constants": resolve("constants"),
|
||||
},
|
||||
},
|
||||
base: "/",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"Image_Proxy_V2": true
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
"prepare": "pnpm exec simple-git-hooks && shx test -f .env || shx cp .env.example .env",
|
||||
"publish": "electron-vite build && electron-forge publish",
|
||||
"start": "electron-vite preview",
|
||||
"sync:ab": "tsx scripts/pull-ab-flags.ts",
|
||||
"test": "pnpm -F web run test",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck:node": "pnpm -F electron-main run typecheck",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import "dotenv/config"
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs"
|
||||
import path, { dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { inspect } from "node:util"
|
||||
|
||||
import { ofetch } from "ofetch"
|
||||
|
||||
const { POSTHOG_TOKEN } = process.env
|
||||
|
||||
if (!POSTHOG_TOKEN) {
|
||||
throw new Error("POSTHOG_TOKEN is not set")
|
||||
}
|
||||
// https://posthog.com/docs/api/feature-flags#post-api-organizations-parent_lookup_organization_id-feature_flags-copy_flags
|
||||
const listRes: ListRes = await ofetch(
|
||||
`https://app.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/feature_flags/?limit=9999`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${POSTHOG_TOKEN}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface ListRes {
|
||||
count: number
|
||||
next: null
|
||||
previous: null
|
||||
results: ResultsItem[]
|
||||
}
|
||||
interface ResultsItem {
|
||||
id: number
|
||||
name: string
|
||||
key: string
|
||||
filters: any[]
|
||||
deleted: boolean
|
||||
active: boolean
|
||||
created_by: any[]
|
||||
created_at: string
|
||||
is_simple_flag: boolean
|
||||
rollout_percentage: number
|
||||
ensure_experience_continuity: boolean
|
||||
experiment_set: any[]
|
||||
surveys: any[]
|
||||
features: any[]
|
||||
rollback_conditions: any[]
|
||||
performed_rollback: boolean
|
||||
can_edit: boolean
|
||||
usage_dashboard: number
|
||||
analytics_dashboards: any[]
|
||||
has_enriched_analytics: boolean
|
||||
tags: any[]
|
||||
}
|
||||
|
||||
const existFlags = {} as Record<string, boolean>
|
||||
|
||||
listRes.results.forEach((flag) => (existFlags[flag.key] = true))
|
||||
|
||||
const __dirname = resolve(dirname(fileURLToPath(import.meta.url)))
|
||||
const localFlagsString = readFileSync(path.join(__dirname, "../constants/flags.json"), "utf8")
|
||||
const localFlags = JSON.parse(localFlagsString as string) as Record<string, boolean>
|
||||
|
||||
const updateToRmoteFlags = {} as Record<string, boolean>
|
||||
|
||||
// If remote key has but local not has, add to Local
|
||||
for (const key in existFlags) {
|
||||
if (!(key in localFlags)) {
|
||||
localFlags[key] = existFlags[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Write to local flags
|
||||
writeFileSync(path.join(__dirname, "../constants/flags.json"), JSON.stringify(localFlags, null, 2))
|
||||
|
||||
console.info("update local flags", inspect(localFlags))
|
||||
|
||||
// Local first
|
||||
for (const key in localFlags) {
|
||||
// existFlags[key] = localFlags[key]
|
||||
if (existFlags[key] !== localFlags[key]) {
|
||||
updateToRmoteFlags[key] = localFlags[key]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateToRmoteFlags).length > 0) {
|
||||
await Promise.allSettled(
|
||||
Object.entries(updateToRmoteFlags).map(([key, flag]) => {
|
||||
return fetch(
|
||||
`https://app.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/feature_flags/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${process.env.POSTHOG_PRIVATE_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
active: flag,
|
||||
}),
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
console.info("update flags", inspect(updateToRmoteFlags))
|
||||
}
|
||||
Loading…
Reference in New Issue