import type * as cf from "@cloudflare/workers-types"; import * as flagship from "@distilled.cloud/cloudflare/flagship"; import * as Effect from "effect/Effect"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import type { RuntimeContext } from "../../RuntimeContext.ts"; import type { Credentials } from "../Credentials.ts"; import { type EvaluationContext, type EvaluationDetails, FlagshipError, type ReadFlagsClient, } from "./ReadFlags.ts"; // Shared HTTP scaffolding for the Flagship `ReadFlags` binding. NOT // re-exported from `index.ts` — only the contract and the impl layers are // public. A future token-scoped `ReadFlagsHttp` layer reuses this builder with // an auth minted from an `AccountApiToken`; `ReadFlagsLocal` builds the auth // from the ambient current-credentials context. /** * Injectable auth shared by the Local (current-credentials) impl and a future * Http (scoped-token) impl. `authorize` discharges the * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the * Cloudflare account the ops run against. */ export interface FlagshipAuth { authorize: ( eff: Effect.Effect, ) => Effect.Effect; accountId: string; } /** * The HTTP evaluate endpoint only supports a single `targetingKey` query * param, not the full flat evaluation context the Worker binding accepts. */ const targetingKeyOf = ( context: EvaluationContext | undefined, ): string | undefined => { const value = context?.["targetingKey"]; return value === undefined ? undefined : String(value); }; /** * Build a {@link ReadFlagsClient} over the Flagship HTTP evaluate endpoint * (`GET .../flagship/apps/{appId}/evaluate`). * * `appId` is an Effect so the resolution stays deferred to each call — inside * an Action it resolves through the apply-time RuntimeContext. Mirrors the * Worker binding's fall-back-to-default semantics: evaluation never fails the * effect — an HTTP error or a value whose type does not match the requested * method resolves to `defaultValue` instead. The `raw` runtime binding has no * HTTP equivalent and dies if used. */ export const makeHttpFlagshipClient = ( auth: FlagshipAuth, appId: Effect.Effect, ): ReadFlagsClient => { const evaluate = (flagKey: string, context?: EvaluationContext) => appId.pipe( Effect.flatMap((id) => auth.authorize( flagship.getAppEvaluate({ accountId: auth.accountId, appId: id, flagKey, targetingKey: targetingKeyOf(context), }), ), ), ); const details = ( flagKey: string, defaultValue: T, match: (value: unknown) => value is T, context?: EvaluationContext, ): Effect.Effect, FlagshipError, RuntimeContext> => evaluate(flagKey, context).pipe( Effect.map((r): EvaluationDetails => match(r.value) ? { flagKey, value: r.value, variant: r.variant, reason: r.reason } : { flagKey, value: defaultValue, variant: r.variant, reason: r.reason, errorCode: "TYPE_MISMATCH", }, ), Effect.catch((error) => Effect.succeed>({ flagKey, value: defaultValue, reason: "ERROR", errorCode: error._tag, }), ), ); const value = ( flagKey: string, defaultValue: T, match: (value: unknown) => value is T, context?: EvaluationContext, ): Effect.Effect => evaluate(flagKey, context).pipe( Effect.map((r) => (match(r.value) ? r.value : defaultValue)), Effect.catch(() => Effect.succeed(defaultValue)), ); const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"; const isString = (v: unknown): v is string => typeof v === "string"; const isNumber = (v: unknown): v is number => typeof v === "number"; const isObjectLike = (v: unknown): boolean => v !== null && typeof v === "object"; return { // The raw runtime binding is a workerd object with no HTTP surface. raw: Effect.die( new FlagshipError({ message: "the raw Flagship runtime binding is unavailable over HTTP; use ReadFlagsBinding inside a Worker", cause: undefined, }), ) as Effect.Effect, get: (flagKey, defaultValue, context) => evaluate(flagKey, context).pipe( Effect.map((r) => r.value ?? defaultValue), Effect.catch(() => Effect.succeed(defaultValue)), ), getBooleanValue: (flagKey, defaultValue, context) => value(flagKey, defaultValue, isBoolean, context), getStringValue: (flagKey, defaultValue, context) => value(flagKey, defaultValue, isString, context), getNumberValue: (flagKey, defaultValue, context) => value(flagKey, defaultValue, isNumber, context), getObjectValue: (flagKey, defaultValue, context) => evaluate(flagKey, context).pipe( Effect.map((r) => isObjectLike(r.value) ? (r.value as typeof defaultValue) : defaultValue, ), Effect.catch(() => Effect.succeed(defaultValue)), ), getBooleanDetails: (flagKey, defaultValue, context) => details(flagKey, defaultValue, isBoolean, context), getStringDetails: (flagKey, defaultValue, context) => details(flagKey, defaultValue, isString, context), getNumberDetails: (flagKey, defaultValue, context) => details(flagKey, defaultValue, isNumber, context), getObjectDetails: (flagKey, defaultValue, context) => evaluate(flagKey, context).pipe( Effect.map((r): EvaluationDetails => isObjectLike(r.value) ? { flagKey, value: r.value as typeof defaultValue, variant: r.variant, reason: r.reason, } : { flagKey, value: defaultValue, variant: r.variant, reason: r.reason, errorCode: "TYPE_MISMATCH", }, ), Effect.catch((error) => Effect.succeed>({ flagKey, value: defaultValue, reason: "ERROR", errorCode: error._tag, }), ), ), } satisfies ReadFlagsClient; };