1import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { isResolved } from "../../Diff.ts";
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.Webhook" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Event that can trigger a RealtimeKit webhook.
17 */
18export type WebhookEvent =
19 | "meeting.started"
20 | "meeting.ended"
21 | "meeting.participantJoined"
22 | "meeting.participantLeft"
23 | "meeting.chatSynced"
24 | "recording.statusUpdate"
25 | "livestreaming.statusUpdate"
26 | "meeting.transcript"
27 | "meeting.summary";
28
29export type WebhookProps = {
30 /**
31 * The RealtimeKit app the webhook belongs to. Changing the app triggers a
32 * replacement.
33 */
34 appId: string;
35 /**
36 * Human readable webhook name. If omitted, a unique name is generated from
37 * the app, stage, and logical ID.
38 * @default ${app}-${stage}-${id}
39 */
40 name?: string;
41 /**
42 * URL this webhook will send events to. Often a deployed Worker URL.
43 */
44 url: string;
45 /**
46 * Events that trigger this webhook.
47 */
48 events: WebhookEvent[];
49 /**
50 * Whether the webhook is active.
51 * @default true
52 */
53 enabled?: boolean;
54};
55
56export type WebhookAttributes = {
57 /**
58 * Server-generated webhook identifier. Stable across updates.
59 */
60 webhookId: string;
61 /**
62 * The Cloudflare account the webhook belongs to.
63 */
64 accountId: string;
65 /**
66 * The RealtimeKit app the webhook belongs to.
67 */
68 appId: string;
69 /**
70 * Human readable webhook name.
71 */
72 name: string;
73 /**
74 * URL the webhook sends events to.
75 */
76 url: string;
77 /**
78 * Events that trigger this webhook.
79 */
80 events: WebhookEvent[];
81 /**
82 * Whether the webhook is active.
83 */
84 enabled: boolean;
85 /**
86 * When the webhook was created.
87 */
88 createdAt: string;
89 /**
90 * When the webhook was last modified.
91 */
92 updatedAt: string;
93};
94
95export type Webhook = Resource<
96 TypeId,
97 WebhookProps,
98 WebhookAttributes,
99 never,
100 Providers
101>;
102
103/**
104 * A Cloudflare RealtimeKit webhook — receives meeting, recording,
105 * livestream, transcript, and summary events for a RealtimeKit app.
106 *
107 * Name, URL, events, and enablement are all mutable in place; only moving
108 * the webhook to a different app forces a replacement.
109 * ### Creating a Webhook
110 * **Example:** Meeting lifecycle events
111 * ```typescript
112 * const app = yield* Cloudflare.RealtimeKit.App("Meetings", {});
113 *
114 * const webhook = yield* Cloudflare.RealtimeKit.Webhook("Lifecycle", {
115 * appId: app.appId,
116 * url: "https://example.com/webhook",
117 * events: ["meeting.started", "meeting.ended"],
118 * });
119 * ```
120 *
121 * **Example:** Recording events to a Worker
122 * ```typescript
123 * const webhook = yield* Cloudflare.RealtimeKit.Webhook("Recordings", {
124 * appId: app.appId,
125 * url: worker.url,
126 * events: ["recording.statusUpdate"],
127 * });
128 * ```
129 *
130 * ### Updating a Webhook
131 * **Example:** Pause delivery without deleting
132 * ```typescript
133 * const webhook = yield* Cloudflare.RealtimeKit.Webhook("Lifecycle", {
134 * appId: app.appId,
135 * url: "https://example.com/webhook",
136 * events: ["meeting.started", "meeting.ended"],
137 * enabled: false,
138 * });
139 * ```
140 *
141 * @see https://developers.cloudflare.com/realtime/realtimekit/
142 *
143 * @resource
144 * @product Realtime Kit
145 * @category Media
146 */
147export const Webhook = Resource<Webhook>(TypeId);
148
149/**
150 * Returns true if the given value is a Webhook resource.
151 */
152export const isWebhook = (value: unknown): value is Webhook =>
153 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
154
155export const WebhookProvider = () =>
156 Provider.succeed(Webhook, {
157 stables: ["webhookId", "accountId", "appId", "createdAt"],
158 diff: Effect.fn(function* ({ olds, news, output }) {
159 if (!isResolved(news)) return undefined;
160 const { accountId } = yield* yield* CloudflareEnvironment;
161 if ((output?.accountId ?? accountId) !== accountId) {
162 return { action: "replace" } as const;
163 }
164 // The app is a path parameter — a webhook cannot move between apps in
165 // place. By diff time both sides are resolved strings.
166 const oldAppId = output?.appId ?? olds?.appId;
167 if (
168 typeof oldAppId === "string" &&
169 typeof news.appId === "string" &&
170 oldAppId !== news.appId
171 ) {
172 return { action: "replace" } as const;
173 }
174 return undefined;
175 }),
176 read: Effect.fn(function* ({ id, olds, output }) {
177 const { accountId } = yield* yield* CloudflareEnvironment;
178 const acct = output?.accountId ?? accountId;
179 const appId = output?.appId ?? (olds?.appId as string | undefined);
180 if (appId === undefined) return undefined;
181
182 if (output?.webhookId) {
183 const observed = yield* getWebhook(acct, appId, output.webhookId);
184 return observed ? toAttributes(observed, acct, appId) : undefined;
185 }
186 // Cold read — recover from lost state by matching the deterministic
187 // physical name. Names are not unique on Cloudflare's side; an exact
188 // match on our generated/explicit name is the best identity we have.
189 const name = yield* createWebhookName(id, olds?.name);
190 const match = yield* findByName(acct, appId, name);
191 return match ? toAttributes(match, acct, appId) : undefined;
192 }),
193 reconcile: Effect.fn(function* ({ id, news, output }) {
194 const { accountId } = yield* yield* CloudflareEnvironment;
195 const appId = news.appId as string;
196 const name = yield* createWebhookName(id, news.name);
197 const desired = {
198 name,
199 url: news.url as string,
200 events: [...news.events],
201 enabled: news.enabled ?? true,
202 };
203
204 // Observe — the webhookId cached on `output` is a hint, not a
205 // guarantee: a missing webhook falls through and we recreate.
206 const observed = output?.webhookId
207 ? yield* getWebhook(
208 output.accountId ?? accountId,
209 appId,
210 output.webhookId,
211 )
212 : undefined;
213
214 if (!observed) {
215 // Ensure — greenfield (or out-of-band delete): create with the full
216 // desired body. The API rejects a duplicate name in the same app
217 // with a 409 (`RealtimeKitWebhookExists`); this happens when a prior
218 // run leaked a same-named webhook (lost state) or when a retried
219 // create races a 500 that actually persisted. Adopt the existing
220 // webhook by name and converge it to the desired body instead of
221 // leaking / failing.
222 const created = yield* realtimeKit
223 .createWebhookWebhook({ accountId, appId, ...desired })
224 .pipe(
225 Effect.catchTag("RealtimeKitWebhookExists", () =>
226 Effect.succeed(undefined),
227 ),
228 );
229 if (created) {
230 return toAttributes(created.data, accountId, appId);
231 }
232 const existing = yield* findByName(accountId, appId, name);
233 if (!existing) {
234 // Conflict reported but the webhook isn't visible yet — let the
235 // engine retry the reconcile rather than silently succeeding.
236 return yield* realtimeKit
237 .createWebhookWebhook({ accountId, appId, ...desired })
238 .pipe(
239 Effect.map((res) => toAttributes(res.data, accountId, appId)),
240 );
241 }
242 yield* realtimeKit.replaceWebhookWebhook({
243 accountId,
244 appId,
245 webhookId: existing.id,
246 ...desired,
247 });
248 const adopted = yield* realtimeKit.getWebhookByIdWebhook({
249 accountId,
250 appId,
251 webhookId: existing.id,
252 });
253 return toAttributes(adopted.data, accountId, appId);
254 }
255
256 // Sync — diff observed cloud state against desired; the update API is
257 // a PUT that takes the full body, so send everything, but skip the
258 // call entirely on a no-op.
259 const observedShape = {
260 name: observed.name,
261 url: observed.url,
262 events: [...observed.events].sort(),
263 enabled: observed.enabled,
264 };
265 const desiredShape = { ...desired, events: [...desired.events].sort() };
266 if (JSON.stringify(observedShape) === JSON.stringify(desiredShape)) {
267 return toAttributes(observed, accountId, appId);
268 }
269 yield* realtimeKit.replaceWebhookWebhook({
270 accountId,
271 appId,
272 webhookId: observed.id,
273 ...desired,
274 });
275 // The PUT response is a partial echo — re-read for fresh attributes.
276 const fresh = yield* realtimeKit.getWebhookByIdWebhook({
277 accountId,
278 appId,
279 webhookId: observed.id,
280 });
281 return toAttributes(fresh.data, accountId, appId);
282 }),
283 delete: Effect.fn(function* ({ output }) {
284 yield* realtimeKit
285 .deleteWebhookWebhook({
286 accountId: output.accountId,
287 appId: output.appId,
288 webhookId: output.webhookId,
289 })
290 .pipe(Effect.catchTag("RealtimeKitWebhookNotFound", () => Effect.void));
291 }),
292 // Webhooks are keyed by {accountId, appId, webhookId} with no account-wide
293 // enumeration API, so fan out: enumerate every RealtimeKit app in the
294 // account, then list each app's webhooks and flatten. The apps endpoint
295 // 403s (`Forbidden`) when RealtimeKit isn't enabled on the account — an
296 // unentitled account simply has no webhooks. Neither endpoint paginates.
297 list: Effect.fn(function* () {
298 const { accountId } = yield* yield* CloudflareEnvironment;
299 const appIds = yield* realtimeKit.getApp({ accountId }).pipe(
300 Effect.map((apps) =>
301 (apps.data ?? [])
302 .map((app) => app?.id)
303 .filter((id): id is string => typeof id === "string"),
304 ),
305 Effect.catchTag("Forbidden", () => Effect.succeed([] as string[])),
306 );
307 const perApp = yield* Effect.forEach(
308 appIds,
309 (appId) =>
310 realtimeKit.getWebhooksWebhook({ accountId, appId }).pipe(
311 Effect.map((res) =>
312 res.data.map((webhook) =>
313 toAttributes(webhook, accountId, appId),
314 ),
315 ),
316 // An app with no webhooks 404s (`RealtimeKitWebhookNotFound`).
317 Effect.catchTag("RealtimeKitWebhookNotFound", () =>
318 Effect.succeed([] as WebhookAttributes[]),
319 ),
320 ),
321 { concurrency: 10 },
322 );
323 return perApp.flat();
324 }),
325 });
326
327type ObservedWebhook = realtimeKit.GetWebhookByIdWebhookResponse["data"];
328
329/**
330 * Read a webhook by id, mapping "gone" (`RealtimeKitWebhookNotFound`,
331 * HTTP 404) to `undefined`.
332 */
333const getWebhook = (accountId: string, appId: string, webhookId: string) =>
334 realtimeKit.getWebhookByIdWebhook({ accountId, appId, webhookId }).pipe(
335 Effect.map((res) => res.data),
336 Effect.catchTag("RealtimeKitWebhookNotFound", () =>
337 Effect.succeed(undefined),
338 ),
339 );
340
341/**
342 * Find a webhook by exact name. The list endpoint 404s
343 * (`RealtimeKitWebhookNotFound`) when the app has no webhooks at all —
344 * treat that as an empty list. If several webhooks carry the same name,
345 * pick the oldest for determinism.
346 */
347const findByName = (accountId: string, appId: string, name: string) =>
348 realtimeKit.getWebhooksWebhook({ accountId, appId }).pipe(
349 Effect.map((list) =>
350 [...list.data]
351 .filter((w) => w.name === name)
352 .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
353 .at(0),
354 ),
355 Effect.catchTag("RealtimeKitWebhookNotFound", () =>
356 Effect.succeed(undefined),
357 ),
358 );
359
360const createWebhookName = (id: string, name: string | undefined) =>
361 Effect.gen(function* () {
362 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
363 });
364
365const toAttributes = (
366 webhook: ObservedWebhook,
367 accountId: string,
368 appId: string,
369): WebhookAttributes => ({
370 webhookId: webhook.id,
371 accountId,
372 appId,
373 name: webhook.name,
374 url: webhook.url,
375 // Distilled widens generated string enums to open unions (`string & {}`).
376 events: [...webhook.events] as WebhookEvent[],
377 enabled: webhook.enabled,
378 createdAt: webhook.createdAt,
379 updatedAt: webhook.updatedAt,
380});