mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
feat(packages): add @civitai/flipt and wire creator-studio to it
Extracts the monolith's Flipt client into a shared workspace package so other apps can gate on the same flags. The package carries over the production hardening as-is — init timeout, failure circuit breaker, generational TTL eval cache, dev-only local overrides, fail-closed on every path — and follows the @civitai/axiom / @civitai/redis shape: raw TS entry, package-owned lazy env, injected logging. Flag keys stay in the app. FLIPT_FEATURE_FLAGS and the cache-bypass set remain in src/server/flipt/client.ts and are passed to the factory as `cacheBypass`; which flags are incident kill-switches is app knowledge, not package knowledge. Connection env resolves on the first evaluation rather than at construction, so a missing FLIPT_URL degrades that instance to fail-closed instead of throwing out of the import that built it. Building it eagerly broke 61 unit suites at collection, whose env mock has no Flipt vars. creator-studio gets a getFlipt() shim mirroring its redis.ts/logger.ts (lazy, globalThis-cached, app logger injected), the transpile + dep entries, documented env, and a test pinning the wiring — nothing else in the app imports the shim yet, so without it neither svelte-check nor a build would prove it resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,16 @@ CLICKHOUSE_PASSWORD=
|
||||
REDIS_URL=redis://:password@localhost:6379
|
||||
REDIS_SYS_URL=redis://:password@localhost:6379
|
||||
|
||||
# --- Feature flags (@civitai/flipt, src/lib/server/flipt.ts) ---
|
||||
# Both required to evaluate flags; without them every flag reads false (the client logs one init error and
|
||||
# stays fail-closed), which is a fine local-dev default. Same values as the main app's .env.
|
||||
FLIPT_URL=
|
||||
FLIPT_FETCHER_SECRET=
|
||||
# Flags live in the `civitai-app` Flipt environment alongside the main app's. Override to split them.
|
||||
# FLIPT_ENVIRONMENT=civitai-app
|
||||
# Dev-only: short-circuit flags without touching shared Flipt state, e.g. `my-flag=on,other-flag=variant`.
|
||||
# FLIPT_LOCAL_OVERRIDES=
|
||||
|
||||
# --- Media CDN (EdgeImage, for later model/creator imagery) ---
|
||||
# Cloudflare-images delivery base. PUBLIC_ prefix exposes it to the browser via $env/dynamic/public.
|
||||
# Mirrors the main app's NEXT_PUBLIC_IMAGE_LOCATION (prod value below; point at your dev cacher for local).
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@civitai/clickhouse": "workspace:*",
|
||||
"@civitai/db": "workspace:*",
|
||||
"@civitai/db-schema": "workspace:*",
|
||||
"@civitai/flipt": "workspace:*",
|
||||
"@civitai/redis": "workspace:*",
|
||||
"@civitai/shared": "workspace:*",
|
||||
"@civitai/ui": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getFlipt } from '../flipt';
|
||||
|
||||
// The wiring guard for @civitai/flipt: without FLIPT_URL the shim must still resolve, hand back a
|
||||
// client, and answer every flag `false` rather than throwing into whatever page evaluated it.
|
||||
describe('getFlipt', () => {
|
||||
afterEach(() => {
|
||||
delete (globalThis as { flipt?: unknown }).flipt;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reuses one instance across calls', () => {
|
||||
expect(getFlipt()).toBe(getFlipt());
|
||||
});
|
||||
|
||||
it('fails closed when Flipt is unconfigured', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
expect(await getFlipt().isEnabled('any-flag')).toBe(false);
|
||||
expect(await getFlipt().getVariant('any-flag')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { safeError } from '@civitai/axiom';
|
||||
import { createFliptClient, type FliptFeatureFlags } from '@civitai/flipt';
|
||||
import { getLogger } from './logger';
|
||||
|
||||
// App shim around `@civitai/flipt`. Reads FLIPT_URL + FLIPT_FETCHER_SECRET from process.env (the
|
||||
// vite.config shim bridges .env → process.env), on the first evaluation rather than at import — an
|
||||
// unconfigured deploy degrades to every flag off instead of failing to boot. Lazily constructed (so
|
||||
// `vite build` never instantiates it) and cached on globalThis (dev HMR reuse).
|
||||
const g = globalThis as unknown as { flipt?: FliptFeatureFlags };
|
||||
|
||||
export function getFlipt(): FliptFeatureFlags {
|
||||
if (!g.flipt) {
|
||||
g.flipt = createFliptClient({
|
||||
onInitError: (error) => {
|
||||
// logToAxiom rejects when Axiom ingest is degraded; telemetry must not fail the caller.
|
||||
getLogger()
|
||||
.logToAxiom({ name: 'init-flipt-error', error: safeError(error) })
|
||||
.catch(() => undefined);
|
||||
},
|
||||
});
|
||||
}
|
||||
return g.flipt;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
|
||||
'@civitai/clickhouse',
|
||||
'@civitai/db',
|
||||
'@civitai/db-schema',
|
||||
'@civitai/flipt',
|
||||
'@civitai/redis',
|
||||
'@civitai/shared',
|
||||
'@civitai/ui',
|
||||
|
||||
@@ -146,6 +146,7 @@ Add to `.env` (real) + `.env.example` (documented) only the rows for packages yo
|
||||
| `@civitai/clickhouse` | `CLICKHOUSE_HOST`/`USERNAME`/`PASSWORD` | required in prod, optional in dev |
|
||||
| `@civitai/email` | *(all optional)* | `isEmailConfigured()` guards sends |
|
||||
| `@civitai/axiom` | *(all optional)* | stderr-only without `AXIOM_TOKEN` |
|
||||
| `@civitai/flipt` | `FLIPT_URL`, `FLIPT_FETCHER_SECRET` | or pass them to the factory; missing ⇒ flags fail closed |
|
||||
| `@civitai/brand`, `@civitai/telemetry`, `@civitai/db-schema` | *(none)* | |
|
||||
|
||||
**Redis footgun**: pulling `@civitai/auth` and setting only `REDIS_URL` (for the session cache) without
|
||||
|
||||
@@ -151,6 +151,7 @@ export default defineNextConfig(
|
||||
'@civitai/redis',
|
||||
'@civitai/clickhouse',
|
||||
'@civitai/axiom',
|
||||
'@civitai/flipt',
|
||||
'@civitai/telemetry',
|
||||
'@civitai/auth',
|
||||
'@civitai/notifications',
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"@civitai/cybertipline-tools": "^0.1.0",
|
||||
"@civitai/db-queries": "workspace:*",
|
||||
"@civitai/db-schema": "workspace:*",
|
||||
"@civitai/flipt": "workspace:*",
|
||||
"@civitai/next-axiom": "^0.17.0",
|
||||
"@civitai/shared": "workspace:*",
|
||||
"@clavata/sdk": "^0.2.3",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# @civitai/flipt
|
||||
|
||||
Feature-flag evaluation via [Flipt](https://flipt.io) for Civitai apps. Wraps the wasm client with the
|
||||
production hardening the monolith needed: init timeout + failure circuit breaker, an in-process TTL
|
||||
eval cache, and dev-only local overrides. Fails **closed** — an unreachable Flipt or an unknown flag
|
||||
evaluates to `false` / `null`, never throws.
|
||||
|
||||
## Add to an app
|
||||
|
||||
```jsonc
|
||||
// package.json
|
||||
"@civitai/flipt": "workspace:*"
|
||||
```
|
||||
|
||||
Transpile (raw TS): Next `transpilePackages: ['@civitai/flipt']`, Vite `ssr.noExternal: ['@civitai/flipt']`.
|
||||
|
||||
## Env
|
||||
|
||||
| Var | Req | Notes |
|
||||
|---|---|---|
|
||||
| `FLIPT_URL` | **yes** | Flipt server |
|
||||
| `FLIPT_FETCHER_SECRET` | **yes** | client token |
|
||||
| `FLIPT_ENVIRONMENT` | no | Flipt environment; defaults to `civitai-app` |
|
||||
| `FLIPT_DEPLOYMENT_ID` | no | carried on `config`, for apps that put it in evaluation context |
|
||||
| `FLIPT_EVAL_CACHE_TTL_MS` | no | default `10000`; `0` disables the eval cache |
|
||||
| `FLIPT_LOCAL_OVERRIDES` | no | dev only — ignored when `NODE_ENV=production` |
|
||||
|
||||
Env is read lazily: importing this package touches nothing, `createFliptClient()` reads only the
|
||||
optional tuning vars, and `FLIPT_URL`/`FLIPT_FETCHER_SECRET` are resolved on the **first evaluation**
|
||||
(skipped entirely if the app passed them). A missing connection var therefore degrades that instance
|
||||
to fail-closed via `onInitError` — it never throws out of the import that built it.
|
||||
|
||||
## Use
|
||||
|
||||
```ts
|
||||
// src/lib/server/flipt.ts (or src/server/flipt/client.ts in the monolith)
|
||||
import { createFliptClient } from '@civitai/flipt';
|
||||
|
||||
export const flipt = createFliptClient({
|
||||
cacheBypass: [MY_FLAGS.SOME_KILL_SWITCH],
|
||||
onInitError: (error) => logToAxiom({ type: 'init-flipt-error', error: safeError(error) }),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
if (await flipt.isEnabled('feed-post-filter', String(userId))) { … }
|
||||
const mode = await flipt.getVariant('bitdex-image-search', String(userId));
|
||||
```
|
||||
|
||||
| Method | Overrides honored | Notes |
|
||||
|---|---|---|
|
||||
| `isEnabled` | yes | the default boolean read |
|
||||
| `getBoolean` | **no** | when the call site must see real Flipt state, not a dev `.env` |
|
||||
| `getVariant` | yes | `null` when unmatched |
|
||||
| `isEnabledSync` | yes | `null` if the client hasn't initialized — caller falls back |
|
||||
| `ensureInitialized` | — | warm it at boot so `isEnabledSync` can answer |
|
||||
|
||||
**Flag keys stay in the app.** This package deliberately ships no flag enum: flags are owned by the
|
||||
app that gates on them (the monolith's list is `FLIPT_FEATURE_FLAGS` in
|
||||
`src/server/flipt/client.ts`). Two apps sharing a flag share the *string*, not an import.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Eval-cache staleness is additive to the config poll.** Worst-case propagation of a flipped flag is
|
||||
~(60s poll + TTL) per pod, and pods converge independently. Fine for rollout flags; put incident
|
||||
kill-switches in `cacheBypass` so an operator's flip takes effect on the next poll alone.
|
||||
- **Prefer lowering `FLIPT_EVAL_CACHE_TTL_MS` over growing `cacheBypass`.** Bypassing a flag evaluated
|
||||
on a hot path re-adds a per-request wasm call — that cache exists because those calls were a top-10
|
||||
CPU frame at ~1500 req/s.
|
||||
- **`FLIPT_EVAL_CACHE_TTL_MS` is parsed with `parseInt`**: `"0.5"` → `0` (cache off) and `"1e4"` → `1`.
|
||||
The resolved value is logged through the factory's `log` on startup — read it if behavior surprises you.
|
||||
- After an init failure the client stays `null` for `failureCooldownMs` (30s) and every read returns
|
||||
the fail-closed value. That's deliberate: a flag store outage must not stall request paths.
|
||||
- Logging is **injected** (`onInitError` / `onEvalError` / `log`), so this package depends on no
|
||||
transport. Without them you get `console`.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@civitai/flipt",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flipt-io/flipt-client-js": "^0.2.0",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fliptCacheKey, TtlCache } from '../cache';
|
||||
import { parseLocalOverrides } from '../env';
|
||||
|
||||
describe('TtlCache', () => {
|
||||
it('expires entries after the TTL', () => {
|
||||
const cache = new TtlCache<boolean>(100, 10);
|
||||
cache.set('k', true, 1_000);
|
||||
expect(cache.get('k', 1_050)).toEqual({ hit: true, value: true });
|
||||
expect(cache.get('k', 1_101)).toEqual({ hit: false });
|
||||
});
|
||||
|
||||
it('keeps hot keys alive across a generation rotation', () => {
|
||||
const cache = new TtlCache<boolean>(10_000, 2);
|
||||
cache.set('hot', true, 0);
|
||||
cache.set('a', false, 0);
|
||||
// Overflow: 'hot' and 'a' rotate into the previous generation.
|
||||
cache.set('b', false, 0);
|
||||
// Reading 'hot' promotes it back into the current generation...
|
||||
expect(cache.get('hot', 0).hit).toBe(true);
|
||||
// ...so the next rotation drops 'a' rather than 'hot'.
|
||||
cache.set('c', false, 0);
|
||||
cache.set('d', false, 0);
|
||||
expect(cache.get('hot', 0).hit).toBe(true);
|
||||
expect(cache.get('a', 0).hit).toBe(false);
|
||||
});
|
||||
|
||||
it('stores nothing when the TTL is 0', () => {
|
||||
const cache = new TtlCache<boolean>(0, 10);
|
||||
cache.set('k', true, 0);
|
||||
expect(cache.get('k', 0).hit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fliptCacheKey', () => {
|
||||
it('is order-independent in context', () => {
|
||||
expect(fliptCacheKey('f', 'e', { a: '1', b: '2' })).toBe(
|
||||
fliptCacheKey('f', 'e', { b: '2', a: '1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not alias across separator characters in values', () => {
|
||||
expect(fliptCacheKey('f', 'a|b', {})).not.toBe(fliptCacheKey('f', 'a', { b: '' }));
|
||||
expect(fliptCacheKey('f', 'e', { a: '1&b=2' })).not.toBe(
|
||||
fliptCacheKey('f', 'e', { a: '1', b: '2' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLocalOverrides', () => {
|
||||
it('parses comma-separated pairs and ignores malformed ones', () => {
|
||||
expect(parseLocalOverrides('a=on, b=primary ,junk,=x,c=')).toEqual({
|
||||
a: 'on',
|
||||
b: 'primary',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty map when unset', () => {
|
||||
expect(parseLocalOverrides(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type * as FliptSdk from '@flipt-io/flipt-client-js';
|
||||
|
||||
const evaluateBoolean = vi.fn(() => ({ enabled: true }));
|
||||
const evaluateVariant = vi.fn(() => ({ match: true, variantKey: 'primary' }));
|
||||
const init = vi.fn(async () => ({ evaluateBoolean, evaluateVariant, refresh: async () => {} }));
|
||||
|
||||
vi.mock('@flipt-io/flipt-client-js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof FliptSdk>()),
|
||||
FliptClient: { init: (...args: unknown[]) => init(...(args as [])) },
|
||||
}));
|
||||
|
||||
import { createFliptClient } from '../client';
|
||||
|
||||
const baseConfig = {
|
||||
url: 'http://flipt.test',
|
||||
clientToken: 'token',
|
||||
environment: 'test-env',
|
||||
updateIntervalSeconds: 60,
|
||||
initTimeoutMs: 1000,
|
||||
failureCooldownMs: 30_000,
|
||||
evalCacheTtlMs: 10_000,
|
||||
evalCacheMaxEntries: 100,
|
||||
localOverrides: {},
|
||||
log: () => {},
|
||||
};
|
||||
|
||||
describe('createFliptClient', () => {
|
||||
beforeEach(() => {
|
||||
evaluateBoolean.mockReset();
|
||||
evaluateBoolean.mockImplementation(() => ({ enabled: true }));
|
||||
evaluateVariant.mockReset();
|
||||
evaluateVariant.mockImplementation(() => ({ match: true, variantKey: 'primary' }));
|
||||
init.mockReset();
|
||||
init.mockImplementation(async () => ({
|
||||
evaluateBoolean,
|
||||
evaluateVariant,
|
||||
refresh: async () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
it('memoizes repeated evaluations of the same (flag, entity, context)', async () => {
|
||||
const flipt = createFliptClient(baseConfig);
|
||||
await flipt.isEnabled('some-flag', 'user-1');
|
||||
await flipt.isEnabled('some-flag', 'user-1');
|
||||
expect(evaluateBoolean).toHaveBeenCalledTimes(1);
|
||||
|
||||
await flipt.isEnabled('some-flag', 'user-2');
|
||||
expect(evaluateBoolean).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips the cache for bypassed flags', async () => {
|
||||
const flipt = createFliptClient({ ...baseConfig, cacheBypass: ['kill-switch'] });
|
||||
await flipt.isEnabled('kill-switch');
|
||||
await flipt.isEnabled('kill-switch');
|
||||
expect(evaluateBoolean).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not evaluate at all when a local override is set', async () => {
|
||||
const flipt = createFliptClient({
|
||||
...baseConfig,
|
||||
localOverrides: { 'off-flag': 'off', 'variant-flag': 'secondary' },
|
||||
});
|
||||
expect(await flipt.isEnabled('off-flag')).toBe(false);
|
||||
expect(await flipt.getVariant('variant-flag')).toBe('secondary');
|
||||
expect(init).not.toHaveBeenCalled();
|
||||
expect(evaluateBoolean).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores local overrides in getBoolean', async () => {
|
||||
const flipt = createFliptClient({ ...baseConfig, localOverrides: { 'off-flag': 'off' } });
|
||||
expect(await flipt.getBoolean('off-flag')).toBe(true);
|
||||
expect(evaluateBoolean).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed and holds the circuit open after an init failure', async () => {
|
||||
init.mockImplementation(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const onInitError = vi.fn();
|
||||
const flipt = createFliptClient({ ...baseConfig, onInitError });
|
||||
|
||||
expect(await flipt.isEnabled('any-flag')).toBe(false);
|
||||
expect(await flipt.isEnabled('any-flag')).toBe(false);
|
||||
expect(init).toHaveBeenCalledTimes(1);
|
||||
expect(onInitError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed when an evaluation throws', async () => {
|
||||
evaluateBoolean.mockImplementation(() => {
|
||||
throw new Error('unknown flag');
|
||||
});
|
||||
const onEvalError = vi.fn();
|
||||
const flipt = createFliptClient({ ...baseConfig, onEvalError });
|
||||
expect(await flipt.isEnabled('missing')).toBe(false);
|
||||
expect(onEvalError).toHaveBeenCalledWith(expect.any(Error), 'missing');
|
||||
});
|
||||
|
||||
it('returns null from isEnabledSync until the client is initialized', async () => {
|
||||
const flipt = createFliptClient(baseConfig);
|
||||
expect(flipt.isEnabledSync('some-flag')).toBeNull();
|
||||
await flipt.ensureInitialized();
|
||||
expect(flipt.isEnabledSync('some-flag')).toBe(true);
|
||||
});
|
||||
|
||||
it('degrades to fail-closed when connection config is missing', async () => {
|
||||
const onInitError = vi.fn();
|
||||
const flipt = createFliptClient({
|
||||
...baseConfig,
|
||||
url: undefined,
|
||||
clientToken: undefined,
|
||||
onInitError,
|
||||
});
|
||||
expect(await flipt.isEnabled('some-flag')).toBe(false);
|
||||
expect(onInitError).toHaveBeenCalledTimes(1);
|
||||
expect(init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no variant match as null', async () => {
|
||||
evaluateVariant.mockImplementation(() => ({ match: false, variantKey: 'primary' }));
|
||||
const flipt = createFliptClient(baseConfig);
|
||||
expect(await flipt.getVariant('some-flag')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// TTL cache with generational rotation instead of full-clear eviction. On overflow the current
|
||||
// generation becomes the "previous" one (the old previous is dropped) and a fresh generation
|
||||
// starts, so hot keys survive at least one rotation and we never thrash to worse-than-no-cache
|
||||
// under high key cardinality. Single-threaded, so Map ops need no locking.
|
||||
//
|
||||
// Steady-state live entries are bounded to ~2x maxEntries; a burst of distinct promoting reads
|
||||
// with no intervening insert can transiently reach ~4x before the next insert rotates — still
|
||||
// bounded, and entries are tiny.
|
||||
|
||||
type Entry<T> = { value: T; expiresAt: number };
|
||||
|
||||
export class TtlCache<T> {
|
||||
private current = new Map<string, Entry<T>>();
|
||||
private previous = new Map<string, Entry<T>>();
|
||||
|
||||
constructor(private readonly ttlMs: number, private readonly maxEntries: number) {}
|
||||
|
||||
get(key: string, now: number): { hit: boolean; value?: T } {
|
||||
const cur = this.current.get(key);
|
||||
if (cur) {
|
||||
if (cur.expiresAt > now) return { hit: true, value: cur.value };
|
||||
this.current.delete(key);
|
||||
}
|
||||
const prev = this.previous.get(key);
|
||||
if (prev) {
|
||||
if (prev.expiresAt > now) {
|
||||
// Promote into the current generation so hot keys aren't lost on rotate.
|
||||
this.previous.delete(key);
|
||||
this.current.set(key, prev);
|
||||
return { hit: true, value: prev.value };
|
||||
}
|
||||
this.previous.delete(key);
|
||||
}
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
set(key: string, value: T, now: number): void {
|
||||
if (this.ttlMs === 0) return;
|
||||
if (this.current.size >= this.maxEntries) {
|
||||
this.previous = this.current;
|
||||
this.current = new Map();
|
||||
}
|
||||
this.current.set(key, { value, expiresAt: now + this.ttlMs });
|
||||
}
|
||||
}
|
||||
|
||||
// Build a collision-proof cache key. Components are URI-encoded so that a `|`, `&`, or `=` inside
|
||||
// an entityId or context value can't alias another key.
|
||||
export function fliptCacheKey(
|
||||
flag: string,
|
||||
entityId: string,
|
||||
context: Record<string, string>
|
||||
): string {
|
||||
const keys = Object.keys(context);
|
||||
if (keys.length === 0) return `${flag}|${encodeURIComponent(entityId)}`;
|
||||
const ctx = keys
|
||||
.sort()
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(context[k])}`)
|
||||
.join('&');
|
||||
return `${flag}|${encodeURIComponent(entityId)}|${ctx}`;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { FliptClient } from '@flipt-io/flipt-client-js';
|
||||
import { fliptCacheKey, TtlCache } from './cache';
|
||||
import { loadFliptConnection, loadFliptTuning, type FliptConfig, type FliptTuning } from './env';
|
||||
|
||||
export type FliptLogFn = (message: string, ...args: unknown[]) => void;
|
||||
|
||||
/**
|
||||
* Tuning is resolved when the instance is built; connection config may still be pending (read from
|
||||
* env on first evaluation), so it's optional here.
|
||||
*/
|
||||
export type FliptResolvedConfig = FliptTuning & Partial<Pick<FliptConfig, 'url' | 'clientToken'>>;
|
||||
|
||||
export type FliptOptions = Partial<FliptConfig> & {
|
||||
/**
|
||||
* Flags exempt from the eval cache: incident kill-switches where an operator expects a flip to
|
||||
* take effect ASAP and the eval is either rare (cold path) or the extra staleness isn't worth
|
||||
* the CPU saved. App-owned, because which flags are kill-switches is an app concern.
|
||||
*/
|
||||
cacheBypass?: Iterable<string>;
|
||||
/** Init failures. INJECTED so this package owns no logging transport. Defaults to console.error. */
|
||||
onInitError?: (error: Error) => void;
|
||||
/** Per-evaluation engine errors (incl. "flag not found"). Defaults to console.error. */
|
||||
onEvalError?: (error: unknown, flag: string) => void;
|
||||
/** Startup/diagnostic lines. Defaults to console.log. */
|
||||
log?: FliptLogFn;
|
||||
};
|
||||
|
||||
export type FliptFeatureFlags = {
|
||||
/** Boolean evaluation honoring local overrides. `false` when Flipt is unreachable or the flag is unknown. */
|
||||
isEnabled(flag: string, entityId?: string, context?: Record<string, string>): Promise<boolean>;
|
||||
/**
|
||||
* Boolean evaluation that IGNORES local overrides — for call sites that must reflect real Flipt
|
||||
* state (e.g. verifying a rollout) rather than a developer's `.env`.
|
||||
*/
|
||||
getBoolean(flag: string, entityId?: string, context?: Record<string, string>): Promise<boolean>;
|
||||
/** Variant evaluation honoring local overrides. `null` when there's no match or Flipt is unreachable. */
|
||||
getVariant(
|
||||
flag: string,
|
||||
entityId?: string,
|
||||
context?: Record<string, string>
|
||||
): Promise<string | null>;
|
||||
/**
|
||||
* Synchronous evaluation. Returns `boolean` if Flipt is ready, or `null` if the client hasn't
|
||||
* initialized yet (caller should fall back).
|
||||
*/
|
||||
isEnabledSync(flag: string, entityId?: string, context?: Record<string, string>): boolean | null;
|
||||
/** Warm the client so `isEnabledSync` can answer. Safe to call repeatedly. */
|
||||
ensureInitialized(): Promise<void>;
|
||||
/** The underlying client if initialized, else `null`. Escape hatch for raw SDK calls. */
|
||||
getClientSync(): FliptClient | null;
|
||||
config: FliptResolvedConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a Flipt feature-flag accessor. `url`/`clientToken` come from `FLIPT_URL` /
|
||||
* `FLIPT_FETCHER_SECRET` unless passed in — an app whose own env module already validates them
|
||||
* (the monolith) passes them and this package never reads process.env for them.
|
||||
*
|
||||
* One instance owns one wasm client + its eval caches; create it once per app and share it.
|
||||
*/
|
||||
export function createFliptClient(options: FliptOptions = {}): FliptFeatureFlags {
|
||||
const {
|
||||
cacheBypass,
|
||||
onInitError = (error: Error) => console.error('[flipt] init error:', error),
|
||||
onEvalError = (error: unknown) => console.error('[flipt] evaluation error:', error),
|
||||
log = console.log,
|
||||
...configOverrides
|
||||
} = options;
|
||||
|
||||
const config: FliptResolvedConfig = { ...loadFliptTuning(), ...configOverrides };
|
||||
const bypass = new Set(cacheBypass ?? []);
|
||||
const { localOverrides } = config;
|
||||
|
||||
log(`[flipt] eval cache TTL: ${config.evalCacheTtlMs}ms (0 = disabled)`);
|
||||
|
||||
const boolCache = new TtlCache<boolean>(config.evalCacheTtlMs, config.evalCacheMaxEntries);
|
||||
const variantCache = new TtlCache<string | null>(
|
||||
config.evalCacheTtlMs,
|
||||
config.evalCacheMaxEntries
|
||||
);
|
||||
|
||||
let instance: FliptClient | null = null;
|
||||
let initializing: Promise<FliptClient | null> | null = null;
|
||||
let lastFailureTime = 0;
|
||||
|
||||
async function getInstance(): Promise<FliptClient | null> {
|
||||
if (instance) return instance;
|
||||
|
||||
// Circuit breaker: skip re-init during cooldown after a failure
|
||||
if (Date.now() - lastFailureTime < config.failureCooldownMs) return null;
|
||||
if (initializing) return initializing;
|
||||
|
||||
initializing = (async () => {
|
||||
try {
|
||||
// Resolved here, not at construction: a missing FLIPT_URL must degrade this instance to
|
||||
// fail-closed, not throw out of the import that built it.
|
||||
const connection =
|
||||
config.url && config.clientToken
|
||||
? { url: config.url, clientToken: config.clientToken }
|
||||
: loadFliptConnection();
|
||||
|
||||
const initPromise = (async () => {
|
||||
const client = await FliptClient.init({
|
||||
environment: config.environment,
|
||||
url: connection.url,
|
||||
authentication: { clientToken: connection.clientToken },
|
||||
updateInterval: config.updateIntervalSeconds,
|
||||
});
|
||||
await client.refresh();
|
||||
return client;
|
||||
})();
|
||||
|
||||
// Attach a no-op catch to prevent unhandled rejection if timeout wins but initPromise
|
||||
// later rejects
|
||||
initPromise.catch(() => null);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Flipt init timeout')), config.initTimeoutMs)
|
||||
);
|
||||
|
||||
instance = await Promise.race([initPromise, timeoutPromise]);
|
||||
return instance;
|
||||
} catch (e) {
|
||||
onInitError(e as Error);
|
||||
instance = null;
|
||||
lastFailureTime = Date.now();
|
||||
return null;
|
||||
} finally {
|
||||
initializing = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return initializing;
|
||||
}
|
||||
|
||||
// Evaluate against the wasm engine, memoized. Throws on engine error so callers keep their
|
||||
// fallback; only successful evaluations are cached.
|
||||
//
|
||||
// Per-request wasm `evaluateBoolean`/`evaluateVariant` calls showed up as a top-10 CPU frame
|
||||
// under load (~1500 req/s on a single JS thread). The engine result for a given
|
||||
// (flag, entityId, context) is stable between config refreshes, so a short in-process TTL cache
|
||||
// collapses the wasm call rate.
|
||||
function evalBooleanCached(
|
||||
client: FliptClient,
|
||||
flag: string,
|
||||
entityId: string,
|
||||
context: Record<string, string>
|
||||
): boolean {
|
||||
if (bypass.has(flag)) {
|
||||
return client.evaluateBoolean({ flagKey: flag, entityId, context }).enabled;
|
||||
}
|
||||
const now = Date.now();
|
||||
const key = fliptCacheKey(flag, entityId, context);
|
||||
const cached = boolCache.get(key, now);
|
||||
if (cached.hit) return cached.value as boolean;
|
||||
const { enabled } = client.evaluateBoolean({ flagKey: flag, entityId, context });
|
||||
boolCache.set(key, enabled, now);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
function evalVariantCached(
|
||||
client: FliptClient,
|
||||
flag: string,
|
||||
entityId: string,
|
||||
context: Record<string, string>
|
||||
): string | null {
|
||||
if (bypass.has(flag)) {
|
||||
const evaluation = client.evaluateVariant({ flagKey: flag, entityId, context });
|
||||
return evaluation.match ? evaluation.variantKey : null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const key = fliptCacheKey(flag, entityId, context);
|
||||
const cached = variantCache.get(key, now);
|
||||
if (cached.hit) return cached.value as string | null;
|
||||
const evaluation = client.evaluateVariant({ flagKey: flag, entityId, context });
|
||||
const result = evaluation.match ? evaluation.variantKey : null;
|
||||
variantCache.set(key, result, now);
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
|
||||
getClientSync: () => instance,
|
||||
|
||||
async ensureInitialized() {
|
||||
await getInstance();
|
||||
},
|
||||
|
||||
async isEnabled(flag, entityId = 'global', context = {}) {
|
||||
if (localOverrides[flag] !== undefined) return localOverrides[flag] === 'on';
|
||||
const client = await getInstance();
|
||||
if (!client) return false;
|
||||
try {
|
||||
return evalBooleanCached(client, flag, entityId, context);
|
||||
} catch (e) {
|
||||
onEvalError(e, flag);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async getBoolean(flag, entityId = 'global', context = {}) {
|
||||
const client = await getInstance();
|
||||
if (!client) return false;
|
||||
try {
|
||||
return evalBooleanCached(client, flag, entityId, context);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async getVariant(flag, entityId = 'global', context = {}) {
|
||||
if (localOverrides[flag] !== undefined) return localOverrides[flag];
|
||||
const client = await getInstance();
|
||||
if (!client) return null;
|
||||
try {
|
||||
return evalVariantCached(client, flag, entityId, context);
|
||||
} catch (e) {
|
||||
onEvalError(e, flag);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
isEnabledSync(flag, entityId = 'global', context = {}) {
|
||||
if (localOverrides[flag] !== undefined) return localOverrides[flag] === 'on';
|
||||
if (!instance) return null;
|
||||
try {
|
||||
return evalBooleanCached(instance, flag, entityId, context);
|
||||
} catch {
|
||||
// Swallow eval errors (incl. "flag not found"); caller falls back to null
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package-owned env schema. Any app that uses @civitai/flipt reads the same vars, so a flag
|
||||
// evaluates identically across apps.
|
||||
import * as z from 'zod';
|
||||
|
||||
// Connection config is REQUIRED, but only read from env when the app didn't pass it explicitly —
|
||||
// an app whose own env module already validates these (the monolith) can hand them to the factory
|
||||
// instead, and then nothing here touches process.env for them.
|
||||
const connectionSchema = z.object({
|
||||
FLIPT_URL: z.string(),
|
||||
FLIPT_FETCHER_SECRET: z.string(),
|
||||
});
|
||||
|
||||
// Tuning is all-optional and never throws.
|
||||
const tuningSchema = z.object({
|
||||
// Flipt "environment" (namespace-of-namespaces). The monolith lives in `civitai-app`; a spoke
|
||||
// app either shares it or declares its own.
|
||||
FLIPT_ENVIRONMENT: z.string().default('civitai-app'),
|
||||
FLIPT_DEPLOYMENT_ID: z.string().optional(),
|
||||
FLIPT_EVAL_CACHE_TTL_MS: z.string().optional(),
|
||||
FLIPT_LOCAL_OVERRIDES: z.string().optional(),
|
||||
});
|
||||
|
||||
// parseInt is intentional (integer ms). Note a non-integer env like "0.5" parses to 0 → cache
|
||||
// disabled, and "1e4" parses to 1 — both surprising, so the resolved value is reported to the
|
||||
// factory's `log` for operator visibility.
|
||||
function parseCacheTtl(raw: string | undefined): number {
|
||||
const parsed = parseInt(raw ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 10_000;
|
||||
}
|
||||
|
||||
// Dev-only local overrides. Set FLIPT_LOCAL_OVERRIDES to short-circuit flag evaluation without
|
||||
// touching shared Flipt state (GitOps overwrites it). Format: comma-separated `flagKey=variantKey`
|
||||
// pairs; use `on`/`off` for booleans.
|
||||
// Example: FLIPT_LOCAL_OVERRIDES=bitdex-image-search=primary,my-bool-flag=on
|
||||
export function parseLocalOverrides(raw: string | undefined): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const pair of raw.split(',')) {
|
||||
const [k, v] = pair.split('=').map((s) => s.trim());
|
||||
if (k && v) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type FliptConnection = {
|
||||
url: string;
|
||||
clientToken: string;
|
||||
};
|
||||
|
||||
export type FliptTuning = {
|
||||
environment: string;
|
||||
deploymentId?: string;
|
||||
/** How often the client pulls new flag config, in seconds. */
|
||||
updateIntervalSeconds: number;
|
||||
initTimeoutMs: number;
|
||||
/** After a failed init, skip re-init attempts for this long (circuit breaker). */
|
||||
failureCooldownMs: number;
|
||||
/** 0 disables the eval cache. Additive to `updateIntervalSeconds` — see README. */
|
||||
evalCacheTtlMs: number;
|
||||
evalCacheMaxEntries: number;
|
||||
localOverrides: Record<string, string>;
|
||||
};
|
||||
|
||||
export type FliptConfig = FliptConnection & FliptTuning;
|
||||
|
||||
export function loadFliptTuning(): FliptTuning {
|
||||
const parsed = tuningSchema.parse(process.env);
|
||||
return {
|
||||
environment: parsed.FLIPT_ENVIRONMENT,
|
||||
deploymentId: parsed.FLIPT_DEPLOYMENT_ID,
|
||||
updateIntervalSeconds: 60,
|
||||
initTimeoutMs: 5000,
|
||||
failureCooldownMs: 30_000,
|
||||
evalCacheTtlMs: parseCacheTtl(parsed.FLIPT_EVAL_CACHE_TTL_MS),
|
||||
evalCacheMaxEntries: 10_000,
|
||||
// NODE_ENV is a universal Node convention (not Next-specific), so it's fine for a package.
|
||||
localOverrides:
|
||||
process.env.NODE_ENV === 'production'
|
||||
? {}
|
||||
: parseLocalOverrides(parsed.FLIPT_LOCAL_OVERRIDES),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadFliptConnection(): FliptConnection {
|
||||
const parsed = connectionSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
'[@civitai/flipt] Invalid environment variables:\n' + z.prettifyError(parsed.error)
|
||||
);
|
||||
}
|
||||
return { url: parsed.data.FLIPT_URL, clientToken: parsed.data.FLIPT_FETCHER_SECRET };
|
||||
}
|
||||
|
||||
// Lazy + memoized: importing this module does NOT touch process.env. Validation runs only when
|
||||
// the factory calls loadFliptEnv() — so a bare import (build, script, test) never throws.
|
||||
let _env: FliptConfig | undefined;
|
||||
export function loadFliptEnv(): FliptConfig {
|
||||
return (_env ??= { ...loadFliptConnection(), ...loadFliptTuning() });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './client';
|
||||
export {
|
||||
loadFliptEnv,
|
||||
loadFliptConnection,
|
||||
loadFliptTuning,
|
||||
parseLocalOverrides,
|
||||
type FliptConfig,
|
||||
type FliptConnection,
|
||||
type FliptTuning,
|
||||
} from './env';
|
||||
export { TtlCache, fliptCacheKey } from './cache';
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Generated
+19
@@ -58,6 +58,9 @@ importers:
|
||||
'@civitai/db-schema':
|
||||
specifier: workspace:*
|
||||
version: link:packages/civitai-db-schema
|
||||
'@civitai/flipt':
|
||||
specifier: workspace:*
|
||||
version: link:packages/civitai-flipt
|
||||
'@civitai/next-axiom':
|
||||
specifier: ^0.17.0
|
||||
version: 0.17.0(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(@types/node@24.13.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))
|
||||
@@ -960,6 +963,9 @@ importers:
|
||||
'@civitai/db-schema':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/civitai-db-schema
|
||||
'@civitai/flipt':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/civitai-flipt
|
||||
'@civitai/redis':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/civitai-redis
|
||||
@@ -1442,6 +1448,19 @@ importers:
|
||||
specifier: ^6.4.7
|
||||
version: 6.4.17
|
||||
|
||||
packages/civitai-flipt:
|
||||
dependencies:
|
||||
'@flipt-io/flipt-client-js':
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
zod:
|
||||
specifier: ^4.0.17
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.13.3)(@vitest/browser-playwright@4.0.18)(happy-dom@20.9.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(jiti@2.7.0)(jsdom@27.4.0(@noble/hashes@1.8.0)(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.32.0)(msw@2.12.10(@types/node@24.13.3)(typescript@5.9.3))(sass@1.90.0)(sugarss@5.0.1(postcss@8.5.23))(terser@5.48.0)(tsx@4.20.3)(yaml@2.8.1)
|
||||
|
||||
packages/civitai-notifications:
|
||||
dependencies:
|
||||
zod:
|
||||
|
||||
+22
-302
@@ -1,4 +1,4 @@
|
||||
import { FliptClient } from '@flipt-io/flipt-client-js';
|
||||
import { createFliptClient } from '@civitai/flipt';
|
||||
import { env } from '~/env/server';
|
||||
import { logToAxiom } from '../logging/client';
|
||||
|
||||
@@ -80,35 +80,6 @@ export enum FLIPT_FEATURE_FLAGS {
|
||||
METRIC_REACTION_REPAIR = 'metric-reaction-repair',
|
||||
}
|
||||
|
||||
const FLIPT_INIT_TIMEOUT_MS = 5000;
|
||||
const FLIPT_FAILURE_COOLDOWN_MS = 30_000;
|
||||
|
||||
// Per-request wasm `evaluateBoolean`/`evaluateVariant` calls showed up as a
|
||||
// top-10 CPU frame under load (~1500 req/s on a single JS thread). The wasm
|
||||
// engine result for a given (flag, entityId, context) is stable between config
|
||||
// refreshes, and the client only pulls new config every `updateInterval` (60s).
|
||||
// A short in-process TTL cache collapses the wasm call rate.
|
||||
//
|
||||
// Staleness note: the TTL is ADDITIVE to the 60s config poll, not absorbed by
|
||||
// it — worst-case propagation of a flipped flag is ~(60s + TTL) per pod, and
|
||||
// pods converge independently. That's fine for gradual rollout flags; incident
|
||||
// kill-switches that must take effect ASAP are listed in BYPASS below. Tune via
|
||||
// FLIPT_EVAL_CACHE_TTL_MS (set to 0 to disable).
|
||||
const FLIPT_EVAL_CACHE_TTL_MS = (() => {
|
||||
// parseInt is intentional (integer ms). Note a non-integer env like "0.5"
|
||||
// parses to 0 → cache disabled, and "1e4" parses to 1 — both surprising, so
|
||||
// the resolved value is logged below for operator visibility.
|
||||
const parsed = parseInt(process.env.FLIPT_EVAL_CACHE_TTL_MS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 10_000;
|
||||
})();
|
||||
console.log(`[flipt] eval cache TTL: ${FLIPT_EVAL_CACHE_TTL_MS}ms (0 = disabled)`);
|
||||
// Per-generation entry cap. entityId/context are per-user for some flags, so the
|
||||
// keyspace is unbounded; we rotate generations at this size (see TtlCache).
|
||||
// Steady-state live entries are bounded to ~2x this; a burst of distinct
|
||||
// promoting reads with no intervening insert can transiently reach ~4x before
|
||||
// the next insert rotates — still bounded, and entries are tiny.
|
||||
const FLIPT_EVAL_CACHE_MAX = 10_000;
|
||||
|
||||
// Flags exempt from caching: incident kill-switches where an operator expects a
|
||||
// flip to take effect ASAP and the eval is either rare (cold path) or the extra
|
||||
// staleness is not worth the CPU saved.
|
||||
@@ -139,277 +110,28 @@ const FLIPT_EVAL_CACHE_BYPASS = new Set<string>([
|
||||
FLIPT_FEATURE_FLAGS.MINOR_HASH_AUTO_FLAG,
|
||||
]);
|
||||
|
||||
type FliptCacheEntry<T> = { value: T; expiresAt: number };
|
||||
const flipt = createFliptClient({
|
||||
url: env.FLIPT_URL,
|
||||
clientToken: env.FLIPT_FETCHER_SECRET,
|
||||
cacheBypass: FLIPT_EVAL_CACHE_BYPASS,
|
||||
onInitError: (error) => {
|
||||
logToAxiom(
|
||||
{
|
||||
type: 'init-flipt-error',
|
||||
error: error.message,
|
||||
cause: error.cause,
|
||||
stack: error.stack,
|
||||
},
|
||||
'temp-search'
|
||||
).catch();
|
||||
},
|
||||
});
|
||||
|
||||
// TTL cache with generational rotation instead of full-clear eviction. On
|
||||
// overflow the current generation becomes the "previous" one (the old previous
|
||||
// is dropped) and a fresh generation starts, so hot keys survive at least one
|
||||
// rotation and we never thrash to worse-than-no-cache under high key
|
||||
// cardinality. Single-threaded, so Map ops need no locking.
|
||||
class TtlCache<T> {
|
||||
private current = new Map<string, FliptCacheEntry<T>>();
|
||||
private previous = new Map<string, FliptCacheEntry<T>>();
|
||||
|
||||
get(key: string, now: number): { hit: boolean; value?: T } {
|
||||
const cur = this.current.get(key);
|
||||
if (cur) {
|
||||
if (cur.expiresAt > now) return { hit: true, value: cur.value };
|
||||
this.current.delete(key);
|
||||
}
|
||||
const prev = this.previous.get(key);
|
||||
if (prev) {
|
||||
if (prev.expiresAt > now) {
|
||||
// Promote into the current generation so hot keys aren't lost on rotate.
|
||||
this.previous.delete(key);
|
||||
this.current.set(key, prev);
|
||||
return { hit: true, value: prev.value };
|
||||
}
|
||||
this.previous.delete(key);
|
||||
}
|
||||
return { hit: false };
|
||||
}
|
||||
|
||||
set(key: string, value: T, now: number): void {
|
||||
if (FLIPT_EVAL_CACHE_TTL_MS === 0) return;
|
||||
if (this.current.size >= FLIPT_EVAL_CACHE_MAX) {
|
||||
this.previous = this.current;
|
||||
this.current = new Map();
|
||||
}
|
||||
this.current.set(key, { value, expiresAt: now + FLIPT_EVAL_CACHE_TTL_MS });
|
||||
}
|
||||
}
|
||||
|
||||
const boolEvalCache = new TtlCache<boolean>();
|
||||
const variantEvalCache = new TtlCache<string | null>();
|
||||
|
||||
// Build a collision-proof cache key. Components are URI-encoded so that a `|`,
|
||||
// `&`, or `=` inside an entityId or context value can't alias another key (the
|
||||
// context signature today is operator-controlled, but encoding makes the cache
|
||||
// safe for any future caller passing free-form values).
|
||||
function fliptCacheKey(flag: string, entityId: string, context: Record<string, string>): string {
|
||||
const keys = Object.keys(context);
|
||||
if (keys.length === 0) return `${flag}|${encodeURIComponent(entityId)}`;
|
||||
const ctx = keys
|
||||
.sort()
|
||||
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(context[k])}`)
|
||||
.join('&');
|
||||
return `${flag}|${encodeURIComponent(entityId)}|${ctx}`;
|
||||
}
|
||||
|
||||
// Evaluate a boolean flag against the wasm engine, memoized. Throws on engine
|
||||
// error so callers keep their existing try/catch fallback; only successful
|
||||
// evaluations are cached.
|
||||
function evalBooleanCached(
|
||||
fliptClient: FliptClient,
|
||||
flag: string,
|
||||
entityId: string,
|
||||
context: Record<string, string>
|
||||
): boolean {
|
||||
if (FLIPT_EVAL_CACHE_BYPASS.has(flag)) {
|
||||
return fliptClient.evaluateBoolean({ flagKey: flag, entityId, context }).enabled;
|
||||
}
|
||||
const now = Date.now();
|
||||
const key = fliptCacheKey(flag, entityId, context);
|
||||
const cached = boolEvalCache.get(key, now);
|
||||
if (cached.hit) return cached.value as boolean;
|
||||
const evaluation = fliptClient.evaluateBoolean({ flagKey: flag, entityId, context });
|
||||
boolEvalCache.set(key, evaluation.enabled, now);
|
||||
return evaluation.enabled;
|
||||
}
|
||||
|
||||
// Evaluate a variant flag against the wasm engine, memoized. Caches the
|
||||
// post-processed result (variantKey, or null when no match).
|
||||
function evalVariantCached(
|
||||
fliptClient: FliptClient,
|
||||
flag: string,
|
||||
entityId: string,
|
||||
context: Record<string, string>
|
||||
): string | null {
|
||||
if (FLIPT_EVAL_CACHE_BYPASS.has(flag)) {
|
||||
const evaluation = fliptClient.evaluateVariant({ flagKey: flag, entityId, context });
|
||||
return evaluation.match ? evaluation.variantKey : null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const key = fliptCacheKey(flag, entityId, context);
|
||||
const cached = variantEvalCache.get(key, now);
|
||||
if (cached.hit) return cached.value as string | null;
|
||||
const evaluation = fliptClient.evaluateVariant({ flagKey: flag, entityId, context });
|
||||
const result = evaluation.match ? evaluation.variantKey : null;
|
||||
variantEvalCache.set(key, result, now);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Dev-only local overrides. Set FLIPT_LOCAL_OVERRIDES in .env to short-circuit
|
||||
// flag evaluation without touching shared Flipt state (GitOps overwrites it).
|
||||
// Format: comma-separated `flagKey=variantKey` pairs. Use `on`/`off` for booleans.
|
||||
// Example: FLIPT_LOCAL_OVERRIDES=bitdex-image-search=primary,my-bool-flag=on
|
||||
function parseLocalOverrides(): Record<string, string> {
|
||||
if (process.env.NODE_ENV === 'production') return {};
|
||||
const raw = process.env.FLIPT_LOCAL_OVERRIDES;
|
||||
if (!raw) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const pair of raw.split(',')) {
|
||||
const [k, v] = pair.split('=').map((s) => s.trim());
|
||||
if (k && v) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const localOverrides = parseLocalOverrides();
|
||||
|
||||
class FliptSingleton {
|
||||
private static instance: FliptClient | null = null;
|
||||
private static initializing: Promise<FliptClient | null> | null = null;
|
||||
private static lastFailureTime = 0;
|
||||
|
||||
private constructor() {
|
||||
// Prevent direct construction
|
||||
}
|
||||
|
||||
static getInstanceSync(): FliptClient | null {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
static async getInstance(): Promise<FliptClient | null> {
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// Circuit breaker: skip re-init during cooldown after a failure
|
||||
if (Date.now() - this.lastFailureTime < FLIPT_FAILURE_COOLDOWN_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.initializing) {
|
||||
// If initialization is already in progress, wait for it
|
||||
return this.initializing;
|
||||
}
|
||||
|
||||
this.initializing = (async () => {
|
||||
try {
|
||||
const internalAuthHeader = env.FLIPT_FETCHER_SECRET;
|
||||
|
||||
const initPromise = (async () => {
|
||||
const fliptClient = await FliptClient.init({
|
||||
environment: 'civitai-app',
|
||||
url: env.FLIPT_URL,
|
||||
authentication: {
|
||||
clientToken: internalAuthHeader,
|
||||
},
|
||||
updateInterval: 60, // Fetch feature flag updates (default: 120 seconds)
|
||||
});
|
||||
await fliptClient.refresh();
|
||||
return fliptClient;
|
||||
})();
|
||||
|
||||
// Attach a no-op catch to prevent unhandled rejection if timeout wins
|
||||
// but initPromise later rejects
|
||||
initPromise.catch(() => null);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Flipt init timeout')), FLIPT_INIT_TIMEOUT_MS)
|
||||
);
|
||||
|
||||
const fliptClient = await Promise.race([initPromise, timeoutPromise]);
|
||||
this.instance = fliptClient;
|
||||
return this.instance;
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
|
||||
logToAxiom(
|
||||
{
|
||||
type: 'init-flipt-error',
|
||||
error: err.message,
|
||||
cause: err.cause,
|
||||
stack: err.stack,
|
||||
},
|
||||
'temp-search'
|
||||
).catch();
|
||||
|
||||
this.instance = null;
|
||||
this.lastFailureTime = Date.now();
|
||||
return null;
|
||||
} finally {
|
||||
this.initializing = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.initializing;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isFlipt(
|
||||
flag: string,
|
||||
entityId = 'global',
|
||||
context: Record<string, string> = {}
|
||||
) {
|
||||
if (localOverrides[flag] !== undefined) return localOverrides[flag] === 'on';
|
||||
const fliptClient = await FliptSingleton.getInstance();
|
||||
if (!fliptClient) return false;
|
||||
|
||||
try {
|
||||
return evalBooleanCached(fliptClient, flag, entityId, context);
|
||||
} catch (e) {
|
||||
console.error('Flipt evaluation error:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFliptVariant(
|
||||
flag: string,
|
||||
entityId = 'global',
|
||||
context: Record<string, string> = {}
|
||||
): Promise<string | null> {
|
||||
if (localOverrides[flag] !== undefined) return localOverrides[flag];
|
||||
const fliptClient = await FliptSingleton.getInstance();
|
||||
if (!fliptClient) return null;
|
||||
|
||||
try {
|
||||
return evalVariantCached(fliptClient, flag, entityId, context);
|
||||
} catch (e) {
|
||||
console.error('Flipt variant evaluation error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFliptBoolean(
|
||||
flag: string,
|
||||
entityId = 'global',
|
||||
context: Record<string, string> = {}
|
||||
): Promise<boolean> {
|
||||
const fliptClient = await FliptSingleton.getInstance();
|
||||
if (!fliptClient) return false;
|
||||
|
||||
try {
|
||||
return evalBooleanCached(fliptClient, flag, entityId, context);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous Flipt evaluation. Returns `boolean` if Flipt is ready,
|
||||
* or `null` if the client hasn't initialized yet (caller should fall back).
|
||||
*/
|
||||
export function isFliptSync(
|
||||
flag: string,
|
||||
entityId = 'global',
|
||||
context: Record<string, string> = {}
|
||||
): boolean | null {
|
||||
if (localOverrides[flag] !== undefined) return localOverrides[flag] === 'on';
|
||||
const fliptClient = FliptSingleton.getInstanceSync();
|
||||
if (!fliptClient) return null;
|
||||
|
||||
try {
|
||||
return evalBooleanCached(fliptClient, flag, entityId, context);
|
||||
} catch (e) {
|
||||
// Swallow eval errors (incl. "flag not found"); caller falls back to null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureFliptInitialized(): Promise<void> {
|
||||
await FliptSingleton.getInstance();
|
||||
}
|
||||
export const isFlipt = flipt.isEnabled;
|
||||
export const getFliptVariant = flipt.getVariant;
|
||||
export const getFliptBoolean = flipt.getBoolean;
|
||||
export const isFliptSync = flipt.isEnabledSync;
|
||||
export const ensureFliptInitialized = flipt.ensureInitialized;
|
||||
|
||||
// Build the inner `(entityId, metricType, day, total)` subquery the direct CH
|
||||
// read sites (search-index / comic populate / metric-helpers) sum over. `where`
|
||||
@@ -433,5 +155,3 @@ export function buildEntityMetricPerDaySource(where: string): string {
|
||||
${where}
|
||||
)`;
|
||||
}
|
||||
|
||||
export default FliptSingleton;
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"@civitai/clickhouse/*": ["./packages/civitai-clickhouse/src/*"],
|
||||
"@civitai/axiom": ["./packages/civitai-axiom/src/index"],
|
||||
"@civitai/axiom/*": ["./packages/civitai-axiom/src/*"],
|
||||
"@civitai/flipt": ["./packages/civitai-flipt/src/index"],
|
||||
"@civitai/flipt/*": ["./packages/civitai-flipt/src/*"],
|
||||
"@civitai/telemetry": ["./packages/civitai-telemetry/src/index"],
|
||||
"@civitai/telemetry/*": ["./packages/civitai-telemetry/src/*"],
|
||||
"@civitai/brand": ["./packages/civitai-brand/src/index"],
|
||||
|
||||
@@ -13,6 +13,10 @@ const civitaiWorkspacePkgs = [
|
||||
'redis',
|
||||
'clickhouse',
|
||||
'axiom',
|
||||
// `@civitai/flipt` (packages/civitai-flipt) backs src/server/flipt/client.ts, which the
|
||||
// feature-flag and image suites pull in. Same story as the others: not symlinked into root
|
||||
// node_modules, so without this alias those suites fail to collect.
|
||||
'flipt',
|
||||
'telemetry',
|
||||
'brand',
|
||||
// `@civitai/notifications` (packages/civitai-notifications) is re-exported by
|
||||
|
||||
Reference in New Issue
Block a user