1import type * as cf from "@cloudflare/workers-types";
2import * as flagship from "@distilled.cloud/cloudflare/flagship";
3import * as Effect from "effect/Effect";
4import type * as HttpClient from "effect/http/HttpClient";
5import type { RuntimeContext } from "../../RuntimeContext.ts";
6import type { Credentials } from "../Credentials.ts";
7import {
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 */
26export 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 */
37const 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 */
55export 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};