| 1 | import type * as cf from "@cloudflare/workers-types"; |
| 2 | import * as flagship from "@distilled.cloud/cloudflare/flagship"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import type * as HttpClient from "effect/http/HttpClient"; |
| 5 | import type { RuntimeContext } from "../../RuntimeContext.ts"; |
| 6 | import type { Credentials } from "../Credentials.ts"; |
| 7 | import { |
| 8 | type EvaluationContext, |
| 9 | type EvaluationDetails, |
| 10 | FlagshipError, |
| 11 | type ReadFlagsClient, |
| 12 | } from "./ReadFlags.ts"; |
| 13 | |
| 14 | // Shared HTTP scaffolding for the Flagship `ReadFlags` binding. NOT |
| 15 | // re-exported from `index.ts` — only the contract and the impl layers are |
| 16 | // public. A future token-scoped `ReadFlagsHttp` layer reuses this builder with |
| 17 | // an auth minted from an `AccountApiToken`; `ReadFlagsLocal` builds the auth |
| 18 | // from the ambient current-credentials context. |
| 19 | |
| 20 | /** |
| 21 | * Injectable auth shared by the Local (current-credentials) impl and a future |
| 22 | * Http (scoped-token) impl. `authorize` discharges the |
| 23 | * `Credentials | HttpClient` requirement of a distilled op; `accountId` is the |
| 24 | * Cloudflare account the ops run against. |
| 25 | */ |
| 26 | export interface FlagshipAuth { |
| 27 | authorize: <A, E>( |
| 28 | eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>, |
| 29 | ) => Effect.Effect<A, E>; |
| 30 | accountId: string; |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * The HTTP evaluate endpoint only supports a single `targetingKey` query |
| 35 | * param, not the full flat evaluation context the Worker binding accepts. |
| 36 | */ |
| 37 | const targetingKeyOf = ( |
| 38 | context: EvaluationContext | undefined, |
| 39 | ): string | undefined => { |
| 40 | const value = context?.["targetingKey"]; |
| 41 | return value === undefined ? undefined : String(value); |
| 42 | }; |
| 43 | |
| 44 | /** |
| 45 | * Build a {@link ReadFlagsClient} over the Flagship HTTP evaluate endpoint |
| 46 | * (`GET .../flagship/apps/{appId}/evaluate`). |
| 47 | * |
| 48 | * `appId` is an Effect so the resolution stays deferred to each call — inside |
| 49 | * an Action it resolves through the apply-time RuntimeContext. Mirrors the |
| 50 | * Worker binding's fall-back-to-default semantics: evaluation never fails the |
| 51 | * effect — an HTTP error or a value whose type does not match the requested |
| 52 | * method resolves to `defaultValue` instead. The `raw` runtime binding has no |
| 53 | * HTTP equivalent and dies if used. |
| 54 | */ |
| 55 | export const makeHttpFlagshipClient = ( |
| 56 | auth: FlagshipAuth, |
| 57 | appId: Effect.Effect<string>, |
| 58 | ): ReadFlagsClient => { |
| 59 | const evaluate = (flagKey: string, context?: EvaluationContext) => |
| 60 | appId.pipe( |
| 61 | Effect.flatMap((id) => |
| 62 | auth.authorize( |
| 63 | flagship.getAppEvaluate({ |
| 64 | accountId: auth.accountId, |
| 65 | appId: id, |
| 66 | flagKey, |
| 67 | targetingKey: targetingKeyOf(context), |
| 68 | }), |
| 69 | ), |
| 70 | ), |
| 71 | ); |
| 72 | |
| 73 | const details = <T>( |
| 74 | flagKey: string, |
| 75 | defaultValue: T, |
| 76 | match: (value: unknown) => value is T, |
| 77 | context?: EvaluationContext, |
| 78 | ): Effect.Effect<EvaluationDetails<T>, FlagshipError, RuntimeContext> => |
| 79 | evaluate(flagKey, context).pipe( |
| 80 | Effect.map((r): EvaluationDetails<T> => |
| 81 | match(r.value) |
| 82 | ? { flagKey, value: r.value, variant: r.variant, reason: r.reason } |
| 83 | : { |
| 84 | flagKey, |
| 85 | value: defaultValue, |
| 86 | variant: r.variant, |
| 87 | reason: r.reason, |
| 88 | errorCode: "TYPE_MISMATCH", |
| 89 | }, |
| 90 | ), |
| 91 | Effect.catch((error) => |
| 92 | Effect.succeed<EvaluationDetails<T>>({ |
| 93 | flagKey, |
| 94 | value: defaultValue, |
| 95 | reason: "ERROR", |
| 96 | errorCode: error._tag, |
| 97 | }), |
| 98 | ), |
| 99 | ); |
| 100 | |
| 101 | const value = <T>( |
| 102 | flagKey: string, |
| 103 | defaultValue: T, |
| 104 | match: (value: unknown) => value is T, |
| 105 | context?: EvaluationContext, |
| 106 | ): Effect.Effect<T, FlagshipError, RuntimeContext> => |
| 107 | evaluate(flagKey, context).pipe( |
| 108 | Effect.map((r) => (match(r.value) ? r.value : defaultValue)), |
| 109 | Effect.catch(() => Effect.succeed(defaultValue)), |
| 110 | ); |
| 111 | |
| 112 | const isBoolean = (v: unknown): v is boolean => typeof v === "boolean"; |
| 113 | const isString = (v: unknown): v is string => typeof v === "string"; |
| 114 | const isNumber = (v: unknown): v is number => typeof v === "number"; |
| 115 | const isObjectLike = (v: unknown): boolean => |
| 116 | v !== null && typeof v === "object"; |
| 117 | |
| 118 | return { |
| 119 | // The raw runtime binding is a workerd object with no HTTP surface. |
| 120 | raw: Effect.die( |
| 121 | new FlagshipError({ |
| 122 | message: |
| 123 | "the raw Flagship runtime binding is unavailable over HTTP; use ReadFlagsBinding inside a Worker", |
| 124 | cause: undefined, |
| 125 | }), |
| 126 | ) as Effect.Effect<cf.Flagship, never, RuntimeContext>, |
| 127 | get: (flagKey, defaultValue, context) => |
| 128 | evaluate(flagKey, context).pipe( |
| 129 | Effect.map((r) => r.value ?? defaultValue), |
| 130 | Effect.catch(() => Effect.succeed(defaultValue)), |
| 131 | ), |
| 132 | getBooleanValue: (flagKey, defaultValue, context) => |
| 133 | value(flagKey, defaultValue, isBoolean, context), |
| 134 | getStringValue: (flagKey, defaultValue, context) => |
| 135 | value(flagKey, defaultValue, isString, context), |
| 136 | getNumberValue: (flagKey, defaultValue, context) => |
| 137 | value(flagKey, defaultValue, isNumber, context), |
| 138 | getObjectValue: (flagKey, defaultValue, context) => |
| 139 | evaluate(flagKey, context).pipe( |
| 140 | Effect.map((r) => |
| 141 | isObjectLike(r.value) |
| 142 | ? (r.value as typeof defaultValue) |
| 143 | : defaultValue, |
| 144 | ), |
| 145 | Effect.catch(() => Effect.succeed(defaultValue)), |
| 146 | ), |
| 147 | getBooleanDetails: (flagKey, defaultValue, context) => |
| 148 | details(flagKey, defaultValue, isBoolean, context), |
| 149 | getStringDetails: (flagKey, defaultValue, context) => |
| 150 | details(flagKey, defaultValue, isString, context), |
| 151 | getNumberDetails: (flagKey, defaultValue, context) => |
| 152 | details(flagKey, defaultValue, isNumber, context), |
| 153 | getObjectDetails: (flagKey, defaultValue, context) => |
| 154 | evaluate(flagKey, context).pipe( |
| 155 | Effect.map((r): EvaluationDetails<typeof defaultValue> => |
| 156 | isObjectLike(r.value) |
| 157 | ? { |
| 158 | flagKey, |
| 159 | value: r.value as typeof defaultValue, |
| 160 | variant: r.variant, |
| 161 | reason: r.reason, |
| 162 | } |
| 163 | : { |
| 164 | flagKey, |
| 165 | value: defaultValue, |
| 166 | variant: r.variant, |
| 167 | reason: r.reason, |
| 168 | errorCode: "TYPE_MISMATCH", |
| 169 | }, |
| 170 | ), |
| 171 | Effect.catch((error) => |
| 172 | Effect.succeed<EvaluationDetails<typeof defaultValue>>({ |
| 173 | flagKey, |
| 174 | value: defaultValue, |
| 175 | reason: "ERROR", |
| 176 | errorCode: error._tag, |
| 177 | }), |
| 178 | ), |
| 179 | ), |
| 180 | } satisfies ReadFlagsClient; |
| 181 | }; |