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:
briant
2026-08-10 12:20:41 -06:00
parent a625df4957
commit 0f5f28b90d
21 changed files with 798 additions and 302 deletions
+10
View File
@@ -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).
+1
View File
@@ -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;
}
+1
View File
@@ -23,6 +23,7 @@ export default defineConfig(({ mode }) => {
'@civitai/clickhouse',
'@civitai/db',
'@civitai/db-schema',
'@civitai/flipt',
'@civitai/redis',
'@civitai/shared',
'@civitai/ui',
+1
View File
@@ -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
+1
View File
@@ -151,6 +151,7 @@ export default defineNextConfig(
'@civitai/redis',
'@civitai/clickhouse',
'@civitai/axiom',
'@civitai/flipt',
'@civitai/telemetry',
'@civitai/auth',
'@civitai/notifications',
+1
View File
@@ -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",
+75
View File
@@ -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`.
+18
View File
@@ -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();
});
});
+61
View File
@@ -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}`;
}
+235
View File
@@ -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;
}
},
};
}
+99
View File
@@ -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() });
}
+11
View File
@@ -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';
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
+19
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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"],
+4
View File
@@ -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