1import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.RealtimeKit.App" as const;
13type TypeId = typeof TypeId;
14
15export 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
30export 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
50export 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 */
87export const App = Resource<App>(TypeId);
88
89/**
90 * Returns true if the given value is a App resource.
91 */
92export const isApp = (value: unknown): value is App =>
93 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
94
95export 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
188const 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 */
196const 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 */
231export 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
240type 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 */
250const 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 */
264const 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
276const createAppName = (id: string, name: string | undefined) =>
277 Effect.gen(function* () {
278 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
279 });
280
281const toAttributes = (app: ObservedApp, accountId: string): AppAttributes => ({
282 appId: app.id ?? "",
283 accountId,
284 name: app.name ?? "",
285 createdAt: app.createdAt ?? "",
286});