mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: bootstrap Krill Switch flags during SSR
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.3.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
|
||||
"@openclaw/krillswitch-react": "0.0.1",
|
||||
"@openclaw/plugin-inspector": "0.3.17",
|
||||
"@radix-ui/react-avatar": "1.2.3",
|
||||
"@radix-ui/react-dialog": "1.1.20",
|
||||
@@ -452,6 +453,10 @@
|
||||
|
||||
"@openclaw/clawhub-admin": ["@openclaw/clawhub-admin@workspace:packages/clawhub-admin"],
|
||||
|
||||
"@openclaw/krillswitch-core": ["@openclaw/krillswitch-core@0.0.1", "", {}, "sha512-wL6vicqkJ+nyiKjpOaM2Z4xH/uPKonVCm/U1Lo23mJ0ubqKPRA/SFDw9haRyBAejkpRlyqu+Pf2YRBX/eq1h/w=="],
|
||||
|
||||
"@openclaw/krillswitch-react": ["@openclaw/krillswitch-react@0.0.1", "", { "dependencies": { "@openclaw/krillswitch-core": "0.0.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-dC42PpC9b1pIeO7k50z5gDgDSagkizkASxFk+WMQ2lf5RA1CLNNy8KmQtex8q1HUtpejrX5xyj7PmBm5sfVzBw=="],
|
||||
|
||||
"@openclaw/plugin-inspector": ["@openclaw/plugin-inspector@0.3.17", "", { "bin": { "plugin-inspector": "src/cli.js" } }, "sha512-JPPHPhiXMsIvrV8UR8RQjhflMjRZX/uIhy9meE81dup7MMSnRJcsTGOXYACohv6e4z2P95z2QuE7nZkWT6Ysuw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"@fontsource/noto-sans-sc": "5.3.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@openclaw/carapace": "git+https://github.com/openclaw/carapace.git#v0.2.0",
|
||||
"@openclaw/krillswitch-react": "0.0.1",
|
||||
"@openclaw/plugin-inspector": "0.3.17",
|
||||
"@radix-ui/react-avatar": "1.2.3",
|
||||
"@radix-ui/react-dialog": "1.1.20",
|
||||
|
||||
@@ -28,6 +28,7 @@ into `docs/` and leave only the design record here.
|
||||
- `manual-testing.md`: maintainer CLI smoke checklist.
|
||||
- `dev-worktrees.md`: disposable Worktrunk/Codex worktree lifecycle contract.
|
||||
- `dev-seeding.md`: local development fixture seeding ownership rules.
|
||||
- `feature-flags.md`: Krill Switch SSR, hydration, identity, and fallback contract.
|
||||
- `mintlify.md`: docs publishing setup notes.
|
||||
- `openclaw-docs-extraction.md`: CLAW-89 extraction classification.
|
||||
- `deploy.md`: maintainer deploy checklist for the ClawHub project.
|
||||
|
||||
+17
-12
@@ -1,26 +1,31 @@
|
||||
# Feature flags
|
||||
|
||||
ClawHub evaluates release flags through Krill Switch. Flags are a client-side
|
||||
progressive enhancement, not an authorization or security boundary: protected
|
||||
operations must continue to enforce their rules in Convex and HTTP handlers.
|
||||
ClawHub evaluates release flags through Krill Switch during server rendering,
|
||||
then keeps them fresh in the browser. Flags are not an authorization or security
|
||||
boundary: protected operations must continue to enforce their rules in Convex
|
||||
and HTTP handlers.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
- The browser calls `POST https://flags.openclaw.ai/v1/eval` using the public
|
||||
environment evaluation key from `VITE_KRILLSWITCH_EVAL_KEY`.
|
||||
- The root server loader calls `POST https://flags.openclaw.ai/v1/eval` using the
|
||||
public environment evaluation key from `VITE_KRILLSWITCH_EVAL_KEY`, then
|
||||
serializes those values for hydration. Visible flagged content must not render
|
||||
a different code default before hydration.
|
||||
- `VITE_KRILLSWITCH_BASE_URL` can override the evaluation origin for local
|
||||
testing. It defaults to the production evaluation host.
|
||||
- Missing configuration, network errors, invalid payloads, and incompatible
|
||||
remote value types preserve code-owned defaults and must not block rendering.
|
||||
- Evaluations use a persisted anonymous context key. Do not add personal or
|
||||
sensitive attributes without documenting why the targeting requires them.
|
||||
remote value types preserve code-owned defaults. Server evaluation has a
|
||||
200 ms budget and must not block rendering beyond it.
|
||||
- Evaluations use an anonymous context key persisted in a first-party HTTP-only
|
||||
cookie and passed to the hydrated provider. The server and browser must use
|
||||
the same key so targeting and percentage rollouts remain stable. Do not add
|
||||
personal or sensitive attributes without documenting why targeting needs them.
|
||||
- Values refresh when the page becomes visible and every 60 seconds. ETags
|
||||
avoid retransmitting unchanged evaluations.
|
||||
|
||||
The Krill Switch SDK packages are private today, so ClawHub owns a small typed
|
||||
adapter around the stable public evaluation API. Replace the adapter with the
|
||||
official React SDK once it is published rather than growing a second general
|
||||
feature-flag SDK here.
|
||||
The official `@openclaw/krillswitch-react` SDK owns response validation, typed
|
||||
manifest merging, SSR evaluation, hydration bootstrap, caching, and polling.
|
||||
Keep ClawHub's adapter limited to runtime configuration and app-specific flags.
|
||||
|
||||
## Initial proof flag
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
routeToBannedAccountPage as navigateToBannedAccountPage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { isCliDeviceUserCode } from "../lib/cliDeviceCode";
|
||||
import { FeatureFlagProvider } from "../lib/featureFlags";
|
||||
import { FeatureFlagProvider, type FeatureFlagValues } from "../lib/featureFlags";
|
||||
import { clearAuthError, setAuthError, useAuthError } from "../lib/useAuthError";
|
||||
import { AuthErrorMessage } from "./AuthErrorMessage";
|
||||
import { ClientOnly } from "./ClientOnly";
|
||||
@@ -181,10 +181,18 @@ export function AuthErrorToast() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
export function AppProviders({
|
||||
children,
|
||||
featureFlagContextKey,
|
||||
initialFeatureFlags,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
featureFlagContextKey: string;
|
||||
initialFeatureFlags: FeatureFlagValues | null;
|
||||
}) {
|
||||
return (
|
||||
<ConvexAuthProvider client={convex} shouldHandleCode={false}>
|
||||
<FeatureFlagProvider>
|
||||
<FeatureFlagProvider contextKey={featureFlagContextKey} initialValues={initialFeatureFlags}>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<AuthCodeHandler />
|
||||
<AuthErrorHandler />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const FEATURE_FLAG_DEFAULTS: FeatureFlagValues = {
|
||||
souls: false,
|
||||
};
|
||||
|
||||
export type FeatureFlagValues = {
|
||||
souls: boolean;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createKrillswitchEvaluator } from "@openclaw/krillswitch-react/server";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getCookie, setCookie } from "@tanstack/react-start/server";
|
||||
import { FEATURE_FLAG_DEFAULTS, type FeatureFlagValues } from "./featureFlagManifest";
|
||||
import { getRuntimeEnv, isDevRuntime } from "./runtimeEnv";
|
||||
|
||||
const DEFAULT_KRILLSWITCH_BASE_URL = "https://flags.openclaw.ai";
|
||||
const FEATURE_FLAG_CONTEXT_COOKIE = "clawhub-feature-flag-context";
|
||||
const FEATURE_FLAG_CONTEXT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||
const SSR_EVALUATION_TIMEOUT_MS = 200;
|
||||
|
||||
const evaluateFlags = createKrillswitchEvaluator(FEATURE_FLAG_DEFAULTS);
|
||||
|
||||
type InitialFeatureFlags = {
|
||||
contextKey: string;
|
||||
values: FeatureFlagValues | null;
|
||||
};
|
||||
|
||||
function getOrCreateContextKey(): string {
|
||||
const existing = getCookie(FEATURE_FLAG_CONTEXT_COOKIE)?.trim();
|
||||
if (existing) return existing;
|
||||
|
||||
const contextKey = `anon-${crypto.randomUUID()}`;
|
||||
setCookie(FEATURE_FLAG_CONTEXT_COOKIE, contextKey, {
|
||||
httpOnly: true,
|
||||
maxAge: FEATURE_FLAG_CONTEXT_MAX_AGE_SECONDS,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: !isDevRuntime(),
|
||||
});
|
||||
return contextKey;
|
||||
}
|
||||
|
||||
export async function evaluateInitialFeatureFlags(args: {
|
||||
baseUrl: string;
|
||||
contextKey: string;
|
||||
evalKey: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<FeatureFlagValues> {
|
||||
return await evaluateFlags({
|
||||
baseUrl: args.baseUrl,
|
||||
context: { key: args.contextKey },
|
||||
evalKey: args.evalKey,
|
||||
signal: args.signal,
|
||||
});
|
||||
}
|
||||
|
||||
export const loadInitialFeatureFlags = createServerFn({ method: "GET" }).handler(
|
||||
async (): Promise<InitialFeatureFlags> => {
|
||||
const contextKey = getOrCreateContextKey();
|
||||
const evalKey = getRuntimeEnv("VITE_KRILLSWITCH_EVAL_KEY");
|
||||
if (!evalKey) return { contextKey, values: null };
|
||||
|
||||
try {
|
||||
const values = await evaluateInitialFeatureFlags({
|
||||
baseUrl: getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL,
|
||||
contextKey,
|
||||
evalKey,
|
||||
signal: AbortSignal.timeout(SSR_EVALUATION_TIMEOUT_MS),
|
||||
});
|
||||
return { contextKey, values };
|
||||
} catch (error) {
|
||||
console.warn("Krill Switch SSR evaluation failed; using code defaults.", error);
|
||||
return { contextKey, values: null };
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { evaluateInitialFeatureFlags } from "./featureFlags.functions";
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
describe("server feature flag evaluation", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("evaluates the manifest with the stable SSR context", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ flags: { souls: { value: true } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
evaluateInitialFeatureFlags({
|
||||
baseUrl: "https://flags.openclaw.ai",
|
||||
contextKey: "anon-stable-context",
|
||||
evalKey: "ks_clawhub_production_public",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toEqual({ souls: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://flags.openclaw.ai/v1/eval",
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ context: { key: "anon-stable-context" } }),
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer ks_clawhub_production_public",
|
||||
}),
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+28
-106
@@ -2,17 +2,14 @@
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { evaluateFeatureFlags, FeatureFlagProvider, useFeatureFlag } from "./featureFlags";
|
||||
import { FeatureFlagProvider, useFeatureFlag } from "./featureFlags";
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
function evalResponse(value: unknown, options?: { etag?: string; status?: number }): Response {
|
||||
function evalResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify({ flags: { souls: { value } } }), {
|
||||
status: options?.status ?? 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(options?.etag ? { etag: options.etag } : {}),
|
||||
},
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,130 +25,55 @@ describe("feature flags", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("evaluates flags through the public Krill Switch API", async () => {
|
||||
fetchMock.mockResolvedValueOnce(evalResponse(true, { etag: 'W/"flags-v1"' }));
|
||||
|
||||
const result = await evaluateFeatureFlags({
|
||||
baseUrl: "https://flags.openclaw.ai",
|
||||
contextKey: "user-123",
|
||||
evalKey: "ks_clawhub_production_public",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "updated",
|
||||
etag: 'W/"flags-v1"',
|
||||
values: { souls: true },
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL("https://flags.openclaw.ai/v1/eval"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
authorization: "Bearer ks_clawhub_production_public",
|
||||
}),
|
||||
body: JSON.stringify({ context: { key: "user-123" } }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the code default when a remote value has the wrong type", async () => {
|
||||
fetchMock.mockResolvedValueOnce(evalResponse("yes"));
|
||||
|
||||
await expect(
|
||||
evaluateFeatureFlags({
|
||||
baseUrl: "https://flags.openclaw.ai/",
|
||||
contextKey: "user-123",
|
||||
evalKey: "ks_clawhub_production_public",
|
||||
}),
|
||||
).resolves.toMatchObject({ values: { souls: false } });
|
||||
});
|
||||
|
||||
it("renders defaults immediately and applies a successful evaluation", async () => {
|
||||
let resolveFetch: ((response: Response) => void) | undefined;
|
||||
fetchMock.mockReturnValueOnce(
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<FeatureFlagProvider
|
||||
baseUrl="https://flags.openclaw.ai"
|
||||
contextKey="user-123"
|
||||
evalKey="ks_clawhub_production_public"
|
||||
>
|
||||
<FlagProbe />
|
||||
</FeatureFlagProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("safe default")).toBeTruthy();
|
||||
await act(async () => {
|
||||
resolveFetch?.(evalResponse(true));
|
||||
});
|
||||
expect(screen.getByText("has soul")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps cached values out of the first render to prevent hydration mismatches", () => {
|
||||
localStorage.setItem(
|
||||
"clawhub.featureFlags.ks_clawhub_production_public.user-123",
|
||||
JSON.stringify({ souls: true }),
|
||||
);
|
||||
it("hydrates from server values without rendering the code default first", () => {
|
||||
fetchMock.mockReturnValueOnce(new Promise<Response>(() => {}));
|
||||
const observedValues: boolean[] = [];
|
||||
|
||||
function FirstRenderProbe() {
|
||||
observedValues.push(useFeatureFlag("souls"));
|
||||
return null;
|
||||
return <FlagProbe />;
|
||||
}
|
||||
|
||||
render(
|
||||
<FeatureFlagProvider contextKey="user-123" evalKey="ks_clawhub_production_public">
|
||||
<FirstRenderProbe />
|
||||
</FeatureFlagProvider>,
|
||||
);
|
||||
|
||||
expect(observedValues[0]).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the ETag on later polls and preserves the current value on 304", async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(evalResponse(true, { etag: 'W/"flags-v1"' }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 304 }));
|
||||
|
||||
render(
|
||||
<FeatureFlagProvider
|
||||
baseUrl="https://flags.openclaw.ai"
|
||||
contextKey="user-123"
|
||||
evalKey="ks_clawhub_production_public"
|
||||
pollIntervalMs={1_000}
|
||||
initialValues={{ souls: true }}
|
||||
>
|
||||
<FirstRenderProbe />
|
||||
</FeatureFlagProvider>,
|
||||
);
|
||||
|
||||
expect(observedValues[0]).toBe(true);
|
||||
expect(screen.getByText("has soul")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies a successful browser refresh after hydration", async () => {
|
||||
fetchMock.mockResolvedValueOnce(evalResponse(false));
|
||||
|
||||
render(
|
||||
<FeatureFlagProvider
|
||||
baseUrl="https://flags.openclaw.ai"
|
||||
contextKey="user-123"
|
||||
evalKey="ks_clawhub_production_public"
|
||||
initialValues={{ souls: true }}
|
||||
>
|
||||
<FlagProbe />
|
||||
</FeatureFlagProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("has soul")).toBeTruthy();
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByText("has soul")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ "if-none-match": 'W/"flags-v1"' }),
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("has soul")).toBeTruthy();
|
||||
expect(screen.getByText("safe default")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not call the service when no evaluation key is configured", () => {
|
||||
it("renders code defaults without contacting Krill when no evaluation key is configured", () => {
|
||||
render(
|
||||
<FeatureFlagProvider evalKey="">
|
||||
<FlagProbe />
|
||||
|
||||
+29
-223
@@ -1,237 +1,43 @@
|
||||
import { createContext, type ReactNode, useContext, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { createKrillswitch } from "@openclaw/krillswitch-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FEATURE_FLAG_DEFAULTS, type FeatureFlagValues } from "./featureFlagManifest";
|
||||
import { getRuntimeEnv } from "./runtimeEnv";
|
||||
|
||||
const DEFAULT_KRILLSWITCH_BASE_URL = "https://flags.openclaw.ai";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 60_000;
|
||||
const ANONYMOUS_CONTEXT_STORAGE_KEY = "clawhub.featureFlags.anonymousContext";
|
||||
const krill = createKrillswitch(FEATURE_FLAG_DEFAULTS);
|
||||
|
||||
export const FEATURE_FLAG_DEFAULTS = {
|
||||
souls: false,
|
||||
};
|
||||
|
||||
export type FeatureFlagValues = {
|
||||
souls: boolean;
|
||||
};
|
||||
|
||||
type FeatureFlagEvaluation =
|
||||
| { kind: "not-modified" }
|
||||
| { kind: "updated"; etag: string | null; values: FeatureFlagValues };
|
||||
|
||||
type EvaluateFeatureFlagsOptions = {
|
||||
baseUrl: string;
|
||||
contextKey: string;
|
||||
etag?: string | null;
|
||||
evalKey: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
type FeatureFlagProviderProps = {
|
||||
baseUrl?: string;
|
||||
children: ReactNode;
|
||||
contextKey?: string;
|
||||
evalKey?: string;
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
const evalResponseSchema = z.object({
|
||||
flags: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
value: z.unknown(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const cachedFlagsSchema = z.object({
|
||||
souls: z.boolean(),
|
||||
});
|
||||
|
||||
const FeatureFlagsContext = createContext<FeatureFlagValues>(FEATURE_FLAG_DEFAULTS);
|
||||
|
||||
function evaluationUrl(baseUrl: string): URL {
|
||||
const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
return new URL("v1/eval", normalizedBaseUrl);
|
||||
}
|
||||
|
||||
function valuesFromResponse(payload: unknown): FeatureFlagValues {
|
||||
const parsed = evalResponseSchema.safeParse(payload);
|
||||
if (!parsed.success) return FEATURE_FLAG_DEFAULTS;
|
||||
|
||||
const souls = parsed.data.flags.souls?.value;
|
||||
return {
|
||||
souls: typeof souls === "boolean" ? souls : FEATURE_FLAG_DEFAULTS.souls,
|
||||
};
|
||||
}
|
||||
|
||||
export async function evaluateFeatureFlags({
|
||||
baseUrl,
|
||||
contextKey,
|
||||
etag,
|
||||
evalKey,
|
||||
signal,
|
||||
}: EvaluateFeatureFlagsOptions): Promise<FeatureFlagEvaluation> {
|
||||
const headers: Record<string, string> = {
|
||||
authorization: `Bearer ${evalKey}`,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (etag) headers["if-none-match"] = etag;
|
||||
|
||||
const response = await fetch(evaluationUrl(baseUrl), {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ context: { key: contextKey } }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.status === 304) return { kind: "not-modified" };
|
||||
if (!response.ok) {
|
||||
throw new Error(`Krill Switch evaluation failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
return {
|
||||
kind: "updated",
|
||||
etag: response.headers.get("etag"),
|
||||
values: valuesFromResponse(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function safeLocalStorage(): Storage | null {
|
||||
try {
|
||||
return typeof window === "undefined" ? null : window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storedValue(key: string): string | null {
|
||||
try {
|
||||
return safeLocalStorage()?.getItem(key) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function storeValue(key: string, value: string): void {
|
||||
try {
|
||||
safeLocalStorage()?.setItem(key, value);
|
||||
} catch {
|
||||
// Flags remain a progressive enhancement when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function anonymousContextKey(): string {
|
||||
const stored = storedValue(ANONYMOUS_CONTEXT_STORAGE_KEY);
|
||||
if (stored) return stored;
|
||||
|
||||
const generatedId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
const generated = `anon-${generatedId}`;
|
||||
storeValue(ANONYMOUS_CONTEXT_STORAGE_KEY, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
function flagStorageKey(evalKey: string, contextKey: string): string {
|
||||
return `clawhub.featureFlags.${evalKey}.${encodeURIComponent(contextKey)}`;
|
||||
}
|
||||
|
||||
function readCachedFlags(storageKey: string): FeatureFlagValues {
|
||||
const raw = storedValue(storageKey);
|
||||
if (!raw) return FEATURE_FLAG_DEFAULTS;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
const cached = cachedFlagsSchema.safeParse(parsed);
|
||||
return cached.success ? cached.data : FEATURE_FLAG_DEFAULTS;
|
||||
} catch {
|
||||
return FEATURE_FLAG_DEFAULTS;
|
||||
}
|
||||
}
|
||||
export type { FeatureFlagValues } from "./featureFlagManifest";
|
||||
export const useFeatureFlag = krill.useFeatureFlag;
|
||||
|
||||
export function FeatureFlagProvider({
|
||||
baseUrl,
|
||||
children,
|
||||
contextKey,
|
||||
evalKey,
|
||||
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
||||
}: FeatureFlagProviderProps) {
|
||||
initialValues,
|
||||
pollIntervalMs,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
children: ReactNode;
|
||||
contextKey?: string;
|
||||
evalKey?: string;
|
||||
initialValues?: Partial<FeatureFlagValues> | null;
|
||||
pollIntervalMs?: number;
|
||||
}) {
|
||||
const resolvedEvalKey = evalKey ?? getRuntimeEnv("VITE_KRILLSWITCH_EVAL_KEY");
|
||||
const resolvedBaseUrl =
|
||||
baseUrl ?? getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL;
|
||||
const anonymousKeyRef = useRef<string | null>(null);
|
||||
if (!contextKey && anonymousKeyRef.current === null) {
|
||||
anonymousKeyRef.current = typeof window === "undefined" ? "anonymous" : anonymousContextKey();
|
||||
}
|
||||
const resolvedContextKey = contextKey ?? anonymousKeyRef.current ?? "anonymous";
|
||||
const storageKey = resolvedEvalKey ? flagStorageKey(resolvedEvalKey, resolvedContextKey) : null;
|
||||
const [state, setState] = useState<{
|
||||
storageKey: string | null;
|
||||
values: FeatureFlagValues;
|
||||
}>({ storageKey: null, values: FEATURE_FLAG_DEFAULTS });
|
||||
const values = state.storageKey === storageKey ? state.values : FEATURE_FLAG_DEFAULTS;
|
||||
if (!resolvedEvalKey) return children;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedEvalKey || !storageKey) {
|
||||
setState({ storageKey: null, values: FEATURE_FLAG_DEFAULTS });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const activeEvalKey = resolvedEvalKey;
|
||||
const activeStorageKey = storageKey;
|
||||
let disposed = false;
|
||||
let activeController: AbortController | null = null;
|
||||
let etag: string | null = null;
|
||||
setState({ storageKey: activeStorageKey, values: readCachedFlags(activeStorageKey) });
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
activeController?.abort();
|
||||
const controller = new AbortController();
|
||||
activeController = controller;
|
||||
try {
|
||||
const result = await evaluateFeatureFlags({
|
||||
baseUrl: resolvedBaseUrl,
|
||||
contextKey: resolvedContextKey,
|
||||
etag,
|
||||
evalKey: activeEvalKey,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (disposed || controller.signal.aborted || result.kind === "not-modified") return;
|
||||
|
||||
etag = result.etag;
|
||||
setState({ storageKey: activeStorageKey, values: result.values });
|
||||
storeValue(activeStorageKey, JSON.stringify(result.values));
|
||||
} catch {
|
||||
// Preserve code defaults or last-known values when evaluation is unavailable.
|
||||
} finally {
|
||||
if (activeController === controller) activeController = null;
|
||||
return (
|
||||
<krill.FeatureFlagProvider
|
||||
baseUrl={
|
||||
baseUrl ?? getRuntimeEnv("VITE_KRILLSWITCH_BASE_URL") ?? DEFAULT_KRILLSWITCH_BASE_URL
|
||||
}
|
||||
}
|
||||
|
||||
void refresh();
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") void refresh();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
const pollTimer = window.setInterval(() => void refresh(), pollIntervalMs);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
activeController?.abort();
|
||||
window.clearInterval(pollTimer);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [pollIntervalMs, resolvedBaseUrl, resolvedContextKey, resolvedEvalKey, storageKey]);
|
||||
|
||||
return <FeatureFlagsContext.Provider value={values}>{children}</FeatureFlagsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useFeatureFlag<Key extends keyof FeatureFlagValues>(
|
||||
key: Key,
|
||||
): FeatureFlagValues[Key] {
|
||||
return useContext(FeatureFlagsContext)[key];
|
||||
contextKey={contextKey}
|
||||
evalKey={resolvedEvalKey}
|
||||
initialValues={initialValues}
|
||||
pollIntervalMs={pollIntervalMs}
|
||||
>
|
||||
{children}
|
||||
</krill.FeatureFlagProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isBannedAccountAuthError,
|
||||
normalizeAuthErrorMessage,
|
||||
} from "../lib/authErrorMessage";
|
||||
import { loadInitialFeatureFlags } from "../lib/featureFlags.functions";
|
||||
import { getClawHubSiteUrl, SITE_DESCRIPTION, SITE_NAME } from "../lib/site";
|
||||
import { getThemeModeFromCookieHeader, normalizeThemeMode } from "../lib/themeCookie";
|
||||
import designSystemCss from "../design-system.css?url";
|
||||
@@ -30,6 +31,7 @@ import appCss from "../styles.css?url";
|
||||
|
||||
const OG_IMAGE_VERSION = "20260723-1";
|
||||
export const Route = createRootRoute({
|
||||
loader: () => loadInitialFeatureFlags(),
|
||||
beforeLoad: ({ location }) => {
|
||||
if (location.pathname === BANNED_ACCOUNT_PATH) return;
|
||||
const authError = getAuthErrorDescription(location);
|
||||
@@ -165,6 +167,7 @@ function getSearchStringValue(search: unknown, key: string) {
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const initialFeatureFlags = Route.useLoaderData();
|
||||
const initialThemeMode = normalizeThemeMode(
|
||||
(router.options.context as { initialThemeMode?: unknown } | undefined)?.initialThemeMode ??
|
||||
(typeof document === "undefined" ? undefined : getThemeModeFromCookieHeader(document.cookie)),
|
||||
@@ -193,7 +196,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<AppProviders>
|
||||
<AppProviders
|
||||
featureFlagContextKey={initialFeatureFlags.contextKey}
|
||||
initialFeatureFlags={initialFeatureFlags.values}
|
||||
>
|
||||
<div className="app-shell">
|
||||
<PromotionsBar />
|
||||
<Header />
|
||||
|
||||
Reference in New Issue
Block a user