diff --git a/.changeset/local-flagship-explorer.md b/.changeset/local-flagship-explorer.md new file mode 100644 index 00000000000..7677c4ba16d --- /dev/null +++ b/.changeset/local-flagship-explorer.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/vite-plugin": minor +"miniflare": minor +--- + +Manage local Flagship flags in Local Explorer + +Bound Flagship apps now appear in Local Explorer. You can create, edit, toggle, delete, and evaluate flags against the same local store used by your Worker, including targeting conditions and percentage rollouts. + +Explorer requests are routed to the development process that owns each app, so Flagship management also works across multiple local Workers. diff --git a/.changeset/local-flagship-runtime.md b/.changeset/local-flagship-runtime.md new file mode 100644 index 00000000000..365d25cad81 --- /dev/null +++ b/.changeset/local-flagship-runtime.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +--- + +Simulate Flagship bindings locally + +Flagship bindings can now evaluate flags against a persisted local store instead of requiring a remote app. Miniflare also exposes an admin API for populating and managing that store in development tools and tests, while bindings configured for remote access continue to proxy to Flagship. diff --git a/.changeset/local-flagship-wrangler.md b/.changeset/local-flagship-wrangler.md new file mode 100644 index 00000000000..e41fc5a7de5 --- /dev/null +++ b/.changeset/local-flagship-wrangler.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/vite-plugin": minor +"wrangler": minor +--- + +Evaluate Flagship flags locally during development + +Flagship bindings now use the local Miniflare store by default in Wrangler and the Vite plugin, keeping development offline and isolated from production flags. Set `remote: true` on a binding to continue using its remote app. + +Use `wrangler flagship flags pull ` to seed the store from a remote app. Flag management commands also accept `--local` to read and update the local store directly. diff --git a/fixtures/worker-with-resources/wrangler.jsonc b/fixtures/worker-with-resources/wrangler.jsonc index d21a1a6e070..b65de48f96d 100644 --- a/fixtures/worker-with-resources/wrangler.jsonc +++ b/fixtures/worker-with-resources/wrangler.jsonc @@ -43,6 +43,12 @@ "name": "my-workflow", }, ], + "flagship": [ + { + "binding": "FLAGS", + "app_id": "e2e-flags", + }, + ], "durable_objects": { "bindings": [ { diff --git a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts index def7654c75d..98727b07d13 100644 --- a/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts +++ b/packages/deploy-helpers/src/deploy/helpers/print-bindings.ts @@ -495,15 +495,13 @@ export function printBindings( if (flagship.length > 0) { output.push( - ...flagship.map(({ binding, app_id }) => { + ...flagship.map(({ binding, app_id, remote }) => { return { name: binding, type: getBindingTypeFriendlyName("flagship"), value: app_id, mode: getMode({ - isSimulatedLocally: !context.remoteBindingsDisabled - ? false - : undefined, + isSimulatedLocally: context.remoteBindingsDisabled || !remote, }), }; }) diff --git a/packages/local-explorer-ui/src/__e2e__/flagship/flagship.spec.ts b/packages/local-explorer-ui/src/__e2e__/flagship/flagship.spec.ts new file mode 100644 index 00000000000..5f5f3f8aea3 --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/flagship/flagship.spec.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, test } from "vitest"; +import { + cleanupFlags, + fetchFlag, + navigateToFlagshipApp, + page, + seedFlag, + waitForBreadcrumbText, + waitForSelector, + waitForText, +} from "../utils"; + +const APP_ID = "e2e-flags"; +const BOOLEAN_FLAG = { + key: "new-checkout", + default_variation: "off", + enabled: true, + variations: { off: false, on: true }, +}; +const STRING_FLAG = { + key: "pricing-experiment", + default_variation: "control", + enabled: true, + variations: { control: "blue", treatment: "red" }, +}; +const RULED_FLAG = { + ...STRING_FLAG, + key: "ruled-flag", + rules: [ + { + priority: 1, + conditions: [{ attribute: "plan", operator: "equals", value: "pro" }], + serve_variation: "treatment", + }, + { + priority: 2, + conditions: [ + { attribute: "country", operator: "in", value: ["NZ", "AU"] }, + ], + serve_variation: "control", + }, + ], +}; + +function flagRow(flagKey: string) { + return page.locator("tr").filter({ hasText: flagKey }).first(); +} + +async function openCreateDialog(): Promise { + await page.getByRole("button", { name: "Create flag" }).first().click(); + await waitForSelector('[role="dialog"]'); +} + +async function openAction(flagKey: string, action: string): Promise { + await flagRow(flagKey).getByRole("button", { name: "Row actions" }).click(); + await page.getByRole("menuitem", { name: action }).click(); +} + +async function openEditDialog(flagKey: string): Promise { + await openAction(flagKey, "Edit"); + await waitForSelector('[role="dialog"]'); +} + +async function chooseOption(name: string, option: string): Promise { + await page.getByRole("combobox", { name }).click(); + await page.getByRole("option", { name: option }).click(); +} + +async function saveFlag(): Promise { + await page.getByRole("button", { name: "Save changes" }).click(); + await page.waitForSelector('[role="dialog"]', { + state: "hidden", + timeout: 10_000, + }); +} + +describe("Flagship", () => { + beforeEach(async () => cleanupFlags(APP_ID)); + + test("shows app identity and the empty state", async () => { + await navigateToFlagshipApp(APP_ID); + await waitForBreadcrumbText("Flagship"); + await waitForBreadcrumbText(APP_ID); + await waitForText("No feature flags found"); + await waitForText("wrangler flagship flags pull"); + }); + + test("searches, sorts, and toggles flags", async ({ expect }) => { + await seedFlag(APP_ID, BOOLEAN_FLAG); + await seedFlag(APP_ID, STRING_FLAG); + await navigateToFlagshipApp(APP_ID); + await waitForText(BOOLEAN_FLAG.key); + + await page.getByRole("button", { name: "Flag key" }).click(); + expect(await page.locator("tbody tr code").allTextContents()).toEqual([ + BOOLEAN_FLAG.key, + STRING_FLAG.key, + ]); + await page.getByRole("button", { name: "Flag key" }).click(); + expect(await page.locator("tbody tr code").allTextContents()).toEqual([ + STRING_FLAG.key, + BOOLEAN_FLAG.key, + ]); + + await page.getByLabel("Search flags").fill("pricing"); + await waitForText("1 of 2"); + expect(await flagRow(BOOLEAN_FLAG.key).isVisible()).toBe(false); + await page.getByLabel("Clear search").click(); + + await openAction(BOOLEAN_FLAG.key, "Disable"); + await flagRow(BOOLEAN_FLAG.key).getByText("Disabled").waitFor(); + await navigateToFlagshipApp(APP_ID); + await flagRow(BOOLEAN_FLAG.key).getByText("Disabled").waitFor(); + }); + + test("creates, edits, and deletes a flag", async ({ expect }) => { + await navigateToFlagshipApp(APP_ID); + await openCreateDialog(); + const dialog = page.getByRole("dialog"); + await dialog.locator("#flag-key").fill("greeting"); + await dialog.getByRole("tab", { name: "String" }).click(); + await dialog.getByLabel("Value for blue").fill("hey"); + await dialog.getByLabel("Label for blue").fill("casual"); + await dialog.getByLabel("Value for red").fill("good day"); + await dialog.getByLabel("Label for red").fill("formal"); + await dialog.locator('input[name="default-variation"]').nth(1).check(); + await dialog.getByRole("button", { name: "Create flag" }).click(); + await page.waitForSelector('[role="dialog"]', { state: "hidden" }); + await waitForText("greeting"); + await waitForText("formal"); + + await openEditDialog("greeting"); + await dialog.locator("#flag-description").fill("Greeting copy"); + await dialog.getByLabel("Value for formal").fill("welcome"); + await saveFlag(); + await waitForText("Greeting copy"); + expect(await fetchFlag(APP_ID, "greeting")).toMatchObject({ + description: "Greeting copy", + default_variation: "formal", + variations: { formal: "welcome" }, + }); + + await openAction("greeting", "Delete"); + await page + .getByRole("dialog") + .getByRole("button", { name: "Delete" }) + .click(); + await waitForText("No feature flags found"); + }); + + test("validates values before creating", async () => { + await navigateToFlagshipApp(APP_ID); + await openCreateDialog(); + const dialog = page.getByRole("dialog"); + await dialog.locator("#flag-key").fill("broken"); + await dialog.getByRole("tab", { name: "JSON" }).click(); + await dialog.getByLabel("Value for dark").fill("not-json"); + await dialog.getByRole("button", { name: "Create flag" }).click(); + await waitForText("JSON values must be valid JSON"); + await waitForSelector('[role="dialog"]'); + }); + + test("resets an unsaved create form", async ({ expect }) => { + await navigateToFlagshipApp(APP_ID); + await openCreateDialog(); + const dialog = page.getByRole("dialog"); + await dialog.locator("#flag-key").fill("discarded"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await openCreateDialog(); + expect(await dialog.locator("#flag-key").inputValue()).toBe(""); + }); + + test("loads, reorders, edits, and removes targeting rules and rollouts", async ({ + expect, + }) => { + await seedFlag(APP_ID, RULED_FLAG); + await navigateToFlagshipApp(APP_ID); + await openEditDialog(RULED_FLAG.key); + const dialog = page.getByRole("dialog"); + expect(await dialog.getByLabel("Attribute").first().inputValue()).toBe( + "plan" + ); + await dialog.getByText("NZ").waitFor(); + + await dialog.getByRole("button", { name: "Move rule 2 up" }).click(); + await dialog + .getByRole("button", { name: "Add percentage rollout" }) + .first() + .click(); + await dialog.getByLabel("Rollout percentage for rule 1").fill("33.5"); + await dialog.getByLabel("Rollout attribute for rule 1").fill("userId"); + await dialog.getByRole("button", { name: "Remove rule 2" }).click(); + await saveFlag(); + + expect((await fetchFlag(APP_ID, RULED_FLAG.key)).rules).toMatchObject([ + { + priority: 1, + serve_variation: "control", + rollout: { percentage: 33.5, attribute: "userId" }, + }, + ]); + }); + + test("creates a rule and rejects incomplete conditions", async ({ + expect, + }) => { + await seedFlag(APP_ID, STRING_FLAG); + await navigateToFlagshipApp(APP_ID); + await openEditDialog(STRING_FLAG.key); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Add rule" }).click(); + await chooseOption("Variant served by rule 1", "treatment"); + await dialog.getByRole("button", { name: "Save changes" }).click(); + await waitForText("Fix the targeting rules below."); + expect((await fetchFlag(APP_ID, STRING_FLAG.key)).rules).toEqual([]); + + await dialog.getByLabel("Attribute").fill("plan"); + await dialog.getByLabel("Value for plan").fill("pro"); + await saveFlag(); + expect((await fetchFlag(APP_ID, STRING_FLAG.key)).rules).toMatchObject([ + { conditions: [{ attribute: "plan", value: "pro" }] }, + ]); + }); + + test("evaluates a flag with an ad-hoc context", async () => { + await seedFlag(APP_ID, RULED_FLAG); + await navigateToFlagshipApp(APP_ID); + await openAction(RULED_FLAG.key, "Test"); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Add attribute" }).click(); + await dialog.getByLabel("Context key").fill("plan"); + await dialog.getByLabel("Context value").fill("pro"); + await dialog.getByRole("button", { name: "Evaluate" }).click(); + await waitForText("TARGETING_MATCH"); + await waitForText("treatment"); + }); +}); diff --git a/packages/local-explorer-ui/src/__e2e__/utils.ts b/packages/local-explorer-ui/src/__e2e__/utils.ts index 747b3b410d9..94ac09ed70f 100644 --- a/packages/local-explorer-ui/src/__e2e__/utils.ts +++ b/packages/local-explorer-ui/src/__e2e__/utils.ts @@ -79,6 +79,90 @@ export async function seedWorkflow(workflowName: string): Promise<{ }; } +export async function seedFlag( + appId: string, + flag: { + key: string; + enabled?: boolean; + default_variation: string; + variations: Record; + rules?: unknown[]; + } +): Promise { + const response = await fetch( + `${workerUrl}${LOCAL_EXPLORER_API_PATH}/flagship/apps/${appId}/flags`, + { + body: JSON.stringify(flag), + headers: { "Content-Type": "application/json" }, + method: "POST", + } + ); + if (!response.ok) { + throw new Error( + `Failed to seed flag '${flag.key}': ${await response.text()}` + ); + } +} + +/** + * Reads a flag back from the local Flagship API so tests can assert on what was + * actually persisted. + */ +export async function fetchFlag( + appId: string, + flagKey: string +): Promise<{ + default_variation: string; + enabled: boolean; + rules: Array<{ + priority: number; + serve_variation: string; + conditions: unknown[]; + rollout?: { percentage: number; attribute?: string }; + }>; + variations: Record; +}> { + const response = await fetch( + `${workerUrl}${LOCAL_EXPLORER_API_PATH}/flagship/apps/${appId}/flags/${flagKey}` + ); + if (!response.ok) { + throw new Error( + `Failed to read flag '${flagKey}': ${await response.text()}` + ); + } + const body = (await response.json()) as { + result: { + default_variation: string; + enabled: boolean; + rules: Array<{ + priority: number; + serve_variation: string; + conditions: unknown[]; + rollout?: { percentage: number; attribute?: string }; + }>; + variations: Record; + }; + }; + return body.result; +} + +export async function cleanupFlags(appId: string): Promise { + const response = await fetch( + `${workerUrl}${LOCAL_EXPLORER_API_PATH}/flagship/apps/${appId}/flags` + ); + const body = (await response.json()) as { + result?: Array<{ key?: string }>; + }; + for (const flag of body.result ?? []) { + if (flag.key !== undefined) { + await fetch( + `${workerUrl}${LOCAL_EXPLORER_API_PATH}/flagship/apps/${appId}/flags/${flag.key}`, + { method: "DELETE" } + ); + } + } +} + /** * Delete all instances of a workflow via the explorer API. */ @@ -223,6 +307,11 @@ export async function navigateToDOObjectByName( return objectId; } +export async function navigateToFlagshipApp(appId: string): Promise { + await navigateTo(`${LOCAL_EXPLORER_BASE_PATH}/flagship/${appId}`); + await waitForPageLoad(); +} + /** * Wait for text to appear on the page. */ diff --git a/packages/local-explorer-ui/src/__tests__/flagship/flag-helpers.test.ts b/packages/local-explorer-ui/src/__tests__/flagship/flag-helpers.test.ts new file mode 100644 index 00000000000..1a49814e47c --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/flagship/flag-helpers.test.ts @@ -0,0 +1,56 @@ +import { describe, test } from "vitest"; +import { + parseVariationValue, + shellQuote, + validateFlagKey, +} from "../../components/flagship/flag-helpers"; + +describe("validateFlagKey", () => { + test("validates syntax and length", ({ expect }) => { + const cases: Array<[string, string | null]> = [ + ["new_ui-2", null], + [" ", "Enter a flag key."], + ["new ui", "Use only letters, numbers, hyphens, and underscores."], + ["a".repeat(65), "Flag key must be 64 characters or fewer."], + ]; + for (const [key, error] of cases) { + expect(validateFlagKey(key, new Set())).toBe(error); + } + }); + + test("checks duplicates case-sensitively", ({ expect }) => { + const existing = new Set(["new-ui"]); + expect(validateFlagKey("new-ui", existing)).toContain("already exists"); + expect(validateFlagKey("New-UI", existing)).toBeNull(); + }); +}); + +describe("parseVariationValue", () => { + test("rejects non-finite numbers", ({ expect }) => { + for (const value of ["Infinity", "NaN", ""]) { + expect(parseVariationValue("number", value).ok).toBe(false); + } + }); + + test("parses finite numbers and structured JSON", ({ expect }) => { + expect(parseVariationValue("number", "33.5")).toEqual({ + ok: true, + value: 33.5, + }); + expect(parseVariationValue("json", '[{"enabled":true}]')).toEqual({ + ok: true, + value: [{ enabled: true }], + }); + }); + + test("rejects JSON primitives", ({ expect }) => { + for (const value of ["null", "true", "1", '"value"']) { + expect(parseVariationValue("json", value).ok).toBe(false); + } + }); +}); + +test("shellQuote escapes shell syntax and embedded quotes", ({ expect }) => { + expect(shellQuote("$(whoami)")).toBe("'$(whoami)'"); + expect(shellQuote("it's")).toBe("'it'\\''s'"); +}); diff --git a/packages/local-explorer-ui/src/__tests__/flagship/rule-helpers.test.ts b/packages/local-explorer-ui/src/__tests__/flagship/rule-helpers.test.ts new file mode 100644 index 00000000000..9d070b28d58 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/flagship/rule-helpers.test.ts @@ -0,0 +1,247 @@ +import { describe, test } from "vitest"; +import { + buildConditions, + flattenConditions, + rulesFrom, + uiRulesFrom, + validateRules, + type Condition, + type UICondition, + type UIRule, +} from "../../components/flagship/rule-helpers"; + +function row( + attribute: string, + join: "AND" | "OR" = "AND", + value = "x" +): UICondition { + return { attribute, join, operator: "equals", value }; +} + +function rule(patch: Partial = {}): UIRule { + return { + conditions: [row("country")], + id: "rule", + rollout: null, + serveVariation: "on", + ...patch, + }; +} + +function show(condition: Condition): string { + return "logical_operator" in condition + ? `(${condition.clauses.map(show).join(` ${condition.logical_operator} `)})` + : condition.attribute; +} + +describe("condition conversion", () => { + test("builds and round-trips ANDs of OR groups", ({ expect }) => { + const rows = [row("a"), row("b", "OR"), row("c"), row("d", "OR")]; + const built = buildConditions(rows); + expect(built.map(show).join(" AND ")).toBe("(a OR b) AND (c OR d)"); + expect(flattenConditions(built)).toEqual(rows); + }); + + test("keeps simple conditions unwrapped and converts list values", ({ + expect, + }) => { + expect(buildConditions([row("country")])).toEqual([ + { attribute: "country", operator: "equals", value: "x" }, + ]); + expect( + buildConditions([ + { attribute: "country", join: "AND", operator: "in", value: "NZ\nAU" }, + ]) + ).toEqual([{ attribute: "country", operator: "in", value: ["NZ", "AU"] }]); + expect( + flattenConditions([ + { attribute: "country", operator: "in", value: ["NZ", "AU"] }, + ]) + ).toEqual([ + { attribute: "country", join: "AND", operator: "in", value: "NZ\nAU" }, + ]); + }); + + test("unwraps API AND nodes", ({ expect }) => { + expect( + flattenConditions([ + { + clauses: [ + { attribute: "a", operator: "equals", value: "x" }, + { + clauses: [ + { attribute: "b", operator: "equals", value: "x" }, + { attribute: "c", operator: "equals", value: "x" }, + ], + logical_operator: "OR", + }, + ], + logical_operator: "AND", + }, + ]) + ).toEqual([row("a"), row("b"), row("c", "OR")]); + }); + + test("refuses unrepresentable conditions", ({ expect }) => { + const cases: Condition[][] = [ + [ + { + clauses: [ + { + clauses: [{ attribute: "a", operator: "equals", value: "x" }], + logical_operator: "AND", + }, + ], + logical_operator: "OR", + }, + ], + [{ clauses: [], logical_operator: "OR" }], + ]; + for (const conditions of cases) { + expect(flattenConditions(conditions)).toBeNull(); + } + }); +}); + +describe("rule conversion", () => { + test("sorts incoming rules and renumbers edited rules", ({ expect }) => { + const uiRules = uiRulesFrom([ + { conditions: [], priority: 2, serve_variation: "second" }, + { conditions: [], priority: 1, serve_variation: "first" }, + ]); + expect(uiRules?.map(({ serveVariation }) => serveVariation)).toEqual([ + "first", + "second", + ]); + expect(rulesFrom(uiRules ?? []).map(({ priority }) => priority)).toEqual([ + 1, 2, + ]); + }); + + test("round-trips rollout fields in canonical form", ({ expect }) => { + const uiRules = uiRulesFrom([ + { + conditions: [], + priority: 1, + rollout: { percentage: 25 }, + serve_variation: "on", + }, + ]); + expect(uiRules?.[0]?.rollout).toEqual({ attribute: "", percentage: 25 }); + expect(rulesFrom(uiRules ?? [])[0]?.rollout).toEqual({ percentage: 25 }); + }); + + test("preserves canonical serialization for unchanged rules", ({ + expect, + }) => { + const stored = [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 25 }, + }, + ]; + const canonical = [ + { + conditions: [], + priority: 1, + serve_variation: "on", + rollout: { percentage: 25 }, + }, + ]; + expect(JSON.stringify(rulesFrom(uiRulesFrom(stored) ?? []))).toBe( + JSON.stringify(canonical) + ); + }); + + test("refuses rules containing unrepresentable conditions", ({ expect }) => { + const conditions = [ + { + clauses: [ + { + clauses: [{ attribute: "a", operator: "equals", value: "x" }], + logical_operator: "AND", + }, + ], + logical_operator: "OR", + }, + ]; + expect( + uiRulesFrom([{ conditions, priority: 1, serve_variation: "on" }]) + ).toBeNull(); + }); +}); + +describe("validateRules", () => { + test("accepts valid fractional and partial rollouts", ({ expect }) => { + expect( + validateRules( + [ + rule({ + conditions: [], + rollout: { attribute: "", percentage: 33.5 }, + }), + rule({ id: "next", serveVariation: "off" }), + ], + ["on", "off"] + ) + ).toEqual([]); + }); + + test("reports invalid rule input", ({ expect }) => { + const cases: Array<[UIRule, string]> = [ + [ + rule({ serveVariation: "gone" }), + "Choose a variant for this rule to serve.", + ], + [rule({ conditions: [row("")] }), "Every condition needs an attribute."], + [ + rule({ + conditions: [ + { + attribute: "age", + join: "AND", + operator: "greater_than", + value: "old", + }, + ], + }), + "The '>' operator needs a number.", + ], + [ + rule({ + conditions: [ + { attribute: "country", join: "AND", operator: "in", value: "" }, + ], + }), + "The 'in' operator needs at least one value.", + ], + [ + rule({ rollout: { attribute: "", percentage: 140 } }), + "Rollout must be a number between 0 and 100.", + ], + ]; + for (const [invalid, message] of cases) { + expect(validateRules([invalid], ["on"])[0]?.message).toBe(message); + } + }); + + test("reports rules hidden by a catch-all", ({ expect }) => { + expect( + validateRules( + [ + rule({ conditions: [] }), + rule({ id: "hidden", serveVariation: "off" }), + ], + ["on", "off"] + ) + ).toEqual([ + { + index: 1, + message: + "This rule can never match because rule 1 applies to everyone.", + }, + ]); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/utils/agent-prompt.test.ts b/packages/local-explorer-ui/src/__tests__/utils/agent-prompt.test.ts index becd35108f4..7adb33b8bed 100644 --- a/packages/local-explorer-ui/src/__tests__/utils/agent-prompt.test.ts +++ b/packages/local-explorer-ui/src/__tests__/utils/agent-prompt.test.ts @@ -20,6 +20,9 @@ describe("llm-prompt utils", () => { const prompt = createLocalExplorerPrompt(TEST_API_ENDPOINT); expect(prompt).toContain(`API endpoint: ${TEST_API_ENDPOINT}`); + expect(prompt).toContain( + "You have access to local Cloudflare services (KV, R2, D1, Durable Objects, Workflows, and Flagship)" + ); expect(prompt).toContain( `Fetch the OpenAPI schema from ${TEST_API_ENDPOINT}` ); diff --git a/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts b/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts index fa335c5aed8..0ff55111cf4 100644 --- a/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts +++ b/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts @@ -112,6 +112,7 @@ describe("sidebar-state", () => { kv: true, r2: false, workflows: true, + flagship: true, }; storageStub.setItem(GROUPS_STORAGE_KEY, JSON.stringify(stored)); expect(loadGroupState()).toEqual(stored); @@ -159,6 +160,7 @@ describe("sidebar-state", () => { kv: false, r2: true, workflows: false, + flagship: false, }; saveGroupState(state); const raw = storageStub.getItem(GROUPS_STORAGE_KEY); @@ -185,6 +187,7 @@ describe("sidebar-state", () => { kv: true, r2: false, workflows: true, + flagship: true, }; saveGroupState(state); expect(loadGroupState()).toEqual(state); diff --git a/packages/local-explorer-ui/src/assets/icons/flagship.svg b/packages/local-explorer-ui/src/assets/icons/flagship.svg new file mode 100644 index 00000000000..72dd89d3300 --- /dev/null +++ b/packages/local-explorer-ui/src/assets/icons/flagship.svg @@ -0,0 +1 @@ + diff --git a/packages/local-explorer-ui/src/components/Sidebar.tsx b/packages/local-explorer-ui/src/components/Sidebar.tsx index 610ffd68259..4261d7d3d7d 100644 --- a/packages/local-explorer-ui/src/components/Sidebar.tsx +++ b/packages/local-explorer-ui/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { useRouter } from "@tanstack/react-router"; import { useCallback, useState } from "react"; import D1Icon from "../assets/icons/d1.svg?react"; import DOIcon from "../assets/icons/durable-objects.svg?react"; +import FlagshipIcon from "../assets/icons/flagship.svg?react"; import KVIcon from "../assets/icons/kv.svg?react"; import R2Icon from "../assets/icons/r2.svg?react"; import WorkflowsIcon from "../assets/icons/workflows.svg?react"; @@ -120,6 +121,7 @@ export function AppSidebar({ const kvNamespaces = bindings?.kv ?? []; const r2Buckets = bindings?.r2 ?? []; const workflows = bindings?.workflows ?? []; + const flagshipApps = bindings?.flagship ?? []; const sidebarItemGroups = [ { @@ -208,6 +210,22 @@ export function AppSidebar({ })), title: "Workflows", }, + { + emptyLabel: "No Flagship apps", + groupId: "flagship" as const, + icon: FlagshipIcon, + items: flagshipApps.map((app) => ({ + id: `${app.id}:${app.bindingName}`, + isActive: currentPath === `/flagship/${app.id}`, + label: app.bindingName, + link: { + params: { appId: app.id }, + search: workerSearch, + to: "/flagship/$appId", + }, + })), + title: "Flagship", + }, ] satisfies Array<{ emptyLabel: string; groupId: SidebarGroupId; diff --git a/packages/local-explorer-ui/src/components/flagship/FlagDialog.tsx b/packages/local-explorer-ui/src/components/flagship/FlagDialog.tsx new file mode 100644 index 00000000000..16513a64a99 --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/FlagDialog.tsx @@ -0,0 +1,677 @@ +import { Banner, Button, Dialog, Label, Switch, Tabs } from "@cloudflare/kumo"; +import { PlusIcon, TrashIcon } from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { flagshipCreateFlag, flagshipUpdateFlag } from "../../api"; +import { + defaultVariationsForType, + FLAG_TYPE_LABELS, + flagshipErrorMessage, + inferFlagType, + parseVariationValue, + validateFlagKey, + variationDraftsFrom, + type FlagType, + type VariationDraft, +} from "./flag-helpers"; +import { Field, TextInput } from "./FormFields"; +import { + rulesFrom, + uiRulesFrom, + validateRules, + type RuleError, + type UIRule, +} from "./rule-helpers"; +import { RuleEditor } from "./RuleEditor"; +import type { FlagshipFlag, FlagshipUpdateFlagData } from "../../api"; +import type { JSX } from "react"; + +const TYPE_TABS: Array<{ className: string; label: string; value: FlagType }> = + [ + { className: "flex-1 justify-center", label: "Boolean", value: "boolean" }, + { className: "flex-1 justify-center", label: "Number", value: "number" }, + { className: "flex-1 justify-center", label: "String", value: "string" }, + { className: "flex-1 justify-center", label: "JSON", value: "json" }, + ]; + +interface FlagDialogProps { + appId: string; + flag: FlagshipFlag | null; + flags: FlagshipFlag[]; + onOpenChange: (open: boolean) => void; + onSaved: () => Promise; + open: boolean; + worker?: string; +} + +interface FormState { + defaultVariationId: string; + description: string; + enabled: boolean; + key: string; + rules: UIRule[] | null; + type: FlagType; + variations: VariationDraft[]; +} + +type ErrorField = "form" | "key" | "rules" | "variations"; + +interface FormError { + field: ErrorField; + message: string; + variationId?: string; + variationField?: "name" | "value"; +} + +function emptyForm(): FormState { + const variations = defaultVariationsForType("boolean"); + return { + defaultVariationId: variations[1].id, + description: "", + enabled: true, + key: "", + rules: [], + type: "boolean", + variations, + }; +} + +type UpdateBody = NonNullable; + +interface SavedValues { + default_variation: string; + description: string | null; + enabled: boolean; + rules: UpdateBody["rules"] | null; + variations: Record; +} + +function changedFields(flag: FlagshipFlag, next: SavedValues): UpdateBody { + const body: UpdateBody = {}; + const currentUiRules = uiRulesFrom(flag.rules); + const currentRules = + currentUiRules === null ? null : rulesFrom(currentUiRules); + if (next.default_variation !== flag.default_variation) { + body.default_variation = next.default_variation; + } + if (next.description !== (flag.description ?? null)) { + body.description = next.description; + } + if (next.enabled !== flag.enabled) { + body.enabled = next.enabled; + } + if ( + JSON.stringify(next.variations) !== JSON.stringify(flag.variations ?? {}) + ) { + body.variations = next.variations; + } + if ( + next.rules !== null && + JSON.stringify(next.rules) !== JSON.stringify(currentRules) + ) { + body.rules = next.rules; + } + return body; +} + +function formFromFlag(flag: FlagshipFlag): FormState { + const type = flag.type ?? inferFlagType(flag.variations); + const variations = variationDraftsFrom(type, flag.variations); + const current = variations.find((row) => row.name === flag.default_variation); + return { + defaultVariationId: current?.id ?? variations[0].id, + description: flag.description ?? "", + enabled: flag.enabled === true, + key: flag.key ?? "", + rules: uiRulesFrom(flag.rules), + type, + variations, + }; +} + +export function FlagDialog({ + appId, + flag, + flags, + onOpenChange, + onSaved, + open, + worker, +}: FlagDialogProps): JSX.Element { + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(null); + const [ruleErrors, setRuleErrors] = useState([]); + const [saving, setSaving] = useState(false); + const errorRef = useRef(null); + const wasOpen = useRef(false); + + const editing = flag !== null; + + // Seed only on open, so a background refresh cannot overwrite what is typed. + useEffect(() => { + if (open && !wasOpen.current) { + setForm(flag === null ? emptyForm() : formFromFlag(flag)); + setError(null); + setRuleErrors([]); + setSaving(false); + } + wasOpen.current = open; + }, [flag, open]); + + useEffect(() => { + if (error !== null) { + errorRef.current?.scrollIntoView({ block: "nearest" }); + } + }, [error]); + + const existing = useMemo( + () => + new Set( + flags.flatMap((entry) => + entry.key === undefined || entry.key === flag?.key ? [] : [entry.key] + ) + ), + [flag?.key, flags] + ); + const isBoolean = form.type === "boolean"; + + function handleOpenChange(next: boolean): void { + if (!next) { + setError(null); + setRuleErrors([]); + setSaving(false); + } + onOpenChange(next); + } + + function handleTypeChange(value: string): void { + const selected = TYPE_TABS.find((tab) => tab.value === value); + if (selected === undefined) { + return; + } + const variations = defaultVariationsForType(selected.value); + setError((current) => (current?.field === "variations" ? null : current)); + setRuleErrors([]); + setForm((current) => ({ + ...current, + defaultVariationId: + selected.value === "boolean" ? variations[1].id : variations[0].id, + // New variants replace the old ones, so existing rules would dangle. + rules: current.rules === null ? null : [], + type: selected.value, + variations, + })); + } + + function updateVariation( + id: string, + patch: Partial> + ): void { + setError((current) => (current?.variationId === id ? null : current)); + setForm((current) => { + const previous = current.variations.find((row) => row.id === id); + const renamedFrom = patch.name === undefined ? undefined : previous?.name; + const renamedTo = patch.name; + return { + ...current, + rules: + current.rules === null || + renamedFrom === undefined || + renamedTo === undefined + ? current.rules + : current.rules.map((rule) => + rule.serveVariation === renamedFrom + ? { ...rule, serveVariation: renamedTo } + : rule + ), + variations: current.variations.map((row) => + row.id === id ? { ...row, ...patch } : row + ), + }; + }); + } + + function addVariation(): void { + setForm((current) => ({ + ...current, + variations: [ + ...current.variations, + { id: crypto.randomUUID(), name: "", value: "" }, + ], + })); + } + + function removeVariation(id: string): void { + setForm((current) => { + const variations = current.variations.filter((row) => row.id !== id); + return { + ...current, + defaultVariationId: + current.defaultVariationId === id + ? (variations[0]?.id ?? current.defaultVariationId) + : current.defaultVariationId, + variations, + }; + }); + } + + function failVariations( + message: string, + variationId?: string, + variationField?: "name" | "value" + ): void { + setError({ field: "variations", message, variationField, variationId }); + } + + async function handleSave(): Promise { + setError(null); + + if (!editing) { + const keyError = validateFlagKey(form.key, existing); + if (keyError !== null) { + setError({ field: "key", message: keyError }); + return; + } + } + + const names = new Set(); + const variations: Record = {}; + for (const row of form.variations) { + const name = row.name.trim(); + if (name === "") { + failVariations("Each variant needs a label.", row.id, "name"); + return; + } + if (names.has(name)) { + failVariations( + `Variant label '${name}' is used more than once.`, + row.id, + "name" + ); + return; + } + names.add(name); + const parsed = parseVariationValue(form.type, row.value); + if (!parsed.ok) { + failVariations(parsed.error, row.id, "value"); + return; + } + variations[name] = parsed.value; + } + + const defaultRow = form.variations.find( + (row) => row.id === form.defaultVariationId + ); + if (defaultRow === undefined) { + failVariations("Choose a default variant."); + return; + } + + if (form.rules !== null) { + const found = validateRules(form.rules, [...names]); + if (found.length > 0) { + setRuleErrors(found); + setError({ field: "rules", message: "Fix the targeting rules below." }); + return; + } + setRuleErrors([]); + } + + if (editing && flag.key === undefined) { + setError({ field: "form", message: "This flag has no key to update." }); + return; + } + + const defaultVariation = defaultRow.name.trim(); + const description = form.description.trim() || null; + const rules = form.rules === null ? null : rulesFrom(form.rules); + + setSaving(true); + try { + if (editing && flag.key !== undefined) { + await flagshipUpdateFlag({ + body: changedFields(flag, { + default_variation: defaultVariation, + description, + enabled: form.enabled, + rules, + variations, + }), + path: { app_id: appId, flag_key: flag.key }, + query: { worker }, + }); + } else { + await flagshipCreateFlag({ + body: { + default_variation: defaultVariation, + description: description ?? undefined, + enabled: form.enabled, + key: form.key.trim(), + ...(rules === null ? {} : { rules }), + variations, + }, + path: { app_id: appId }, + query: { worker }, + }); + } + } catch (caught) { + setError({ + field: "form", + message: flagshipErrorMessage( + caught, + editing ? "Failed to update flag" : "Failed to create flag" + ), + }); + setSaving(false); + return; + } + setSaving(false); + onOpenChange(false); + await onSaved(); + } + + return ( + + +
+ {/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */} + + {editing ? "Edit flag" : "Create flag"} + +

+ {editing + ? "Saves to the local store. The Worker running in dev picks the change up on its next read." + : "Adds a flag to the local store. The Worker running in dev can read it immediately."} +

+
+ +
+ {error?.field === "form" ? ( + + ) : null} + + + {editing ? ( +

+ {form.key} +

+ ) : ( + void handleSave()} + onValueChange={(value) => { + setError((current) => + current?.field === "key" ? null : current + ); + setForm((current) => ({ + ...current, + key: value.replaceAll(" ", "-"), + })); + }} + placeholder="new-checkout" + value={form.key} + /> + )} +
+ + + void handleSave()} + onValueChange={(value) => + setForm((current) => ({ ...current, description: value })) + } + placeholder="Serves the rebuilt checkout flow" + value={form.description} + /> + + +
+ + {editing ? ( + <> +

+ {FLAG_TYPE_LABELS[form.type]} +

+

+ Changing a flag's type would invalidate every variant, so + delete and recreate the flag instead. +

+ + ) : ( + + )} +
+ +
+
+ +

+ {isBoolean + ? "Boolean flags always serve true or false." + : "Pick which variant is served when no targeting rule matches."} +

+
+ +
+
+ Default + Label + Value + +
+ {form.variations.map((row) => ( +
+ + setForm((current) => ({ + ...current, + defaultVariationId: row.id, + })) + } + type="radio" + /> + void handleSave()} + onValueChange={(value) => + updateVariation(row.id, { + name: value.replaceAll(" ", "-"), + }) + } + placeholder="label" + value={row.name} + /> + {isBoolean ? ( + + {row.value} + + ) : ( + void handleSave()} + onValueChange={(value) => + updateVariation(row.id, { value }) + } + placeholder="value" + value={row.value} + /> + )} + {form.variations.length < 3 ? ( + + ) : ( +
+ ))} +
+ + {isBoolean ? null : ( + + )} + + {error?.field === "variations" ? ( +

+ {error.message} +

+ ) : null} +
+ +
+
+ +

+ Checked from top to bottom. The first rule that matches decides + what is served, and anything unmatched falls through to the + default variant. +

+
+ + {form.rules === null ? ( +
+

+ This flag's rules use nested conditions that this editor + cannot show. They are left untouched when you save. Edit them + with{" "} + + wrangler flagship flags rules + {" "} + instead. +

+
+ ) : ( + { + setRuleErrors([]); + setError((current) => + current?.field === "rules" ? null : current + ); + setForm((current) => ({ ...current, rules })); + }} + rules={form.rules} + variationNames={form.variations.flatMap((row) => + row.name.trim() === "" ? [] : [row.name.trim()] + )} + /> + )} + + {error?.field === "rules" ? ( +

+ {error.message} +

+ ) : null} +
+ +
+
+

Enabled

+

+ When off, every request receives the default variant. +

+
+ + setForm((current) => ({ ...current, enabled })) + } + size="sm" + /> +
+
+ +
+ + +
+
+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/flagship/FlagTable.tsx b/packages/local-explorer-ui/src/components/flagship/FlagTable.tsx new file mode 100644 index 00000000000..74d9a1b959d --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/FlagTable.tsx @@ -0,0 +1,387 @@ +import { + Badge, + Button, + cn, + DropdownMenu, + Table, + Tooltip, +} from "@cloudflare/kumo"; +import { + ArrowDownIcon, + ArrowsDownUpIcon, + ArrowUpIcon, + DotsThreeIcon, + FlaskIcon, + PencilSimpleIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { formatDate } from "../../utils/format"; +import { timeAgo } from "../../utils/time"; +import { CopyButton } from "../CopyButton"; +import { FLAG_TYPE_LABELS } from "./flag-helpers"; +import type { FlagshipFlag } from "../../api"; +import type { JSX, ReactNode } from "react"; + +interface FlagTableProps { + flags: FlagshipFlag[]; + onDelete: (flag: FlagshipFlag) => void; + onEdit: (flag: FlagshipFlag) => void; + onTest: (flag: FlagshipFlag) => void; + onToggle: (flag: FlagshipFlag) => void; + pendingKey: string | null; +} + +type SortColumn = "key" | "status" | "updated"; + +type SortDirection = "asc" | "desc"; + +interface SortState { + column: SortColumn; + direction: SortDirection; +} + +function SortIcon({ + direction, +}: { + direction: SortDirection | null; +}): JSX.Element { + if (direction === "asc") { + return ; + } + if (direction === "desc") { + return ; + } + return ( + + ); +} + +interface SortableHeadProps { + children: ReactNode; + className?: string; + column: SortColumn; + onSort: (column: SortColumn) => void; + sort: SortState; +} + +function SortableHead({ + children, + className, + column, + onSort, + sort, +}: SortableHeadProps): JSX.Element { + const active = sort.column === column; + const direction = active ? sort.direction : null; + return ( + + + + ); +} + +interface ActionMenuProps { + enabled: boolean; + onDelete: () => void; + onEdit: () => void; + onTest: () => void; + onToggle: () => void; + pending: boolean; +} + +function ActionMenu({ + enabled, + onDelete, + onEdit, + onTest, + onToggle, + pending, +}: ActionMenuProps): JSX.Element { + return ( + + + + + } + /> + + + + Edit + + + + Test + + + {enabled ? "Disable" : "Enable"} + + + + + Delete + + + + ); +} + +function formatValue(value: unknown): string { + if (value === undefined) { + return ""; + } + return JSON.stringify(value) ?? String(value); +} + +function ValueText({ value }: { value: string }): JSX.Element { + if (value === "") { + return ; + } + const text = ( + + {value} + + ); + if (value.length <= 24) { + return text; + } + return ( + + {text} + + ); +} + +function updatedTime(flag: FlagshipFlag): number { + if (flag.updated_at === undefined) { + return 0; + } + const time = new Date(flag.updated_at).getTime(); + return Number.isNaN(time) ? 0 : time; +} + +function compareFlags( + a: FlagshipFlag, + b: FlagshipFlag, + column: SortColumn +): number { + if (column === "status") { + return Number(a.enabled === true) - Number(b.enabled === true); + } + if (column === "updated") { + return updatedTime(a) - updatedTime(b); + } + return 0; +} + +export function FlagTable({ + flags, + onDelete, + onEdit, + onTest, + onToggle, + pendingKey, +}: FlagTableProps): JSX.Element { + const [sort, setSort] = useState({ + column: "updated", + direction: "desc", + }); + + const sortedFlags = useMemo(() => { + const factor = sort.direction === "asc" ? 1 : -1; + return [...flags].sort((a, b) => { + const primary = compareFlags(a, b, sort.column) * factor; + if (primary !== 0) { + return primary; + } + const byKey = (a.key ?? "").localeCompare(b.key ?? ""); + return sort.column === "key" ? byKey * factor : byKey; + }); + }, [flags, sort]); + + function handleSort(column: SortColumn): void { + setSort((current) => { + if (current.column === column) { + return { + column, + direction: current.direction === "asc" ? "desc" : "asc", + }; + } + return { column, direction: column === "key" ? "asc" : "desc" }; + }); + } + + return ( +
+ + + + + Flag key + + Type + Default variant + + Status + + + Last modified + + + Actions + + + + + {sortedFlags.map((flag) => { + const key = flag.key ?? ""; + const enabled = flag.enabled === true; + const pending = pendingKey === key; + const defaultVariation = flag.default_variation ?? ""; + const defaultValue = formatValue( + flag.variations?.[defaultVariation] + ); + const relative = timeAgo(flag.updated_at); + return ( + { + const target = event.target as HTMLElement; + if ( + !event.currentTarget.contains(target) || + target.closest("button") !== null || + target.closest("a") !== null + ) { + return; + } + onEdit(flag); + }} + > + +
+ + +
+
+ + {flag.type === undefined ? null : ( + + {FLAG_TYPE_LABELS[flag.type]} + + )} + + +
+ + {defaultVariation || "unset"} + + +
+
+ + + + + + {relative === "" ? ( + + ) : ( + + + {relative} + + + )} + + + onDelete(flag)} + onEdit={() => onEdit(flag)} + onTest={() => onTest(flag)} + onToggle={() => onToggle(flag)} + pending={pending} + /> + +
+ ); + })} +
+
+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/flagship/FormFields.tsx b/packages/local-explorer-ui/src/components/flagship/FormFields.tsx new file mode 100644 index 00000000000..bf8bcc3baaf --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/FormFields.tsx @@ -0,0 +1,107 @@ +import { cn, inputVariants, Label } from "@cloudflare/kumo"; +import type { JSX, KeyboardEvent, ReactNode } from "react"; + +type InputSize = "xs" | "sm" | "base" | "lg"; + +interface TextInputProps { + ariaLabel?: string; + className?: string; + disabled?: boolean; + id?: string; + invalid?: boolean; + maxLength?: number; + mono?: boolean; + numeric?: boolean; + onEnter?: () => void; + onValueChange: (value: string) => void; + placeholder?: string; + size?: InputSize; + value: string; +} + +export function TextInput({ + ariaLabel, + className, + disabled, + id, + invalid, + maxLength, + mono, + numeric, + onEnter, + onValueChange, + placeholder, + size = "base", + value, +}: TextInputProps): JSX.Element { + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === "Enter" && onEnter !== undefined) { + event.preventDefault(); + onEnter(); + } + } + + return ( + onValueChange(event.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + spellCheck={false} + type="text" + value={value} + /> + ); +} + +interface FieldProps { + children: ReactNode; + description?: string; + error?: string; + htmlFor?: string; + label: string; + optional?: boolean; +} + +export function Field({ + children, + description, + error, + htmlFor, + label, + optional, +}: FieldProps): JSX.Element { + return ( +
+ + {children} + {error === undefined ? ( + description === undefined ? null : ( +

{description}

+ ) + ) : ( +

+ {error} +

+ )} +
+ ); +} diff --git a/packages/local-explorer-ui/src/components/flagship/RuleEditor.tsx b/packages/local-explorer-ui/src/components/flagship/RuleEditor.tsx new file mode 100644 index 00000000000..f98e2aa7585 --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/RuleEditor.tsx @@ -0,0 +1,547 @@ +import { + Badge, + Button, + cn, + inputVariants, + Select, + Tooltip, +} from "@cloudflare/kumo"; +import { + CaretDownIcon, + CaretUpIcon, + PlusIcon, + TrashIcon, + XIcon, +} from "@phosphor-icons/react"; +import { TextInput } from "./FormFields"; +import { + emptyCondition, + emptyRule, + groupRows, + isOperator, + LIST_OPERATORS, + NUMERIC_OPERATORS, + OPERATOR_LABELS, + type RuleError, + type UICondition, + type UIRule, +} from "./rule-helpers"; +import type { JSX, KeyboardEvent } from "react"; + +const ROW_GRID = + "grid grid-cols-[2.75rem_minmax(0,1fr)_11rem_minmax(0,1fr)_2.25rem] items-center gap-2"; + +const KEYWORD = "text-xs font-medium tracking-wide text-kumo-subtle uppercase"; + +interface TagInputProps { + ariaLabel: string; + disabled?: boolean; + onChange: (value: string) => void; + value: string; +} + +function TagInput({ + ariaLabel, + disabled, + onChange, + value, +}: TagInputProps): JSX.Element { + const entries = value.split("\n").filter((entry) => entry !== ""); + + function handleKeyDown(event: KeyboardEvent): void { + const input = event.currentTarget; + const typed = input.value.trim(); + + if ((event.key === "Enter" || event.key === ",") && typed !== "") { + event.preventDefault(); + event.stopPropagation(); + if (!entries.includes(typed)) { + onChange([...entries, typed].join("\n")); + } + input.value = ""; + return; + } + if (event.key === "Backspace" && input.value === "" && entries.length > 0) { + event.preventDefault(); + onChange(entries.slice(0, -1).join("\n")); + } + } + + return ( +
+ {entries.map((entry) => ( + + {entry} + + + ))} + { + const typed = event.currentTarget.value.trim(); + if (typed !== "" && !entries.includes(typed)) { + onChange([...entries, typed].join("\n")); + } + event.currentTarget.value = ""; + }} + onKeyDown={handleKeyDown} + placeholder={entries.length === 0 ? "Value, then Enter" : ""} + spellCheck={false} + type="text" + /> +
+ ); +} + +interface ConditionRowProps { + condition: UICondition; + disabled: boolean; + label: string; + onChange: (patch: Partial) => void; + onRemove: () => void; +} + +function ConditionRow({ + condition, + disabled, + label, + onChange, + onRemove, +}: ConditionRowProps): JSX.Element { + const isList = LIST_OPERATORS.has(condition.operator); + + return ( +
+ {label} + onChange({ attribute })} + placeholder="attribute" + value={condition.attribute} + /> + [name, name]) + )} + onValueChange={(next) => { + if (next !== null) { + onChange({ ...rule, serveVariation: next }); + } + }} + value={rule.serveVariation} + /> + {rule.rollout === null ? ( +
+ +
+ ) : null} +
+ + {rule.rollout === null ? null : ( +
+ To +
+
+ { + const parsed = Number(next); + updateRollout({ + percentage: Number.isNaN(parsed) ? 0 : parsed, + }); + }} + value={String(rule.rollout.percentage)} + /> +
+ + % of matches, bucketed by + +
+ updateRollout({ attribute })} + placeholder="targetingKey" + value={rule.rollout.attribute} + /> +
+
+
+ )} + + + {error === undefined ? null : ( +

+ {error} +

+ )} + + ); +} + +interface RuleEditorProps { + disabled: boolean; + errors: RuleError[]; + onChange: (rules: UIRule[]) => void; + rules: UIRule[]; + variationNames: string[]; +} + +export function RuleEditor({ + disabled, + errors, + onChange, + rules, + variationNames, +}: RuleEditorProps): JSX.Element { + const [firstVariation] = variationNames; + + function moveRule(index: number, direction: -1 | 1): void { + const target = index + direction; + const moved = rules[index]; + const displaced = rules[target]; + if (moved === undefined || displaced === undefined) { + return; + } + const next = [...rules]; + next[index] = displaced; + next[target] = moved; + onChange(next); + } + + return ( +
+ {rules.length === 0 ? ( +

+ No targeting rules. Every request receives the default variant. +

+ ) : ( +
+ {rules.map((rule, index) => ( + entry.index === index)?.message} + index={index} + key={rule.id} + onChange={(next) => + onChange( + rules.map((current, position) => + position === index ? next : current + ) + ) + } + onMove={(direction) => moveRule(index, direction)} + onRemove={() => + onChange(rules.filter((_, position) => position !== index)) + } + rule={rule} + ruleCount={rules.length} + variationNames={variationNames} + /> + ))} +
+ )} + + +
+ ); +} diff --git a/packages/local-explorer-ui/src/components/flagship/TestFlagDialog.tsx b/packages/local-explorer-ui/src/components/flagship/TestFlagDialog.tsx new file mode 100644 index 00000000000..1b082a8cceb --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/TestFlagDialog.tsx @@ -0,0 +1,427 @@ +import { + Badge, + Banner, + Button, + Dialog, + Label, + Select, + SkeletonLine, +} from "@cloudflare/kumo"; +import { + CheckIcon, + CopyIcon, + FlagBannerIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { flagshipEvaluateFlag } from "../../api"; +import { LOCAL_EXPLORER_API_PATH } from "../../constants"; +import { flagshipErrorMessage, shellQuote } from "./flag-helpers"; +import { TextInput } from "./FormFields"; +import type { FlagshipEvaluation, FlagshipFlag } from "../../api"; +import type { BadgeVariant } from "@cloudflare/kumo"; +import type { JSX } from "react"; + +type EvaluationReason = NonNullable; + +const REASON_VARIANTS: Record = { + DEFAULT: "secondary", + DISABLED: "warning", + ERROR: "error", + SPLIT: "info", + TARGETING_MATCH: "success", +}; + +interface ContextRow { + id: string; + key: string; + value: string; +} + +interface TestFlagDialogProps { + appId: string; + flags: FlagshipFlag[]; + initialFlagKey: string | null; + onOpenChange: (open: boolean) => void; + open: boolean; + worker?: string; +} + +function contextFromRows(rows: ContextRow[]): Record { + const context: Record = {}; + for (const row of rows) { + const key = row.key.trim(); + if (key !== "") { + context[key] = row.value.trim(); + } + } + return context; +} + +function localEvaluateCurl( + appId: string, + flagKey: string, + context: Record, + worker?: string +): string { + const origin = window.location.origin; + const url = new URL( + `${LOCAL_EXPLORER_API_PATH}/flagship/apps/${encodeURIComponent(appId)}/flags/${encodeURIComponent(flagKey)}/evaluate`, + origin + ); + if (worker !== undefined) { + url.searchParams.set("worker", worker); + } + return [ + `curl -X POST ${shellQuote(url.toString())} \\`, + ` -H 'Content-Type: application/json' \\`, + ` -d ${shellQuote(JSON.stringify({ context }))}`, + ].join("\n"); +} + +function formatResultValue(value: unknown): string { + if (value === undefined) { + return "undefined"; + } + if (typeof value === "string") { + return value; + } + return JSON.stringify(value); +} + +function ResultHeading(): JSX.Element { + return ( +

Evaluation result

+ ); +} + +function ResultSkeleton(): JSX.Element { + return ( +
+ +
+ + + + +
+
+ ); +} + +export function TestFlagDialog({ + appId, + flags, + initialFlagKey, + onOpenChange, + open, + worker, +}: TestFlagDialogProps): JSX.Element { + const flagKeys = useMemo( + () => flags.flatMap((flag) => (flag.key === undefined ? [] : [flag.key])), + [flags] + ); + const defaultFlagKey = initialFlagKey ?? flagKeys[0] ?? ""; + + const [selectedFlagKey, setSelectedFlagKey] = useState(defaultFlagKey); + const [rows, setRows] = useState([]); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [evaluating, setEvaluating] = useState(false); + const [copied, setCopied] = useState<"result" | "curl" | null>(null); + const wasOpen = useRef(false); + const latestRequest = useRef(0); + const copyTimeout = useRef | null>(null); + + useEffect(() => { + // Seed only on open, so a background refresh cannot discard the selection. + if (open && !wasOpen.current) { + setSelectedFlagKey(defaultFlagKey); + } + wasOpen.current = open; + }, [defaultFlagKey, open]); + + useEffect(() => { + return () => { + if (copyTimeout.current !== null) { + clearTimeout(copyTimeout.current); + } + }; + }, []); + + function reset(): void { + latestRequest.current += 1; + setRows([]); + setResult(null); + setError(null); + setEvaluating(false); + setCopied(null); + } + + function handleOpenChange(next: boolean): void { + if (!next) { + reset(); + } + onOpenChange(next); + } + + async function evaluate(): Promise { + if (selectedFlagKey === "" || evaluating) { + return; + } + const request = ++latestRequest.current; + setEvaluating(true); + setError(null); + setResult(null); + try { + const response = await flagshipEvaluateFlag({ + body: { context: contextFromRows(rows) }, + path: { app_id: appId, flag_key: selectedFlagKey }, + query: { worker }, + }); + if (request !== latestRequest.current) { + return; + } + const next = response.data?.result; + if (next === undefined) { + setError("The flag evaluated but no result was returned."); + return; + } + setResult(next); + } catch (caught) { + if (request === latestRequest.current) { + setError(flagshipErrorMessage(caught, "Failed to evaluate flag")); + } + } finally { + if (request === latestRequest.current) { + setEvaluating(false); + } + } + } + + async function copy(kind: "result" | "curl", value: string): Promise { + try { + await navigator.clipboard.writeText(value); + } catch { + return; + } + setCopied(kind); + if (copyTimeout.current !== null) { + clearTimeout(copyTimeout.current); + } + copyTimeout.current = setTimeout(() => setCopied(null), 1500); + } + + function updateRow(id: string, patch: Partial): void { + setRows((current) => + current.map((row) => (row.id === id ? { ...row, ...patch } : row)) + ); + } + + const curl = localEvaluateCurl( + appId, + selectedFlagKey, + contextFromRows(rows), + worker + ); + const resultJson = result === null ? "" : JSON.stringify(result, null, 2); + + return ( + + +
+ {/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */} + + Test a flag + +

+ Runs the same evaluation your Worker performs through its Flagship + binding. +

+
+ +
+
+ + +
+
+ +

+ Attributes your targeting rules can match on. +

+
+ + {rows.map((row) => ( +
+
+ void evaluate()} + onValueChange={(value) => + updateRow(row.id, { key: value }) + } + placeholder="Enter key" + value={row.key} + /> +
+ void evaluate()} + onValueChange={(value) => updateRow(row.id, { value })} + placeholder="Enter value" + value={row.value} + /> +
+ ))} + + +
+
+ +
+ {evaluating ? ( + + ) : error !== null ? ( +
+ +
+ ) : result !== null ? ( +
+
+ +
+ +
+

Value

+

+ {formatResultValue(result.value)} +

+
+ {result.variant === undefined ? null : ( + + {result.variant} + + )} + {result.reason === undefined ? null : ( + + {result.reason} + + )} +
+
+ +
+									{resultJson}
+								
+
+ ) : ( +
+ +

+ No evaluation yet +

+

+ Evaluate the flag to see the value, variant, and reason your + Worker receives. +

+
+ )} +
+
+ +
+ + + +
+
+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/flagship/flag-helpers.ts b/packages/local-explorer-ui/src/components/flagship/flag-helpers.ts new file mode 100644 index 00000000000..60807b52d3c --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/flag-helpers.ts @@ -0,0 +1,174 @@ +export type FlagType = "boolean" | "string" | "number" | "json"; + +export const FLAG_TYPE_LABELS: Record = { + boolean: "Boolean", + json: "JSON", + number: "Number", + string: "String", +}; + +export interface VariationDraft { + id: string; + name: string; + value: string; +} + +const FLAG_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; + +const DEFAULT_VARIATIONS: Record< + FlagType, + [{ name: string; value: string }, { name: string; value: string }] +> = { + boolean: [ + { name: "on", value: "true" }, + { name: "off", value: "false" }, + ], + number: [ + { name: "small", value: "10" }, + { name: "large", value: "100" }, + ], + string: [ + { name: "blue", value: "hex-0000ff" }, + { name: "red", value: "hex-ff0000" }, + ], + json: [ + { name: "dark", value: '{"theme":"dark","fontSize":14}' }, + { name: "light", value: '{"theme":"light","fontSize":16}' }, + ], +}; + +export function defaultVariationsForType( + type: FlagType +): [VariationDraft, VariationDraft] { + const [first, second] = DEFAULT_VARIATIONS[type]; + return [ + { id: crypto.randomUUID(), name: first.name, value: first.value }, + { id: crypto.randomUUID(), name: second.name, value: second.value }, + ]; +} + +export function inferFlagType( + variations: Record | undefined +): FlagType { + const [first] = Object.values(variations ?? {}); + if (typeof first === "boolean") { + return "boolean"; + } + if (typeof first === "number") { + return "number"; + } + if (typeof first === "string") { + return "string"; + } + return "json"; +} + +export function serializeVariationValue( + type: FlagType, + value: unknown +): string { + if (type === "boolean") { + return value === true ? "true" : "false"; + } + if (type === "string") { + return typeof value === "string" ? value : String(value); + } + if (type === "number") { + return typeof value === "number" ? String(value) : String(value ?? ""); + } + return JSON.stringify(value) ?? ""; +} + +export function variationDraftsFrom( + type: FlagType, + variations: Record | undefined +): [VariationDraft, ...VariationDraft[]] { + function toDraft([name, value]: [string, unknown]): VariationDraft { + return { + id: crypto.randomUUID(), + name, + value: serializeVariationValue(type, value), + }; + } + + const [first, ...rest] = Object.entries(variations ?? {}); + if (first === undefined) { + return defaultVariationsForType(type); + } + return [toDraft(first), ...rest.map(toDraft)]; +} + +export function validateFlagKey( + key: string, + existingKeys: Set +): string | null { + const trimmed = key.trim(); + if (trimmed.length === 0) { + return "Enter a flag key."; + } + if (!FLAG_KEY_PATTERN.test(trimmed)) { + return trimmed.length > 64 + ? "Flag key must be 64 characters or fewer." + : "Use only letters, numbers, hyphens, and underscores."; + } + if (existingKeys.has(trimmed)) { + return "A flag with this key already exists in this application."; + } + return null; +} + +export function parseVariationValue( + type: FlagType, + raw: string +): { ok: true; value: unknown } | { ok: false; error: string } { + if (type === "boolean") { + if (raw === "true") { + return { ok: true, value: true }; + } + if (raw === "false") { + return { ok: true, value: false }; + } + return { ok: false, error: "Boolean values must be true or false." }; + } + if (type === "number") { + if (raw.trim() === "" || !Number.isFinite(Number(raw))) { + return { ok: false, error: "Number values must be numeric." }; + } + return { ok: true, value: Number(raw) }; + } + if (type === "json") { + try { + const value = JSON.parse(raw) as unknown; + if (typeof value !== "object" || value === null) { + return { ok: false, error: "JSON values must be objects or arrays." }; + } + return { ok: true, value }; + } catch { + return { ok: false, error: "JSON values must be valid JSON." }; + } + } + return { ok: true, value: raw }; +} + +export function flagshipErrorMessage(error: unknown, fallback: string): string { + if ( + typeof error === "object" && + error !== null && + "errors" in error && + Array.isArray((error as { errors: unknown }).errors) + ) { + const [first] = (error as { errors: Array<{ message?: string }> }).errors; + if (first?.message) { + return first.message; + } + } + if (error instanceof Error) { + return error.message; + } + return fallback; +} + +export function shellQuote(value: string): string { + // Single quotes keep copied shell arguments inert; embedded quotes close and reopen safely. + return `'${value.replaceAll("'", `'\\''`)}'`; +} diff --git a/packages/local-explorer-ui/src/components/flagship/rule-helpers.ts b/packages/local-explorer-ui/src/components/flagship/rule-helpers.ts new file mode 100644 index 00000000000..6a2fc0d0800 --- /dev/null +++ b/packages/local-explorer-ui/src/components/flagship/rule-helpers.ts @@ -0,0 +1,326 @@ +import type { FlagshipRule } from "../../api"; + +export type Operator = + | "equals" + | "not_equals" + | "greater_than" + | "less_than" + | "greater_than_or_equals" + | "less_than_or_equals" + | "contains" + | "starts_with" + | "ends_with" + | "in" + | "not_in"; + +export type LogicalOperator = "AND" | "OR"; + +export interface BaseCondition { + attribute: string; + operator: Operator; + value: unknown; +} + +export interface LogicalCondition { + logical_operator: LogicalOperator; + clauses: Condition[]; +} + +export type Condition = BaseCondition | LogicalCondition; + +export interface Rollout { + percentage: number; + attribute?: string; +} + +export interface Rule { + priority: number; + conditions: Condition[]; + serve_variation: string; + rollout?: Rollout; +} + +export interface UICondition { + attribute: string; + join: LogicalOperator; + operator: Operator; + value: string; +} + +export interface UIRule { + conditions: UICondition[]; + id: string; + rollout: { attribute: string; percentage: number } | null; + serveVariation: string; +} + +export const OPERATOR_LABELS: Record = { + contains: "contains", + ends_with: "ends with", + equals: "equals", + greater_than: ">", + greater_than_or_equals: ">=", + in: "in", + less_than: "<", + less_than_or_equals: "<=", + not_equals: "does not equal", + not_in: "not in", + starts_with: "starts with", +}; + +const OPERATORS = new Set(Object.keys(OPERATOR_LABELS)); + +export const LIST_OPERATORS = new Set(["in", "not_in"]); + +export const NUMERIC_OPERATORS = new Set([ + "greater_than", + "greater_than_or_equals", + "less_than", + "less_than_or_equals", +]); + +export function isOperator(value: string): value is Operator { + return OPERATORS.has(value); +} + +function isLogical(condition: Condition): condition is LogicalCondition { + return "logical_operator" in condition && "clauses" in condition; +} + +function valueToText(value: unknown): string { + if (Array.isArray(value)) { + return value.map((entry) => String(entry)).join("\n"); + } + if (value === null || value === undefined) { + return ""; + } + return String(value); +} + +function textToValue(operator: Operator, text: string): unknown { + if (LIST_OPERATORS.has(operator)) { + return text + .split("\n") + .map((entry) => entry.trim()) + .filter((entry) => entry !== ""); + } + return text.trim(); +} + +function toRow(condition: BaseCondition, join: LogicalOperator): UICondition { + return { + attribute: condition.attribute, + join, + operator: condition.operator, + value: valueToText(condition.value), + }; +} + +export function flattenConditions( + conditions: Condition[] +): UICondition[] | null { + // A lone AND node matches the implicit AND across the top level, so unwrap it. + const [only] = conditions; + const groups = + only !== undefined && + conditions.length === 1 && + isLogical(only) && + only.logical_operator === "AND" + ? only.clauses + : conditions; + + const rows: UICondition[] = []; + for (const group of groups) { + if (!isLogical(group)) { + rows.push(toRow(group, "AND")); + continue; + } + if (group.logical_operator !== "OR" || group.clauses.length === 0) { + return null; + } + for (const [index, clause] of group.clauses.entries()) { + if (isLogical(clause)) { + return null; + } + rows.push(toRow(clause, index === 0 ? "AND" : "OR")); + } + } + + const [firstRow] = rows; + if (firstRow !== undefined) { + rows[0] = { ...firstRow, join: "AND" }; + } + return rows; +} + +export function buildConditions(rows: UICondition[]): Condition[] { + return groupRows(rows).map(({ rows: group }) => { + const clauses = group.map((row) => ({ + attribute: row.attribute.trim(), + operator: row.operator, + value: textToValue(row.operator, row.value), + })); + const [first] = clauses; + if (clauses.length === 1 && first !== undefined) { + return first; + } + return { clauses, logical_operator: "OR" }; + }); +} + +export function groupRows( + rows: UICondition[] +): Array<{ rows: UICondition[]; startIndex: number }> { + const groups: Array<{ rows: UICondition[]; startIndex: number }> = []; + let group: { rows: UICondition[]; startIndex: number } | undefined; + for (const [index, row] of rows.entries()) { + if (index === 0 || row.join === "AND" || group === undefined) { + group = { rows: [row], startIndex: index }; + groups.push(group); + } else { + group.rows.push(row); + } + } + return groups; +} + +export function emptyCondition(join: LogicalOperator): UICondition { + return { attribute: "", join, operator: "equals", value: "" }; +} + +export function emptyRule(serveVariation: string): UIRule { + return { + conditions: [emptyCondition("AND")], + id: crypto.randomUUID(), + rollout: null, + serveVariation, + }; +} + +export function uiRulesFrom( + rules: FlagshipRule[] | undefined +): UIRule[] | null { + const sorted = [...(rules ?? [])].sort( + (a, b) => (a.priority ?? 0) - (b.priority ?? 0) + ); + + const uiRules: UIRule[] = []; + for (const rule of sorted) { + const conditions = flattenConditions( + (rule.conditions ?? []) as unknown as Condition[] + ); + if (conditions === null) { + return null; + } + uiRules.push({ + conditions, + id: crypto.randomUUID(), + rollout: + rule.rollout === undefined + ? null + : { + attribute: rule.rollout.attribute ?? "", + percentage: rule.rollout.percentage ?? 100, + }, + serveVariation: rule.serve_variation ?? "", + }); + } + return uiRules; +} + +export function rulesFrom(uiRules: UIRule[]): FlagshipRule[] { + return uiRules.map((rule, index) => ({ + conditions: buildConditions( + rule.conditions + ) as unknown as FlagshipRule["conditions"], + priority: index + 1, + serve_variation: rule.serveVariation, + ...(rule.rollout === null + ? {} + : { + rollout: { + percentage: rule.rollout.percentage, + ...(rule.rollout.attribute.trim() === "" + ? {} + : { attribute: rule.rollout.attribute.trim() }), + }, + }), + })); +} + +export interface RuleError { + index: number; + message: string; +} + +export function validateRules( + uiRules: UIRule[], + variationNames: string[] +): RuleError[] { + const names = new Set(variationNames); + const errors: RuleError[] = []; + let catchAllIndex: number | null = null; + + for (const [index, rule] of uiRules.entries()) { + function fail(message: string): void { + if (errors.some((error) => error.index === index)) { + return; + } + errors.push({ index, message }); + } + + if (rule.serveVariation === "" || !names.has(rule.serveVariation)) { + fail("Choose a variant for this rule to serve."); + } + + for (const condition of rule.conditions) { + if (condition.attribute.trim() === "") { + fail("Every condition needs an attribute."); + break; + } + if (LIST_OPERATORS.has(condition.operator)) { + const entries = condition.value + .split("\n") + .filter((entry) => entry.trim() !== ""); + if (entries.length === 0) { + fail( + `The '${OPERATOR_LABELS[condition.operator]}' operator needs at least one value.` + ); + break; + } + } else if (condition.value.trim() === "") { + fail("Every condition needs a value."); + break; + } else if ( + NUMERIC_OPERATORS.has(condition.operator) && + Number.isNaN(Number(condition.value.trim())) + ) { + fail( + `The '${OPERATOR_LABELS[condition.operator]}' operator needs a number.` + ); + break; + } + } + + if (rule.rollout !== null) { + const { percentage } = rule.rollout; + if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) { + fail("Rollout must be a number between 0 and 100."); + } + } + + // The evaluator stops at the first match, so a catch-all hides what follows. + if (catchAllIndex !== null) { + fail( + `This rule can never match because rule ${catchAllIndex + 1} applies to everyone.` + ); + } else if ( + rule.conditions.length === 0 && + (rule.rollout === null || rule.rollout.percentage === 100) + ) { + catchAllIndex = index; + } + } + + return errors; +} diff --git a/packages/local-explorer-ui/src/components/workflows/Timestamp.tsx b/packages/local-explorer-ui/src/components/workflows/Timestamp.tsx index 790b4a9cbaa..1d41daa5b47 100644 --- a/packages/local-explorer-ui/src/components/workflows/Timestamp.tsx +++ b/packages/local-explorer-ui/src/components/workflows/Timestamp.tsx @@ -1,5 +1,5 @@ import { Tooltip } from "@cloudflare/kumo"; -import { timeAgo } from "./helpers"; +import { timeAgo } from "../../utils/time"; import type { JSX } from "react"; function formatShort(ts: string): string { diff --git a/packages/local-explorer-ui/src/components/workflows/helpers.ts b/packages/local-explorer-ui/src/components/workflows/helpers.ts index d2966056987..1c17fd7a2f0 100644 --- a/packages/local-explorer-ui/src/components/workflows/helpers.ts +++ b/packages/local-explorer-ui/src/components/workflows/helpers.ts @@ -27,34 +27,6 @@ export function formatDuration( return remainMins > 0 ? `${hours}h ${remainMins}m` : `${hours}h`; } -export function timeAgo(dateString: string | undefined): string { - if (!dateString) { - return ""; - } - const now = Date.now(); - const then = new Date(dateString).getTime(); - if (isNaN(then)) { - return ""; - } - const seconds = Math.floor((now - then) / 1000); - if (seconds < 5) { - return "just now"; - } - if (seconds < 60) { - return `${seconds}s ago`; - } - const minutes = Math.floor(seconds / 60); - if (minutes < 60) { - return `${minutes}m ago`; - } - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return `${hours}h ago`; - } - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - export function formatJson(value: unknown): string { if (value === null || value === undefined) { return "N/A"; diff --git a/packages/local-explorer-ui/src/routeTree.gen.ts b/packages/local-explorer-ui/src/routeTree.gen.ts index 22b722bac71..d9422da5cae 100644 --- a/packages/local-explorer-ui/src/routeTree.gen.ts +++ b/packages/local-explorer-ui/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as WorkflowsWorkflowNameRouteImport } from './routes/workflows/$w import { Route as R2BucketNameRouteImport } from './routes/r2/$bucketName' import { Route as ObservabilityEventsRouteImport } from './routes/observability/events' import { Route as KvNamespaceIdRouteImport } from './routes/kv/$namespaceId' +import { Route as FlagshipAppIdRouteImport } from './routes/flagship/$appId' import { Route as DoClassNameRouteImport } from './routes/do/$className' import { Route as D1DatabaseIdRouteImport } from './routes/d1/$databaseId' import { Route as WorkflowsWorkflowNameIndexRouteImport } from './routes/workflows/$workflowName/index' @@ -54,6 +55,11 @@ const KvNamespaceIdRoute = KvNamespaceIdRouteImport.update({ path: '/kv/$namespaceId', getParentRoute: () => rootRouteImport, } as any) +const FlagshipAppIdRoute = FlagshipAppIdRouteImport.update({ + id: '/flagship/$appId', + path: '/flagship/$appId', + getParentRoute: () => rootRouteImport, +} as any) const DoClassNameRoute = DoClassNameRouteImport.update({ id: '/do/$className', path: '/do/$className', @@ -101,6 +107,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/d1/$databaseId': typeof D1DatabaseIdRoute '/do/$className': typeof DoClassNameRouteWithChildren + '/flagship/$appId': typeof FlagshipAppIdRoute '/kv/$namespaceId': typeof KvNamespaceIdRoute '/observability/events': typeof ObservabilityEventsRoute '/r2/$bucketName': typeof R2BucketNameRouteWithChildren @@ -116,6 +123,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/d1/$databaseId': typeof D1DatabaseIdRoute + '/flagship/$appId': typeof FlagshipAppIdRoute '/kv/$namespaceId': typeof KvNamespaceIdRoute '/observability/events': typeof ObservabilityEventsRoute '/observability': typeof ObservabilityIndexRoute @@ -131,6 +139,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/d1/$databaseId': typeof D1DatabaseIdRoute '/do/$className': typeof DoClassNameRouteWithChildren + '/flagship/$appId': typeof FlagshipAppIdRoute '/kv/$namespaceId': typeof KvNamespaceIdRoute '/observability/events': typeof ObservabilityEventsRoute '/r2/$bucketName': typeof R2BucketNameRouteWithChildren @@ -149,6 +158,7 @@ export interface FileRouteTypes { | '/' | '/d1/$databaseId' | '/do/$className' + | '/flagship/$appId' | '/kv/$namespaceId' | '/observability/events' | '/r2/$bucketName' @@ -164,6 +174,7 @@ export interface FileRouteTypes { to: | '/' | '/d1/$databaseId' + | '/flagship/$appId' | '/kv/$namespaceId' | '/observability/events' | '/observability' @@ -178,6 +189,7 @@ export interface FileRouteTypes { | '/' | '/d1/$databaseId' | '/do/$className' + | '/flagship/$appId' | '/kv/$namespaceId' | '/observability/events' | '/r2/$bucketName' @@ -195,6 +207,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute D1DatabaseIdRoute: typeof D1DatabaseIdRoute DoClassNameRoute: typeof DoClassNameRouteWithChildren + FlagshipAppIdRoute: typeof FlagshipAppIdRoute KvNamespaceIdRoute: typeof KvNamespaceIdRoute ObservabilityEventsRoute: typeof ObservabilityEventsRoute R2BucketNameRoute: typeof R2BucketNameRouteWithChildren @@ -246,6 +259,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof KvNamespaceIdRouteImport parentRoute: typeof rootRouteImport } + '/flagship/$appId': { + id: '/flagship/$appId' + path: '/flagship/$appId' + fullPath: '/flagship/$appId' + preLoaderRoute: typeof FlagshipAppIdRouteImport + parentRoute: typeof rootRouteImport + } '/do/$className': { id: '/do/$className' path: '/do/$className' @@ -352,6 +372,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, D1DatabaseIdRoute: D1DatabaseIdRoute, DoClassNameRoute: DoClassNameRouteWithChildren, + FlagshipAppIdRoute: FlagshipAppIdRoute, KvNamespaceIdRoute: KvNamespaceIdRoute, ObservabilityEventsRoute: ObservabilityEventsRoute, R2BucketNameRoute: R2BucketNameRouteWithChildren, diff --git a/packages/local-explorer-ui/src/routes/flagship/$appId.tsx b/packages/local-explorer-ui/src/routes/flagship/$appId.tsx new file mode 100644 index 00000000000..c40e4b5859a --- /dev/null +++ b/packages/local-explorer-ui/src/routes/flagship/$appId.tsx @@ -0,0 +1,382 @@ +import { + Button, + Dialog, + Empty, + InputGroup, + RefreshButton, + useKumoToastManager, +} from "@cloudflare/kumo"; +import { + FlagBannerIcon, + FlaskIcon, + MagnifyingGlassIcon, + PlusIcon, + XIcon, +} from "@phosphor-icons/react"; +import { + createFileRoute, + getRouteApi, + notFound, + useRouter, + useRouterState, +} from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { + flagshipDeleteFlag, + flagshipListFlags, + flagshipUpdateFlag, +} from "../../api"; +import FlagshipIcon from "../../assets/icons/flagship.svg?react"; +import { Breadcrumbs } from "../../components/Breadcrumbs"; +import { flagshipErrorMessage } from "../../components/flagship/flag-helpers"; +import { FlagDialog } from "../../components/flagship/FlagDialog"; +import { FlagTable } from "../../components/flagship/FlagTable"; +import { TextInput } from "../../components/flagship/FormFields"; +import { TestFlagDialog } from "../../components/flagship/TestFlagDialog"; +import { NotFound } from "../../components/NotFound"; +import { ResourceError } from "../../components/ResourceError"; +import { getSelectedWorker } from "../../components/WorkerSelector"; +import type { FlagshipFlag } from "../../api"; +import type { JSX } from "react"; + +const rootRoute = getRouteApi("__root__"); + +export const Route = createFileRoute("/flagship/$appId")({ + component: FlagshipAppView, + errorComponent: ResourceError, + loaderDeps: ({ search }) => ({ worker: search.worker }), + loader: async ({ deps, params }) => { + const response = await flagshipListFlags({ + path: { app_id: params.appId }, + query: { worker: deps.worker }, + throwOnError: false, + }); + if (response.response?.status === 404) { + throw notFound(); + } + if (response.error) { + throw new Error(`Failed to list flags for app "${params.appId}"`); + } + return { flags: response.data?.result ?? [] }; + }, + notFoundComponent: NotFound, + validateSearch: (search: Record): { worker?: string } => ({ + worker: typeof search.worker === "string" ? search.worker : undefined, + }), +}); + +/** + * Renders the flag list for a locally simulated Flagship application. + */ +function FlagshipAppView(): JSX.Element { + const { appId } = Route.useParams(); + const { worker } = Route.useSearch(); + const { flags } = Route.useLoaderData(); + const router = useRouter(); + const toast = useKumoToastManager(); + const rootData = rootRoute.useLoaderData(); + const routerState = useRouterState(); + + const bindingName = useMemo(() => { + const selectedWorker = getSelectedWorker( + rootData.workers, + routerState.location.searchStr + ); + return selectedWorker?.bindings?.flagship?.find((app) => app.id === appId) + ?.bindingName; + }, [appId, rootData.workers, routerState.location.searchStr]); + + const [creating, setCreating] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [testTarget, setTestTarget] = useState(); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [pendingKey, setPendingKey] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + + const filteredFlags = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (needle === "") { + return flags; + } + return flags.filter((flag) => { + const key = flag.key?.toLowerCase() ?? ""; + const description = flag.description?.toLowerCase() ?? ""; + return key.includes(needle) || description.includes(needle); + }); + }, [flags, query]); + + function openTest(flag?: FlagshipFlag): void { + setTestTarget(flag?.key ?? null); + } + + async function refresh(): Promise { + setRefreshing(true); + try { + await router.invalidate(); + } catch (error) { + toast.add({ + description: flagshipErrorMessage(error, "Failed to refresh flags"), + title: "Refresh failed", + variant: "error", + }); + } finally { + setRefreshing(false); + } + } + + async function toggleFlag(flag: FlagshipFlag): Promise { + if (flag.key === undefined) { + return; + } + setPendingKey(flag.key); + try { + await flagshipUpdateFlag({ + body: { enabled: !flag.enabled }, + path: { app_id: appId, flag_key: flag.key }, + query: { worker }, + }); + } catch (error) { + toast.add({ + description: flagshipErrorMessage(error, "Failed to update flag"), + title: "Update failed", + variant: "error", + }); + return; + } finally { + setPendingKey(null); + } + await refresh(); + } + + async function confirmDelete(): Promise { + if (deleteTarget?.key === undefined) { + return; + } + setDeleting(true); + try { + await flagshipDeleteFlag({ + path: { app_id: appId, flag_key: deleteTarget.key }, + query: { worker }, + }); + } catch (error) { + toast.add({ + description: flagshipErrorMessage(error, "Failed to delete flag"), + title: "Delete failed", + variant: "error", + }); + return; + } finally { + setDeleting(false); + } + setDeleteTarget(null); + await refresh(); + } + + const searching = query.trim() !== ""; + + return ( +
+ + {bindingName && bindingName !== appId ? ( + <> + {bindingName} + ({appId}) + + ) : ( + appId + )} + , + ]} + title="Flagship" + > + {flags.length === 0 ? null : ( + + )} + + + +
+ {flags.length === 0 ? ( +
+ setCreating(true)} + variant="primary" + > + Create flag + + } + description="Flags you create here live in the local store, so the Worker running in dev reads them straight away. You can also pull an existing application's flags from Cloudflare." + icon={ + + } + title="No feature flags found" + /> +
+ ) : ( +
+
+
+ + + + {searching ? ( + setQuery("")} + shape="square" + variant="ghost" + > + + + ) : null} + +
+ + {searching + ? `${filteredFlags.length} of ${flags.length}` + : `${flags.length} ${flags.length === 1 ? "flag" : "flags"}`} + + void refresh()} + /> +
+ + {filteredFlags.length === 0 ? ( + + } + size="sm" + title="No matching flags" + /> + ) : ( + void toggleFlag(flag)} + pendingKey={pendingKey} + /> + )} +
+ )} +
+ + { + if (!open) { + setCreating(false); + setEditTarget(null); + } + }} + onSaved={refresh} + open={creating || editTarget !== null} + worker={worker} + /> + + { + if (!open) { + setTestTarget(undefined); + } + }} + open={testTarget !== undefined} + worker={worker} + /> + + { + if (!open && !deleting) { + setDeleteTarget(null); + } + }} + open={deleteTarget !== null} + > + + {/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */} + + Delete flag? + + {/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */} + + + {deleteTarget?.key} + {" "} + will be removed from the local store. Workers reading it will fall + back to the default value passed in code. + +
+ + +
+
+
+
+ ); +} diff --git a/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx b/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx index c9ad962444f..2415ec8ed32 100644 --- a/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx +++ b/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx @@ -38,10 +38,10 @@ import { Breadcrumbs } from "../../../components/Breadcrumbs"; import { NotFound } from "../../../components/NotFound"; import { ResourceError } from "../../../components/ResourceError"; import { CreateWorkflowInstanceDialog } from "../../../components/workflows/CreateInstanceDialog"; -import { timeAgo } from "../../../components/workflows/helpers"; import { WorkflowStatusBadge } from "../../../components/workflows/StatusBadge"; import { getAvailableActions } from "../../../components/workflows/types"; import { withMinimumDelay } from "../../../utils/async"; +import { timeAgo } from "../../../utils/time"; import type { WorkflowsInstance } from "../../../api"; import type { Action } from "../../../components/workflows/types"; diff --git a/packages/local-explorer-ui/src/utils/agent-prompt.ts b/packages/local-explorer-ui/src/utils/agent-prompt.ts index c9843e5e539..1ba28fe2505 100644 --- a/packages/local-explorer-ui/src/utils/agent-prompt.ts +++ b/packages/local-explorer-ui/src/utils/agent-prompt.ts @@ -1,4 +1,4 @@ -const AGENT_PROMPT_TEMPLATE = `You have access to local Cloudflare services (KV, R2, D1, Durable Objects, and Workflows) for this app via the Explorer API. +const AGENT_PROMPT_TEMPLATE = `You have access to local Cloudflare services (KV, R2, D1, Durable Objects, Workflows, and Flagship) for this app via the Explorer API. API endpoint: {{apiEndpoint}} Fetch the OpenAPI schema from {{apiEndpoint}} to discover available operations. Use these endpoints to list, query, and manage local resources during development.`; diff --git a/packages/local-explorer-ui/src/utils/sidebar-state.ts b/packages/local-explorer-ui/src/utils/sidebar-state.ts index 90a07935cef..a8bd736c773 100644 --- a/packages/local-explorer-ui/src/utils/sidebar-state.ts +++ b/packages/local-explorer-ui/src/utils/sidebar-state.ts @@ -8,6 +8,7 @@ export const SIDEBAR_GROUP_IDS = [ "kv", "r2", "workflows", + "flagship", ] as const; export type SidebarGroupId = (typeof SIDEBAR_GROUP_IDS)[number]; @@ -21,6 +22,7 @@ export const DEFAULT_GROUP_STATE: SidebarGroupState = { kv: true, r2: true, workflows: true, + flagship: true, }; /** diff --git a/packages/local-explorer-ui/src/utils/time.ts b/packages/local-explorer-ui/src/utils/time.ts new file mode 100644 index 00000000000..032f4668ddb --- /dev/null +++ b/packages/local-explorer-ui/src/utils/time.ts @@ -0,0 +1,32 @@ +/** + * Formats a timestamp as a compact relative label, for example "5m ago". + * + * @returns A relative label, or an empty string when the value is missing or unparseable + */ +export function timeAgo(dateString: string | undefined): string { + if (!dateString) { + return ""; + } + const now = Date.now(); + const then = new Date(dateString).getTime(); + if (isNaN(then)) { + return ""; + } + const seconds = Math.floor((now - then) / 1000); + if (seconds < 5) { + return "just now"; + } + if (seconds < 60) { + return `${seconds}s ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/packages/miniflare/scripts/openapi-filter-config.ts b/packages/miniflare/scripts/openapi-filter-config.ts index 615ee19d5e3..6f0c4aea2c1 100644 --- a/packages/miniflare/scripts/openapi-filter-config.ts +++ b/packages/miniflare/scripts/openapi-filter-config.ts @@ -1,6 +1,16 @@ import { EMAIL_OPENAPI_SCHEMAS } from "./email-openapi"; import type { FilterConfig } from "./filter-openapi"; +function flagshipWorkerParameter() { + return { + description: "Worker whose local Flagship store should be used.", + in: "query" as const, + name: "worker", + required: false, + schema: { type: "string" as const }, + }; +} + /** * Configuration for filtering Cloudflare's OpenAPI spec for local explorer. * This defines which endpoints to include and what features to ignore. @@ -1797,8 +1807,596 @@ const config = { tags: ["Observability"], }, }, + // Flagship endpoints (local-only, feature flags are not in the public API) + "/flagship/apps": { + get: { + description: "Returns the Flagship apps bound for local development.", + operationId: "flagship-list-apps", + parameters: [], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + items: { + $ref: "#/components/schemas/flagship_app", + }, + type: "array", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "List Flagship Apps response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "List Flagship Apps response failure.", + }, + }, + summary: "List Flagship Apps", + tags: ["Flagship"], + }, + }, + "/flagship/apps/{app_id}/flags": { + get: { + description: "Returns the flags in a local Flagship app.", + operationId: "flagship-list-flags", + parameters: [ + { + in: "path", + name: "app_id", + required: true, + schema: { type: "string" }, + }, + flagshipWorkerParameter(), + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + items: { + $ref: "#/components/schemas/flagship_flag", + }, + type: "array", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "List Flagship Flags response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "List Flagship Flags response failure.", + }, + }, + summary: "List Flagship Flags", + tags: ["Flagship"], + }, + post: { + description: "Creates a flag in a local Flagship app.", + operationId: "flagship-create-flag", + parameters: [ + { + in: "path", + name: "app_id", + required: true, + schema: { type: "string" }, + }, + flagshipWorkerParameter(), + ], + requestBody: { + content: { + "application/json": { + schema: { + required: ["key", "default_variation", "variations"], + properties: { + key: { + description: "Flag key.", + type: "string", + }, + description: { + description: "Human readable description.", + nullable: true, + type: "string", + }, + enabled: { + description: "Whether targeting rules are evaluated.", + type: "boolean", + }, + default_variation: { + description: "Variation served when no rule matches.", + type: "string", + }, + variations: { + additionalProperties: true, + description: "Named values the flag can serve.", + type: "object", + }, + rules: { + description: "Targeting rules, in priority order.", + items: { + $ref: "#/components/schemas/flagship_rule", + }, + type: "array", + }, + }, + type: "object", + }, + }, + }, + required: true, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + $ref: "#/components/schemas/flagship_flag", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Create Flagship Flag response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Create Flagship Flag response failure.", + }, + }, + summary: "Create Flagship Flag", + tags: ["Flagship"], + }, + }, + "/flagship/apps/{app_id}/flags/{flag_key}": { + get: { + description: "Returns a single flag from a local Flagship app.", + operationId: "flagship-get-flag", + parameters: [ + { + in: "path", + name: "app_id", + required: true, + schema: { type: "string" }, + }, + { + in: "path", + name: "flag_key", + required: true, + schema: { type: "string" }, + }, + flagshipWorkerParameter(), + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + $ref: "#/components/schemas/flagship_flag", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Get Flagship Flag response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Get Flagship Flag response failure.", + }, + }, + summary: "Get Flagship Flag", + tags: ["Flagship"], + }, + patch: { + description: + "Updates a flag. Omitted fields, including targeting rules, keep their current values.", + operationId: "flagship-update-flag", + parameters: [ + { + in: "path", + name: "app_id", + required: true, + schema: { type: "string" }, + }, + { + in: "path", + name: "flag_key", + required: true, + schema: { type: "string" }, + }, + flagshipWorkerParameter(), + ], + requestBody: { + content: { + "application/json": { + schema: { + properties: { + description: { + description: "Human readable description.", + nullable: true, + type: "string", + }, + enabled: { + description: "Whether the flag is enabled.", + type: "boolean", + }, + default_variation: { + description: + "The variation served when no targeting rule matches.", + type: "string", + }, + variations: { + additionalProperties: true, + description: "Named values the flag can serve.", + type: "object", + }, + rules: { + description: + "Targeting rules, in priority order. Replaces the existing rules.", + items: { + $ref: "#/components/schemas/flagship_rule", + }, + type: "array", + }, + }, + type: "object", + }, + }, + }, + required: true, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + $ref: "#/components/schemas/flagship_flag", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Update Flagship Flag response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Update Flagship Flag response failure.", + }, + }, + summary: "Update Flagship Flag", + tags: ["Flagship"], + }, + delete: { + description: "Deletes a flag from a local Flagship app.", + operationId: "flagship-delete-flag", + parameters: [ + { + in: "path", + name: "app_id", + required: true, + schema: { type: "string" }, + }, + { + in: "path", + name: "flag_key", + required: true, + schema: { type: "string" }, + }, + flagshipWorkerParameter(), + ], + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + properties: { + success: { type: "boolean" }, + }, + type: "object", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Delete Flagship Flag response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Delete Flagship Flag response failure.", + }, + }, + summary: "Delete Flagship Flag", + tags: ["Flagship"], + }, + }, + "/flagship/apps/{app_id}/flags/{flag_key}/evaluate": { + post: { + description: + "Evaluates a flag against an evaluation context, as a Worker binding would.", + operationId: "flagship-evaluate-flag", + parameters: [ + { + in: "path", + name: "app_id", + required: true, + schema: { type: "string" }, + }, + { + in: "path", + name: "flag_key", + required: true, + schema: { type: "string" }, + }, + flagshipWorkerParameter(), + ], + requestBody: { + content: { + "application/json": { + schema: { + properties: { + context: { + additionalProperties: true, + description: + "Attributes used for rule matching and rollout bucketing.", + type: "object", + }, + }, + type: "object", + }, + }, + }, + required: true, + }, + responses: { + "200": { + content: { + "application/json": { + schema: { + allOf: [ + { + $ref: "#/components/schemas/workers_api-response-common", + }, + { + properties: { + result: { + $ref: "#/components/schemas/flagship_evaluation", + }, + }, + type: "object", + }, + ], + }, + }, + }, + description: "Evaluate Flagship Flag response.", + }, + "4XX": { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/workers_api-response-common-failure", + }, + }, + }, + description: "Evaluate Flagship Flag response failure.", + }, + }, + summary: "Evaluate Flagship Flag", + tags: ["Flagship"], + }, + }, }, schemas: { + // Flagship schemas — the local flag store's management shapes + flagship_app: { + type: "object", + required: ["id", "bindings"], + properties: { + id: { + type: "string", + description: "The Flagship app id the bindings point at", + }, + bindings: { + type: "array", + items: { type: "string" }, + description: "Binding names in this instance using the app", + }, + }, + }, + flagship_rule: { + type: "object", + required: ["priority", "conditions", "serve_variation"], + properties: { + priority: { + type: "integer", + description: "Evaluation order, lowest first", + }, + conditions: { + type: "array", + items: { type: "object", additionalProperties: true }, + description: "Conditions that must match for the rule to apply", + }, + serve_variation: { + type: "string", + description: "Variation served when the rule matches", + }, + rollout: { + type: "object", + required: ["percentage"], + properties: { + percentage: { type: "number", minimum: 0, maximum: 100 }, + attribute: { type: "string" }, + }, + description: "Percentage rollout applied to matching contexts", + }, + }, + }, + flagship_flag: { + type: "object", + required: [ + "key", + "type", + "enabled", + "default_variation", + "variations", + "rules", + "updated_at", + ], + properties: { + key: { type: "string", description: "Flag key" }, + type: { + type: "string", + enum: ["boolean", "string", "number", "json"], + description: "Type shared by the flag's variations", + }, + description: { + type: "string", + nullable: true, + description: "Human readable description", + }, + enabled: { + type: "boolean", + description: "Whether targeting rules are evaluated", + }, + default_variation: { + type: "string", + description: "Variation served when no rule matches", + }, + variations: { + type: "object", + additionalProperties: true, + description: "Named values the flag can serve", + }, + rules: { + type: "array", + items: { $ref: "#/components/schemas/flagship_rule" }, + description: "Targeting rules, in priority order", + }, + updated_at: { + type: "string", + description: "When the flag was last written locally", + }, + }, + }, + flagship_evaluation: { + type: "object", + required: ["flagKey", "value", "variant", "reason"], + properties: { + flagKey: { type: "string" }, + value: { description: "The resolved flag value" }, + variant: { + type: "string", + description: "Name of the variation served", + }, + reason: { + type: "string", + enum: ["TARGETING_MATCH", "DEFAULT", "DISABLED", "SPLIT", "ERROR"], + description: "Why this value was served", + }, + errorCode: { type: "string" }, + errorMessage: { type: "string" }, + }, + }, // R2 schemas - matches stratus dashboard API shapes // Note: storage_class and jurisdiction/location not supported locally r2_object: { @@ -2045,6 +2643,13 @@ const config = { }, description: "Send Email bindings", }, + flagship: { + type: "array", + items: { + $ref: "#/components/schemas/local-explorer_resource-binding", + }, + description: "Flagship app bindings", + }, }, }, "local-explorer_named-binding": { diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index c7699efb2b4..6215537ca2d 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -133,6 +133,7 @@ import { SharedHeaders, SiteBindings, } from "./workers"; +import { ADMIN_API as FLAGSHIP_ADMIN_API } from "./workers/flagship/constants"; import { ADMIN_API } from "./workers/secrets-store/constants"; import type { MiniflareOptions, @@ -170,6 +171,9 @@ import type { } from "./shared/dev-control"; import type { WorkerDefinition } from "./shared/dev-registry-types"; import type { Awaitable } from "./workers"; +import type { FlagshipAdmin } from "./workers/flagship/admin"; +import type { EvaluationDetails, FlagValue } from "./workers/flagship/evaluate"; +import type { Flag, FlagInput } from "./workers/flagship/flags"; import type { CacheStorage, D1Database, @@ -3465,6 +3469,17 @@ export class Miniflare { ): Promise { return this.#getProxy(FLAGSHIP_PLUGIN_NAME, bindingName, workerName); } + getFlagshipBindingAPI( + bindingName: string, + workerName?: string + ): Promise<() => FlagshipAdmin> { + return this.#getProxy(FLAGSHIP_PLUGIN_NAME, bindingName, workerName).then( + (binding) => { + // @ts-expect-error We exposed an admin API on this key + return binding[FLAGSHIP_ADMIN_API]; + } + ); + } getStreamBinding( bindingName: string, workerName?: string @@ -3625,6 +3640,28 @@ export class Miniflare { export type { WorkerdStructuredLog } from "./plugins/core"; +export type { FlagshipAdmin } from "./workers/flagship/admin"; + +export type { + BaseCondition, + Condition, + ErrorCode, + LogicalCondition, + EvaluationContext, + EvaluationDetails, + EvaluationReason, + FlagValue, + Operator, + Rollout, +} from "./workers/flagship/evaluate"; +export type { + Flag, + FlagChanges, + FlagInput, + FlagType, + Rule, +} from "./workers/flagship/flags"; + export interface SecretsStoreSecretAdmin { create(value: string): Promise; update(value: string, id: string): Promise; diff --git a/packages/miniflare/src/plugins/core/explorer.ts b/packages/miniflare/src/plugins/core/explorer.ts index c1614589eba..78631ba1004 100644 --- a/packages/miniflare/src/plugins/core/explorer.ts +++ b/packages/miniflare/src/plugins/core/explorer.ts @@ -8,9 +8,12 @@ import { type Worker_Module, } from "../../runtime"; import { CoreBindings } from "../../workers"; +import { FLAGSHIP_PLUGIN_NAME } from "../flagship"; import { extractObjectEntryId, getEnvBindingsOfType, + getRemoteProxyConnectionString, + getUserBindingServiceName, WORKER_BINDING_SERVICE_LOOPBACK, SERVICE_DEV_REGISTRY_PROXY, } from "../shared"; @@ -29,6 +32,7 @@ import type { import type { BindingIdMap, ExplorerWorkerOpts, + FlagshipBindingInfo, WorkerResourceBindings, WorkflowBindingInfo, } from "./types"; @@ -154,6 +158,21 @@ export function getExplorerServices( }); } + // Bind each locally simulated Flagship app's binding worker, so the + // explorer can read and write its flag store through the admin API. + for (const flagshipInfo of Object.values(bindingIdMap.flagship)) { + explorerBindings.push({ + name: flagshipInfo.binding, + service: { + name: getUserBindingServiceName( + FLAGSHIP_PLUGIN_NAME, + flagshipInfo.appId + ), + entrypoint: "FlagshipBinding", + }, + }); + } + return [ // Disk service for serving explorer UI assets { @@ -184,7 +203,8 @@ export function getExplorerServices( export function constructExplorerBindingMap( proxyBindings: Worker_Binding[], durableObjectClassNames: DurableObjectClassNames, - workflowOptions?: Map + workflowOptions?: Map, + flagshipApps?: Map ): BindingIdMap { const IDToBindingName: BindingIdMap = { d1: {}, @@ -192,8 +212,19 @@ export function constructExplorerBindingMap( do: {}, r2: {}, workflows: {}, + flagship: Object.create(null) as BindingIdMap["flagship"], }; + // Flagship apps are addressed by app id rather than through a proxy + // binding, so they are passed in directly rather than parsed out. + for (const [appId, bindings] of flagshipApps ?? []) { + IDToBindingName.flagship[appId] = { + appId, + binding: `EXPLORER_FLAGSHIP_${appId}`, + bindings, + } satisfies FlagshipBindingInfo; + } + for (const binding of proxyBindings) { // D1 bindings: name = "MINIFLARE_PROXY:d1:worker-*:BINDING". // Local databases share one entry service ("d1:db:entry") and carry their @@ -361,6 +392,7 @@ export function constructExplorerWorkerOpts( do: [], workflows: [], sendEmail: [], + flagship: [], }; for (const [bindingName, binding] of getEnvBindingsOfType( @@ -425,6 +457,18 @@ export function constructExplorerWorkerOpts( bindings.sendEmail.push({ bindingName }); } + for (const [bindingName, binding] of getEnvBindingsOfType( + workerOpts.config, + "flagship" + )) { + if ( + getRemoteProxyConnectionString(binding, workerOpts.dev) !== undefined + ) { + continue; + } + bindings.flagship.push({ id: binding.id, bindingName }); + } + result[workerName] = bindings; } diff --git a/packages/miniflare/src/plugins/core/index.ts b/packages/miniflare/src/plugins/core/index.ts index 6544c08a96e..8d350f72d39 100644 --- a/packages/miniflare/src/plugins/core/index.ts +++ b/packages/miniflare/src/plugins/core/index.ts @@ -983,10 +983,30 @@ export function getGlobalServices({ }); } } + // Only locally simulated Flagship apps have a store the explorer can + // read; remote bindings are served by the remote app. + const flagshipApps = new Map(); + for (const workerOpts of allWorkerOpts ?? []) { + for (const [name, binding] of getEnvBindingsOfType( + workerOpts.config, + "flagship" + )) { + if ( + getRemoteProxyConnectionString(binding, workerOpts.dev) !== undefined + ) { + continue; + } + flagshipApps.set(binding.id, [ + ...(flagshipApps.get(binding.id) ?? []), + name, + ]); + } + } const IDToBindingMap: BindingIdMap = constructExplorerBindingMap( proxyBindings, durableObjectClassNames, - workflowOptions + workflowOptions, + flagshipApps ); const hasDurableObjects = Object.keys(IDToBindingMap.do).length > 0; diff --git a/packages/miniflare/src/plugins/core/types.ts b/packages/miniflare/src/plugins/core/types.ts index 5133ecf3f0c..68ae96af7e6 100644 --- a/packages/miniflare/src/plugins/core/types.ts +++ b/packages/miniflare/src/plugins/core/types.ts @@ -8,6 +8,13 @@ export type BindingIdMap = { do: Record; // uniqueKey -> namespace info r2: Record; // bucketName -> bindingName workflows: Record; // workflowName -> binding info + flagship: Record; // appId -> binding info +}; + +export type FlagshipBindingInfo = { + appId: string; // Flagship app id + binding: string; // service binding name in the explorer's env + bindings: string[]; // user-facing binding names using this app }; type DONamespaceInfo = { @@ -54,6 +61,11 @@ export type WorkerResourceBindings = { sendEmail: { bindingName: string; }[]; + flagship: { + /** id = Flagship app id */ + id: string; + bindingName: string; + }[]; }; export type ExplorerWorkerOpts = Record; diff --git a/packages/miniflare/src/plugins/flagship/index.ts b/packages/miniflare/src/plugins/flagship/index.ts index 6ba12ffa818..b1b26790a2c 100644 --- a/packages/miniflare/src/plugins/flagship/index.ts +++ b/packages/miniflare/src/plugins/flagship/index.ts @@ -1,30 +1,56 @@ +import fs from "node:fs/promises"; +import BINDING_SCRIPT from "worker:flagship/binding"; +import OBJECT_SCRIPT from "worker:flagship/object"; import { buildRemoteProxyProps, getEnvBindingsOfType, + getPersistPath, getRemoteProxyConnectionString, + getUserBindingServiceName, ProxyNodeBinding, remoteProxyClientWorker, } from "../shared"; -import type { Worker_Binding } from "../../runtime"; +import type { Service, Worker_Binding } from "../../runtime"; import type { Plugin } from "../shared"; export const FLAGSHIP_PLUGIN_NAME = "flagship"; -const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}:remote`; +const FLAGSHIP_REMOTE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:remote`; +const FLAGSHIP_OBJECT_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:object`; +const FLAGSHIP_STORAGE_SERVICE_NAME = `${FLAGSHIP_PLUGIN_NAME}-internal:storage`; +const FLAGSHIP_OBJECT_CLASS_NAME = "FlagshipObject"; + +// Rollout bucketing is seeded with the account tag. Local flag definitions are +// their own source of truth and an account tag is not reliably available +// offline, so a constant keeps bucketing deterministic across machines; it does +// not reproduce production buckets. +const LOCAL_ACCOUNT_TAG = "local"; export const FLAGSHIP_PLUGIN: Plugin = { bindingTypeDescription: "Flagship", async getBindings(options) { return getEnvBindingsOfType(options.config, "flagship").map( - ([name, binding]) => ({ - name, - service: { - name: FLAGSHIP_REMOTE_SERVICE_NAME, - props: buildRemoteProxyProps( - getRemoteProxyConnectionString(binding, options.dev), - name - ), - }, - }) + ([name, binding]) => { + const remoteProxyConnectionString = getRemoteProxyConnectionString( + binding, + options.dev + ); + if (remoteProxyConnectionString) { + return { + name, + service: { + name: FLAGSHIP_REMOTE_SERVICE_NAME, + props: buildRemoteProxyProps(remoteProxyConnectionString, name), + }, + }; + } + return { + name, + service: { + name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, binding.id), + entrypoint: "FlagshipBinding", + }, + }; + } ); }, getNodeBindings(options) { @@ -35,16 +61,90 @@ export const FLAGSHIP_PLUGIN: Plugin = { ]) ); }, - async getServices({ options }) { - if (getEnvBindingsOfType(options.config, "flagship").length === 0) { + async getServices({ options, tmpPath, sharedOptions }) { + const bindings = getEnvBindingsOfType(options.config, "flagship"); + if (bindings.length === 0) { return []; } - return [ - { + const services: Service[] = []; + const hasRemote = bindings.some(([, binding]) => + getRemoteProxyConnectionString(binding, options.dev) + ); + if (hasRemote) { + services.push({ name: FLAGSHIP_REMOTE_SERVICE_NAME, worker: remoteProxyClientWorker(), + }); + } + + const localAppIds = new Set( + bindings + .filter( + ([, binding]) => + getRemoteProxyConnectionString(binding, options.dev) === undefined + ) + .map(([, binding]) => binding.id) + ); + if (localAppIds.size === 0) { + return services; + } + + const persistPath = getPersistPath( + FLAGSHIP_PLUGIN_NAME, + tmpPath, + sharedOptions.resourcePersistencePath + ); + await fs.mkdir(persistPath, { recursive: true }); + + services.push( + { + name: FLAGSHIP_STORAGE_SERVICE_NAME, + disk: { path: persistPath, writable: true }, }, - ]; + { + name: FLAGSHIP_OBJECT_SERVICE_NAME, + worker: { + compatibilityDate: "2025-01-01", + modules: [{ name: "object.worker.js", esModule: OBJECT_SCRIPT() }], + durableObjectNamespaces: [ + { + className: FLAGSHIP_OBJECT_CLASS_NAME, + uniqueKey: `miniflare-flagship-${FLAGSHIP_OBJECT_CLASS_NAME}`, + enableSql: true, + }, + ], + durableObjectStorage: { localDisk: FLAGSHIP_STORAGE_SERVICE_NAME }, + }, + } + ); + + for (const appId of localAppIds) { + services.push({ + name: getUserBindingServiceName(FLAGSHIP_PLUGIN_NAME, appId), + worker: { + compatibilityDate: "2025-01-01", + modules: [{ name: "binding.worker.js", esModule: BINDING_SCRIPT() }], + bindings: [ + { + name: "config", + json: JSON.stringify({ + appId, + accountTag: LOCAL_ACCOUNT_TAG, + }), + }, + { + name: "store", + durableObjectNamespace: { + className: FLAGSHIP_OBJECT_CLASS_NAME, + serviceName: FLAGSHIP_OBJECT_SERVICE_NAME, + }, + }, + ], + }, + }); + } + + return services; }, }; diff --git a/packages/miniflare/src/workers/flagship/admin.ts b/packages/miniflare/src/workers/flagship/admin.ts new file mode 100644 index 00000000000..71284adda53 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/admin.ts @@ -0,0 +1,23 @@ +import type { + EvaluationContext, + EvaluationDetails, + FlagValue, +} from "./evaluate"; +import type { Flag, FlagChanges, FlagInput } from "./flags"; + +export interface FlagshipAdmin { + listFlags(): Promise; + getFlag(flagKey: string): Promise; + getAccountTag(): Promise; + setAccountTag(accountTag: string): Promise; + createFlag(input: FlagInput): Promise; + updateFlag(flagKey: string, input: FlagInput): Promise; + patchFlag(flagKey: string, changes: FlagChanges): Promise; + putFlag(input: FlagInput): Promise; + putFlags(inputs: FlagInput[], accountTag: string): Promise; + deleteFlag(flagKey: string): Promise; + evaluateFlag( + flagKey: string, + context?: EvaluationContext + ): Promise>; +} diff --git a/packages/miniflare/src/workers/flagship/binding.worker.ts b/packages/miniflare/src/workers/flagship/binding.worker.ts new file mode 100644 index 00000000000..14edbd98346 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/binding.worker.ts @@ -0,0 +1,293 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { ADMIN_API } from "./constants"; +import { + evaluateFlag, + FlagConfigError, + matchesType, + TypeCastError, +} from "./evaluate"; +import { flagNotFoundMessage } from "./flags"; +import type { FlagshipAdmin } from "./admin"; +import type { + ErrorCode, + EvaluationContext, + EvaluationDetails, + FlagType, + FlagValue, +} from "./evaluate"; +import type { Flag, FlagInput } from "./flags"; +import type { FlagshipObject, WriteResult } from "./object.worker"; + +interface Env { + config: { appId: string; accountTag: string }; + store: DurableObjectNamespace; +} + +// Keep the default name: workerd prefixes custom error names during RPC serialization. +class FlagNotFoundError extends Error { + constructor(flagKey: string) { + super(flagNotFoundMessage(flagKey)); + } +} + +class FlagConflictError extends Error { + constructor(flagKey: string) { + super(`Flag '${flagKey}' already exists`); + } +} + +// Module scope, so the warning fires at most once per isolate rather than once +// per evaluation. +let warnedAboutUnseededRollout = false; + +function validateAccountTag(accountTag: string): void { + if (typeof accountTag !== "string" || accountTag === "") { + throw new Error("accountTag must be a non-empty string"); + } +} + +function hasPartialRollout(flag: Flag): boolean { + return flag.rules.some( + (rule) => rule.rollout !== undefined && rule.rollout.percentage < 100 + ); +} + +function warnIfBucketingUnseeded(flag: Flag): void { + if (warnedAboutUnseededRollout || !hasPartialRollout(flag)) { + return; + } + warnedAboutUnseededRollout = true; + console.warn( + `Flagship: flag '${flag.key}' has a percentage rollout, but the local flag store has no account tag, so its buckets will not match your remote app. Run \`wrangler flagship flags pull\` to seed the store.` + ); +} + +function errorCodeFor(error: unknown): ErrorCode | undefined { + if (error instanceof FlagNotFoundError) { + return "FLAG_NOT_FOUND"; + } + if (error instanceof FlagConfigError) { + return "PARSE_ERROR"; + } + return undefined; +} + +export class FlagshipBinding extends WorkerEntrypoint { + get #stub() { + const namespace = this.env.store; + return namespace.get(namespace.idFromName(this.env.config.appId)); + } + + async #evaluate( + flagKey: string, + context: EvaluationContext + ): Promise> { + if (typeof flagKey !== "string" || flagKey === "") { + throw new Error("flagKey must be a non-empty string"); + } + const { flag, accountTag } = await this.#stub.getForEvaluation(flagKey); + if (flag === null) { + throw new FlagNotFoundError(flagKey); + } + if (accountTag === null) { + warnIfBucketingUnseeded(flag); + } + const { value, variant, reason } = evaluateFlag( + flag, + context, + accountTag ?? this.env.config.accountTag + ); + return { flagKey, value, variant, reason }; + } + + async #typedDetails( + flagKey: string, + defaultValue: T, + expectedType: FlagType, + context?: EvaluationContext + ): Promise> { + const failure = ( + errorCode: ErrorCode, + errorMessage: string + ): EvaluationDetails => ({ + flagKey, + value: defaultValue, + variant: "default", + reason: "ERROR", + errorCode, + errorMessage, + }); + let result: EvaluationDetails; + try { + result = await this.#evaluate(flagKey, context ?? {}); + } catch (error) { + const errorCode = errorCodeFor(error); + if (errorCode === undefined) { + throw error; + } + return failure(errorCode, (error as Error).message); + } + + if (!matchesType(result.value, expectedType)) { + return failure( + "TYPE_MISMATCH", + new TypeCastError(flagKey, expectedType, result.value).message + ); + } + + return { + flagKey, + value: result.value as T, + variant: result.variant, + reason: result.reason, + }; + } + + async get( + flagKey: string, + defaultValue?: unknown, + context?: EvaluationContext + ): Promise { + try { + return (await this.#evaluate(flagKey, context ?? {})).value; + } catch (error) { + if (errorCodeFor(error) !== undefined && defaultValue !== undefined) { + return defaultValue; + } + throw error; + } + } + + async getBooleanValue( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext + ): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "boolean", context)) + .value; + } + + async getStringValue( + flagKey: string, + defaultValue: string, + context?: EvaluationContext + ): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "string", context)) + .value; + } + + async getNumberValue( + flagKey: string, + defaultValue: number, + context?: EvaluationContext + ): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "number", context)) + .value; + } + + async getObjectValue< + T extends Record | unknown[] = Record, + >(flagKey: string, defaultValue: T, context?: EvaluationContext): Promise { + return (await this.#typedDetails(flagKey, defaultValue, "object", context)) + .value; + } + + async getBooleanDetails( + flagKey: string, + defaultValue: boolean, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "boolean", context); + } + + async getStringDetails( + flagKey: string, + defaultValue: string, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "string", context); + } + + async getNumberDetails( + flagKey: string, + defaultValue: number, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "number", context); + } + + async getObjectDetails< + T extends Record | unknown[] = Record, + >( + flagKey: string, + defaultValue: T, + context?: EvaluationContext + ): Promise> { + return this.#typedDetails(flagKey, defaultValue, "object", context); + } + + [ADMIN_API](): FlagshipAdmin { + const stub = this.#stub; + function unwrap(result: WriteResult, flagKey: string): Flag { + switch (result.status) { + case "written": + return result.flag; + case "missing": + throw new FlagNotFoundError(flagKey); + case "exists": + throw new FlagConflictError(flagKey); + case "invalid": + throw new Error(result.message); + } + } + async function getFlag(flagKey: string): Promise { + const flag = await stub.get(flagKey); + if (flag === null) { + throw new FlagNotFoundError(flagKey); + } + return flag; + } + async function write( + operation: Promise, + flagKey: string + ): Promise { + return unwrap(await operation, flagKey); + } + + return { + listFlags: (): Promise => stub.list(), + getFlag, + getAccountTag: (): Promise => stub.getAccountTag(), + setAccountTag: (accountTag: string): Promise => { + validateAccountTag(accountTag); + return stub.setAccountTag(accountTag); + }, + createFlag: (input) => write(stub.create(input), input.key), + updateFlag: (flagKey, input) => + write(stub.update(flagKey, input), flagKey), + patchFlag: (flagKey, changes) => + write(stub.patch(flagKey, changes), flagKey), + putFlag: (input) => write(stub.put(input), input.key), + putFlags: async ( + inputs: FlagInput[], + accountTag: string + ): Promise => { + validateAccountTag(accountTag); + const result = await stub.putAll(inputs, accountTag); + if (result.status === "invalid") { + throw new Error(result.message); + } + }, + deleteFlag: async (flagKey: string): Promise => { + if (!(await stub.delete(flagKey))) { + throw new FlagNotFoundError(flagKey); + } + }, + evaluateFlag: ( + flagKey: string, + context?: EvaluationContext + ): Promise> => + this.#evaluate(flagKey, context ?? {}), + }; + } +} diff --git a/packages/miniflare/src/workers/flagship/constants.ts b/packages/miniflare/src/workers/flagship/constants.ts new file mode 100644 index 00000000000..adadc0b5b1f --- /dev/null +++ b/packages/miniflare/src/workers/flagship/constants.ts @@ -0,0 +1 @@ +export const ADMIN_API = "FlagshipBinding::admin_api"; diff --git a/packages/miniflare/src/workers/flagship/evaluate.ts b/packages/miniflare/src/workers/flagship/evaluate.ts new file mode 100644 index 00000000000..9033a5d1e92 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/evaluate.ts @@ -0,0 +1,270 @@ +import type { Condition, FlagInput, FlagValue, Rule } from "./flags"; + +// Vendored from Flagship data-plane commit f32a8bf1607a7493175ea3a919f56dcd6b8a4fca. +// Hashing and matching must remain byte-compatible with production. + +export type EvaluationReason = + | "TARGETING_MATCH" + | "DEFAULT" + | "DISABLED" + | "SPLIT" + | "ERROR"; + +export type ErrorCode = + | "FLAG_NOT_FOUND" + | "PARSE_ERROR" + | "TYPE_MISMATCH" + | "GENERAL"; + +export type EvaluationContext = Record; + +export type FlagType = "boolean" | "string" | "number" | "object"; + +export type EvalRule = Omit; +export type EvalFlag = Omit & { rules: EvalRule[] }; + +export interface EvaluationDetails { + flagKey: string; + value: T; + variant: string; + reason: EvaluationReason; + errorCode?: ErrorCode; + errorMessage?: string; +} + +export class TypeCastError extends Error { + constructor(flagKey: string, expectedType: string, actualValue: unknown) { + super( + `Flag '${flagKey}' has type '${typeof actualValue}', expected '${expectedType}'` + ); + this.name = "TypeCastError"; + } +} + +export class FlagConfigError extends Error { + constructor(flagKey: string, message: string) { + super(`Flag '${flagKey}' ${message}`); + this.name = "FlagConfigError"; + } +} + +const ISO_8601_REGEX = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + +const encoder = new TextEncoder(); +const randomBuf = new Uint32Array(1); +let hashBuf = new Uint8Array(512); + +function murmurhash3(str: string, seed: number): number { + if (hashBuf.byteLength < str.length * 3) { + hashBuf = new Uint8Array(str.length * 3); + } + const { written: n } = encoder.encodeInto(str, hashBuf); + const b = hashBuf; + let h = seed >>> 0; + let i = 0; + while (i + 4 <= n) { + let k = b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24); + k = Math.imul(k, 0xcc9e2d51) >>> 0; + k = ((k << 15) | (k >>> 17)) >>> 0; + k = Math.imul(k, 0x1b873593) >>> 0; + h ^= k; + h = ((h << 13) | (h >>> 19)) >>> 0; + h = (Math.imul(h, 5) + 0xe6546b64) >>> 0; + i += 4; + } + let k = 0; + if (n - i >= 3) { + k ^= b[i + 2] << 16; + } + if (n - i >= 2) { + k ^= b[i + 1] << 8; + } + if (n > i) { + k ^= b[i]; + k = Math.imul(k, 0xcc9e2d51) >>> 0; + k = ((k << 15) | (k >>> 17)) >>> 0; + k = Math.imul(k, 0x1b873593) >>> 0; + h ^= k; + } + h ^= n; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b) >>> 0; + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35) >>> 0; + h ^= h >>> 16; + return (h >>> 0) % 100; +} + +function compareTemporalOrNumeric( + attrValue: unknown, + target: unknown, + compare: (a: number, b: number) => boolean +): boolean { + if ( + typeof target === "string" && + ISO_8601_REGEX.test(target) && + typeof attrValue === "string" + ) { + const ts = Date.parse(attrValue); + if (!isNaN(ts)) { + return compare(ts, Date.parse(target)); + } + } + return compare(Number(attrValue), Number(target)); +} + +function evaluateCondition( + condition: Condition, + context: EvaluationContext +): boolean { + if ("logical_operator" in condition) { + const { logical_operator, clauses } = condition; + if (logical_operator === "AND") { + for (const clause of clauses) { + if (!evaluateCondition(clause, context)) { + return false; + } + } + return true; + } + for (const clause of clauses) { + if (evaluateCondition(clause, context)) { + return true; + } + } + return false; + } + + const { attribute, operator, value: target } = condition; + const attrValue = context[attribute]; + if (attrValue === undefined) { + return false; + } + + switch (operator) { + case "equals": + return String(attrValue) === String(target); + case "not_equals": + return String(attrValue) !== String(target); + case "contains": + return String(attrValue).includes(String(target)); + case "starts_with": + return String(attrValue).startsWith(String(target)); + case "ends_with": + return String(attrValue).endsWith(String(target)); + case "greater_than": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a > b); + case "less_than": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a < b); + case "greater_than_or_equals": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a >= b); + case "less_than_or_equals": + return compareTemporalOrNumeric(attrValue, target, (a, b) => a <= b); + case "in": + return ( + Array.isArray(target) && + target.some((value) => String(value) === String(attrValue)) + ); + case "not_in": + return ( + Array.isArray(target) && + !target.some((value) => String(value) === String(attrValue)) + ); + default: + return false; + } +} + +export function evaluateFlag( + flagDef: EvalFlag | FlagInput, + context: EvaluationContext, + accountId: string +): { value: FlagValue; variant: string; reason: EvaluationReason } { + const serve = (variant: string, reason: EvaluationReason) => { + if (!Object.hasOwn(flagDef.variations, variant)) { + throw new FlagConfigError( + flagDef.key, + `variation '${variant}' is not defined` + ); + } + return { + value: flagDef.variations[variant] as FlagValue, + variant, + reason, + }; + }; + + if (!flagDef.enabled) { + return serve(flagDef.default_variation, "DISABLED"); + } + + // Seeded per account+flag so the same targetingKey lands in different + // buckets across flags, preventing correlated rollouts. + let seed: number | undefined; + + const rules = [...flagDef.rules].sort((a, b) => { + const aPriority = "priority" in a ? a.priority : 0; + const bPriority = "priority" in b ? b.priority : 0; + return aPriority - bPriority; + }); + for (const rule of rules) { + let ruleMatches = true; + + for (const condition of rule.conditions) { + if (!evaluateCondition(condition, context)) { + ruleMatches = false; + break; + } + } + + if ( + ruleMatches && + rule.rollout !== undefined && + rule.rollout.percentage < 100 + ) { + seed ??= murmurhash3(`${accountId}:${flagDef.key}`, 0); + const attr = context[rule.rollout.attribute || "targetingKey"]; + const bucket = + attr !== null && attr !== undefined + ? murmurhash3(String(attr), seed) + : (crypto.getRandomValues(randomBuf)[0] / 0x100000000) * 100; + if (bucket >= rule.rollout.percentage) { + ruleMatches = false; + } + } + + if (ruleMatches) { + return serve( + rule.serve_variation, + rule.rollout !== undefined ? "SPLIT" : "TARGETING_MATCH" + ); + } + } + + return serve(flagDef.default_variation, "DEFAULT"); +} + +export type { + BaseCondition, + Condition, + FlagValue, + LogicalCondition, + Operator, + Rollout, +} from "./flags"; + +export function matchesType(value: FlagValue, expectedType: FlagType): boolean { + switch (expectedType) { + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "object": + return typeof value === "object" && value !== null; + default: + return false; + } +} diff --git a/packages/miniflare/src/workers/flagship/flags.ts b/packages/miniflare/src/workers/flagship/flags.ts new file mode 100644 index 00000000000..a6b78fe05f7 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/flags.ts @@ -0,0 +1,316 @@ +export type FlagType = "boolean" | "string" | "number" | "json"; + +export type FlagValue = + | boolean + | string + | number + | Record + | unknown[]; + +export type Operator = + | "equals" + | "not_equals" + | "greater_than" + | "less_than" + | "greater_than_or_equals" + | "less_than_or_equals" + | "contains" + | "starts_with" + | "ends_with" + | "in" + | "not_in"; + +export interface BaseCondition { + attribute: string; + operator: Operator; + value: unknown; +} + +export interface LogicalCondition { + logical_operator: "AND" | "OR"; + clauses: Condition[]; +} + +export type Condition = BaseCondition | LogicalCondition; + +export interface Rollout { + percentage: number; + attribute?: string; +} + +export interface Rule { + priority: number; + conditions: Condition[]; + serve_variation: string; + rollout?: Rollout; +} + +export interface FlagInput { + key: string; + description?: string | null; + enabled: boolean; + default_variation: string; + variations: Record; + rules: Rule[]; +} + +export interface Flag extends FlagInput { + type: FlagType; + updated_at: string; +} + +export interface FlagChanges { + description?: string | null; + enabled?: boolean; + default_variation?: string; + variations?: Record; + rules?: Rule[]; +} + +const FLAG_KEY_REGEX = /^[a-zA-Z0-9_-]{1,64}$/; + +export function flagNotFoundMessage(flagKey: string): string { + return `Flag '${flagKey}' not found`; +} + +const OPERATORS = new Set([ + "equals", + "not_equals", + "greater_than", + "less_than", + "greater_than_or_equals", + "less_than_or_equals", + "contains", + "starts_with", + "ends_with", + "in", + "not_in", +]); + +const LIST_OPERATORS = new Set(["in", "not_in"]); + +const MAX_CONDITION_DEPTH = 5; + +function isJsonValue(value: unknown, seen = new Set()): boolean { + if ( + value === null || + typeof value === "boolean" || + typeof value === "string" + ) { + return true; + } + if (typeof value === "number") { + return Number.isFinite(value); + } + if (typeof value !== "object" || seen.has(value)) { + return false; + } + if ( + !Array.isArray(value) && + Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null + ) { + return false; + } + + seen.add(value); + const values = Array.isArray(value) ? value : Object.values(value); + const valid = values.every((entry) => isJsonValue(entry, seen)); + seen.delete(value); + return valid; +} + +export function getFlagType(variations: Record): FlagType { + const [first] = Object.values(variations); + switch (typeof first) { + case "boolean": + return "boolean"; + case "string": + return "string"; + case "number": + return "number"; + default: + return "json"; + } +} + +function validateCondition( + key: string, + condition: unknown, + depth: number +): void { + if ( + typeof condition !== "object" || + condition === null || + Array.isArray(condition) + ) { + throw new Error(`Flag '${key}' has a condition that is not an object`); + } + + if ("logical_operator" in condition) { + const { logical_operator: operator, clauses } = condition as { + logical_operator: unknown; + clauses?: unknown; + }; + if (operator !== "AND" && operator !== "OR") { + throw new Error( + `Flag '${key}' has a condition with an unknown logical operator '${String(operator)}'` + ); + } + if (!Array.isArray(clauses)) { + throw new Error( + `Flag '${key}' has a '${operator}' condition without a list of clauses` + ); + } + if (depth === 0) { + throw new Error(`Flag '${key}' has conditions nested too deeply`); + } + for (const clause of clauses) { + validateCondition(key, clause, depth - 1); + } + return; + } + + const { attribute, operator, value } = condition as { + attribute?: unknown; + operator?: unknown; + value?: unknown; + }; + if (typeof attribute !== "string" || attribute === "") { + throw new Error( + `Flag '${key}' has a condition without an attribute to match on` + ); + } + if (typeof operator !== "string" || !OPERATORS.has(operator as Operator)) { + throw new Error( + `Flag '${key}' has a condition with an unknown operator '${String(operator)}'` + ); + } + if (LIST_OPERATORS.has(operator as Operator) && !Array.isArray(value)) { + throw new Error( + `Flag '${key}' has a '${operator}' condition whose value is not a list` + ); + } + if (value === undefined) { + throw new Error(`Flag '${key}' has a condition without a value`); + } + if (!isJsonValue(value)) { + throw new Error( + `Flag '${key}' has a condition with a value that cannot be stored as JSON` + ); + } +} + +export function validateFlagInput(input: FlagInput): void { + if (!FLAG_KEY_REGEX.test(input.key)) { + throw new Error( + `Flag key '${input.key}' must be 1-64 alphanumeric, hyphen or underscore characters` + ); + } + + const variationNames = Object.keys(input.variations); + if (variationNames.length === 0) { + throw new Error(`Flag '${input.key}' must define at least one variation`); + } + + const types = new Set( + Object.values(input.variations).map((value) => + typeof value === "boolean" || + typeof value === "string" || + typeof value === "number" + ? typeof value + : "object" + ) + ); + if (types.size > 1) { + throw new Error( + `Flag '${input.key}' variations must all share the same type` + ); + } + if (Object.values(input.variations).some((value) => value === null)) { + throw new Error(`Flag '${input.key}' variations cannot be null`); + } + if (Object.values(input.variations).some((value) => !isJsonValue(value))) { + throw new Error( + `Flag '${input.key}' variations must contain values that can be stored as JSON` + ); + } + + if (!variationNames.includes(input.default_variation)) { + throw new Error( + `Flag '${input.key}' default variation '${input.default_variation}' is not defined` + ); + } + + if (!Array.isArray(input.rules)) { + throw new Error(`Flag '${input.key}' rules must be a list`); + } + + const priorities = new Set(); + for (const rule of input.rules) { + if (!Array.isArray(rule.conditions)) { + throw new Error(`Flag '${input.key}' rule conditions must be a list`); + } + for (const condition of rule.conditions) { + validateCondition(input.key, condition, MAX_CONDITION_DEPTH); + } + if (!variationNames.includes(rule.serve_variation)) { + throw new Error( + `Flag '${input.key}' rule serves undefined variation '${rule.serve_variation}'` + ); + } + if (!Number.isInteger(rule.priority) || rule.priority < 1) { + throw new Error( + `Flag '${input.key}' rule priorities must be integers greater than or equal to 1` + ); + } + if (priorities.has(rule.priority)) { + throw new Error( + `Flag '${input.key}' has duplicate rule priority ${rule.priority}` + ); + } + priorities.add(rule.priority); + + if (rule.rollout !== undefined) { + const { percentage, attribute } = rule.rollout; + if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) { + throw new Error( + `Flag '${input.key}' rollout percentage must be a number between 0 and 100` + ); + } + if (attribute !== undefined && typeof attribute !== "string") { + throw new Error( + `Flag '${input.key}' rollout attribute must be a string` + ); + } + } + } + + let seenCatchAll = false; + for (const rule of [...input.rules].sort((a, b) => a.priority - b.priority)) { + if ( + rule.conditions.length === 0 && + (rule.rollout === undefined || rule.rollout.percentage === 100) + ) { + seenCatchAll = true; + } else if (seenCatchAll) { + throw new Error( + `Flag '${input.key}' has targeting rules after a rule with no conditions` + ); + } + } +} + +export function toStoredFlag(input: FlagInput): Flag { + return { + key: input.key, + description: input.description ?? null, + enabled: input.enabled, + default_variation: input.default_variation, + variations: input.variations, + // Evaluation order is defined by priority, not input array order. + rules: [...input.rules].sort((a, b) => a.priority - b.priority), + type: getFlagType(input.variations), + updated_at: new Date().toISOString(), + }; +} diff --git a/packages/miniflare/src/workers/flagship/object.worker.ts b/packages/miniflare/src/workers/flagship/object.worker.ts new file mode 100644 index 00000000000..eb6ead8cef2 --- /dev/null +++ b/packages/miniflare/src/workers/flagship/object.worker.ts @@ -0,0 +1,183 @@ +import { DurableObject } from "cloudflare:workers"; +import { toStoredFlag, validateFlagInput } from "./flags"; +import type { Flag, FlagChanges, FlagInput } from "./flags"; + +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS flags ( + key TEXT PRIMARY KEY, + definition TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, +]; + +const ACCOUNT_TAG_KEY = "accountTag"; + +export type WriteResult = + | { status: "written"; flag: Flag } + | { status: "invalid"; message: string } + | { status: "missing" } + | { status: "exists" }; + +type InvalidResult = Extract; + +function invalidResult(input: FlagInput): InvalidResult | undefined { + try { + validateFlagInput(input); + } catch (error) { + return { + status: "invalid", + message: error instanceof Error ? error.message : String(error), + }; + } +} + +export class FlagshipObject extends DurableObject { + private sql = this.ctx.storage.sql; + + constructor(ctx: DurableObjectState, env: unknown) { + super(ctx, env as never); + this.ctx.blockConcurrencyWhile(async () => { + for (const statement of SCHEMA) { + this.sql.exec(statement); + } + }); + } + + list(): Flag[] { + return [ + ...this.sql.exec<{ definition: string }>( + "SELECT definition FROM flags ORDER BY key" + ), + ].map((row) => JSON.parse(row.definition) as Flag); + } + + get(key: string): Flag | null { + const [row] = [ + ...this.sql.exec<{ definition: string }>( + "SELECT definition FROM flags WHERE key = ?", + key + ), + ]; + return row === undefined ? null : (JSON.parse(row.definition) as Flag); + } + + getAccountTag(): string | null { + const [row] = [ + ...this.sql.exec<{ value: string }>( + "SELECT value FROM metadata WHERE key = ?", + ACCOUNT_TAG_KEY + ), + ]; + return row?.value ?? null; + } + + setAccountTag(accountTag: string): void { + this.#writeAccountTag(accountTag); + } + + getForEvaluation(key: string): { + flag: Flag | null; + accountTag: string | null; + } { + return { flag: this.get(key), accountTag: this.getAccountTag() }; + } + + create(input: FlagInput): WriteResult { + const invalid = invalidResult(input); + if (invalid !== undefined) { + return invalid; + } + if (this.get(input.key) !== null) { + return { status: "exists" }; + } + return this.#writeResult(input); + } + + update(key: string, input: FlagInput): WriteResult { + if (this.get(key) === null) { + return { status: "missing" }; + } + return this.#validateAndWrite({ ...input, key }); + } + + patch(key: string, changes: FlagChanges): WriteResult { + const current = this.get(key); + if (current === null) { + return { status: "missing" }; + } + const next: FlagInput = { + key, + description: + changes.description === undefined + ? current.description + : changes.description, + enabled: changes.enabled ?? current.enabled, + default_variation: changes.default_variation ?? current.default_variation, + variations: changes.variations ?? current.variations, + rules: changes.rules ?? current.rules, + }; + return this.#validateAndWrite(next); + } + + put(input: FlagInput): WriteResult { + return this.#validateAndWrite(input); + } + + putAll( + inputs: FlagInput[], + accountTag: string + ): { status: "written" } | { status: "invalid"; message: string } { + for (const input of inputs) { + const invalid = invalidResult(input); + if (invalid !== undefined) { + return invalid; + } + } + const stored = inputs.map(toStoredFlag); + this.ctx.storage.transactionSync(() => { + this.#writeAccountTag(accountTag); + for (const flag of stored) { + this.#write(flag); + } + }); + return { status: "written" }; + } + + delete(key: string): boolean { + if (this.get(key) === null) { + return false; + } + this.sql.exec("DELETE FROM flags WHERE key = ?", key); + return true; + } + + #validateAndWrite(input: FlagInput): WriteResult { + return invalidResult(input) ?? this.#writeResult(input); + } + + #writeResult(input: FlagInput): WriteResult { + return { status: "written", flag: this.#write(toStoredFlag(input)) }; + } + + #write(flag: Flag): Flag { + this.sql.exec( + `INSERT INTO flags (key, definition) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET definition = excluded.definition`, + flag.key, + JSON.stringify(flag) + ); + return flag; + } + + #writeAccountTag(accountTag: string): void { + this.sql.exec( + `INSERT INTO metadata (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value`, + ACCOUNT_TAG_KEY, + accountTag + ); + } +} diff --git a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts index 4ecc4d86319..0ddae493fd3 100644 --- a/packages/miniflare/src/workers/local-explorer/explorer.worker.ts +++ b/packages/miniflare/src/workers/local-explorer/explorer.worker.ts @@ -20,6 +20,9 @@ import { zWorkersKvNamespaceGetMultipleKeyValuePairsData, zWorkersKvNamespaceListANamespaceSKeysData, zWorkersKvNamespaceListNamespacesData, + zFlagshipCreateFlagData, + zFlagshipEvaluateFlagData, + zFlagshipUpdateFlagData, zObservabilityQueryData, zWorkflowsBatchDeleteInstancesData, zWorkflowsChangeInstanceStatusData, @@ -35,6 +38,15 @@ import { listSentEmails, sendTestEmail, } from "./resources/email"; +import { + createFlagshipFlag, + deleteFlagshipFlag, + evaluateFlagshipFlag, + getFlagshipFlag, + listFlagshipApps, + listFlagshipFlags, + updateFlagshipFlag, +} from "./resources/flagship"; import { bulkGetKVValues, deleteKVValue, @@ -380,6 +392,54 @@ app.delete("/api/workflows/:workflow_name/instances/:instance_id", (c) => ) ); +// ============================================================================ +// Flagship Endpoints +// ============================================================================ + +app.get("/api/flagship/apps", (c) => listFlagshipApps(c)); + +app.get("/api/flagship/apps/:app_id/flags", (c) => + listFlagshipFlags(c, c.req.param("app_id")) +); + +app.post( + "/api/flagship/apps/:app_id/flags", + validateRequestBody(zFlagshipCreateFlagData.shape.body), + (c) => createFlagshipFlag(c, c.req.param("app_id"), c.req.valid("json")) +); + +app.get("/api/flagship/apps/:app_id/flags/:flag_key", (c) => + getFlagshipFlag(c, c.req.param("app_id"), c.req.param("flag_key")) +); + +app.patch( + "/api/flagship/apps/:app_id/flags/:flag_key", + validateRequestBody(zFlagshipUpdateFlagData.shape.body), + (c) => + updateFlagshipFlag( + c, + c.req.param("app_id"), + c.req.param("flag_key"), + c.req.valid("json") + ) +); + +app.delete("/api/flagship/apps/:app_id/flags/:flag_key", (c) => + deleteFlagshipFlag(c, c.req.param("app_id"), c.req.param("flag_key")) +); + +app.post( + "/api/flagship/apps/:app_id/flags/:flag_key/evaluate", + validateRequestBody(zFlagshipEvaluateFlagData.shape.body), + (c) => + evaluateFlagshipFlag( + c, + c.req.param("app_id"), + c.req.param("flag_key"), + c.req.valid("json").context ?? {} + ) +); + // ============================================================================ // Observability Endpoints // ============================================================================ diff --git a/packages/miniflare/src/workers/local-explorer/generated/index.ts b/packages/miniflare/src/workers/local-explorer/generated/index.ts index d636ff44abf..5c3a1fb9e95 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/index.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/index.ts @@ -71,6 +71,45 @@ export type { EmailSendRoutingErrors, EmailSendRoutingResponse, EmailSendRoutingResponses, + FlagshipApp, + FlagshipCreateFlagData, + FlagshipCreateFlagError, + FlagshipCreateFlagErrors, + FlagshipCreateFlagResponse, + FlagshipCreateFlagResponses, + FlagshipDeleteFlagData, + FlagshipDeleteFlagError, + FlagshipDeleteFlagErrors, + FlagshipDeleteFlagResponse, + FlagshipDeleteFlagResponses, + FlagshipEvaluateFlagData, + FlagshipEvaluateFlagError, + FlagshipEvaluateFlagErrors, + FlagshipEvaluateFlagResponse, + FlagshipEvaluateFlagResponses, + FlagshipEvaluation, + FlagshipFlag, + FlagshipGetFlagData, + FlagshipGetFlagError, + FlagshipGetFlagErrors, + FlagshipGetFlagResponse, + FlagshipGetFlagResponses, + FlagshipListAppsData, + FlagshipListAppsError, + FlagshipListAppsErrors, + FlagshipListAppsResponse, + FlagshipListAppsResponses, + FlagshipListFlagsData, + FlagshipListFlagsError, + FlagshipListFlagsErrors, + FlagshipListFlagsResponse, + FlagshipListFlagsResponses, + FlagshipRule, + FlagshipUpdateFlagData, + FlagshipUpdateFlagError, + FlagshipUpdateFlagErrors, + FlagshipUpdateFlagResponse, + FlagshipUpdateFlagResponses, LocalExplorerDoBinding, LocalExplorerListWorkersData, LocalExplorerListWorkersError, diff --git a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts index 07a684f6072..a98b5870f95 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/types.gen.ts @@ -421,6 +421,96 @@ export type WorkersKvResultInfo = { count?: number; }; +export type FlagshipApp = { + /** + * The Flagship app id the bindings point at + */ + id: string; + /** + * Binding names in this instance using the app + */ + bindings: Array; +}; + +export type FlagshipRule = { + /** + * Evaluation order, lowest first + */ + priority: number; + /** + * Conditions that must match for the rule to apply + */ + conditions: Array<{ + [key: string]: unknown; + }>; + /** + * Variation served when the rule matches + */ + serve_variation: string; + /** + * Percentage rollout applied to matching contexts + */ + rollout?: { + percentage: number; + attribute?: string; + }; +}; + +export type FlagshipFlag = { + /** + * Flag key + */ + key: string; + /** + * Type shared by the flag's variations + */ + type: "boolean" | "string" | "number" | "json"; + /** + * Human readable description + */ + description?: string | null; + /** + * Whether targeting rules are evaluated + */ + enabled: boolean; + /** + * Variation served when no rule matches + */ + default_variation: string; + /** + * Named values the flag can serve + */ + variations: { + [key: string]: unknown; + }; + /** + * Targeting rules, in priority order + */ + rules: Array; + /** + * When the flag was last written locally + */ + updated_at: string; +}; + +export type FlagshipEvaluation = { + flagKey: string; + /** + * The resolved flag value + */ + value: unknown; + /** + * Name of the variation served + */ + variant: string; + /** + * Why this value was served + */ + reason: "TARGETING_MATCH" | "DEFAULT" | "DISABLED" | "SPLIT" | "ERROR"; + errorCode?: string; + errorMessage?: string; +}; + export type R2Object = { /** * Object key (path) @@ -611,6 +701,10 @@ export type LocalExplorerWorkerBindings = { * Send Email bindings */ sendEmail?: Array; + /** + * Flagship app bindings + */ + flagship?: Array; }; export type LocalExplorerNamedBinding = { @@ -2508,3 +2602,311 @@ export type ObservabilityClearResponses = { export type ObservabilityClearResponse = ObservabilityClearResponses[keyof ObservabilityClearResponses]; + +export type FlagshipListAppsData = { + body?: never; + path?: never; + query?: never; + url: "/flagship/apps"; +}; + +export type FlagshipListAppsErrors = { + /** + * List Flagship Apps response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipListAppsError = + FlagshipListAppsErrors[keyof FlagshipListAppsErrors]; + +export type FlagshipListAppsResponses = { + /** + * List Flagship Apps response. + */ + 200: WorkersApiResponseCommon & { + result?: Array; + }; +}; + +export type FlagshipListAppsResponse = + FlagshipListAppsResponses[keyof FlagshipListAppsResponses]; + +export type FlagshipListFlagsData = { + body?: never; + path: { + app_id: string; + }; + query?: { + /** + * Worker whose local Flagship store should be used. + */ + worker?: string; + }; + url: "/flagship/apps/{app_id}/flags"; +}; + +export type FlagshipListFlagsErrors = { + /** + * List Flagship Flags response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipListFlagsError = + FlagshipListFlagsErrors[keyof FlagshipListFlagsErrors]; + +export type FlagshipListFlagsResponses = { + /** + * List Flagship Flags response. + */ + 200: WorkersApiResponseCommon & { + result?: Array; + }; +}; + +export type FlagshipListFlagsResponse = + FlagshipListFlagsResponses[keyof FlagshipListFlagsResponses]; + +export type FlagshipCreateFlagData = { + body: { + /** + * Flag key. + */ + key: string; + /** + * Human readable description. + */ + description?: string | null; + /** + * Whether targeting rules are evaluated. + */ + enabled?: boolean; + /** + * Variation served when no rule matches. + */ + default_variation: string; + /** + * Named values the flag can serve. + */ + variations: { + [key: string]: unknown; + }; + /** + * Targeting rules, in priority order. + */ + rules?: Array; + }; + path: { + app_id: string; + }; + query?: { + /** + * Worker whose local Flagship store should be used. + */ + worker?: string; + }; + url: "/flagship/apps/{app_id}/flags"; +}; + +export type FlagshipCreateFlagErrors = { + /** + * Create Flagship Flag response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipCreateFlagError = + FlagshipCreateFlagErrors[keyof FlagshipCreateFlagErrors]; + +export type FlagshipCreateFlagResponses = { + /** + * Create Flagship Flag response. + */ + 200: WorkersApiResponseCommon & { + result?: FlagshipFlag; + }; +}; + +export type FlagshipCreateFlagResponse = + FlagshipCreateFlagResponses[keyof FlagshipCreateFlagResponses]; + +export type FlagshipDeleteFlagData = { + body?: never; + path: { + app_id: string; + flag_key: string; + }; + query?: { + /** + * Worker whose local Flagship store should be used. + */ + worker?: string; + }; + url: "/flagship/apps/{app_id}/flags/{flag_key}"; +}; + +export type FlagshipDeleteFlagErrors = { + /** + * Delete Flagship Flag response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipDeleteFlagError = + FlagshipDeleteFlagErrors[keyof FlagshipDeleteFlagErrors]; + +export type FlagshipDeleteFlagResponses = { + /** + * Delete Flagship Flag response. + */ + 200: WorkersApiResponseCommon & { + result?: { + success?: boolean; + }; + }; +}; + +export type FlagshipDeleteFlagResponse = + FlagshipDeleteFlagResponses[keyof FlagshipDeleteFlagResponses]; + +export type FlagshipGetFlagData = { + body?: never; + path: { + app_id: string; + flag_key: string; + }; + query?: { + /** + * Worker whose local Flagship store should be used. + */ + worker?: string; + }; + url: "/flagship/apps/{app_id}/flags/{flag_key}"; +}; + +export type FlagshipGetFlagErrors = { + /** + * Get Flagship Flag response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipGetFlagError = + FlagshipGetFlagErrors[keyof FlagshipGetFlagErrors]; + +export type FlagshipGetFlagResponses = { + /** + * Get Flagship Flag response. + */ + 200: WorkersApiResponseCommon & { + result?: FlagshipFlag; + }; +}; + +export type FlagshipGetFlagResponse = + FlagshipGetFlagResponses[keyof FlagshipGetFlagResponses]; + +export type FlagshipUpdateFlagData = { + body: { + /** + * Human readable description. + */ + description?: string | null; + /** + * Whether the flag is enabled. + */ + enabled?: boolean; + /** + * The variation served when no targeting rule matches. + */ + default_variation?: string; + /** + * Named values the flag can serve. + */ + variations?: { + [key: string]: unknown; + }; + /** + * Targeting rules, in priority order. Replaces the existing rules. + */ + rules?: Array; + }; + path: { + app_id: string; + flag_key: string; + }; + query?: { + /** + * Worker whose local Flagship store should be used. + */ + worker?: string; + }; + url: "/flagship/apps/{app_id}/flags/{flag_key}"; +}; + +export type FlagshipUpdateFlagErrors = { + /** + * Update Flagship Flag response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipUpdateFlagError = + FlagshipUpdateFlagErrors[keyof FlagshipUpdateFlagErrors]; + +export type FlagshipUpdateFlagResponses = { + /** + * Update Flagship Flag response. + */ + 200: WorkersApiResponseCommon & { + result?: FlagshipFlag; + }; +}; + +export type FlagshipUpdateFlagResponse = + FlagshipUpdateFlagResponses[keyof FlagshipUpdateFlagResponses]; + +export type FlagshipEvaluateFlagData = { + body: { + /** + * Attributes used for rule matching and rollout bucketing. + */ + context?: { + [key: string]: unknown; + }; + }; + path: { + app_id: string; + flag_key: string; + }; + query?: { + /** + * Worker whose local Flagship store should be used. + */ + worker?: string; + }; + url: "/flagship/apps/{app_id}/flags/{flag_key}/evaluate"; +}; + +export type FlagshipEvaluateFlagErrors = { + /** + * Evaluate Flagship Flag response failure. + */ + "4XX": WorkersApiResponseCommonFailure; +}; + +export type FlagshipEvaluateFlagError = + FlagshipEvaluateFlagErrors[keyof FlagshipEvaluateFlagErrors]; + +export type FlagshipEvaluateFlagResponses = { + /** + * Evaluate Flagship Flag response. + */ + 200: WorkersApiResponseCommon & { + result?: FlagshipEvaluation; + }; +}; + +export type FlagshipEvaluateFlagResponse = + FlagshipEvaluateFlagResponses[keyof FlagshipEvaluateFlagResponses]; diff --git a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts index f4067d8477d..40cdf61929c 100644 --- a/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts +++ b/packages/miniflare/src/workers/local-explorer/generated/zod.gen.ts @@ -354,6 +354,43 @@ export const zWorkersKvApiResponseCollection = zWorkersKvApiResponseCommon.and( }) ); +export const zFlagshipApp = z.object({ + id: z.string(), + bindings: z.array(z.string()), +}); + +export const zFlagshipRule = z.object({ + priority: z.int(), + conditions: z.array(z.record(z.string(), z.unknown())), + serve_variation: z.string(), + rollout: z + .object({ + percentage: z.number().gte(0).lte(100), + attribute: z.string().optional(), + }) + .optional(), +}); + +export const zFlagshipFlag = z.object({ + key: z.string(), + type: z.enum(["boolean", "string", "number", "json"]), + description: z.string().nullish(), + enabled: z.boolean(), + default_variation: z.string(), + variations: z.record(z.string(), z.unknown()), + rules: z.array(zFlagshipRule), + updated_at: z.string(), +}); + +export const zFlagshipEvaluation = z.object({ + flagKey: z.string(), + value: z.unknown(), + variant: z.string(), + reason: z.enum(["TARGETING_MATCH", "DEFAULT", "DISABLED", "SPLIT", "ERROR"]), + errorCode: z.string().optional(), + errorMessage: z.string().optional(), +}); + export const zR2Object = z.object({ key: z.string().optional(), etag: z.string().optional(), @@ -445,6 +482,7 @@ export const zLocalExplorerWorkerBindings = z.object({ do: z.array(zLocalExplorerDoBinding).optional(), workflows: z.array(zLocalExplorerWorkflowBinding).optional(), sendEmail: z.array(zLocalExplorerNamedBinding).optional(), + flagship: z.array(zLocalExplorerResourceBinding).optional(), }); export const zLocalExplorerWorker = z.object({ @@ -1536,3 +1574,167 @@ export const zObservabilityClearData = z.object({ * Clear response. */ export const zObservabilityClearResponse = zWorkersApiResponseCommon; + +export const zFlagshipListAppsData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional(), +}); + +/** + * List Flagship Apps response. + */ +export const zFlagshipListAppsResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z.array(zFlagshipApp).optional(), + }) +); + +export const zFlagshipListFlagsData = z.object({ + body: z.never().optional(), + path: z.object({ + app_id: z.string(), + }), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), +}); + +/** + * List Flagship Flags response. + */ +export const zFlagshipListFlagsResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z.array(zFlagshipFlag).optional(), + }) +); + +export const zFlagshipCreateFlagData = z.object({ + body: z.object({ + key: z.string(), + description: z.string().nullish(), + enabled: z.boolean().optional(), + default_variation: z.string(), + variations: z.record(z.string(), z.unknown()), + rules: z.array(zFlagshipRule).optional(), + }), + path: z.object({ + app_id: z.string(), + }), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), +}); + +/** + * Create Flagship Flag response. + */ +export const zFlagshipCreateFlagResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zFlagshipFlag.optional(), + }) +); + +export const zFlagshipDeleteFlagData = z.object({ + body: z.never().optional(), + path: z.object({ + app_id: z.string(), + flag_key: z.string(), + }), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), +}); + +/** + * Delete Flagship Flag response. + */ +export const zFlagshipDeleteFlagResponse = zWorkersApiResponseCommon.and( + z.object({ + result: z + .object({ + success: z.boolean().optional(), + }) + .optional(), + }) +); + +export const zFlagshipGetFlagData = z.object({ + body: z.never().optional(), + path: z.object({ + app_id: z.string(), + flag_key: z.string(), + }), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), +}); + +/** + * Get Flagship Flag response. + */ +export const zFlagshipGetFlagResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zFlagshipFlag.optional(), + }) +); + +export const zFlagshipUpdateFlagData = z.object({ + body: z.object({ + description: z.string().nullish(), + enabled: z.boolean().optional(), + default_variation: z.string().optional(), + variations: z.record(z.string(), z.unknown()).optional(), + rules: z.array(zFlagshipRule).optional(), + }), + path: z.object({ + app_id: z.string(), + flag_key: z.string(), + }), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), +}); + +/** + * Update Flagship Flag response. + */ +export const zFlagshipUpdateFlagResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zFlagshipFlag.optional(), + }) +); + +export const zFlagshipEvaluateFlagData = z.object({ + body: z.object({ + context: z.record(z.string(), z.unknown()).optional(), + }), + path: z.object({ + app_id: z.string(), + flag_key: z.string(), + }), + query: z + .object({ + worker: z.string().optional(), + }) + .optional(), +}); + +/** + * Evaluate Flagship Flag response. + */ +export const zFlagshipEvaluateFlagResponse = zWorkersApiResponseCommon.and( + z.object({ + result: zFlagshipEvaluation.optional(), + }) +); diff --git a/packages/miniflare/src/workers/local-explorer/openapi.local.json b/packages/miniflare/src/workers/local-explorer/openapi.local.json index 497fff1f977..15cadddcd88 100644 --- a/packages/miniflare/src/workers/local-explorer/openapi.local.json +++ b/packages/miniflare/src/workers/local-explorer/openapi.local.json @@ -2423,6 +2423,548 @@ "summary": "Clear Observability Store", "tags": ["Observability"] } + }, + "/flagship/apps": { + "get": { + "description": "Returns the Flagship apps bound for local development.", + "operationId": "flagship-list-apps", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "items": { + "$ref": "#/components/schemas/flagship_app" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "List Flagship Apps response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "List Flagship Apps response failure." + } + }, + "summary": "List Flagship Apps", + "tags": ["Flagship"] + } + }, + "/flagship/apps/{app_id}/flags": { + "get": { + "description": "Returns the flags in a local Flagship app.", + "operationId": "flagship-list-flags", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Worker whose local Flagship store should be used.", + "in": "query", + "name": "worker", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "items": { + "$ref": "#/components/schemas/flagship_flag" + }, + "type": "array" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "List Flagship Flags response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "List Flagship Flags response failure." + } + }, + "summary": "List Flagship Flags", + "tags": ["Flagship"] + }, + "post": { + "description": "Creates a flag in a local Flagship app.", + "operationId": "flagship-create-flag", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Worker whose local Flagship store should be used.", + "in": "query", + "name": "worker", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "required": ["key", "default_variation", "variations"], + "properties": { + "key": { + "description": "Flag key.", + "type": "string" + }, + "description": { + "description": "Human readable description.", + "nullable": true, + "type": "string" + }, + "enabled": { + "description": "Whether targeting rules are evaluated.", + "type": "boolean" + }, + "default_variation": { + "description": "Variation served when no rule matches.", + "type": "string" + }, + "variations": { + "additionalProperties": true, + "description": "Named values the flag can serve.", + "type": "object" + }, + "rules": { + "description": "Targeting rules, in priority order.", + "items": { + "$ref": "#/components/schemas/flagship_rule" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "$ref": "#/components/schemas/flagship_flag" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Create Flagship Flag response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Create Flagship Flag response failure." + } + }, + "summary": "Create Flagship Flag", + "tags": ["Flagship"] + } + }, + "/flagship/apps/{app_id}/flags/{flag_key}": { + "get": { + "description": "Returns a single flag from a local Flagship app.", + "operationId": "flagship-get-flag", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "flag_key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Worker whose local Flagship store should be used.", + "in": "query", + "name": "worker", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "$ref": "#/components/schemas/flagship_flag" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Get Flagship Flag response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Get Flagship Flag response failure." + } + }, + "summary": "Get Flagship Flag", + "tags": ["Flagship"] + }, + "patch": { + "description": "Updates a flag. Omitted fields, including targeting rules, keep their current values.", + "operationId": "flagship-update-flag", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "flag_key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Worker whose local Flagship store should be used.", + "in": "query", + "name": "worker", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "description": { + "description": "Human readable description.", + "nullable": true, + "type": "string" + }, + "enabled": { + "description": "Whether the flag is enabled.", + "type": "boolean" + }, + "default_variation": { + "description": "The variation served when no targeting rule matches.", + "type": "string" + }, + "variations": { + "additionalProperties": true, + "description": "Named values the flag can serve.", + "type": "object" + }, + "rules": { + "description": "Targeting rules, in priority order. Replaces the existing rules.", + "items": { + "$ref": "#/components/schemas/flagship_rule" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "$ref": "#/components/schemas/flagship_flag" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Update Flagship Flag response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Update Flagship Flag response failure." + } + }, + "summary": "Update Flagship Flag", + "tags": ["Flagship"] + }, + "delete": { + "description": "Deletes a flag from a local Flagship app.", + "operationId": "flagship-delete-flag", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "flag_key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Worker whose local Flagship store should be used.", + "in": "query", + "name": "worker", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "properties": { + "success": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Delete Flagship Flag response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Delete Flagship Flag response failure." + } + }, + "summary": "Delete Flagship Flag", + "tags": ["Flagship"] + } + }, + "/flagship/apps/{app_id}/flags/{flag_key}/evaluate": { + "post": { + "description": "Evaluates a flag against an evaluation context, as a Worker binding would.", + "operationId": "flagship-evaluate-flag", + "parameters": [ + { + "in": "path", + "name": "app_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "flag_key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Worker whose local Flagship store should be used.", + "in": "query", + "name": "worker", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "context": { + "additionalProperties": true, + "description": "Attributes used for rule matching and rollout bucketing.", + "type": "object" + } + }, + "type": "object" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/workers_api-response-common" + }, + { + "properties": { + "result": { + "$ref": "#/components/schemas/flagship_evaluation" + } + }, + "type": "object" + } + ] + } + } + }, + "description": "Evaluate Flagship Flag response." + }, + "4XX": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/workers_api-response-common-failure" + } + } + }, + "description": "Evaluate Flagship Flag response failure." + } + }, + "summary": "Evaluate Flagship Flag", + "tags": ["Flagship"] + } } }, "components": { @@ -3311,6 +3853,145 @@ }, "type": "object" }, + "flagship_app": { + "type": "object", + "required": ["id", "bindings"], + "properties": { + "id": { + "type": "string", + "description": "The Flagship app id the bindings point at" + }, + "bindings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Binding names in this instance using the app" + } + } + }, + "flagship_rule": { + "type": "object", + "required": ["priority", "conditions", "serve_variation"], + "properties": { + "priority": { + "type": "integer", + "description": "Evaluation order, lowest first" + }, + "conditions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "description": "Conditions that must match for the rule to apply" + }, + "serve_variation": { + "type": "string", + "description": "Variation served when the rule matches" + }, + "rollout": { + "type": "object", + "required": ["percentage"], + "properties": { + "percentage": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "attribute": { + "type": "string" + } + }, + "description": "Percentage rollout applied to matching contexts" + } + } + }, + "flagship_flag": { + "type": "object", + "required": [ + "key", + "type", + "enabled", + "default_variation", + "variations", + "rules", + "updated_at" + ], + "properties": { + "key": { + "type": "string", + "description": "Flag key" + }, + "type": { + "type": "string", + "enum": ["boolean", "string", "number", "json"], + "description": "Type shared by the flag's variations" + }, + "description": { + "type": "string", + "nullable": true, + "description": "Human readable description" + }, + "enabled": { + "type": "boolean", + "description": "Whether targeting rules are evaluated" + }, + "default_variation": { + "type": "string", + "description": "Variation served when no rule matches" + }, + "variations": { + "type": "object", + "additionalProperties": true, + "description": "Named values the flag can serve" + }, + "rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/flagship_rule" + }, + "description": "Targeting rules, in priority order" + }, + "updated_at": { + "type": "string", + "description": "When the flag was last written locally" + } + } + }, + "flagship_evaluation": { + "type": "object", + "required": ["flagKey", "value", "variant", "reason"], + "properties": { + "flagKey": { + "type": "string" + }, + "value": { + "description": "The resolved flag value" + }, + "variant": { + "type": "string", + "description": "Name of the variation served" + }, + "reason": { + "type": "string", + "enum": [ + "TARGETING_MATCH", + "DEFAULT", + "DISABLED", + "SPLIT", + "ERROR" + ], + "description": "Why this value was served" + }, + "errorCode": { + "type": "string" + }, + "errorMessage": { + "type": "string" + } + } + }, "r2_object": { "type": "object", "properties": { @@ -3571,6 +4252,13 @@ "$ref": "#/components/schemas/local-explorer_named-binding" }, "description": "Send Email bindings" + }, + "flagship": { + "type": "array", + "items": { + "$ref": "#/components/schemas/local-explorer_resource-binding" + }, + "description": "Flagship app bindings" } } }, diff --git a/packages/miniflare/src/workers/local-explorer/resources/flagship.ts b/packages/miniflare/src/workers/local-explorer/resources/flagship.ts new file mode 100644 index 00000000000..909ae8304a7 --- /dev/null +++ b/packages/miniflare/src/workers/local-explorer/resources/flagship.ts @@ -0,0 +1,360 @@ +import { ADMIN_API as ADMIN_API_KEY } from "../../flagship/constants"; +import { flagNotFoundMessage } from "../../flagship/flags"; +import { + aggregateListResults, + fetchFromPeer, + getPeerUrlsIfAggregating, +} from "../aggregation"; +import { errorResponse, wrapResponse } from "../common"; +import type { FlagshipAdmin } from "../../flagship/admin"; +import type { ADMIN_API } from "../../flagship/constants"; +import type { FlagChanges, Rule } from "../../flagship/flags"; +import type { AppContext } from "../common"; +import type { Env } from "../explorer.worker"; +import type { + FlagshipApp, + FlagshipCreateFlagData, + LocalExplorerWorker, + FlagshipRule, + FlagshipUpdateFlagData, +} from "../generated"; + +const FLAGSHIP_ERROR_NOT_FOUND = 10801; +const FLAGSHIP_ERROR_INVALID_FLAG = 10802; + +const APPS_PATH = "/flagship/apps"; + +function getAdmin(env: Env, appId: string): FlagshipAdmin | null { + const info = env.LOCAL_EXPLORER_BINDING_MAP.flagship[appId]; + if (!info) { + return null; + } + const binding = env[info.binding] as + | Record FlagshipAdmin> + | undefined; + if (!binding) { + return null; + } + return binding[ADMIN_API_KEY](); +} + +function notFound(appId: string): Response { + return errorResponse( + 404, + FLAGSHIP_ERROR_NOT_FOUND, + `Flagship app '${appId}' is not simulated locally.` + ); +} + +function appPath(appId: string, suffix = ""): string { + return `${APPS_PATH}/${encodeURIComponent(appId)}${suffix}`; +} + +function flagPath(appId: string, flagKey: string, suffix = ""): string { + return appPath(appId, `/flags/${encodeURIComponent(flagKey)}${suffix}`); +} + +async function findAppOwner( + c: AppContext, + appId: string +): Promise { + const peerUrls = await getPeerUrlsIfAggregating(c); + if (peerUrls.length === 0) { + return null; + } + const owners = await Promise.all( + peerUrls.map(async (url) => { + const response = await fetchFromPeer(url, APPS_PATH); + if (!response?.ok) { + return null; + } + try { + const data = (await response.json()) as { result?: FlagshipApp[] }; + return data.result?.some((app) => app.id === appId) === true + ? url + : null; + } catch { + return null; + } + }) + ); + return owners.find((url) => url !== null) ?? null; +} + +function workerHasApp(worker: LocalExplorerWorker, appId: string): boolean { + return ( + worker.bindings?.flagship?.some((binding) => binding.id === appId) === true + ); +} + +async function findWorkerOwner( + c: AppContext, + workerName: string, + appId: string +): Promise { + const peerUrls = await getPeerUrlsIfAggregating(c); + const owners = await Promise.all( + peerUrls.map(async (url) => { + const response = await fetchFromPeer(url, "/local/workers"); + if (!response?.ok) { + return null; + } + try { + const data = (await response.json()) as { + result?: LocalExplorerWorker[]; + }; + return data.result?.some( + (worker) => worker.name === workerName && workerHasApp(worker, appId) + ) === true + ? url + : null; + } catch { + return null; + } + }) + ); + return owners.find((url) => url !== null) ?? null; +} + +function withWorkerQuery(path: string, worker: string): string { + const url = new URL(path, "http://localhost"); + url.searchParams.set("worker", worker); + return `${url.pathname}${url.search}`; +} + +async function withApp( + c: AppContext, + appId: string, + peerPath: string, + handler: (admin: FlagshipAdmin) => Promise, + init?: RequestInit +): Promise { + const workerName = c.req.query("worker"); + if (workerName !== undefined) { + const localWorker = c.env.MINIFLARE_EXPLORER_WORKER_OPTS[workerName]; + if (localWorker !== undefined) { + const admin = getAdmin(c.env, appId); + return localWorker.flagship.some((binding) => binding.id === appId) && + admin + ? handler(admin) + : notFound(appId); + } + + const owner = await findWorkerOwner(c, workerName, appId); + if (owner !== null) { + const response = await fetchFromPeer( + owner, + withWorkerQuery(peerPath, workerName), + init + ); + if (response !== null) { + return response; + } + } + return notFound(appId); + } + + // Aggregated apps may belong to another local dev process. + const admin = getAdmin(c.env, appId); + if (admin !== null) { + return handler(admin); + } + const owner = await findAppOwner(c, appId); + if (owner !== null) { + const response = await fetchFromPeer(owner, peerPath, init); + if (response !== null) { + return response; + } + } + return notFound(appId); +} + +function jsonInit(method: string, body: unknown): RequestInit { + return { + method, + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }; +} + +export async function listFlagshipApps(c: AppContext): Promise { + const local: FlagshipApp[] = Object.values( + c.env.LOCAL_EXPLORER_BINDING_MAP.flagship + ).map((info) => ({ id: info.appId, bindings: info.bindings })); + + const aggregated = await aggregateListResults(c, local, APPS_PATH); + + const seen = new Set(); + const apps = aggregated.filter((app) => { + if (app.id === undefined || seen.has(app.id)) { + return false; + } + seen.add(app.id); + return true; + }); + + return c.json({ + ...wrapResponse(apps), + result_info: { count: apps.length }, + }); +} + +export async function listFlagshipFlags( + c: AppContext, + appId: string +): Promise { + return withApp(c, appId, appPath(appId, "/flags"), async (admin) => { + const flags = await admin.listFlags(); + return c.json({ + ...wrapResponse(flags), + result_info: { count: flags.length }, + }); + }); +} + +export async function getFlagshipFlag( + c: AppContext, + appId: string, + flagKey: string +): Promise { + return withApp(c, appId, flagPath(appId, flagKey), async (admin) => { + try { + return c.json(wrapResponse(await admin.getFlag(flagKey))); + } catch (error) { + return flagError(error, flagKey, 500); + } + }); +} + +export async function createFlagshipFlag( + c: AppContext, + appId: string, + body: FlagshipCreateFlagData["body"] +): Promise { + return withApp( + c, + appId, + appPath(appId, "/flags"), + async (admin) => { + try { + const created = await admin.createFlag({ + key: body.key, + description: body.description ?? undefined, + enabled: body.enabled ?? false, + default_variation: body.default_variation, + variations: body.variations, + rules: toRules(body.rules ?? []), + }); + return c.json(wrapResponse(created)); + } catch (error) { + return flagError(error, body.key, 400); + } + }, + jsonInit("POST", body) + ); +} + +function toRules(rules: FlagshipRule[]): Rule[] { + return rules.map((rule, index) => ({ + priority: rule.priority ?? index + 1, + conditions: (rule.conditions ?? []) as unknown as Rule["conditions"], + serve_variation: rule.serve_variation ?? "", + ...(rule.rollout === undefined ? {} : { rollout: rule.rollout }), + })); +} + +export async function updateFlagshipFlag( + c: AppContext, + appId: string, + flagKey: string, + body: FlagshipUpdateFlagData["body"] +): Promise { + return withApp( + c, + appId, + flagPath(appId, flagKey), + async (admin) => { + const changes: FlagChanges = { + ...(body.description === undefined + ? {} + : { description: body.description }), + ...(body.enabled === undefined ? {} : { enabled: body.enabled }), + ...(body.default_variation === undefined + ? {} + : { default_variation: body.default_variation }), + ...(body.variations === undefined + ? {} + : { variations: body.variations }), + ...(body.rules === undefined ? {} : { rules: toRules(body.rules) }), + }; + try { + return c.json(wrapResponse(await admin.patchFlag(flagKey, changes))); + } catch (error) { + return flagError(error, flagKey, 400); + } + }, + jsonInit("PATCH", body) + ); +} + +export async function deleteFlagshipFlag( + c: AppContext, + appId: string, + flagKey: string +): Promise { + return withApp( + c, + appId, + flagPath(appId, flagKey), + async (admin) => { + try { + await admin.deleteFlag(flagKey); + return c.json(wrapResponse({ success: true })); + } catch (error) { + return flagError(error, flagKey, 400); + } + }, + { method: "DELETE" } + ); +} + +export async function evaluateFlagshipFlag( + c: AppContext, + appId: string, + flagKey: string, + context: Record +): Promise { + return withApp( + c, + appId, + flagPath(appId, flagKey, "/evaluate"), + async (admin) => { + try { + return c.json(wrapResponse(await admin.evaluateFlag(flagKey, context))); + } catch (error) { + return flagError(error, flagKey, 500); + } + }, + jsonInit("POST", { context }) + ); +} + +function flagError( + error: unknown, + flagKey: string, + fallbackStatus: 400 | 500 +): Response { + const message = error instanceof Error ? error.message : String(error); + if (message === flagNotFoundMessage(flagKey)) { + return errorResponse( + 404, + FLAGSHIP_ERROR_NOT_FOUND, + `Flag '${flagKey}' not found.` + ); + } + return errorResponse( + fallbackStatus, + fallbackStatus === 400 ? FLAGSHIP_ERROR_INVALID_FLAG : 10001, + message + ); +} diff --git a/packages/miniflare/src/workers/local-explorer/route-names.ts b/packages/miniflare/src/workers/local-explorer/route-names.ts index 90e0e318005..a692018773a 100644 --- a/packages/miniflare/src/workers/local-explorer/route-names.ts +++ b/packages/miniflare/src/workers/local-explorer/route-names.ts @@ -32,6 +32,13 @@ const ROUTE_PATTERNS: [RegExp, string][] = [ [/^\/workflows\/[^/]+\/instances$/, "workflows.instances"], [/^\/workflows\/[^/]+$/, "workflows.details"], [/^\/workflows$/, "workflows.list"], + [ + /^\/flagship\/apps\/[^/]+\/flags\/[^/]+\/evaluate$/, + "flagship.flag.evaluate", + ], + [/^\/flagship\/apps\/[^/]+\/flags\/[^/]+$/, "flagship.flag"], + [/^\/flagship\/apps\/[^/]+\/flags$/, "flagship.flags"], + [/^\/flagship\/apps$/, "flagship.apps"], [/^\/local\/observability\/query$/, "observability.query"], [/^\/local\/observability\/clear$/, "observability.clear"], [/^\/local\/email\/routing\/send$/, "email.routing.send"], diff --git a/packages/miniflare/test/plugins/flagship/evaluate.spec.ts b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts new file mode 100644 index 00000000000..cba74f7ecd1 --- /dev/null +++ b/packages/miniflare/test/plugins/flagship/evaluate.spec.ts @@ -0,0 +1,210 @@ +import { describe, test } from "vitest"; +import { evaluateFlag } from "../../../src/workers/flagship/evaluate"; +import type { EvaluationContext } from "../../../src/workers/flagship/evaluate"; +import type { FlagInput, Rule } from "../../../src/workers/flagship/flags"; + +const ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; + +function flag(overrides: Partial = {}): FlagInput { + return { + key: "test", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [], + ...overrides, + }; +} + +function rolloutFlag(percentage: number, attribute?: string): FlagInput { + return flag({ + key: "rollout_test", + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage, attribute }, + }, + ], + }); +} + +function matches( + conditions: Rule["conditions"], + context: EvaluationContext +): boolean { + return ( + evaluateFlag( + flag({ rules: [{ priority: 1, conditions, serve_variation: "on" }] }), + context, + "local" + ).reason === "TARGETING_MATCH" + ); +} + +function bucketFor(targetingKey: unknown): number { + for (let percentage = 1; percentage <= 100; percentage++) { + if ( + evaluateFlag(rolloutFlag(percentage), { targetingKey }, ACCOUNT_TAG) + .reason === "SPLIT" + ) { + return percentage - 1; + } + } + return 100; +} + +describe("flagship evaluation", () => { + test("serves defaults, disabled flags, and the first matching rule", ({ + expect, + }) => { + expect(evaluateFlag(flag(), {}, "local")).toEqual({ + value: false, + variant: "off", + reason: "DEFAULT", + }); + expect( + evaluateFlag( + flag({ + enabled: false, + rules: [{ priority: 1, conditions: [], serve_variation: "on" }], + }), + {}, + "local" + ) + ).toMatchObject({ variant: "off", reason: "DISABLED" }); + + const ordered = flag({ + variations: { first: "a", second: "b", off: "off" }, + rules: [ + { + priority: 1, + conditions: [{ attribute: "id", operator: "equals", value: 1 }], + serve_variation: "first", + }, + { priority: 2, conditions: [], serve_variation: "second" }, + ], + }); + expect(evaluateFlag(ordered, { id: "1" }, "local").variant).toBe("first"); + expect(evaluateFlag(ordered, { id: "2" }, "local").variant).toBe("second"); + ordered.rules.reverse(); + expect(evaluateFlag(ordered, { id: "1" }, "local").variant).toBe("first"); + }); + + test("rejects an undefined served variation", ({ expect }) => { + expect(() => + evaluateFlag(flag({ default_variation: "missing" }), {}, "local") + ).toThrow("Flag 'test' variation 'missing' is not defined"); + }); + + test("matches comparison and logical conditions with production coercions", ({ + expect, + }) => { + expect( + matches([{ attribute: "id", operator: "equals", value: 42 }], { + id: "42", + }) + ).toBe(true); + expect( + matches([{ attribute: "country", operator: "in", value: ["US"] }], { + country: "US", + }) + ).toBe(true); + expect( + matches([{ attribute: "country", operator: "not_in", value: [] }], { + country: "US", + }) + ).toBe(true); + expect( + matches( + [ + { + attribute: "now", + operator: "greater_than", + value: "2025-05-01T15:00:00Z", + }, + ], + { now: "2025-06-01T15:00:00Z" } + ) + ).toBe(true); + expect(matches([{ logical_operator: "AND", clauses: [] }], {})).toBe(true); + expect(matches([{ logical_operator: "OR", clauses: [] }], {})).toBe(false); + }); + + test("does not match missing attributes or malformed operators", ({ + expect, + }) => { + expect( + matches([{ attribute: "plan", operator: "equals", value: "pro" }], {}) + ).toBe(false); + expect( + matches([{ attribute: "country", operator: "in", value: "US" }], { + country: "US", + }) + ).toBe(false); + expect( + matches([{ attribute: "id", operator: "invalid" as never, value: 1 }], { + id: 1, + }) + ).toBe(false); + }); + + describe("rollouts", () => { + test("matches upstream hash vectors and stringifies targeting keys", ({ + expect, + }) => { + expect( + Object.fromEntries( + ["0", "1", "2", "", "日本語", "héllo", "false"].map((key) => [ + key, + bucketFor(key), + ]) + ) + ).toEqual({ + "0": 15, + "1": 8, + "2": 91, + "": 50, + 日本語: 9, + héllo: 73, + false: 33, + }); + expect(bucketFor(0)).toBe(bucketFor("0")); + expect(bucketFor(false)).toBe(bucketFor("false")); + }); + + test("honors rollout boundaries and custom attributes", ({ expect }) => { + expect(evaluateFlag(rolloutFlag(100), {}, ACCOUNT_TAG).reason).toBe( + "SPLIT" + ); + expect( + evaluateFlag(rolloutFlag(0), { targetingKey: "1" }, ACCOUNT_TAG).reason + ).toBe("DEFAULT"); + const custom = rolloutFlag(50, "userId"); + expect(evaluateFlag(custom, { userId: "1" }, ACCOUNT_TAG).reason).toBe( + "SPLIT" + ); + expect(evaluateFlag(custom, { userId: "2" }, ACCOUNT_TAG).reason).toBe( + "DEFAULT" + ); + expect(["SPLIT", "DEFAULT"]).toContain( + evaluateFlag(rolloutFlag(50), {}, ACCOUNT_TAG).reason + ); + }); + + test("seeds buckets by account and flag", ({ expect }) => { + const reasons = (flagKey: string, accountTag: string) => + ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"].map( + (targetingKey) => { + const rollout = rolloutFlag(50); + rollout.key = flagKey; + return evaluateFlag(rollout, { targetingKey }, accountTag).reason; + } + ); + const baseline = reasons("rollout_test", ACCOUNT_TAG); + expect(reasons("rollout_test", "local")).not.toEqual(baseline); + expect(reasons("other", ACCOUNT_TAG)).not.toEqual(baseline); + }); + }); +}); diff --git a/packages/miniflare/test/plugins/flagship/index.spec.ts b/packages/miniflare/test/plugins/flagship/index.spec.ts index 48d49416c51..d8a7d00713f 100644 --- a/packages/miniflare/test/plugins/flagship/index.spec.ts +++ b/packages/miniflare/test/plugins/flagship/index.spec.ts @@ -1,5 +1,7 @@ -import { WorkerOptionsSchema } from "miniflare"; -import { test } from "vitest"; +import { Miniflare, WorkerOptionsSchema } from "miniflare"; +import { describe, test } from "vitest"; +import { singleModuleManifest, useDispose, useTmp } from "../../test-shared"; +import type { FlagInput, MiniflareOptions } from "miniflare"; function workerConfigBase( overrides?: Record @@ -18,6 +20,49 @@ function workerConfigBase( }; } +const WORKER_SCRIPT = ` + export default { + async fetch(request, env) { + const { method, args } = await request.json(); + return Response.json({ result: await env.FLAGS[method](...args) }); + }, + }; +`; + +function options( + env: Record = { + FLAGS: { type: "flagship", id: "app" }, + } +): MiniflareOptions { + return { + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-05-01", + env, + manifest: singleModuleManifest(WORKER_SCRIPT), + }, + }, + ], + }; +} + +const BOOL_FLAG: FlagInput = { + key: "new_checkout", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [ + { + priority: 1, + conditions: [{ attribute: "plan", operator: "equals", value: "pro" }], + serve_variation: "on", + }, + ], +}; + test("flagship: accepts valid flagship binding", ({ expect }) => { const result = WorkerOptionsSchema.safeParse({ config: workerConfigBase({ @@ -53,3 +98,401 @@ test("flagship: accepts config with no flagship binding", ({ expect }) => { }); expect(result.success).toBe(true); }); + +const ACCOUNT_TAG = "aaaabbbbccccdddd1111222233334444"; +const BUCKETS = { "0": 15, "1": 8, "2": 91, "": 50, 日本語: 9, héllo: 73 }; +const ROLLOUT_FLAG: FlagInput = { + ...BOOL_FLAG, + key: "rollout_test", + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 50 }, + }, + ], +}; + +async function getAdmin(mf: Miniflare, binding = "FLAGS") { + return (await mf.getFlagshipBindingAPI(binding))(); +} + +async function call(mf: Miniflare, method: string, ...args: unknown[]) { + const response = await mf.dispatchFetch("http://placeholder", { + method: "POST", + body: JSON.stringify({ method, args }), + }); + if (!response.ok) throw new Error(await response.text()); + return ((await response.json()) as { result: unknown }).result; +} + +async function rejection(call: () => Promise): Promise { + try { + await call(); + } catch (error) { + return (error as Error).message; + } + throw new Error("expected rejection"); +} + +describe("flagship plugin", () => { + test("keeps app service names separate from internal services", async ({ + expect, + }) => { + const mf = new Miniflare( + options({ + OBJECT: { type: "flagship", id: "internal:object" }, + REMOTE: { type: "flagship", id: "internal:remote" }, + STORAGE: { type: "flagship", id: "internal:storage" }, + }) + ); + useDispose(mf); + + await expect(getAdmin(mf, "OBJECT")).resolves.toBeDefined(); + await expect(getAdmin(mf, "REMOTE")).resolves.toBeDefined(); + await expect(getAdmin(mf, "STORAGE")).resolves.toBeDefined(); + }); + + test("implements binding values, details, defaults, and errors", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + + expect( + await call(mf, "getBooleanValue", BOOL_FLAG.key, false, { plan: "pro" }) + ).toBe(true); + expect( + await call(mf, "getBooleanDetails", BOOL_FLAG.key, false, { plan: "pro" }) + ).toEqual({ + flagKey: BOOL_FLAG.key, + value: true, + variant: "on", + reason: "TARGETING_MATCH", + }); + expect(await call(mf, "getStringValue", "missing", "fallback")).toBe( + "fallback" + ); + expect(await call(mf, "getStringDetails", "missing", "fallback")).toEqual({ + flagKey: "missing", + value: "fallback", + variant: "default", + reason: "ERROR", + errorCode: "FLAG_NOT_FOUND", + errorMessage: "Flag 'missing' not found", + }); + expect( + await call(mf, "getStringDetails", BOOL_FLAG.key, "fallback") + ).toEqual( + expect.objectContaining({ + value: "fallback", + errorCode: "TYPE_MISMATCH", + errorMessage: + "Flag 'new_checkout' has type 'boolean', expected 'string'", + }) + ); + await expect(call(mf, "get", "missing")).rejects.toThrow( + "Flag 'missing' not found" + ); + expect(await call(mf, "get", "missing", false)).toBe(false); + }); + + test("supports every admin mutation", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + + expect(await admin.listFlags()).toEqual([]); + const created = await admin.createFlag(BOOL_FLAG); + expect(await admin.getFlag(BOOL_FLAG.key)).toEqual(created); + await admin.updateFlag(BOOL_FLAG.key, { ...BOOL_FLAG, enabled: false }); + expect( + await admin.evaluateFlag(BOOL_FLAG.key, { plan: "pro" }) + ).toMatchObject({ + value: false, + reason: "DISABLED", + }); + await admin.patchFlag(BOOL_FLAG.key, { description: "description" }); + expect(await admin.getFlag(BOOL_FLAG.key)).toMatchObject({ + description: "description", + enabled: false, + }); + await admin.putFlag({ ...BOOL_FLAG, enabled: true }); + await admin.putFlag({ ...BOOL_FLAG, enabled: false }); + expect(await admin.listFlags()).toHaveLength(1); + await admin.putFlags([{ ...BOOL_FLAG, enabled: true }], ACCOUNT_TAG); + expect(await admin.getAccountTag()).toBe(ACCOUNT_TAG); + await admin.deleteFlag(BOOL_FLAG.key); + expect(await admin.listFlags()).toEqual([]); + }); + + test("reports missing and conflicting admin operations", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + expect(await rejection(() => admin.createFlag(BOOL_FLAG))).toBe( + "Flag 'new_checkout' already exists" + ); + for (const operation of [ + () => admin.getFlag("missing"), + () => admin.updateFlag("missing", BOOL_FLAG), + () => admin.patchFlag("missing", {}), + () => admin.deleteFlag("missing"), + ]) { + expect(await rejection(operation)).toBe("Flag 'missing' not found"); + } + }); + + test("enforces flag validation", async ({ expect }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + const cases: [Partial, string][] = [ + [ + { key: "not valid!" }, + "Flag key 'not valid!' must be 1-64 alphanumeric, hyphen or underscore characters", + ], + [{ variations: {} }, "must define at least one variation"], + [{ variations: { on: true, off: "no" } }, "must all share the same type"], + [{ variations: { on: null, off: null } }, "variations cannot be null"], + [ + { variations: { on: Number.POSITIVE_INFINITY, off: 0 } }, + "variations must contain values that can be stored as JSON", + ], + [ + { variations: { on: new Date(), off: {} } }, + "variations must contain values that can be stored as JSON", + ], + [ + { default_variation: "missing" }, + "default variation 'missing' is not defined", + ], + [ + { rules: [{ ...BOOL_FLAG.rules[0], serve_variation: "missing" }] }, + "rule serves undefined variation 'missing'", + ], + [ + { rules: [{ ...BOOL_FLAG.rules[0], priority: 0 }] }, + "rule priorities must be integers greater than or equal to 1", + ], + [ + { rules: [BOOL_FLAG.rules[0], { ...BOOL_FLAG.rules[0] }] }, + "duplicate rule priority 1", + ], + [ + { + rules: [ + { priority: 1, conditions: [], serve_variation: "on" }, + { ...BOOL_FLAG.rules[0], priority: 2 }, + ], + }, + "targeting rules after a rule with no conditions", + ], + [ + { rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 101 } }] }, + "rollout percentage must be a number between 0 and 100", + ], + [ + { + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [{ logical_operator: "AND" } as never], + }, + ], + }, + "'AND' condition without a list of clauses", + ], + [ + { + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [ + { attribute: "plan", operator: "invalid", value: "pro" }, + ] as never, + }, + ], + }, + "condition with an unknown operator 'invalid'", + ], + [ + { + rules: [ + { + ...BOOL_FLAG.rules[0], + conditions: [ + { attribute: "plan", operator: "in", value: "pro" }, + ] as never, + }, + ], + }, + "'in' condition whose value is not a list", + ], + ]; + for (const [changes, message] of cases) { + expect( + await rejection(() => + admin.putFlag({ ...BOOL_FLAG, ...changes } as FlagInput) + ) + ).toContain(message); + } + const fractional = await admin.putFlag({ + ...BOOL_FLAG, + rules: [{ ...BOOL_FLAG.rules[0], rollout: { percentage: 33.333333 } }], + }); + expect(fractional.rules[0].rollout?.percentage).toBe(33.333333); + const partialRollout = await admin.putFlag({ + ...BOOL_FLAG, + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 50 }, + }, + { ...BOOL_FLAG.rules[0], priority: 2 }, + ], + }); + expect(partialRollout.rules).toHaveLength(2); + }); + + test("validates account tags and makes batch writes atomic", async ({ + expect, + }) => { + const mf = new Miniflare(options()); + useDispose(mf); + const admin = await getAdmin(mf); + for (const operation of [ + () => admin.setAccountTag(""), + () => admin.putFlags([BOOL_FLAG], ""), + ]) { + expect(await rejection(operation)).toBe( + "accountTag must be a non-empty string" + ); + } + expect( + await rejection(() => + admin.putFlags( + [BOOL_FLAG, { ...BOOL_FLAG, key: "invalid", variations: {} }], + ACCOUNT_TAG + ) + ) + ).toBe("Flag 'invalid' must define at least one variation"); + expect(await admin.listFlags()).toEqual([]); + expect(await admin.getAccountTag()).toBeNull(); + }); + + test("isolates apps and shares aliases", async ({ expect }) => { + const mf = new Miniflare( + options({ + FLAGS: { type: "flagship", id: "app-a" }, + ALIAS: { type: "flagship", id: "app-a" }, + OTHER: { type: "flagship", id: "app-b" }, + }) + ); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + await admin.setAccountTag(ACCOUNT_TAG); + expect(await (await getAdmin(mf, "ALIAS")).listFlags()).toHaveLength(1); + expect(await (await getAdmin(mf, "OTHER")).listFlags()).toEqual([]); + expect(await (await getAdmin(mf, "OTHER")).getAccountTag()).toBeNull(); + }); + + test("persists flags and account tags", async ({ expect }) => { + const persistence = await useTmp(); + const opts = { ...options(), resourcePersistencePath: persistence }; + const first = new Miniflare(opts); + const admin = await getAdmin(first); + await admin.createFlag(BOOL_FLAG); + await admin.setAccountTag(ACCOUNT_TAG); + await first.dispose(); + + const second = new Miniflare(opts); + useDispose(second); + expect(await (await getAdmin(second)).getAccountTag()).toBe(ACCOUNT_TAG); + expect( + await call(second, "getBooleanValue", BOOL_FLAG.key, false, { + plan: "pro", + }) + ).toBe(true); + }); + + test("shares live persistent storage across instances", async ({ + expect, + }) => { + const persistence = await useTmp(); + const opts = { ...options(), resourcePersistencePath: persistence }; + const first = new Miniflare(opts); + useDispose(first); + const firstAdmin = await getAdmin(first); + expect(await firstAdmin.listFlags()).toEqual([]); + const second = new Miniflare(opts); + useDispose(second); + await (await getAdmin(second)).createFlag(BOOL_FLAG); + expect(await firstAdmin.listFlags()).toEqual([ + expect.objectContaining({ key: BOOL_FLAG.key }), + ]); + }); + + test("reproduces seeded rollout buckets", async ({ expect }) => { + const warnings: string[] = []; + const mf = new Miniflare({ + ...options(), + handleStructuredLogs(log) { + if (log.level === "warn") warnings.push(log.message); + }, + }); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.setAccountTag(ACCOUNT_TAG); + await admin.createFlag(ROLLOUT_FLAG); + for (const [targetingKey, bucket] of Object.entries(BUCKETS)) { + expect( + await call(mf, "getBooleanValue", ROLLOUT_FLAG.key, false, { + targetingKey, + }) + ).toBe(bucket < 50); + } + expect(warnings).toEqual([]); + }); + + test("warns once for unseeded partial rollouts only", async ({ expect }) => { + const warnings: string[] = []; + const mf = new Miniflare({ + ...options(), + handleStructuredLogs(log) { + if (log.level === "warn") warnings.push(log.message); + }, + }); + useDispose(mf); + const admin = await getAdmin(mf); + await admin.createFlag(BOOL_FLAG); + await call(mf, "getBooleanValue", BOOL_FLAG.key, false, { plan: "pro" }); + await admin.createFlag({ + ...ROLLOUT_FLAG, + key: "full_rollout", + rules: [{ ...ROLLOUT_FLAG.rules[0], rollout: { percentage: 100 } }], + }); + await call(mf, "getBooleanValue", "full_rollout", false, { + targetingKey: "0", + }); + expect(warnings).toEqual([]); + await admin.createFlag(ROLLOUT_FLAG); + for (const targetingKey of ["0", "1"]) { + await call(mf, "getBooleanValue", ROLLOUT_FLAG.key, false, { + targetingKey, + }); + } + expect(warnings).toEqual([ + "Flagship: flag 'rollout_test' has a percentage rollout, but the local flag store has no account tag, so its buckets will not match your remote app. Run `wrangler flagship flags pull` to seed the store.", + ]); + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts b/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts index 5f361187222..656b967cd05 100644 --- a/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/aggregation.spec.ts @@ -69,6 +69,8 @@ describe("Cross-process aggregation", () => { exportName: "MyDO", }, BUCKET_A: { type: "r2", name: "bucket-a" }, + FLAGS_A: { type: "flagship", id: "app-a" }, + SHARED_A: { type: "flagship", id: "shared-app" }, }, exports: { MyDO: { type: "durable-object", storage: "legacy-kv" }, @@ -105,6 +107,8 @@ describe("Cross-process aggregation", () => { exportName: "OtherDO", }, BUCKET_B: { type: "r2", name: "bucket-b" }, + FLAGS_B: { type: "flagship", id: "app-b" }, + SHARED_B: { type: "flagship", id: "shared-app" }, }, exports: { OtherDO: { type: "durable-object", storage: "legacy-kv" }, @@ -386,6 +390,151 @@ describe("Cross-process aggregation", () => { }); }); + describe("Flagship aggregation", () => { + const FLAG = { + key: "peer-flag", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + }; + + test("lists apps from both instances", async ({ expect }) => { + const response = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps` + ); + const data = (await response.json()) as ListResponse; + + expect(normalizeListResponse(data)).toMatchInlineSnapshot(` + { + "result": [ + { + "bindings": [ + "FLAGS_A", + ], + "id": "app-a", + }, + { + "bindings": [ + "FLAGS_B", + ], + "id": "app-b", + }, + { + "bindings": [ + "SHARED_A", + ], + "id": "shared-app", + }, + ], + "result_info": { + "count": 3, + }, + } + `); + }); + + test("routes a shared app to the selected worker", async ({ expect }) => { + const created = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/shared-app/flags?worker=worker-b`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...FLAG, key: "worker-b-flag" }), + } + ); + await created.text(); + expect(created.status).toBe(200); + + const local = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/shared-app/flags?worker=worker-a` + ); + expect(((await local.json()) as ListResponse).result).toStrictEqual([]); + + const peer = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/shared-app/flags?worker=worker-b` + ); + expect( + ((await peer.json()) as ListResponse).result?.map((flag) => flag.key) + ).toStrictEqual(["worker-b-flag"]); + }); + + test("creates, reads, evaluates and deletes a flag owned by a peer", async ({ + expect, + }) => { + const created = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(FLAG), + } + ); + await created.text(); + expect(created.status).toBe(200); + + const listed = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags` + ); + const listedData = (await listed.json()) as ListResponse; + expect(listedData.result?.map((flag) => flag.key)).toStrictEqual([ + "peer-flag", + ]); + + const owned = await instanceB.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags/peer-flag` + ); + await owned.text(); + expect(owned.status).toBe(200); + + const evaluated = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags/peer-flag/evaluate`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ context: {} }), + } + ); + expect( + ((await evaluated.json()) as { result?: { value?: unknown } }).result + ?.value + ).toBe(false); + + const patched = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags/peer-flag`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }), + } + ); + await patched.text(); + expect(patched.status).toBe(200); + + const deleted = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags/peer-flag`, + { method: "DELETE" } + ); + await deleted.text(); + expect(deleted.status).toBe(200); + + const afterDelete = await instanceB.dispatchFetch( + `${BASE_URL}/flagship/apps/app-b/flags` + ); + expect(((await afterDelete.json()) as ListResponse).result).toStrictEqual( + [] + ); + }); + + test("404s for an app no instance simulates", async ({ expect }) => { + const response = await instanceA.dispatchFetch( + `${BASE_URL}/flagship/apps/app-missing/flags` + ); + await response.text(); + + expect(response.status).toBe(404); + }); + }); + describe("r2 bucket aggregation", () => { test("lists r2 buckets from both instances", async ({ expect }) => { const response = await instanceA.dispatchFetch(`${BASE_URL}/r2/buckets`); diff --git a/packages/miniflare/test/plugins/local-explorer/flagship.spec.ts b/packages/miniflare/test/plugins/local-explorer/flagship.spec.ts new file mode 100644 index 00000000000..d367cbf7977 --- /dev/null +++ b/packages/miniflare/test/plugins/local-explorer/flagship.spec.ts @@ -0,0 +1,322 @@ +import { Miniflare } from "miniflare"; +import { afterAll, beforeAll, describe, test } from "vitest"; +import { CorePaths } from "../../../src/workers/core/constants"; +import { + zFlagshipCreateFlagResponse, + zFlagshipDeleteFlagResponse, + zFlagshipEvaluateFlagResponse, + zFlagshipGetFlagResponse, + zFlagshipListAppsResponse, + zFlagshipListFlagsResponse, + zFlagshipUpdateFlagResponse, +} from "../../../src/workers/local-explorer/generated/zod.gen"; +import { disposeWithRetry, singleModuleManifest } from "../../test-shared"; +import { expectValidResponse } from "./helpers"; +import type { FlagshipAdmin } from "miniflare"; + +const BASE_URL = `http://localhost${CorePaths.EXPLORER}/api/flagship/apps`; +const BOOLEAN_FLAG = { + key: "new-ui", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [], +}; + +describe("Flagship API", () => { + let mf: Miniflare; + let admin: FlagshipAdmin; + + function request( + path = "", + method = "GET", + body?: unknown + ): Promise { + return mf.dispatchFetch(`${BASE_URL}${path}`, { + method, + ...(body === undefined + ? {} + : { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + }); + } + + async function getStatus( + path: string, + method: string, + body?: unknown + ): Promise { + const response = await request(path, method, body); + await response.body?.cancel(); + return response.status; + } + + beforeAll(async () => { + mf = new Miniflare({ + inspectorPort: 0, + unsafeLocalExplorer: true, + workers: [ + { + config: { + type: "worker", + name: "", + compatibilityDate: "2025-01-01", + manifest: singleModuleManifest( + `export default { fetch() { return new Response("user worker"); } }` + ), + env: { + FLAGS: { type: "flagship", id: "app-1" }, + ALIAS: { type: "flagship", id: "app-1" }, + OTHER: { type: "flagship", id: "app-2" }, + PROTO: { type: "flagship", id: "__proto__" }, + }, + }, + }, + ], + }); + admin = (await mf.getFlagshipBindingAPI("FLAGS"))(); + await admin.putFlag(BOOLEAN_FLAG); + }); + + afterAll(async () => disposeWithRetry(mf)); + + test("lists apps and keeps app stores isolated", async ({ expect }) => { + const apps = await expectValidResponse( + await request(), + zFlagshipListAppsResponse, + expect + ); + expect(apps.result).toEqual([ + { id: "app-1", bindings: ["FLAGS", "ALIAS"] }, + { id: "app-2", bindings: ["OTHER"] }, + { id: "__proto__", bindings: ["PROTO"] }, + ]); + + const flags = await expectValidResponse( + await request("/app-1/flags"), + zFlagshipListFlagsResponse, + expect + ); + expect(flags.result).toMatchObject([ + { key: "new-ui", type: "boolean", enabled: true }, + ]); + const other = await expectValidResponse( + await request("/app-2/flags"), + zFlagshipListFlagsResponse, + expect + ); + expect(other.result).toEqual([]); + const proto = await expectValidResponse( + await request("/__proto__/flags"), + zFlagshipListFlagsResponse, + expect + ); + expect(proto.result).toEqual([]); + }); + + test("supports full CRUD and evaluates changes through the binding", async ({ + expect, + }) => { + const body = { + key: "managed", + description: "created locally", + enabled: true, + default_variation: "off", + variations: { on: "yes", off: "no" }, + rules: [ + { + priority: 1, + conditions: [{ attribute: "plan", operator: "equals", value: "pro" }], + serve_variation: "on", + }, + ], + }; + const created = await expectValidResponse( + await request("/app-1/flags", "POST", body), + zFlagshipCreateFlagResponse, + expect + ); + expect(created.result).toMatchObject({ key: "managed", type: "string" }); + expect(await admin.evaluateFlag("managed", { plan: "pro" })).toMatchObject({ + value: "yes", + reason: "TARGETING_MATCH", + }); + + const fetched = await expectValidResponse( + await request("/app-1/flags/managed"), + zFlagshipGetFlagResponse, + expect + ); + expect(fetched.result).toMatchObject(body); + + const updated = await expectValidResponse( + await request("/app-1/flags/managed", "PATCH", { + description: null, + default_variation: "on", + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "off", + rollout: { percentage: 33.5, attribute: "userId" }, + }, + ], + }), + zFlagshipUpdateFlagResponse, + expect + ); + expect(updated.result).toMatchObject({ + default_variation: "on", + rules: [{ rollout: { percentage: 33.5, attribute: "userId" } }], + }); + expect(updated.result?.description ?? null).toBeNull(); + + const evaluation = await expectValidResponse( + await request("/app-1/flags/managed/evaluate", "POST", { + context: { userId: "same-user" }, + }), + zFlagshipEvaluateFlagResponse, + expect + ); + expect(evaluation.result?.flagKey).toBe("managed"); + + const deleted = await expectValidResponse( + await request("/app-1/flags/managed", "DELETE"), + zFlagshipDeleteFlagResponse, + expect + ); + expect(deleted.result).toEqual({ success: true }); + expect((await admin.listFlags()).map(({ key }) => key)).not.toContain( + "managed" + ); + }); + + test("PATCH leaves omitted fields and rules untouched", async ({ + expect, + }) => { + await admin.updateFlag("new-ui", { + ...BOOLEAN_FLAG, + description: "another writer", + rules: [ + { + priority: 1, + conditions: [{ attribute: "country", operator: "in", value: ["NZ"] }], + serve_variation: "on", + }, + ], + }); + await expectValidResponse( + await request("/app-1/flags/new-ui", "PATCH", { enabled: false }), + zFlagshipUpdateFlagResponse, + expect + ); + expect(await admin.getFlag("new-ui")).toMatchObject({ + description: "another writer", + enabled: false, + rules: [{ serve_variation: "on" }], + }); + await admin.updateFlag("new-ui", BOOLEAN_FLAG); + }); + + test("stores rules by priority and applies stable percentage rollout", async ({ + expect, + }) => { + const response = await request("/app-1/flags/new-ui", "PATCH", { + rules: [ + { + priority: 2, + conditions: [], + serve_variation: "off", + rollout: { percentage: 100 }, + }, + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 50, attribute: "userId" }, + }, + ], + }); + await response.body?.cancel(); + expect(response.status).toBe(200); + expect( + (await admin.getFlag("new-ui")).rules.map(({ priority }) => priority) + ).toEqual([1, 2]); + const first = await admin.evaluateFlag("new-ui", { userId: "user-1" }); + expect(await admin.evaluateFlag("new-ui", { userId: "user-1" })).toEqual( + first + ); + await admin.updateFlag("new-ui", BOOLEAN_FLAG); + }); + + test("defaults optional create fields", async ({ expect }) => { + await expectValidResponse( + await request("/app-1/flags", "POST", { + key: "quiet", + default_variation: "off", + variations: { on: true, off: false }, + }), + zFlagshipCreateFlagResponse, + expect + ); + expect(await admin.getFlag("quiet")).toMatchObject({ + enabled: false, + rules: [], + }); + await admin.deleteFlag("quiet"); + }); + + test("returns API errors for invalid flags and missing resources", async ({ + expect, + }) => { + const duplicate = await request("/app-1/flags", "POST", BOOLEAN_FLAG); + expect(duplicate.status).toBe(400); + expect(await duplicate.json()).toMatchObject({ + success: false, + errors: [{ message: "Flag 'new-ui' already exists" }], + }); + + expect( + await getStatus("/app-1/flags", "POST", { + key: "malformed", + default_variation: "off", + variations: { on: true, off: false }, + rules: [ + { + priority: 1, + conditions: [{ logical_operator: "XOR", clauses: [] }], + serve_variation: "on", + }, + ], + }) + ).toBe(400); + expect(await getStatus("/missing/flags", "GET")).toBe(404); + expect(await getStatus("/app-1/flags/missing", "GET")).toBe(404); + expect(await getStatus("/app-1/flags/missing", "DELETE")).toBe(404); + }); + + test("rejects invalid creates without writing them", async ({ expect }) => { + const cases = [ + { + key: "bad-default", + default_variation: "missing", + variations: { on: true, off: false }, + }, + { + key: "mixed-types", + default_variation: "on", + variations: { on: true, off: "false" }, + }, + ]; + for (const flag of cases) { + const response = await request("/app-1/flags", "POST", flag); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ success: false }); + await expect( + Promise.resolve().then(() => admin.getFlag(flag.key)) + ).rejects.toThrow(); + } + }); +}); diff --git a/packages/miniflare/test/plugins/local-explorer/index.spec.ts b/packages/miniflare/test/plugins/local-explorer/index.spec.ts index 8cffc88acc3..d18f79d056c 100644 --- a/packages/miniflare/test/plugins/local-explorer/index.spec.ts +++ b/packages/miniflare/test/plugins/local-explorer/index.spec.ts @@ -758,6 +758,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { "useSqlite": false, }, ], + "flagship": [], "kv": [ { "bindingName": "MY_KV", @@ -787,6 +788,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { "bindings": { "d1": [], "do": [], + "flagship": [], "kv": [ { "bindingName": "KV_A2", @@ -809,6 +811,7 @@ describe("Local Explorer /api/local/workers endpoint", () => { }, ], "do": [], + "flagship": [], "kv": [], "r2": [], "sendEmail": [], diff --git a/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts index 5e730f10c50..ccdeae8d4c3 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts +++ b/packages/vite-plugin-cloudflare/src/__tests__/agent-hint.spec.ts @@ -62,13 +62,16 @@ describe("Local Explorer agent hint", () => { expect(output).toContain( "GET http://localhost:5173/cdn-cgi/local/explorer/api/local/workers - local Workers and bindings" ); + expect(output).toContain( + "GET http://localhost:5173/cdn-cgi/local/explorer/api/flagship/apps - Flagship apps" + ); expect(output).toContain( "POST http://localhost:5173/cdn-cgi/local/explorer/api/local/observability/query" ); // The OpenAPI schema is listed last, as a fallback, so agents reach for the // specific routes first. expect(output.indexOf("- OpenAPI schema")).toBeGreaterThan( - output.indexOf("- Workflows") + output.indexOf("- Flagship apps") ); }); diff --git a/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts b/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts index b87c777af5e..f38fac96ffd 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/agent-hint.ts @@ -85,6 +85,7 @@ function printLocalExplorerAgentHint( ` GET ${explorerApiUrl}/r2/buckets - R2 buckets`, ` GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces`, ` GET ${explorerApiUrl}/workflows - Workflows`, + ` GET ${explorerApiUrl}/flagship/apps - Flagship apps`, ` POST ${explorerApiUrl}/local/observability/query - run a read-only SQL query (SELECT/WITH only) over captured request traces and console logs. Tables: spans, logs (read attributes via json(attributes)). Example:`, ` curl -X POST ${explorerApiUrl}/local/observability/query -H 'Content-Type: application/json' -d '{"sql":"SELECT service, name, outcome, duration_ms FROM spans WHERE parent_id IS NULL LIMIT 20"}'`, `If the routes above don't cover what you need, fetch the full OpenAPI schema (large - use only as a last resort):`, diff --git a/packages/workers-utils/src/config/binding-local-support.ts b/packages/workers-utils/src/config/binding-local-support.ts index 22222557a2b..fc722f9b877 100644 --- a/packages/workers-utils/src/config/binding-local-support.ts +++ b/packages/workers-utils/src/config/binding-local-support.ts @@ -54,6 +54,7 @@ const BINDING_LOCAL_SUPPORT: Record< service: "local-and-remote", // TODO: Miniflare currently ignores `remote: true` on queues, tracked in #13727. queue: "local-and-remote", + flagship: "local-and-remote", vectorize: "remote", mtls_certificate: "remote", @@ -66,7 +67,6 @@ const BINDING_LOCAL_SUPPORT: Record< "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", media: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", artifacts: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", - flagship: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", vpc_service: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", vpc_network: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", websearch: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator", diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index 4619c0f5ce4..9b453a915e7 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -1678,7 +1678,7 @@ export interface EnvironmentNonInheritable { /** The Flagship app ID to bind to. */ app_id?: string; - /** Set to `true` to suppress the remote binding warning in local dev. Flagship bindings are always remote. */ + /** Set to `true` to evaluate flags against the remote Flagship app during local dev, instead of the local simulator. */ remote?: boolean; }[]; diff --git a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts index 27cdbef74be..8823712e314 100644 --- a/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts +++ b/packages/wrangler/src/__tests__/dev/remote-bindings.test.ts @@ -337,6 +337,35 @@ describe("dev with remote bindings", { sequential: true, retry: 2 }, () => { }), ], }, + { + name: "flagship app", + config: { + flagship: [ + { + binding: "FLAGS", + app_id: "mock-flagship-app", + remote: true, + }, + ], + }, + expectedProxyWorkerBindings: { + FLAGS: { + app_id: "mock-flagship-app", + remote: true, + type: "flagship", + }, + }, + expectedWorkerOptions: [ + expect.objectContaining({ + flagship: { + FLAGS: { + app_id: "mock-flagship-app", + remoteProxyConnectionString, + }, + }, + }), + ], + }, { name: "r2 bucket", config: { diff --git a/packages/wrangler/src/__tests__/dev/start-dev.test.ts b/packages/wrangler/src/__tests__/dev/start-dev.test.ts index 2bbef5ee599..4c8e8b396d6 100644 --- a/packages/wrangler/src/__tests__/dev/start-dev.test.ts +++ b/packages/wrangler/src/__tests__/dev/start-dev.test.ts @@ -109,6 +109,9 @@ describe("startDev", () => { expect(std.out).toContain( "GET http://127.0.0.1:8787/cdn-cgi/local/explorer/api/local/workers - local Workers and bindings" ); + expect(std.out).toContain( + "GET http://127.0.0.1:8787/cdn-cgi/local/explorer/api/flagship/apps - Flagship apps" + ); expect(std.out).toContain( "POST http://127.0.0.1:8787/cdn-cgi/local/explorer/api/local/observability/query - run a read-only SQL query (SELECT/WITH only) over captured request traces and console logs. Tables: spans, logs (read attributes via json(attributes)). Example:" ); diff --git a/packages/wrangler/src/__tests__/flagship.test.ts b/packages/wrangler/src/__tests__/flagship.test.ts index 42d2b1503e6..f251c0c5aea 100644 --- a/packages/wrangler/src/__tests__/flagship.test.ts +++ b/packages/wrangler/src/__tests__/flagship.test.ts @@ -1,10 +1,16 @@ +import { UserError } from "@cloudflare/workers-utils"; import { readWranglerConfig, runInTempDir, writeWranglerConfig, } from "@cloudflare/workers-utils/test-helpers"; +import { convertV4MiniflareOptions, Miniflare } from "miniflare"; import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, it } from "vitest"; +import { readConfig } from "../config"; +import { getLocalPersistencePath } from "../dev/get-local-persistence-path"; +import { getDefaultPersistRoot } from "../dev/miniflare"; +import { usingLocalFlagshipAPI } from "../flagship/store"; import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; import { mockConsoleMethods } from "./helpers/mock-console"; import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs"; @@ -104,6 +110,17 @@ describe("flagship", () => { runInTempDir(); const { setIsTTY } = useMockIsTTY(); const std = mockConsoleMethods(); + async function readLocalState() { + return usingLocalFlagshipAPI( + undefined, + readConfig({}), + "app-1", + async (admin) => ({ + accountTag: await admin.getAccountTag(), + flags: await admin.listFlags(), + }) + ); + } beforeEach(() => setIsTTY(true)); afterEach(() => clearDialogs()); @@ -1741,6 +1758,196 @@ describe("flagship", () => { }); }); + describe("flags pull", () => { + const REMOTE_FLAG = { + key: "new-ui", + type: "boolean", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [ + { + priority: 1, + conditions: [], + serve_variation: "on", + rollout: { percentage: 50 }, + }, + ], + }; + + beforeEach(() => writeWranglerConfig()); + + it("writes remote flags and the account tag into the local store", async ({ + expect, + }) => { + mockGet("apps/app-1/flags", [REMOTE_FLAG], { count: 1, cursor: null }); + + await runWrangler("flagship flags pull app-1"); + + const { accountTag, flags } = await readLocalState(); + expect(accountTag).toBe("some-account-id"); + expect(flags).toEqual([ + expect.objectContaining({ + key: "new-ui", + enabled: true, + default_variation: "off", + rules: REMOTE_FLAG.rules, + }), + ]); + expect(std.out).toContain("Pulled 1 flag from app-1"); + }); + + it("leaves local-only flags untouched and reports them", async ({ + expect, + }) => { + await usingLocalFlagshipAPI( + undefined, + readConfig({}), + "app-1", + async (admin) => { + await admin.putFlag({ + key: "local_only", + enabled: true, + default_variation: "off", + variations: { on: true, off: false }, + rules: [], + }); + } + ); + mockGet("apps/app-1/flags", [REMOTE_FLAG], { count: 1, cursor: null }); + + await runWrangler("flagship flags pull app-1"); + + const { flags } = await readLocalState(); + expect(flags.map((flag) => flag.key)).toEqual(["local_only", "new-ui"]); + expect(std.out).toContain("Left 1 local-only flag untouched: local_only"); + }); + + it("writes where a dev session reads, under a different binding name", async ({ + expect, + }) => { + mockGet("apps/app-1/flags", [REMOTE_FLAG], { count: 1, cursor: null }); + await runWrangler("flagship flags pull app-1"); + + const persist = getLocalPersistencePath(undefined, readConfig({})); + const mf = new Miniflare( + convertV4MiniflareOptions({ + script: + 'addEventListener("fetch", (e) => e.respondWith(new Response(null, { status: 404 })))', + resourcePersistencePath: getDefaultPersistRoot(persist), + flagship: { MY_FLAGS: { app_id: "app-1" } }, + }) + ); + try { + const admin = (await mf.getFlagshipBindingAPI("MY_FLAGS"))(); + expect((await admin.listFlags()).map((flag) => flag.key)).toEqual([ + "new-ui", + ]); + expect(await admin.getAccountTag()).toBe("some-account-id"); + + expect( + await admin.evaluateFlag("new-ui", { targetingKey: "3" }) + ).toMatchObject({ value: true, reason: "SPLIT" }); + expect( + await admin.evaluateFlag("new-ui", { targetingKey: "1" }) + ).toMatchObject({ value: false, reason: "DEFAULT" }); + } finally { + await mf.dispose(); + } + }); + + it("follows pagination and emits JSON on --json", async ({ expect }) => { + mockPaged("apps/app-1/flags", [ + { items: [REMOTE_FLAG], cursor: "next" }, + { items: [{ ...REMOTE_FLAG, key: "second" }], cursor: null }, + ]); + + await runWrangler("flagship flags pull app-1 --json"); + + expect(JSON.parse(std.out)).toEqual({ + appId: "app-1", + pulled: ["new-ui", "second"], + localOnly: [], + }); + const { flags } = await readLocalState(); + expect(flags.map((flag) => flag.key)).toEqual(["new-ui", "second"]); + }); + }); + + describe("--local", () => { + beforeEach(() => writeWranglerConfig()); + + it("creates, lists, gets and deletes without touching the network", async ({ + expect, + }) => { + await runWrangler( + "flagship flags create app-1 new-ui --type boolean --local" + ); + expect(std.out).toContain("Created flag"); + expect(await readLocalState()).toMatchObject({ + accountTag: null, + flags: [{ key: "new-ui" }], + }); + + await runWrangler("flagship flags list app-1 --local"); + expect(std.out).toContain("new-ui"); + + await runWrangler("flagship flags get app-1 new-ui --json --local"); + expect(std.out).toContain('"key": "new-ui"'); + + await runWrangler("flagship flags delete app-1 new-ui --force --local"); + expect((await readLocalState()).flags).toEqual([]); + }); + + it("round-trips a rollout through update and evaluate", async ({ + expect, + }) => { + await runWrangler( + "flagship flags create app-1 new-ui --type boolean --local" + ); + await runWrangler( + "flagship flags rollout app-1 new-ui --to on --percentage 100 --force --local" + ); + + await runWrangler( + "flagship flags evaluate app-1 new-ui --targeting-key user-1 --local" + ); + expect(std.out).toContain("SPLIT"); + expect(std.out).toContain("true"); + }); + + it("reports local failures as user errors without falling back", async ({ + expect, + }) => { + const error = await runWrangler( + "flagship flags get app-1 absent --local" + ).catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(UserError); + expect(error).toHaveProperty("message", "Flag 'absent' not found"); + + await runWrangler( + "flagship flags create app-1 new-ui --type boolean --local" + ); + await expect( + runWrangler("flagship flags create app-1 new-ui --type boolean --local") + ).rejects.toThrow(UserError); + }); + + it("rejects --cursor, which the local store cannot honour", async ({ + expect, + }) => { + await expect( + runWrangler("flagship flags list app-1 --cursor abc --local") + ).rejects.toThrow("The local flag store is not paginated"); + }); + + it("rejects --persist-to without --local", async ({ expect }) => { + await expect( + runWrangler("flagship flags list app-1 --persist-to /tmp/flags") + ).rejects.toThrow("Cannot use --persist-to without --local"); + }); + }); + describe("app id is required", () => { it("requires an app id for flags list", async ({ expect }) => { await expect(runWrangler("flagship flags list")).rejects.toThrow( diff --git a/packages/wrangler/src/__tests__/print-bindings.test.ts b/packages/wrangler/src/__tests__/print-bindings.test.ts index c26eaa3c828..f50c014a9d2 100644 --- a/packages/wrangler/src/__tests__/print-bindings.test.ts +++ b/packages/wrangler/src/__tests__/print-bindings.test.ts @@ -5,10 +5,14 @@ import { printBindings, warnOrError } from "../utils/print-bindings"; import { mockConsoleMethods } from "./helpers/mock-console"; import type { StartDevWorkerInput } from "../api/startDevWorker/types"; -function callPrintBindings(bindings: StartDevWorkerInput["bindings"]) { +function callPrintBindings( + bindings: StartDevWorkerInput["bindings"], + local = false +) { const lines: string[] = []; printBindings(bindings, [], [], [], { log: (msg: string) => lines.push(msg), + local, }); return lines.map((l) => stripVTControlCharacters(l)).join("\n"); } @@ -145,6 +149,33 @@ describe("printBindings — AI Search bindings", () => { }); }); +describe("printBindings -- Flagship bindings", () => { + it("shows Flagship bindings as local by default", ({ expect }) => { + const output = callPrintBindings( + { FLAGS: { type: "flagship", app_id: "my-app" } }, + true + ); + + expect(output).toContain("FLAGS"); + expect(output).toContain("Flagship"); + expect(output).toContain("my-app"); + expect(output).toContain("local"); + expect(output).not.toContain("remote"); + }); + + it("shows Flagship bindings as remote when `remote: true` is set", ({ + expect, + }) => { + const output = callPrintBindings( + { FLAGS: { type: "flagship", app_id: "my-app", remote: true } }, + true + ); + + expect(output).toContain("remote"); + expect(output).not.toContain("local"); + }); +}); + describe("printBindings -- Artifacts bindings", () => { it("shows Artifacts bindings", ({ expect }) => { const output = callPrintBindings({ @@ -234,6 +265,13 @@ describe("warnOrError", () => { }); describe("local-and-remote bindings", () => { + it("no longer warns that Flagship is always remote", ({ expect }) => { + expect(() => warnOrError("flagship", true)).not.toThrow(); + expect(() => warnOrError("flagship", false)).not.toThrow(); + expect(() => warnOrError("flagship", undefined)).not.toThrow(); + expect(std.warn).toBe(""); + }); + it("does not throw or warn for any `remote` value", ({ expect }) => { expect(() => warnOrError("kv_namespace", true)).not.toThrow(); expect(() => warnOrError("kv_namespace", false)).not.toThrow(); diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 68d18457e4f..e375e6c06a1 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -29,6 +29,7 @@ import type { Binding, CfD1Database, CfDispatchNamespace, + CfFlagship, CfHyperdrive, CfKvNamespace, CfPipeline, @@ -242,6 +243,20 @@ function kvNamespaceEntry( } return [binding, { id, remoteProxyConnectionString }]; } +function flagshipEntry( + { binding, app_id, remote }: CfFlagship, + remoteProxyConnectionString?: RemoteProxyConnectionString +): [ + string, + { app_id: string; remoteProxyConnectionString?: RemoteProxyConnectionString }, +] { + const id = getRemoteId(app_id) ?? binding; + if (!remoteProxyConnectionString || !remote) { + return [binding, { app_id: id }]; + } + return [binding, { app_id: id, remoteProxyConnectionString }]; +} + function r2BucketEntry( { binding, bucket_name, remote, local_dev }: CfR2Bucket, remoteProxyConnectionString?: RemoteProxyConnectionString @@ -904,13 +919,9 @@ export function buildMiniflareBindingOptions( helloWorldBindings.map((binding) => [binding.binding, binding]) ), flagship: Object.fromEntries( - flagshipBindings.map((binding) => [ - binding.binding, - { - app_id: getRemoteId(binding.app_id) ?? binding.binding, - remoteProxyConnectionString, - }, - ]) + flagshipBindings.map((binding) => + flagshipEntry(binding, remoteProxyConnectionString) + ) ), artifacts: Object.fromEntries( artifactsBindings.map((binding) => [ diff --git a/packages/wrangler/src/dev/start-dev.ts b/packages/wrangler/src/dev/start-dev.ts index 215524887d0..87f202c548a 100644 --- a/packages/wrangler/src/dev/start-dev.ts +++ b/packages/wrangler/src/dev/start-dev.ts @@ -385,6 +385,7 @@ function printLocalExplorerAgentHint(url: URL): void { GET ${explorerApiUrl}/r2/buckets - R2 buckets GET ${explorerApiUrl}/workers/durable_objects/namespaces - Durable Object namespaces GET ${explorerApiUrl}/workflows - Workflows + GET ${explorerApiUrl}/flagship/apps - Flagship apps POST ${explorerApiUrl}/local/observability/query - run a read-only SQL query (SELECT/WITH only) over captured request traces and console logs. Tables: spans, logs (read attributes via json(attributes)). Example: curl -X POST ${explorerApiUrl}/local/observability/query -H 'Content-Type: application/json' -d '{"sql":"SELECT service, name, outcome, duration_ms FROM spans WHERE parent_id IS NULL LIMIT 20"}' If the routes above don't cover what you need, fetch the full OpenAPI schema (large - use only as a last resort): diff --git a/packages/wrangler/src/flagship/client.ts b/packages/wrangler/src/flagship/client.ts index 84d399101fe..415c4aa52f1 100644 --- a/packages/wrangler/src/flagship/client.ts +++ b/packages/wrangler/src/flagship/client.ts @@ -96,7 +96,8 @@ export type EvaluationReason = | "TARGETING_MATCH" | "DEFAULT" | "DISABLED" - | "SPLIT"; + | "SPLIT" + | "ERROR"; export type EvaluationResult = { flagKey: string; diff --git a/packages/wrangler/src/flagship/flags/create.ts b/packages/wrangler/src/flagship/flags/create.ts index 6942cb8398b..9ecb218ef30 100644 --- a/packages/wrangler/src/flagship/flags/create.ts +++ b/packages/wrangler/src/flagship/flags/create.ts @@ -1,6 +1,5 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { createFlag } from "../client"; import { renderFlag } from "../render"; import { assertConsistentVariationTypes, @@ -10,6 +9,7 @@ import { parseRuleJson, parseRules, } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; import type { FlagType } from "../client"; export const flagshipFlagsCreateCommand = createCommand({ @@ -87,6 +87,7 @@ export const flagshipFlagsCreateCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { @@ -102,14 +103,16 @@ export const flagshipFlagsCreateCommand = createCommand({ ...parseRuleJson(args.ruleJson ?? []), ]); assertVariationsExist(variations, default_variation, rules); - const flag = await createFlag(config, appId, { - key, - description: args.description, - enabled: !args.disabled, - default_variation, - variations, - rules, - }); + const flag = await withFlagStore(args, config, appId, (store) => + store.createFlag({ + key, + description: args.description, + enabled: !args.disabled, + default_variation, + variations, + rules, + }) + ); if (args.json) { logger.json(flag); return; diff --git a/packages/wrangler/src/flagship/flags/delete.ts b/packages/wrangler/src/flagship/flags/delete.ts index 7ce172d3d19..264e89b7ff7 100644 --- a/packages/wrangler/src/flagship/flags/delete.ts +++ b/packages/wrangler/src/flagship/flags/delete.ts @@ -1,8 +1,8 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { runBulk } from "../bulk"; -import { deleteFlag } from "../client"; import { jsonFriendlyError } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; export const flagshipFlagsDeleteCommand = createCommand({ metadata: { @@ -36,6 +36,7 @@ export const flagshipFlagsDeleteCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config, confirm }) { @@ -56,9 +57,11 @@ export const flagshipFlagsDeleteCommand = createCommand({ return; } } - await runBulk(keys, (key) => deleteFlag(config, appId, key), { - json: args.json, - onSuccess: (_flag, key) => logger.log(`✅ Deleted flag '${key}'`), - }); + await withFlagStore(args, config, appId, (store) => + runBulk(keys, (key) => store.deleteFlag(key), { + json: args.json, + onSuccess: (_flag, key) => logger.log(`✅ Deleted flag '${key}'`), + }) + ); }, }); diff --git a/packages/wrangler/src/flagship/flags/enable.ts b/packages/wrangler/src/flagship/flags/enable.ts index fc11cdc817e..fd42de86f0d 100644 --- a/packages/wrangler/src/flagship/flags/enable.ts +++ b/packages/wrangler/src/flagship/flags/enable.ts @@ -1,8 +1,9 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; import { runBulk } from "../bulk"; -import { getFlag, toFlagInput, updateFlag } from "../client"; +import { toFlagInput } from "../client"; import { renderFlag } from "../render"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; function makeToggleCommand(enabled: boolean) { const verb = enabled ? "Enable" : "Disable"; @@ -32,26 +33,29 @@ function makeToggleCommand(enabled: boolean) { default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { const { appId, key: keys } = args; - await runBulk( - keys, - async (key) => { - const current = await getFlag(config, appId, key); - return updateFlag(config, appId, key, { - ...toFlagInput(current), - enabled, - }); - }, - { - json: args.json, - onSuccess: (flag) => { - logger.log(`✅ ${verb}d flag\n`); - logger.log(renderFlag(flag)); + await withFlagStore(args, config, appId, (store) => + runBulk( + keys, + async (key) => { + const current = await store.getFlag(key); + return store.updateFlag(key, { + ...toFlagInput(current), + enabled, + }); }, - } + { + json: args.json, + onSuccess: (flag) => { + logger.log(`✅ ${verb}d flag\n`); + logger.log(renderFlag(flag)); + }, + } + ) ); }, }); diff --git a/packages/wrangler/src/flagship/flags/evaluate.ts b/packages/wrangler/src/flagship/flags/evaluate.ts index 4a611c972d8..6c11f6c5e7c 100644 --- a/packages/wrangler/src/flagship/flags/evaluate.ts +++ b/packages/wrangler/src/flagship/flags/evaluate.ts @@ -1,8 +1,8 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { evaluateFlag } from "../client"; import { renderEvaluation } from "../render"; import { parseContext } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; export const flagshipFlagsEvaluateCommand = createCommand({ metadata: { @@ -46,6 +46,7 @@ export const flagshipFlagsEvaluateCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { @@ -54,7 +55,9 @@ export const flagshipFlagsEvaluateCommand = createCommand({ if (args.targetingKey) { context.targetingKey = args.targetingKey; } - const result = await evaluateFlag(config, appId, key, context); + const result = await withFlagStore(args, config, appId, (store) => + store.evaluateFlag(key, context) + ); if (args.json) { logger.json(result); return; diff --git a/packages/wrangler/src/flagship/flags/get.ts b/packages/wrangler/src/flagship/flags/get.ts index 60c1e1b8b5e..ccb61cdb184 100644 --- a/packages/wrangler/src/flagship/flags/get.ts +++ b/packages/wrangler/src/flagship/flags/get.ts @@ -1,7 +1,7 @@ import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getFlag } from "../client"; import { renderFlag } from "../render"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; export const flagshipFlagsGetCommand = createCommand({ metadata: { @@ -28,10 +28,14 @@ export const flagshipFlagsGetCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], - async handler({ appId, key, json }, { config }) { - const flag = await getFlag(config, appId, key); + async handler(args, { config }) { + const { appId, key, json } = args; + const flag = await withFlagStore(args, config, appId, (store) => + store.getFlag(key) + ); if (json) { logger.json(flag); return; diff --git a/packages/wrangler/src/flagship/flags/list.ts b/packages/wrangler/src/flagship/flags/list.ts index 7ae2ee81dc3..295e6d34003 100644 --- a/packages/wrangler/src/flagship/flags/list.ts +++ b/packages/wrangler/src/flagship/flags/list.ts @@ -2,9 +2,9 @@ import { dim } from "@cloudflare/cli-shared-helpers/colors"; import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { listAllFlags, listFlags } from "../client"; import { statusBadge } from "../render"; import { validateLimit } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; import type { Flag } from "../client"; export const flagshipFlagsListCommand = createCommand({ @@ -40,9 +40,11 @@ export const flagshipFlagsListCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id"], - async handler({ appId, limit, cursor, all, json }, { config }) { + async handler(args, { config }) { + const { appId, limit, cursor, all, json } = args; if (all && (limit !== undefined || cursor !== undefined)) { throw new UserError( "Cannot use --all together with --limit or --cursor.", @@ -50,15 +52,15 @@ export const flagshipFlagsListCommand = createCommand({ ); } validateLimit(limit, "flagship list invalid limit"); - let items: Flag[]; - let nextCursor: string | null = null; - if (all) { - items = await listAllFlags(config, appId); - } else { - const page = await listFlags(config, appId, limit, cursor); - items = page.items; - nextCursor = page.cursor; - } + const { items, cursor: nextCursor } = await withFlagStore( + args, + config, + appId, + async (store): Promise<{ items: Flag[]; cursor: string | null }> => + all + ? { items: await store.listAllFlags(), cursor: null } + : store.listFlags(limit, cursor) + ); if (json) { logger.json({ items, cursor: nextCursor }); return; diff --git a/packages/wrangler/src/flagship/flags/pull.ts b/packages/wrangler/src/flagship/flags/pull.ts new file mode 100644 index 00000000000..5179970ace6 --- /dev/null +++ b/packages/wrangler/src/flagship/flags/pull.ts @@ -0,0 +1,79 @@ +import { dim } from "@cloudflare/cli-shared-helpers/colors"; +import { createCommand } from "../../core/create-command"; +import { logger } from "../../logger"; +import { requireAuth } from "../../user"; +import { listAllFlags, toFlagInput } from "../client"; +import { usingLocalFlagshipAPI } from "../store"; + +export const flagshipFlagsPullCommand = createCommand({ + metadata: { + description: + "Pull feature flags from a Flagship app into the local flag store", + status: "open beta", + owner: "Product: Flagship", + }, + behaviour: { + printBanner: (args) => !args.json, + }, + args: { + "app-id": { + type: "string", + demandOption: true, + description: "The ID of the app to pull flags from", + }, + "persist-to": { + type: "string", + description: "Specify directory to use for local persistence", + requiresArg: true, + }, + json: { + type: "boolean", + default: false, + description: "Return output as JSON", + }, + }, + positionalArgs: ["app-id"], + async handler({ appId, persistTo, json }, { config }) { + const accountId = await requireAuth(config); + const flags = await listAllFlags(config, appId); + + const localOnly = await usingLocalFlagshipAPI( + persistTo, + config, + appId, + async (admin) => { + const pulledKeys = new Set(flags.map((flag) => flag.key)); + const existing = await admin.listFlags(); + await admin.putFlags(flags.map(toFlagInput), accountId); + return existing + .map((flag) => flag.key) + .filter((key) => !pulledKeys.has(key)); + } + ); + + if (json) { + logger.json({ + appId, + pulled: flags.map((flag) => flag.key), + localOnly, + }); + return; + } + + logger.log( + `Pulled ${flags.length} flag${flags.length === 1 ? "" : "s"} from ${appId} into the local flag store.` + ); + if (localOnly.length > 0) { + logger.log( + dim( + `\nLeft ${localOnly.length} local-only flag${localOnly.length === 1 ? "" : "s"} untouched: ${localOnly.join(", ")}` + ) + ); + logger.log( + dim( + "These do not exist in the remote app, either because they were created locally or because they were deleted remotely." + ) + ); + } + }, +}); diff --git a/packages/wrangler/src/flagship/flags/rollout.ts b/packages/wrangler/src/flagship/flags/rollout.ts index 6280e6b7190..eede5d892b5 100644 --- a/packages/wrangler/src/flagship/flags/rollout.ts +++ b/packages/wrangler/src/flagship/flags/rollout.ts @@ -1,9 +1,10 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getFlag, toFlagInput, updateFlag } from "../client"; +import { toFlagInput } from "../client"; import { renderFlag } from "../render"; import { confirmRuleReplacement } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; export const flagshipFlagsRolloutCommand = createCommand({ metadata: { @@ -64,6 +65,7 @@ export const flagshipFlagsRolloutCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config, confirm }) { @@ -77,48 +79,53 @@ export const flagshipFlagsRolloutCommand = createCommand({ telemetryMessage: "flagship rollout invalid percentage", }); } - const current = await getFlag(config, appId, key); - if (!(args.to in current.variations)) { - throw new UserError( - `Unknown variation "${args.to}". Available variations: ${Object.keys(current.variations).join(", ")}`, - { telemetryMessage: "flagship rollout unknown variation" } - ); - } - const clearing = args.percentage === 0; - const fallback = args.fromVariation ?? current.default_variation; - if (!clearing && !(fallback in current.variations)) { - throw new UserError( - `Unknown fallback variation "${fallback}". Available variations: ${Object.keys(current.variations).join(", ")}`, - { telemetryMessage: "flagship rollout unknown fallback variation" } - ); - } - const confirmed = await confirmRuleReplacement(current.rules, { - json: args.json, - force: args.force, - action: "rollout", - confirm, + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); + if (!(args.to in current.variations)) { + throw new UserError( + `Unknown variation "${args.to}". Available variations: ${Object.keys(current.variations).join(", ")}`, + { telemetryMessage: "flagship rollout unknown variation" } + ); + } + const clearing = args.percentage === 0; + const fallback = args.fromVariation ?? current.default_variation; + if (!clearing && !(fallback in current.variations)) { + throw new UserError( + `Unknown fallback variation "${fallback}". Available variations: ${Object.keys(current.variations).join(", ")}`, + { telemetryMessage: "flagship rollout unknown fallback variation" } + ); + } + const confirmed = await confirmRuleReplacement(current.rules, { + json: args.json, + force: args.force, + action: "rollout", + confirm, + }); + if (!confirmed) { + return undefined; + } + return store.updateFlag(key, { + ...toFlagInput(current), + default_variation: clearing ? current.default_variation : fallback, + rules: clearing + ? [] + : [ + { + priority: 1, + conditions: [], + serve_variation: args.to, + rollout: { + percentage: args.percentage, + attribute: args.by, + }, + }, + ], + }); }); - if (!confirmed) { + if (flag === undefined) { logger.log("Aborting rollout."); return; } - const flag = await updateFlag(config, appId, key, { - ...toFlagInput(current), - default_variation: clearing ? current.default_variation : fallback, - rules: clearing - ? [] - : [ - { - priority: 1, - conditions: [], - serve_variation: args.to, - rollout: { - percentage: args.percentage, - attribute: args.by, - }, - }, - ], - }); if (args.json) { logger.json(flag); return; diff --git a/packages/wrangler/src/flagship/flags/rules/delete.ts b/packages/wrangler/src/flagship/flags/rules/delete.ts index 2b8486eedf6..587d18f1d0b 100644 --- a/packages/wrangler/src/flagship/flags/rules/delete.ts +++ b/packages/wrangler/src/flagship/flags/rules/delete.ts @@ -1,8 +1,9 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../../core/create-command"; import { logger } from "../../../logger"; -import { getFlag, toFlagInput, updateFlag } from "../../client"; +import { toFlagInput } from "../../client"; import { renderFlag } from "../../render"; +import { flagStoreArgDefinitions, withFlagStore } from "../../store"; import { withoutRule } from "./shared"; export const flagshipFlagsRulesDeleteCommand = createCommand({ @@ -35,6 +36,7 @@ export const flagshipFlagsRulesDeleteCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { @@ -44,11 +46,12 @@ export const flagshipFlagsRulesDeleteCommand = createCommand({ }); } const { appId, key } = args; - const current = await getFlag(config, appId, key); - const rules = withoutRule(current.rules, args.priority); - const flag = await updateFlag(config, appId, key, { - ...toFlagInput(current), - rules, + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); + return store.updateFlag(key, { + ...toFlagInput(current), + rules: withoutRule(current.rules, args.priority), + }); }); if (args.json) { logger.json(flag); diff --git a/packages/wrangler/src/flagship/flags/rules/list.ts b/packages/wrangler/src/flagship/flags/rules/list.ts index 89e0e29aa1e..1f6611cff0e 100644 --- a/packages/wrangler/src/flagship/flags/rules/list.ts +++ b/packages/wrangler/src/flagship/flags/rules/list.ts @@ -1,6 +1,6 @@ import { createCommand } from "../../../core/create-command"; import { logger } from "../../../logger"; -import { getFlag } from "../../client"; +import { flagStoreArgDefinitions, withFlagStore } from "../../store"; import { sortedRules, stringifyConditions, stringifyRollout } from "./shared"; export const flagshipFlagsRulesListCommand = createCommand({ @@ -28,10 +28,14 @@ export const flagshipFlagsRulesListCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], - async handler({ appId, key, json }, { config }) { - const flag = await getFlag(config, appId, key); + async handler(args, { config }) { + const { appId, key, json } = args; + const flag = await withFlagStore(args, config, appId, (store) => + store.getFlag(key) + ); const rules = sortedRules(flag.rules); if (json) { logger.json(rules); diff --git a/packages/wrangler/src/flagship/flags/rules/reorder.ts b/packages/wrangler/src/flagship/flags/rules/reorder.ts index a2ba7e74a7d..af4a2077eeb 100644 --- a/packages/wrangler/src/flagship/flags/rules/reorder.ts +++ b/packages/wrangler/src/flagship/flags/rules/reorder.ts @@ -1,8 +1,9 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../../core/create-command"; import { logger } from "../../../logger"; -import { getFlag, toFlagInput, updateFlag } from "../../client"; +import { toFlagInput } from "../../client"; import { renderFlag } from "../../render"; +import { flagStoreArgDefinitions, withFlagStore } from "../../store"; import { sortedRules } from "./shared"; import type { Rule } from "../../client"; @@ -45,15 +46,17 @@ export const flagshipFlagsRulesReorderCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { const { appId, key } = args; - const current = await getFlag(config, appId, key); - const rules = reorderRules(current.rules, args.order); - const flag = await updateFlag(config, appId, key, { - ...toFlagInput(current), - rules, + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); + return store.updateFlag(key, { + ...toFlagInput(current), + rules: reorderRules(current.rules, args.order), + }); }); if (args.json) { logger.json(flag); diff --git a/packages/wrangler/src/flagship/flags/rules/update.ts b/packages/wrangler/src/flagship/flags/rules/update.ts index c9b07a3c584..21393809867 100644 --- a/packages/wrangler/src/flagship/flags/rules/update.ts +++ b/packages/wrangler/src/flagship/flags/rules/update.ts @@ -1,13 +1,14 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../../core/create-command"; import { logger } from "../../../logger"; -import { getFlag, toFlagInput, updateFlag } from "../../client"; +import { toFlagInput } from "../../client"; import { renderFlag } from "../../render"; import { assertVariationsExist, parseConditions, parseRollout, } from "../../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../../store"; import { findRule } from "./shared"; import type { Rule } from "../../client"; @@ -72,6 +73,7 @@ export const flagshipFlagsRulesUpdateCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { @@ -106,30 +108,36 @@ export const flagshipFlagsRulesUpdateCommand = createCommand({ } const { appId, key } = args; - const current = await getFlag(config, appId, key); - findRule(current.rules, args.priority); - const rules: Rule[] = current.rules.map((rule) => - rule.priority === args.priority - ? { - ...rule, - serve_variation: args.serve ?? rule.serve_variation, - conditions: args.clearConditions - ? [] - : args.when !== undefined - ? parseConditions(args.when) - : rule.conditions, - rollout: args.clearRollout - ? undefined - : args.rollout !== undefined - ? parseRollout(args.rollout) - : rule.rollout, - } - : rule - ); - assertVariationsExist(current.variations, current.default_variation, rules); - const flag = await updateFlag(config, appId, key, { - ...toFlagInput(current), - rules, + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); + findRule(current.rules, args.priority); + const rules: Rule[] = current.rules.map((rule) => + rule.priority === args.priority + ? { + ...rule, + serve_variation: args.serve ?? rule.serve_variation, + conditions: args.clearConditions + ? [] + : args.when !== undefined + ? parseConditions(args.when) + : rule.conditions, + rollout: args.clearRollout + ? undefined + : args.rollout !== undefined + ? parseRollout(args.rollout) + : rule.rollout, + } + : rule + ); + assertVariationsExist( + current.variations, + current.default_variation, + rules + ); + return store.updateFlag(key, { + ...toFlagInput(current), + rules, + }); }); if (args.json) { logger.json(flag); diff --git a/packages/wrangler/src/flagship/flags/set.ts b/packages/wrangler/src/flagship/flags/set.ts index 16ea1d43e38..25de835591d 100644 --- a/packages/wrangler/src/flagship/flags/set.ts +++ b/packages/wrangler/src/flagship/flags/set.ts @@ -1,8 +1,9 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getFlag, toFlagInput, updateFlag } from "../client"; +import { toFlagInput } from "../client"; import { renderFlag } from "../render"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; export const flagshipFlagsSetCommand = createCommand({ metadata: { @@ -40,21 +41,24 @@ export const flagshipFlagsSetCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { const { appId, key } = args; - const current = await getFlag(config, appId, key); - if (!(args.variation in current.variations)) { - throw new UserError( - `Unknown variation "${args.variation}". Available variations: ${Object.keys(current.variations).join(", ")}`, - { telemetryMessage: "flagship set unknown variation" } - ); - } - const flag = await updateFlag(config, appId, key, { - ...toFlagInput(current), - default_variation: args.variation, - rules: args.clearRules ? [] : current.rules, + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); + if (!(args.variation in current.variations)) { + throw new UserError( + `Unknown variation "${args.variation}". Available variations: ${Object.keys(current.variations).join(", ")}`, + { telemetryMessage: "flagship set unknown variation" } + ); + } + return store.updateFlag(key, { + ...toFlagInput(current), + default_variation: args.variation, + rules: args.clearRules ? [] : current.rules, + }); }); if (args.json) { logger.json(flag); diff --git a/packages/wrangler/src/flagship/flags/split.ts b/packages/wrangler/src/flagship/flags/split.ts index aafeefae003..5f17af94946 100644 --- a/packages/wrangler/src/flagship/flags/split.ts +++ b/packages/wrangler/src/flagship/flags/split.ts @@ -1,9 +1,10 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getFlag, toFlagInput, updateFlag } from "../client"; +import { toFlagInput } from "../client"; import { renderFlag } from "../render"; import { confirmRuleReplacement, parseWeights } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; import type { Rule } from "../client"; export const flagshipFlagsSplitCommand = createCommand({ @@ -61,57 +62,63 @@ export const flagshipFlagsSplitCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config, confirm }) { const { appId, key } = args; - const current = await getFlag(config, appId, key); - const weights = parseWeights(args.weight); - for (const variation of Object.keys(weights)) { - if (!(variation in current.variations)) { - throw new UserError( - `Unknown variation "${variation}". Available variations: ${Object.keys(current.variations).join(", ")}`, - { telemetryMessage: "flagship split unknown variation" } - ); + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); + const weights = parseWeights(args.weight); + for (const variation of Object.keys(weights)) { + if (!(variation in current.variations)) { + throw new UserError( + `Unknown variation "${variation}". Available variations: ${Object.keys(current.variations).join(", ")}`, + { telemetryMessage: "flagship split unknown variation" } + ); + } } - } - const total = Object.values(weights).reduce( - (sum, weight) => sum + weight, - 0 - ); - let cumulative = 0; - let priority = 1; - const rules: Rule[] = []; - for (const [variation, weight] of Object.entries(weights)) { - if (weight === 0) { - continue; + const total = Object.values(weights).reduce( + (sum, weight) => sum + weight, + 0 + ); + let cumulative = 0; + let priority = 1; + const rules: Rule[] = []; + for (const [variation, weight] of Object.entries(weights)) { + if (weight === 0) { + continue; + } + cumulative += (weight / total) * 100; + rules.push({ + priority: priority++, + conditions: [], + serve_variation: variation, + rollout: { + percentage: Math.min(100, Number(cumulative.toFixed(6))), + attribute: args.by, + }, + }); } - cumulative += (weight / total) * 100; - rules.push({ - priority: priority++, - conditions: [], - serve_variation: variation, - rollout: { - percentage: Math.min(100, Number(cumulative.toFixed(6))), - attribute: args.by, - }, + const confirmed = await confirmRuleReplacement(current.rules, { + json: args.json, + force: args.force, + action: "split", + confirm, + }); + if (!confirmed) { + return undefined; + } + return store.updateFlag(key, { + ...toFlagInput(current), + default_variation: args.defaultVariation ?? current.default_variation, + rules, }); - } - const confirmed = await confirmRuleReplacement(current.rules, { - json: args.json, - force: args.force, - action: "split", - confirm, }); - if (!confirmed) { + if (flag === undefined) { logger.log("Aborting split."); return; } - const flag = await updateFlag(config, appId, key, { - ...toFlagInput(current), - default_variation: args.defaultVariation ?? current.default_variation, - rules, - }); if (args.json) { logger.json(flag); return; diff --git a/packages/wrangler/src/flagship/flags/update.ts b/packages/wrangler/src/flagship/flags/update.ts index 1209b3601a1..e6e252893d7 100644 --- a/packages/wrangler/src/flagship/flags/update.ts +++ b/packages/wrangler/src/flagship/flags/update.ts @@ -1,7 +1,6 @@ import { UserError } from "@cloudflare/workers-utils"; import { createCommand } from "../../core/create-command"; import { logger } from "../../logger"; -import { getFlag, updateFlag } from "../client"; import { renderFlag } from "../render"; import { assertConsistentVariationTypes, @@ -11,6 +10,7 @@ import { parseRules, parseVariations, } from "../shared"; +import { flagStoreArgDefinitions, withFlagStore } from "../store"; import type { FlagType, Rule } from "../client"; export const flagshipFlagsUpdateCommand = createCommand({ @@ -123,77 +123,79 @@ export const flagshipFlagsUpdateCommand = createCommand({ default: false, description: "Return output as JSON", }, + ...flagStoreArgDefinitions, }, positionalArgs: ["app-id", "key"], async handler(args, { config }) { const { appId, key } = args; - const current = await getFlag(config, appId, key); + const flag = await withFlagStore(args, config, appId, async (store) => { + const current = await store.getFlag(key); - const variations = { ...current.variations }; - for (const [name, value] of Object.entries( - parseVariations(args.setVariation ?? [], args.type as FlagType) - )) { - variations[name] = value; - } - for (const name of args.removeVariation ?? []) { - delete variations[name]; - } - assertConsistentVariationTypes(variations); - - const replacementRules = [ - ...parseRules(args.rule ?? []), - ...parseRuleJson(args.ruleJson ?? []), - ]; - const addedRules = [ - ...parseRules(args.addRule ?? []), - ...parseRuleJson(args.addRuleJson ?? []), - ]; - if ( - args.clearRules && - (replacementRules.length > 0 || addedRules.length > 0) - ) { - throw new UserError( - "Cannot use --clear-rules together with --rule, --rule-json, --add-rule, or --add-rule-json.", - { telemetryMessage: "flagship update conflicting rule flags" } - ); - } - if (replacementRules.length > 0 && addedRules.length > 0) { - throw new UserError( - "Cannot replace rules (--rule/--rule-json) and append rules (--add-rule/--add-rule-json) in the same command.", - { telemetryMessage: "flagship update conflicting rule flags" } - ); - } + const variations = { ...current.variations }; + for (const [name, value] of Object.entries( + parseVariations(args.setVariation ?? [], args.type as FlagType) + )) { + variations[name] = value; + } + for (const name of args.removeVariation ?? []) { + delete variations[name]; + } + assertConsistentVariationTypes(variations); - let rules: Rule[] = current.rules; - if (args.clearRules) { - rules = []; - } else if (replacementRules.length > 0) { - rules = finalizeRules(replacementRules); - } else if (addedRules.length > 0) { - rules = [ - ...current.rules, - ...finalizeRules(addedRules, { existing: current.rules }), + const replacementRules = [ + ...parseRules(args.rule ?? []), + ...parseRuleJson(args.ruleJson ?? []), ]; - } + const addedRules = [ + ...parseRules(args.addRule ?? []), + ...parseRuleJson(args.addRuleJson ?? []), + ]; + if ( + args.clearRules && + (replacementRules.length > 0 || addedRules.length > 0) + ) { + throw new UserError( + "Cannot use --clear-rules together with --rule, --rule-json, --add-rule, or --add-rule-json.", + { telemetryMessage: "flagship update conflicting rule flags" } + ); + } + if (replacementRules.length > 0 && addedRules.length > 0) { + throw new UserError( + "Cannot replace rules (--rule/--rule-json) and append rules (--add-rule/--add-rule-json) in the same command.", + { telemetryMessage: "flagship update conflicting rule flags" } + ); + } - const default_variation = - args.defaultVariation ?? current.default_variation; - assertVariationsExist(variations, default_variation, rules); + let rules: Rule[] = current.rules; + if (args.clearRules) { + rules = []; + } else if (replacementRules.length > 0) { + rules = finalizeRules(replacementRules); + } else if (addedRules.length > 0) { + rules = [ + ...current.rules, + ...finalizeRules(addedRules, { existing: current.rules }), + ]; + } - const flag = await updateFlag(config, appId, key, { - key: current.key, - description: - args.description === undefined - ? current.description - : args.description === "" - ? null - : args.description, - enabled: args.enable ? true : args.disable ? false : current.enabled, - default_variation, - variations, - rules, - }); + const default_variation = + args.defaultVariation ?? current.default_variation; + assertVariationsExist(variations, default_variation, rules); + return store.updateFlag(key, { + key: current.key, + description: + args.description === undefined + ? current.description + : args.description === "" + ? null + : args.description, + enabled: args.enable ? true : args.disable ? false : current.enabled, + default_variation, + variations, + rules, + }); + }); if (args.json) { logger.json(flag); return; diff --git a/packages/wrangler/src/flagship/render.ts b/packages/wrangler/src/flagship/render.ts index ef3b74392c4..bacc593690a 100644 --- a/packages/wrangler/src/flagship/render.ts +++ b/packages/wrangler/src/flagship/render.ts @@ -5,6 +5,7 @@ import { dim, gray, green, + red, white, } from "@cloudflare/cli-shared-helpers/colors"; import type { App, ChangelogEntry, Condition, Flag, Rule } from "./client"; @@ -117,9 +118,13 @@ export function renderChangelogEntry(entry: ChangelogEntry): string { } export function renderEvaluation(result: EvaluationResult): string { - const reason = result.reason - ? (result.reason === "DISABLED" ? gray : brandColor)(result.reason) - : dim("(unknown)"); + const reasonColor = + result.reason === "DISABLED" + ? gray + : result.reason === "ERROR" + ? red + : brandColor; + const reason = result.reason ? reasonColor(result.reason) : dim("(unknown)"); return [ `🚩 ${bold(white(result.flagKey))} ${brandColor("evaluated")}`, `${INDENT}${gray("Value")} ${formatValue(result.value)}`, diff --git a/packages/wrangler/src/flagship/store.ts b/packages/wrangler/src/flagship/store.ts new file mode 100644 index 00000000000..b280ad44db1 --- /dev/null +++ b/packages/wrangler/src/flagship/store.ts @@ -0,0 +1,182 @@ +import { UserError } from "@cloudflare/workers-utils"; +import { convertV4MiniflareOptions, Miniflare } from "miniflare"; +import { getLocalPersistencePath } from "../dev/get-local-persistence-path"; +import { getDefaultPersistRoot } from "../dev/miniflare"; +import { + createFlag, + deleteFlag, + evaluateFlag, + getFlag, + listAllFlags, + listFlags, + updateFlag, +} from "./client"; +import type { EvaluationResult, Flag, FlagInput, Page } from "./client"; +import type { Config } from "@cloudflare/workers-utils"; +import type { FlagshipAdmin } from "miniflare"; + +export interface FlagStore { + listFlags(limit?: number, cursor?: string): Promise>; + listAllFlags(): Promise; + getFlag(flagKey: string): Promise; + createFlag(flag: FlagInput): Promise; + updateFlag(flagKey: string, flag: FlagInput): Promise; + deleteFlag(flagKey: string): Promise<{ key: string }>; + evaluateFlag( + flagKey: string, + context: Record + ): Promise; +} + +export interface FlagStoreArgs { + local?: boolean; + remote?: boolean; + persistTo?: string; +} + +export const flagStoreArgDefinitions = { + local: { + type: "boolean", + description: "Use the local flag store instead of the remote app", + }, + remote: { + type: "boolean", + description: "Use the remote app instead of the local flag store", + }, + "persist-to": { + type: "string", + description: "Specify directory to use for local persistence", + requiresArg: true, + }, +} as const; + +export function useLocalStore(args: FlagStoreArgs): boolean { + if (args.local === true && args.remote === true) { + throw new UserError( + "Cannot use --local and --remote together. Choose the local flag store or the remote app.", + { telemetryMessage: "flagship local and remote conflict" } + ); + } + return args.local === true || args.remote === false; +} + +export async function usingLocalFlagshipAPI( + persistTo: string | undefined, + config: Config, + appId: string, + callback: (admin: FlagshipAdmin) => Promise +): Promise { + const mf = new Miniflare( + convertV4MiniflareOptions({ + script: + 'addEventListener("fetch", (e) => e.respondWith(new Response(null, { status: 404 })))', + resourcePersistencePath: getDefaultPersistRoot( + getLocalPersistencePath(persistTo, config) + ), + flagship: { FLAGS: { app_id: appId } }, + }) + ); + try { + const binding = await mf.getFlagshipBindingAPI("FLAGS"); + const result = await callback(binding()); + return result === undefined + ? result + : (JSON.parse(JSON.stringify(result)) as T); + } finally { + await mf.dispose(); + } +} + +function remoteStore(config: Config, appId: string): FlagStore { + return { + listFlags: (limit, cursor) => listFlags(config, appId, limit, cursor), + listAllFlags: () => listAllFlags(config, appId), + getFlag: (flagKey) => getFlag(config, appId, flagKey), + createFlag: (flag) => createFlag(config, appId, flag), + updateFlag: (flagKey, flag) => updateFlag(config, appId, flagKey, flag), + deleteFlag: (flagKey) => deleteFlag(config, appId, flagKey), + evaluateFlag: (flagKey, context) => + evaluateFlag(config, appId, flagKey, context), + }; +} + +async function localOperation( + telemetryMessage: string, + operation: () => Promise +): Promise { + try { + return await operation(); + } catch (error) { + throw new UserError( + error instanceof Error ? error.message : String(error), + { telemetryMessage } + ); + } +} + +function localStore(admin: FlagshipAdmin): FlagStore { + return { + listFlags: async (limit, cursor) => { + if (cursor !== undefined) { + throw new UserError( + "The local flag store is not paginated, so --cursor cannot be used with --local.", + { telemetryMessage: "flagship local store cursor unsupported" } + ); + } + const items = await localOperation( + "flagship local store list failed", + () => admin.listFlags() + ); + return { + items: limit === undefined ? items : items.slice(0, limit), + cursor: null, + }; + }, + listAllFlags: () => + localOperation("flagship local store list failed", () => + admin.listFlags() + ), + getFlag: (flagKey) => + localOperation("flagship local store get failed", () => + admin.getFlag(flagKey) + ), + createFlag: (flag) => + localOperation("flagship local store create failed", () => + admin.createFlag(flag) + ), + updateFlag: (flagKey, flag) => + localOperation("flagship local store update failed", () => + admin.updateFlag(flagKey, flag) + ), + deleteFlag: async (flagKey) => { + await localOperation("flagship local store delete failed", () => + admin.deleteFlag(flagKey) + ); + return { key: flagKey }; + }, + evaluateFlag: (flagKey, context) => + localOperation("flagship local store evaluate failed", () => + admin.evaluateFlag(flagKey, context) + ), + }; +} + +export async function withFlagStore( + args: FlagStoreArgs, + config: Config, + appId: string, + closure: (store: FlagStore) => Promise +): Promise { + if (!useLocalStore(args)) { + if (args.persistTo !== undefined) { + throw new UserError( + "Cannot use --persist-to without --local. The --persist-to flag specifies a local persistence directory, which requires the --local flag.", + { telemetryMessage: "flagship persist-to requires local" } + ); + } + return closure(remoteStore(config, appId)); + } + return usingLocalFlagshipAPI(args.persistTo, config, appId, (admin) => + closure(localStore(admin)) + ); +} diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 710956e00ac..8e3ab7420f4 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -191,6 +191,7 @@ import { import { flagshipFlagsEvaluateCommand } from "./flagship/flags/evaluate"; import { flagshipFlagsGetCommand } from "./flagship/flags/get"; import { flagshipFlagsListCommand } from "./flagship/flags/list"; +import { flagshipFlagsPullCommand } from "./flagship/flags/pull"; import { flagshipFlagsRolloutCommand } from "./flagship/flags/rollout"; import { flagshipFlagsRulesDeleteCommand } from "./flagship/flags/rules/delete"; import { flagshipFlagsRulesListCommand } from "./flagship/flags/rules/list"; @@ -1642,6 +1643,10 @@ export function createCLIParser(argv: string[]) { command: "wrangler flagship flags get", definition: flagshipFlagsGetCommand, }, + { + command: "wrangler flagship flags pull", + definition: flagshipFlagsPullCommand, + }, { command: "wrangler flagship flags inspect", definition: flagshipFlagsGetAlias,