| 1 | import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Predicate from "effect/Predicate"; |
| 5 | |
| 6 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const TypeId = "Cloudflare.RealtimeKit.App" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | export type AppProps = { |
| 16 | /** |
| 17 | * Human readable app name. App names are not unique on Cloudflare's side. |
| 18 | * If omitted, a unique name is generated from the app, stage, and logical |
| 19 | * ID. |
| 20 | * |
| 21 | * RealtimeKit currently ships no update API, so the name cannot be changed |
| 22 | * after creation — and no delete API either, so a name change cannot be |
| 23 | * modeled as a replacement (the old app could never be removed). Changing |
| 24 | * this property fails the deploy. |
| 25 | * @default ${app}-${stage}-${id} |
| 26 | */ |
| 27 | name?: string; |
| 28 | }; |
| 29 | |
| 30 | export type AppAttributes = { |
| 31 | /** |
| 32 | * Server-generated app identifier (also called the organization id). |
| 33 | * Stable for the lifetime of the app. |
| 34 | */ |
| 35 | appId: string; |
| 36 | /** |
| 37 | * The Cloudflare account the app belongs to. |
| 38 | */ |
| 39 | accountId: string; |
| 40 | /** |
| 41 | * Human readable app name. |
| 42 | */ |
| 43 | name: string; |
| 44 | /** |
| 45 | * When the app was created. |
| 46 | */ |
| 47 | createdAt: string; |
| 48 | }; |
| 49 | |
| 50 | export type App = Resource<TypeId, AppProps, AppAttributes, never, Providers>; |
| 51 | |
| 52 | /** |
| 53 | * A Cloudflare RealtimeKit app — the organizational container for RealtimeKit |
| 54 | * meetings, presets, and webhooks. |
| 55 | * |
| 56 | * RealtimeKit (the acquired Dyte platform) is in beta and must be enabled on |
| 57 | * the account. The API is create-only today: there is no update and no delete |
| 58 | * endpoint. Destroying the resource therefore only forgets the app from state |
| 59 | * (with a warning) — the app itself remains on the account until Cloudflare |
| 60 | * ships a delete API. Because of this, an existing app with the same name is |
| 61 | * adopted rather than duplicated. |
| 62 | * ### Creating an App |
| 63 | * **Example:** Basic app |
| 64 | * ```typescript |
| 65 | * const app = yield* Cloudflare.RealtimeKit.App("Meetings", { |
| 66 | * name: "my-meetings-app", |
| 67 | * }); |
| 68 | * ``` |
| 69 | * |
| 70 | * **Example:** Child resources |
| 71 | * ```typescript |
| 72 | * const app = yield* Cloudflare.RealtimeKit.App("Meetings", {}); |
| 73 | * |
| 74 | * const webhook = yield* Cloudflare.RealtimeKit.Webhook("Events", { |
| 75 | * appId: app.appId, |
| 76 | * url: "https://example.com/webhook", |
| 77 | * events: ["meeting.started", "meeting.ended"], |
| 78 | * }); |
| 79 | * ``` |
| 80 | * |
| 81 | * @see https://developers.cloudflare.com/realtime/realtimekit/ |
| 82 | * |
| 83 | * @resource |
| 84 | * @product Realtime Kit |
| 85 | * @category Media |
| 86 | */ |
| 87 | export const App = Resource<App>(TypeId); |
| 88 | |
| 89 | /** |
| 90 | * Returns true if the given value is a App resource. |
| 91 | */ |
| 92 | export const isApp = (value: unknown): value is App => |
| 93 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 94 | |
| 95 | export const AppProvider = () => |
| 96 | Provider.succeed(App, { |
| 97 | stables: ["appId", "accountId", "name", "createdAt"], |
| 98 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 99 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 100 | if ((output?.accountId ?? accountId) !== accountId) { |
| 101 | return { action: "replace" } as const; |
| 102 | } |
| 103 | // No update API and no delete API — a name change can neither be |
| 104 | // applied in place nor modeled as a replacement (the old app could |
| 105 | // never be removed). Reconcile fails loudly on a drifted name; diff |
| 106 | // lets the default update flow surface that error. |
| 107 | void olds; |
| 108 | void news; |
| 109 | return undefined; |
| 110 | }), |
| 111 | read: Effect.fn(function* ({ id, olds, output }) { |
| 112 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 113 | const acct = output?.accountId ?? accountId; |
| 114 | |
| 115 | if (output?.appId) { |
| 116 | const observed = yield* findById(acct, output.appId); |
| 117 | return observed ? toAttributes(observed, acct) : undefined; |
| 118 | } |
| 119 | // Cold read — recover from lost state by matching the deterministic |
| 120 | // physical name. App names are not unique; an exact match on our |
| 121 | // generated/explicit name is the best identity we have, and adoption |
| 122 | // is preferable to orphan-spam since apps cannot be deleted. |
| 123 | const name = yield* createAppName(id, olds?.name); |
| 124 | const match = yield* findByName(acct, name); |
| 125 | return match ? toAttributes(match, acct) : undefined; |
| 126 | }), |
| 127 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 128 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 129 | const name = yield* createAppName(id, news.name); |
| 130 | |
| 131 | // Observe — the appId cached on `output` is a hint, not a guarantee. |
| 132 | // Fall back to a name scan so an existing same-named app is adopted |
| 133 | // instead of duplicated (apps can never be deleted). |
| 134 | const observed = output?.appId |
| 135 | ? yield* findById(output.accountId ?? accountId, output.appId) |
| 136 | : yield* findByName(accountId, name); |
| 137 | |
| 138 | if (!observed) { |
| 139 | // Ensure — greenfield: create. Names are not unique so there is no |
| 140 | // AlreadyExists race to tolerate. |
| 141 | const created = yield* realtimeKit.postApp({ accountId, name }); |
| 142 | const app = created.data?.app; |
| 143 | return { |
| 144 | appId: app?.id ?? "", |
| 145 | accountId, |
| 146 | name: app?.name ?? name, |
| 147 | createdAt: app?.createdAt ?? "", |
| 148 | }; |
| 149 | } |
| 150 | |
| 151 | // Sync — RealtimeKit has no update API. The only mutable-looking prop |
| 152 | // is the name; fail loudly instead of silently ignoring drift. |
| 153 | if ((observed.name ?? "") !== name) { |
| 154 | return yield* Effect.fail( |
| 155 | new AppRenameNotSupported({ |
| 156 | appId: observed.id ?? "", |
| 157 | currentName: observed.name ?? "", |
| 158 | desiredName: name, |
| 159 | message: `Cloudflare RealtimeKit apps cannot be renamed (no update API) or replaced (no delete API). App ${observed.id} is named "${observed.name}" but "${name}" was requested.`, |
| 160 | }), |
| 161 | ); |
| 162 | } |
| 163 | return toAttributes(observed, observed.accountId); |
| 164 | }), |
| 165 | // No delete API exists, so `delete` cannot actually remove the app — it |
| 166 | // would re-appear on every `nuke` scan. Skip it in account-wide teardown |
| 167 | // instead of falsely reporting it deleted. |
| 168 | nuke: { skip: true }, |
| 169 | delete: Effect.fn(function* ({ output }) { |
| 170 | // RealtimeKit ships no delete API. Forget the app from state and warn — |
| 171 | // the app remains on the account until Cloudflare adds deletion. |
| 172 | yield* Effect.logWarning( |
| 173 | `Cloudflare RealtimeKit has no delete API — app "${output.name}" (${output.appId}) was removed from state but still exists on account ${output.accountId}.`, |
| 174 | ); |
| 175 | }), |
| 176 | // Account-scoped collection: enumerate every RealtimeKit app on the |
| 177 | // account, exhaustively paginated, and hydrate each into the exact `read` |
| 178 | // Attributes shape. Unentitled accounts reject the list with the typed |
| 179 | // `Forbidden` (403) which propagates — the suite gates the live assertion |
| 180 | // behind an entitlement probe. |
| 181 | list: Effect.fn(function* () { |
| 182 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 183 | const apps = yield* listAllApps(accountId); |
| 184 | return apps.map((app) => toAttributes({ ...app, accountId }, accountId)); |
| 185 | }), |
| 186 | }); |
| 187 | |
| 188 | const LIST_PER_PAGE = 100; |
| 189 | |
| 190 | /** |
| 191 | * Exhaustively enumerate every RealtimeKit app in the account. The list op is |
| 192 | * not generated as a paginated method, so fetch the first page, derive the |
| 193 | * page count from `paging.totalCount`, then fan out the remaining pages with |
| 194 | * bounded concurrency. |
| 195 | */ |
| 196 | const listAllApps = (accountId: string) => |
| 197 | Effect.gen(function* () { |
| 198 | const first = yield* realtimeKit.getApp({ |
| 199 | accountId, |
| 200 | pageNo: 1, |
| 201 | perPage: LIST_PER_PAGE, |
| 202 | }); |
| 203 | const apps = (first.data ?? []).filter( |
| 204 | (a): a is NonNullable<typeof a> => a !== null, |
| 205 | ); |
| 206 | const total = first.paging?.totalCount ?? apps.length; |
| 207 | const pages = Math.ceil(total / LIST_PER_PAGE); |
| 208 | if (pages <= 1) return apps; |
| 209 | const rest = yield* Effect.forEach( |
| 210 | Array.from({ length: pages - 1 }, (_, i) => i + 2), |
| 211 | (pageNo) => |
| 212 | realtimeKit |
| 213 | .getApp({ accountId, pageNo, perPage: LIST_PER_PAGE }) |
| 214 | .pipe( |
| 215 | Effect.map((res) => |
| 216 | (res.data ?? []).filter( |
| 217 | (a): a is NonNullable<typeof a> => a !== null, |
| 218 | ), |
| 219 | ), |
| 220 | ), |
| 221 | { concurrency: 10 }, |
| 222 | ); |
| 223 | return [...apps, ...rest.flat()]; |
| 224 | }); |
| 225 | |
| 226 | /** |
| 227 | * Error raised when a deploy attempts to rename a RealtimeKit app. The API |
| 228 | * has neither an update endpoint (to rename in place) nor a delete endpoint |
| 229 | * (to model the change as a replacement). |
| 230 | */ |
| 231 | export class AppRenameNotSupported extends Data.TaggedError( |
| 232 | "AppRenameNotSupported", |
| 233 | )<{ |
| 234 | readonly appId: string; |
| 235 | readonly currentName: string; |
| 236 | readonly desiredName: string; |
| 237 | readonly message: string; |
| 238 | }> {} |
| 239 | |
| 240 | type ObservedApp = { |
| 241 | id?: string | null; |
| 242 | createdAt?: string | null; |
| 243 | name?: string | null; |
| 244 | accountId: string; |
| 245 | }; |
| 246 | |
| 247 | /** |
| 248 | * Find an app by id. The API only exposes a list endpoint, so scan it. |
| 249 | */ |
| 250 | const findById = (accountId: string, appId: string) => |
| 251 | realtimeKit.getApp({ accountId }).pipe( |
| 252 | Effect.map((list) => |
| 253 | (list.data ?? []) |
| 254 | .filter((a): a is NonNullable<typeof a> => a !== null) |
| 255 | .map((a): ObservedApp => ({ ...a, accountId })) |
| 256 | .find((a) => a.id === appId), |
| 257 | ), |
| 258 | ); |
| 259 | |
| 260 | /** |
| 261 | * Find an app by exact name. If several apps carry the same name, pick the |
| 262 | * oldest for determinism. |
| 263 | */ |
| 264 | const findByName = (accountId: string, name: string) => |
| 265 | realtimeKit.getApp({ accountId }).pipe( |
| 266 | Effect.map((list) => |
| 267 | (list.data ?? []) |
| 268 | .filter((a): a is NonNullable<typeof a> => a !== null) |
| 269 | .map((a): ObservedApp => ({ ...a, accountId })) |
| 270 | .filter((a) => a.name === name) |
| 271 | .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")) |
| 272 | .at(0), |
| 273 | ), |
| 274 | ); |
| 275 | |
| 276 | const createAppName = (id: string, name: string | undefined) => |
| 277 | Effect.gen(function* () { |
| 278 | return name ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 279 | }); |
| 280 | |
| 281 | const toAttributes = (app: ObservedApp, accountId: string): AppAttributes => ({ |
| 282 | appId: app.id ?? "", |
| 283 | accountId, |
| 284 | name: app.name ?? "", |
| 285 | createdAt: app.createdAt ?? "", |
| 286 | }); |