1import * as flagship from "@distilled.cloud/cloudflare/flagship";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { isResourceOfType, Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9
10const TypeId = "Cloudflare.Flagship.App" as const;
11type TypeId = typeof TypeId;
12
13export type AppProps = {
14 /**
15 * Human readable app name. Apps group flags by project or service. If
16 * omitted, a unique name is generated from the app, stage, and logical ID.
17 * @default ${app}-${stage}-${id}
18 */
19 name?: string;
20};
21
22export type AppAttributes = {
23 /**
24 * Server-generated app identifier. Stable across updates; used as the
25 * `appId` for flags, the Worker binding, and all evaluation calls.
26 */
27 appId: string;
28 /**
29 * The Cloudflare account the app belongs to.
30 */
31 accountId: string;
32 /**
33 * Human readable app name.
34 */
35 name: string;
36 /**
37 * When the app was created.
38 */
39 createdAt: string;
40 /**
41 * When the app was last modified.
42 */
43 updatedAt: string;
44 /**
45 * Email of the actor who last modified the app, or `edge-gateway` for
46 * gateway-authenticated changes.
47 */
48 updatedBy: string;
49};
50
51export type App = Resource<TypeId, AppProps, AppAttributes, never, Providers>;
52
53/**
54 * A Cloudflare Flagship app — a container for feature flags.
55 *
56 * Flagship is Cloudflare's feature flag service. Flags are organized into
57 * apps that map to your projects or services; the app's `appId` is what a
58 * Worker's `Flagship` binding points at and what every evaluation call is
59 * scoped to. The name is mutable in place; the app id never changes.
60 * ### Creating an App
61 * **Example:** App with a generated name
62 * ```typescript
63 * const app = yield* Cloudflare.Flagship.App("Flags", {});
64 * ```
65 *
66 * **Example:** App with an explicit name
67 * ```typescript
68 * const app = yield* Cloudflare.Flagship.App("Flags", {
69 * name: "my-service-flags",
70 * });
71 * ```
72 *
73 * ### Using the App
74 * **Example:** Define flags in the app
75 * ```typescript
76 * const app = yield* Cloudflare.Flagship.App("Flags", {});
77 *
78 * const flag = yield* Cloudflare.Flagship.Flag("NewCheckout", {
79 * appId: app.appId,
80 * key: "new-checkout",
81 * defaultVariation: "off",
82 * variations: { off: false, on: true },
83 * });
84 * ```
85 *
86 * ### Binding to a Worker
87 * **Example:** Effect-style Worker (recommended)
88 * `Cloudflare.Flagship.ReadFlags(app)` attaches the binding to the surrounding
89 * Worker and returns the runtime client for evaluating flags. Every `Flagship`
90 * method is mirrored as an Effect, so no `Effect.tryPromise` wrapping is needed.
91 * ```typescript
92 * export const App = Cloudflare.Flagship.App("Flags", {});
93 *
94 * Cloudflare.Worker(
95 * "FlagsWorker",
96 * { main: import.meta.url },
97 * Effect.gen(function* () {
98 * const flags = yield* Cloudflare.Flagship.ReadFlags(App);
99 * return {
100 * fetch: Effect.gen(function* () {
101 * const enabled = yield* flags.getBooleanValue("new-checkout", false, {
102 * userId: "user-42",
103 * });
104 * return HttpServerResponse.text(enabled ? "on" : "off");
105 * }),
106 * };
107 * }).pipe(Effect.provide(Cloudflare.Flagship.ReadFlagsBinding)),
108 * );
109 * ```
110 *
111 * **Example:** Declare the binding on `env`
112 * Declaring the app on a Worker's `env` maps it to the native `Flagship`
113 * runtime binding via `InferEnv`.
114 * ```typescript
115 * export const App = Cloudflare.Flagship.App("Flags", {});
116 *
117 * export const Worker = Cloudflare.Worker("Worker", {
118 * main: "./src/worker.ts",
119 * env: { FLAGS: App },
120 * });
121 *
122 * export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
123 * // { FLAGS: Flagship }
124 * ```
125 *
126 * **Example:** Async-style worker with the raw runtime binding
127 * ```typescript
128 * import type { WorkerEnv } from "../alchemy.run.ts";
129 *
130 * export default {
131 * async fetch(request: Request, env: WorkerEnv) {
132 * const enabled = await env.FLAGS.getBooleanValue("new-checkout", false, {
133 * userId: "user-42",
134 * });
135 * return new Response(enabled ? "on" : "off");
136 * },
137 * };
138 * ```
139 *
140 * @see https://developers.cloudflare.com/flagship/
141 * @see https://developers.cloudflare.com/api/resources/flagship/
142 *
143 * @resource
144 * @product Flagship
145 * @category Developer Platform
146 */
147export const App = Resource<App>(TypeId);
148
149/**
150 * Returns true if the given value is a Flagship App resource.
151 */
152export const isApp = (value: unknown): value is App =>
153 isResourceOfType(value, TypeId);
154
155export const AppProvider = () =>
156 Provider.succeed(App, {
157 stables: ["appId", "accountId", "createdAt"],
158 diff: Effect.fn(function* ({ output }) {
159 const { accountId } = yield* yield* CloudflareEnvironment;
160 if ((output?.accountId ?? accountId) !== accountId) {
161 return { action: "replace" } as const;
162 }
163 return undefined;
164 }),
165 read: Effect.fn(function* ({ id, olds, output }) {
166 const { accountId } = yield* yield* CloudflareEnvironment;
167 const acct = output?.accountId ?? accountId;
168
169 if (output?.appId) {
170 const observed = yield* getApp(acct, output.appId);
171 return observed ? toAttributes(observed, acct) : undefined;
172 }
173 // Cold read — recover from lost state by matching the deterministic
174 // physical name. App names are not unique on Cloudflare's side; an
175 // exact match on our generated/explicit name is the best identity we
176 // have. Pick the oldest match for determinism.
177 const name = yield* createAppName(id, olds?.name);
178 const match = yield* findByName(acct, name);
179 return match ? toAttributes(match, acct) : undefined;
180 }),
181 reconcile: Effect.fn(function* ({ id, news, output }) {
182 const { accountId } = yield* yield* CloudflareEnvironment;
183 const name = yield* createAppName(id, news.name);
184
185 // Observe — the appId cached on `output` is a hint, not a guarantee:
186 // a missing app falls through and we recreate.
187 const observed = output?.appId
188 ? yield* getApp(output.accountId ?? accountId, output.appId)
189 : undefined;
190
191 if (!observed) {
192 // Ensure — greenfield (or out-of-band delete). App names are not
193 // unique so there is no AlreadyExists race to tolerate.
194 const created = yield* flagship.createApp({ accountId, name });
195 return toAttributes(created, accountId);
196 }
197
198 // Sync — the only mutable aspect is the name; skip the API on a no-op.
199 if (observed.name === name) {
200 return toAttributes(observed, accountId);
201 }
202 const updated = yield* flagship.updateApp({
203 accountId,
204 appId: observed.id,
205 name,
206 });
207 return toAttributes(updated, accountId);
208 }),
209 delete: Effect.fn(function* ({ output }) {
210 yield* flagship
211 .deleteApp({ accountId: output.accountId, appId: output.appId })
212 .pipe(Effect.catchTag("FlagshipAppNotFound", () => Effect.void));
213 }),
214 // Account-scoped collection (pattern b): enumerate every app in the
215 // ambient account via the paginated listApps op. Cloudflare's list
216 // index can contain ghost rows — apps that were deleted but survive in
217 // the listing while GET/DELETE both return "App not found" — so each
218 // row is validated via getApp and ghosts are dropped (otherwise census
219 // tooling re-discovers an undeletable app forever).
220 list: Effect.fn(function* () {
221 const { accountId } = yield* yield* CloudflareEnvironment;
222 const listed = yield* flagship.listApps.pages({ accountId }).pipe(
223 Stream.runCollect,
224 Effect.map((chunk) =>
225 Array.from(chunk).flatMap((page) => page.result ?? []),
226 ),
227 );
228 const rows = yield* Effect.forEach(
229 listed,
230 (app) =>
231 getApp(accountId, app.id).pipe(
232 Effect.map((observed) =>
233 observed ? toAttributes(observed, accountId) : undefined,
234 ),
235 ),
236 { concurrency: 10 },
237 );
238 return rows.filter((row): row is AppAttributes => row !== undefined);
239 }),
240 });
241
242/**
243 * Read an app by id, mapping "gone" (`FlagshipAppNotFound`) to `undefined`.
244 */
245const getApp = (accountId: string, appId: string) =>
246 flagship
247 .getApp({ accountId, appId })
248 .pipe(
249 Effect.catchTag("FlagshipAppNotFound", () => Effect.succeed(undefined)),
250 );
251
252/**
253 * Find an app by exact name. If several apps carry the same name, pick the
254 * oldest for determinism.
255 */
256const findByName = (accountId: string, name: string) =>
257 flagship.listApps.items({ accountId }).pipe(
258 Stream.filter((a) => a.name === name),
259 Stream.runCollect,
260 Effect.map((chunk) =>
261 Array.from(chunk)
262 .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
263 .at(0),
264 ),
265 );
266
267const createAppName = (id: string, name: string | undefined) =>
268 Effect.gen(function* () {
269 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
270 });
271
272const toAttributes = (
273 app:
274 | flagship.GetAppResponse
275 | flagship.CreateAppResponse
276 | flagship.UpdateAppResponse,
277 accountId: string,
278): AppAttributes => ({
279 appId: app.id,
280 accountId,
281 name: app.name,
282 createdAt: app.createdAt,
283 updatedAt: app.updatedAt,
284 updatedBy: app.updatedBy,
285});