1import * as calls from "@distilled.cloud/cloudflare/calls";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Calls.App" as const;
14type TypeId = typeof TypeId;
15
16export type AppProps = {
17 /**
18 * A short description of the app, not shown to end users and not unique.
19 * Mutable in place. If omitted, a unique name is generated from the app,
20 * stage, and logical ID.
21 * @default ${app}-${stage}-${id}
22 */
23 name?: string;
24};
25
26export type AppAttributes = {
27 /**
28 * Cloudflare-generated unique identifier for the app. Used in client SDK
29 * session URLs (`https://rtc.live.cloudflare.com/v1/apps/{appId}/...`).
30 */
31 appId: string;
32 /**
33 * The Cloudflare account the app belongs to.
34 */
35 accountId: string;
36 /**
37 * App secret (bearer token) used to authenticate against the Realtime SFU
38 * HTTPS API. Returned only at creation time and never re-readable — Alchemy
39 * persists it in state and carries it forward across updates.
40 */
41 secret: Redacted.Redacted<string>;
42 /**
43 * A short description of the app.
44 */
45 name: string;
46 /**
47 * When the app was created.
48 */
49 created: string;
50 /**
51 * When the app was last modified.
52 */
53 modified: string;
54};
55
56export type App = Resource<TypeId, AppProps, AppAttributes, never, Providers>;
57
58/**
59 * A Cloudflare Realtime (formerly "Calls") SFU application.
60 *
61 * An app is the unit of isolation for Cloudflare's WebRTC SFU: clients
62 * connect to sessions scoped to the app's auto-assigned `appId`, and your
63 * backend authenticates management calls with the create-only `secret`
64 * (a bearer token). The only configurable property is the human-readable
65 * `name`, which is mutable in place.
66 * ### Creating an App
67 * **Example:** App with a generated name
68 * ```typescript
69 * const app = yield* Cloudflare.Calls.App("realtime", {});
70 * ```
71 *
72 * **Example:** App with an explicit name
73 * ```typescript
74 * const app = yield* Cloudflare.Calls.App("realtime", {
75 * name: "my-realtime-app",
76 * });
77 * ```
78 *
79 * ### Using the credentials
80 * **Example:** Passing the appId and secret to a backend
81 * ```typescript
82 * // appId is public — it appears in client session URLs:
83 * const appId = app.appId;
84 *
85 * // The secret is redacted — use it server-side as a bearer token
86 * // against https://rtc.live.cloudflare.com/v1/apps/{appId}/...
87 * const secret = app.secret; // Redacted<string>
88 * ```
89 *
90 * @see https://developers.cloudflare.com/realtime/
91 *
92 * @resource
93 * @product Calls
94 * @category Media
95 */
96export const App = Resource<App>(TypeId);
97
98/**
99 * Returns true if the given value is a App resource.
100 */
101export const isApp = (value: unknown): value is App =>
102 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
103
104export const AppProvider = () =>
105 Provider.succeed(App, {
106 stables: ["appId", "accountId", "secret", "created"],
107 list: Effect.fn(function* () {
108 const { accountId } = yield* yield* CloudflareEnvironment;
109 // Account-scoped collection: enumerate every Calls app under the
110 // account, exhaustively paginating `result`. The create-only secret
111 // is never returned by the list endpoint, so — matching `read` for a
112 // resource without prior state — hydrate it as an empty Redacted.
113 return yield* calls.listSfus.pages({ accountId }).pipe(
114 Stream.runCollect,
115 Effect.map((chunk) =>
116 Array.from(chunk).flatMap((page) =>
117 (page.result ?? []).map((app) =>
118 toAttributes(app, accountId, Redacted.make("")),
119 ),
120 ),
121 ),
122 );
123 }),
124 diff: Effect.fn(function* ({ output }) {
125 const { accountId } = yield* yield* CloudflareEnvironment;
126 if (output !== undefined && output.accountId !== accountId) {
127 return { action: "replace" } as const;
128 }
129 return undefined;
130 }),
131 read: Effect.fn(function* ({ output }) {
132 // The app secret is returned only at creation time, so an app cannot
133 // be re-hydrated without prior state — no cold read / adoption path.
134 if (!output?.appId) return undefined;
135 const observed = yield* getApp(output.accountId, output.appId);
136 return observed
137 ? toAttributes(observed, output.accountId, output.secret)
138 : undefined;
139 }),
140 reconcile: Effect.fn(function* ({ id, news, output }) {
141 const { accountId } = yield* yield* CloudflareEnvironment;
142 const name = yield* createAppName(id, news.name);
143
144 // Observe — the appId cached on `output` is a hint, not a guarantee:
145 // a missing app (code 20007) falls through and we recreate.
146 const observed = output?.appId
147 ? yield* getApp(output.accountId ?? accountId, output.appId)
148 : undefined;
149
150 if (!observed || !output) {
151 // Ensure — greenfield (or out-of-band delete): create and capture
152 // the create-only secret. Names are not unique on Cloudflare's
153 // side, so there is no AlreadyExists race to tolerate.
154 const created = yield* calls.createSfu({ accountId, name });
155 return toAttributes(
156 created,
157 accountId,
158 Redacted.make(created.secret ?? ""),
159 );
160 }
161
162 // Sync — the only mutable aspect is `name`; diff observed cloud
163 // state against desired and skip the PUT entirely on a no-op.
164 if (observed.name === name) {
165 return toAttributes(observed, output.accountId, output.secret);
166 }
167 const updated = yield* calls.updateSfu({
168 accountId: output.accountId,
169 appId: output.appId,
170 name,
171 });
172 return toAttributes(updated, output.accountId, output.secret);
173 }),
174 delete: Effect.fn(function* ({ output }) {
175 yield* calls
176 .deleteSfu({ accountId: output.accountId, appId: output.appId })
177 .pipe(Effect.catchTag("CallsAppNotFound", () => Effect.void));
178 }),
179 });
180
181/**
182 * Read an app by id, mapping "gone" (`CallsAppNotFound`, Cloudflare error
183 * code 20007) to `undefined`.
184 */
185const getApp = (accountId: string, appId: string) =>
186 calls
187 .getSfu({ accountId, appId })
188 .pipe(Effect.catchTag("CallsAppNotFound", () => Effect.succeed(undefined)));
189
190const createAppName = (id: string, name: string | undefined) =>
191 Effect.gen(function* () {
192 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
193 });
194
195const toAttributes = (
196 app: calls.GetSfuResponse | calls.CreateSfuResponse | calls.UpdateSfuResponse,
197 accountId: string,
198 secret: Redacted.Redacted<string>,
199): AppAttributes => ({
200 appId: app.uid ?? "",
201 accountId,
202 secret,
203 name: app.name ?? "",
204 created: app.created ?? "",
205 modified: app.modified ?? "",
206});