| 1 | import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 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.Preset" as const; |
| 13 | type TypeId = typeof TypeId; |
| 14 | |
| 15 | /** |
| 16 | * Media quality tier for video / screenshare streams. |
| 17 | */ |
| 18 | export type MediaQuality = "hd" | "vga" | "qvga"; |
| 19 | |
| 20 | /** |
| 21 | * Meeting layout the preset applies to. |
| 22 | */ |
| 23 | export type ViewType = "GROUP_CALL" | "WEBINAR" | "AUDIO_ROOM"; |
| 24 | |
| 25 | /** |
| 26 | * Whether a participant may produce a media kind. |
| 27 | */ |
| 28 | export type CanProduce = "ALLOWED" | "NOT_ALLOWED" | "CAN_REQUEST"; |
| 29 | |
| 30 | /** |
| 31 | * What a participant joining with this preset records as. |
| 32 | */ |
| 33 | export type RecorderType = "RECORDER" | "LIVESTREAMER" | "NONE"; |
| 34 | |
| 35 | /** |
| 36 | * Waiting-room behavior for participants joining with this preset. |
| 37 | */ |
| 38 | export type WaitingRoomType = |
| 39 | | "SKIP" |
| 40 | | "ON_PRIVILEGED_USER_ENTRY" |
| 41 | | "SKIP_ON_ACCEPT"; |
| 42 | |
| 43 | /** |
| 44 | * Media configuration of a preset (stream counts, quality, frame rates). |
| 45 | */ |
| 46 | export type PresetConfig = { |
| 47 | /** |
| 48 | * Maximum number of simultaneous screenshares. |
| 49 | */ |
| 50 | maxScreenshareCount: number; |
| 51 | /** |
| 52 | * Maximum simultaneous video streams per device class. |
| 53 | */ |
| 54 | maxVideoStreams: { desktop: number; mobile: number }; |
| 55 | /** |
| 56 | * Quality / frame-rate settings for each media kind. |
| 57 | */ |
| 58 | media: { |
| 59 | screenshare: { frameRate: number; quality: MediaQuality }; |
| 60 | video: { frameRate: number; quality: MediaQuality }; |
| 61 | audio?: { enableHighBitrate?: boolean; enableStereo?: boolean }; |
| 62 | }; |
| 63 | /** |
| 64 | * Meeting layout this preset applies to. |
| 65 | */ |
| 66 | viewType: ViewType; |
| 67 | }; |
| 68 | |
| 69 | /** |
| 70 | * UI design tokens of a preset (colors, logo, spacing). |
| 71 | */ |
| 72 | /** Corner rounding of UI elements. */ |
| 73 | export type BorderRadius = "sharp" | "rounded" | "extra-rounded" | "circular"; |
| 74 | |
| 75 | /** Border width of UI elements. */ |
| 76 | export type BorderWidth = "none" | "thin" | "fat"; |
| 77 | |
| 78 | /** Base color theme of the meeting UI. */ |
| 79 | export type Theme = "darkest" | "dark" | "light"; |
| 80 | |
| 81 | export type PresetUi = { |
| 82 | designTokens: { |
| 83 | borderRadius: BorderRadius; |
| 84 | borderWidth: BorderWidth; |
| 85 | colors: { |
| 86 | background: { |
| 87 | "600": string; |
| 88 | "700": string; |
| 89 | "800": string; |
| 90 | "900": string; |
| 91 | "1000": string; |
| 92 | }; |
| 93 | brand: { |
| 94 | "300": string; |
| 95 | "400": string; |
| 96 | "500": string; |
| 97 | "600": string; |
| 98 | "700": string; |
| 99 | }; |
| 100 | danger: string; |
| 101 | success: string; |
| 102 | text: string; |
| 103 | textOnBrand: string; |
| 104 | videoBg: string; |
| 105 | warning: string; |
| 106 | }; |
| 107 | logo: string; |
| 108 | spacingBase: number; |
| 109 | theme: Theme; |
| 110 | }; |
| 111 | /** |
| 112 | * Raw UI-kit config diff applied on top of the design tokens. Required by |
| 113 | * the live API (defaulted to `{}` when omitted). |
| 114 | * @default {} |
| 115 | */ |
| 116 | configDiff?: unknown; |
| 117 | }; |
| 118 | |
| 119 | /** |
| 120 | * Participant permissions granted by a preset. |
| 121 | */ |
| 122 | export type PresetPermissions = { |
| 123 | acceptWaitingRequests: boolean; |
| 124 | canAcceptProductionRequests: boolean; |
| 125 | canChangeParticipantPermissions: boolean; |
| 126 | canEditDisplayName: boolean; |
| 127 | canLivestream: boolean; |
| 128 | canRecord: boolean; |
| 129 | canSpotlight: boolean; |
| 130 | chat: { |
| 131 | private: { |
| 132 | canReceive: boolean; |
| 133 | canSend: boolean; |
| 134 | files: boolean; |
| 135 | text: boolean; |
| 136 | }; |
| 137 | public: { canSend: boolean; files: boolean; text: boolean }; |
| 138 | }; |
| 139 | connectedMeetings: { |
| 140 | canAlterConnectedMeetings: boolean; |
| 141 | canSwitchConnectedMeetings: boolean; |
| 142 | canSwitchToParentMeeting: boolean; |
| 143 | }; |
| 144 | disableParticipantAudio: boolean; |
| 145 | disableParticipantScreensharing: boolean; |
| 146 | disableParticipantVideo: boolean; |
| 147 | hiddenParticipant: boolean; |
| 148 | kickParticipant: boolean; |
| 149 | media: { |
| 150 | audio: { canProduce: CanProduce }; |
| 151 | screenshare: { canProduce: CanProduce }; |
| 152 | video: { canProduce: CanProduce }; |
| 153 | }; |
| 154 | pinParticipant: boolean; |
| 155 | plugins: { |
| 156 | canClose: boolean; |
| 157 | canEditConfig: boolean; |
| 158 | canStart: boolean; |
| 159 | /** |
| 160 | * Per-plugin access config keyed by plugin UUID. |
| 161 | * @default {} |
| 162 | */ |
| 163 | config?: unknown; |
| 164 | }; |
| 165 | polls: { canCreate: boolean; canView: boolean; canVote: boolean }; |
| 166 | recorderType: RecorderType; |
| 167 | showParticipantList: boolean; |
| 168 | waitingRoomType: WaitingRoomType; |
| 169 | isRecorder?: boolean; |
| 170 | }; |
| 171 | |
| 172 | export type PresetProps = { |
| 173 | /** |
| 174 | * The RealtimeKit app the preset belongs to. Changing the app triggers a |
| 175 | * replacement. |
| 176 | */ |
| 177 | appId: string; |
| 178 | /** |
| 179 | * Human readable preset name (e.g. `host`, `guest`). If omitted, a unique |
| 180 | * name is generated from the app, stage, and logical ID. |
| 181 | * @default ${app}-${stage}-${id} |
| 182 | */ |
| 183 | name?: string; |
| 184 | /** |
| 185 | * Media configuration (stream counts, quality, frame rates). |
| 186 | * @default a group-call config (hd video at 30fps, hd screenshare at 5fps, 9 desktop / 4 mobile streams) |
| 187 | */ |
| 188 | config?: PresetConfig; |
| 189 | /** |
| 190 | * UI design tokens. |
| 191 | * @default RealtimeKit's dark theme defaults |
| 192 | */ |
| 193 | ui?: PresetUi; |
| 194 | /** |
| 195 | * Participant permissions. The API requires the full object on create, so |
| 196 | * unspecified permissions fall back to conservative attendee defaults |
| 197 | * (chat and polls allowed, no recording / livestreaming / moderation). |
| 198 | * @default conservative attendee defaults |
| 199 | */ |
| 200 | permissions?: PresetPermissions; |
| 201 | }; |
| 202 | |
| 203 | export type PresetAttributes = { |
| 204 | /** |
| 205 | * Server-generated preset identifier. Stable across updates. |
| 206 | */ |
| 207 | presetId: string; |
| 208 | /** |
| 209 | * The Cloudflare account the preset belongs to. |
| 210 | */ |
| 211 | accountId: string; |
| 212 | /** |
| 213 | * The RealtimeKit app the preset belongs to. |
| 214 | */ |
| 215 | appId: string; |
| 216 | /** |
| 217 | * Human readable preset name. |
| 218 | */ |
| 219 | name: string; |
| 220 | /** |
| 221 | * Media configuration as stored by Cloudflare. |
| 222 | */ |
| 223 | config: PresetConfig; |
| 224 | /** |
| 225 | * UI design tokens as stored by Cloudflare. |
| 226 | */ |
| 227 | ui: PresetUi; |
| 228 | /** |
| 229 | * Participant permissions as stored by Cloudflare. |
| 230 | */ |
| 231 | permissions: PresetPermissions | undefined; |
| 232 | }; |
| 233 | |
| 234 | export type Preset = Resource< |
| 235 | TypeId, |
| 236 | PresetProps, |
| 237 | PresetAttributes, |
| 238 | never, |
| 239 | Providers |
| 240 | >; |
| 241 | |
| 242 | /** |
| 243 | * A Cloudflare RealtimeKit preset — a named participant role (e.g. `host`, |
| 244 | * `guest`) bundling permissions, media quality, and UI design tokens for a |
| 245 | * RealtimeKit app. |
| 246 | * |
| 247 | * Name, config, UI, and permissions are all mutable in place; only moving |
| 248 | * the preset to a different app forces a replacement. The create API |
| 249 | * requires the full config / UI / permissions objects, so the resource fills |
| 250 | * unspecified sections with sensible defaults. |
| 251 | * ### Creating a Preset |
| 252 | * **Example:** Default group-call preset |
| 253 | * ```typescript |
| 254 | * const app = yield* Cloudflare.RealtimeKit.App("Meetings", {}); |
| 255 | * |
| 256 | * const guest = yield* Cloudflare.RealtimeKit.Preset("Guest", { |
| 257 | * appId: app.appId, |
| 258 | * name: "guest", |
| 259 | * }); |
| 260 | * ``` |
| 261 | * |
| 262 | * **Example:** Host preset with moderation permissions |
| 263 | * ```typescript |
| 264 | * const host = yield* Cloudflare.RealtimeKit.Preset("Host", { |
| 265 | * appId: app.appId, |
| 266 | * name: "host", |
| 267 | * permissions: { |
| 268 | * ...Cloudflare.RealtimeKit.defaultRealtimeKitPresetPermissions(), |
| 269 | * canRecord: true, |
| 270 | * kickParticipant: true, |
| 271 | * pinParticipant: true, |
| 272 | * acceptWaitingRequests: true, |
| 273 | * }, |
| 274 | * }); |
| 275 | * ``` |
| 276 | * |
| 277 | * ### Updating a Preset |
| 278 | * **Example:** Switch to a webinar layout |
| 279 | * ```typescript |
| 280 | * const preset = yield* Cloudflare.RealtimeKit.Preset("Guest", { |
| 281 | * appId: app.appId, |
| 282 | * name: "guest", |
| 283 | * config: { |
| 284 | * ...Cloudflare.RealtimeKit.defaultRealtimeKitPresetConfig(), |
| 285 | * viewType: "WEBINAR", |
| 286 | * }, |
| 287 | * }); |
| 288 | * ``` |
| 289 | * |
| 290 | * @see https://developers.cloudflare.com/realtime/realtimekit/ |
| 291 | * |
| 292 | * @resource |
| 293 | * @product Realtime Kit |
| 294 | * @category Media |
| 295 | */ |
| 296 | export const Preset = Resource<Preset>(TypeId); |
| 297 | |
| 298 | /** |
| 299 | * Returns true if the given value is a Preset resource. |
| 300 | */ |
| 301 | export const isPreset = (value: unknown): value is Preset => |
| 302 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 303 | |
| 304 | /** |
| 305 | * The default media configuration used when `config` is omitted: a group |
| 306 | * call with hd video at 30fps, hd screenshare at 5fps, and 9 desktop / |
| 307 | * 4 mobile video streams. |
| 308 | */ |
| 309 | export const defaultRealtimeKitPresetConfig = (): PresetConfig => ({ |
| 310 | maxScreenshareCount: 1, |
| 311 | maxVideoStreams: { desktop: 9, mobile: 4 }, |
| 312 | media: { |
| 313 | screenshare: { frameRate: 5, quality: "hd" }, |
| 314 | video: { frameRate: 30, quality: "hd" }, |
| 315 | }, |
| 316 | viewType: "GROUP_CALL", |
| 317 | }); |
| 318 | |
| 319 | /** |
| 320 | * The default UI design tokens used when `ui` is omitted: RealtimeKit's |
| 321 | * dark theme. |
| 322 | */ |
| 323 | export const defaultRealtimeKitPresetUi = (): PresetUi => ({ |
| 324 | designTokens: { |
| 325 | borderRadius: "rounded", |
| 326 | borderWidth: "thin", |
| 327 | colors: { |
| 328 | background: { |
| 329 | "600": "#1f2937", |
| 330 | "700": "#1a232e", |
| 331 | "800": "#141d26", |
| 332 | "900": "#10171e", |
| 333 | "1000": "#0b1116", |
| 334 | }, |
| 335 | brand: { |
| 336 | "300": "#93c5fd", |
| 337 | "400": "#60a5fa", |
| 338 | "500": "#3b82f6", |
| 339 | "600": "#2563eb", |
| 340 | "700": "#1d4ed8", |
| 341 | }, |
| 342 | danger: "#ff2d2d", |
| 343 | success: "#62a504", |
| 344 | text: "#ffffff", |
| 345 | textOnBrand: "#ffffff", |
| 346 | videoBg: "#191919", |
| 347 | warning: "#ffc107", |
| 348 | }, |
| 349 | logo: "", |
| 350 | spacingBase: 4, |
| 351 | theme: "dark", |
| 352 | }, |
| 353 | configDiff: {}, |
| 354 | }); |
| 355 | |
| 356 | /** |
| 357 | * The default permissions used when `permissions` is omitted: a conservative |
| 358 | * attendee role — chat, polls, and media production allowed; recording, |
| 359 | * livestreaming, and moderation denied. |
| 360 | */ |
| 361 | export const defaultRealtimeKitPresetPermissions = (): PresetPermissions => ({ |
| 362 | acceptWaitingRequests: false, |
| 363 | canAcceptProductionRequests: false, |
| 364 | canChangeParticipantPermissions: false, |
| 365 | canEditDisplayName: false, |
| 366 | canLivestream: false, |
| 367 | canRecord: false, |
| 368 | canSpotlight: false, |
| 369 | chat: { |
| 370 | private: { canReceive: true, canSend: true, files: true, text: true }, |
| 371 | public: { canSend: true, files: true, text: true }, |
| 372 | }, |
| 373 | connectedMeetings: { |
| 374 | canAlterConnectedMeetings: false, |
| 375 | canSwitchConnectedMeetings: false, |
| 376 | canSwitchToParentMeeting: false, |
| 377 | }, |
| 378 | disableParticipantAudio: false, |
| 379 | disableParticipantScreensharing: false, |
| 380 | disableParticipantVideo: false, |
| 381 | hiddenParticipant: false, |
| 382 | kickParticipant: false, |
| 383 | media: { |
| 384 | audio: { canProduce: "ALLOWED" }, |
| 385 | screenshare: { canProduce: "ALLOWED" }, |
| 386 | video: { canProduce: "ALLOWED" }, |
| 387 | }, |
| 388 | pinParticipant: false, |
| 389 | plugins: { |
| 390 | canClose: true, |
| 391 | canEditConfig: true, |
| 392 | canStart: true, |
| 393 | config: {}, |
| 394 | }, |
| 395 | polls: { canCreate: true, canView: true, canVote: true }, |
| 396 | recorderType: "NONE", |
| 397 | showParticipantList: true, |
| 398 | waitingRoomType: "SKIP", |
| 399 | }); |
| 400 | |
| 401 | export const PresetProvider = () => |
| 402 | Provider.succeed(Preset, { |
| 403 | stables: ["presetId", "accountId", "appId"], |
| 404 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 405 | if (!isResolved(news)) return undefined; |
| 406 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 407 | if ((output?.accountId ?? accountId) !== accountId) { |
| 408 | return { action: "replace" } as const; |
| 409 | } |
| 410 | // The app is a path parameter — a preset cannot move between apps in |
| 411 | // place. By diff time both sides are resolved strings. |
| 412 | const oldAppId = output?.appId ?? olds?.appId; |
| 413 | if ( |
| 414 | typeof oldAppId === "string" && |
| 415 | typeof news.appId === "string" && |
| 416 | oldAppId !== news.appId |
| 417 | ) { |
| 418 | return { action: "replace" } as const; |
| 419 | } |
| 420 | return undefined; |
| 421 | }), |
| 422 | read: Effect.fn(function* ({ id, olds, output }) { |
| 423 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 424 | const acct = output?.accountId ?? accountId; |
| 425 | const appId = output?.appId ?? (olds?.appId as string | undefined); |
| 426 | if (appId === undefined) return undefined; |
| 427 | |
| 428 | if (output?.presetId) { |
| 429 | const observed = yield* getPreset(acct, appId, output.presetId); |
| 430 | return observed ? toAttributes(observed, acct, appId) : undefined; |
| 431 | } |
| 432 | // Cold read — recover from lost state by matching the deterministic |
| 433 | // physical name. Names are not unique on Cloudflare's side; an exact |
| 434 | // match on our generated/explicit name is the best identity we have. |
| 435 | const name = yield* createPresetName(id, olds?.name); |
| 436 | const match = yield* findByName(acct, appId, name); |
| 437 | if (match?.id) { |
| 438 | const observed = yield* getPreset(acct, appId, match.id); |
| 439 | return observed ? toAttributes(observed, acct, appId) : undefined; |
| 440 | } |
| 441 | return undefined; |
| 442 | }), |
| 443 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 444 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 445 | const appId = news.appId as string; |
| 446 | const name = yield* createPresetName(id, news.name); |
| 447 | const desired = { |
| 448 | name, |
| 449 | config: news.config ?? defaultRealtimeKitPresetConfig(), |
| 450 | ui: withUiDefaults(news.ui), |
| 451 | permissions: withPermissionsDefaults(news.permissions), |
| 452 | }; |
| 453 | |
| 454 | // Observe — the presetId cached on `output` is a hint, not a |
| 455 | // guarantee: a missing preset falls through and we recreate. |
| 456 | const observed = output?.presetId |
| 457 | ? yield* getPreset( |
| 458 | output.accountId ?? accountId, |
| 459 | appId, |
| 460 | output.presetId, |
| 461 | ) |
| 462 | : undefined; |
| 463 | |
| 464 | if (!observed) { |
| 465 | // Ensure — greenfield (or out-of-band delete): create with the full |
| 466 | // desired body. The API rejects a duplicate name in the same app |
| 467 | // with a 409 (`RealtimeKitPresetExists`); this happens when a prior |
| 468 | // run leaked a same-named preset (lost state) or when a retried |
| 469 | // create races a 500 that actually persisted. Adopt the existing |
| 470 | // preset by name and converge it to the desired body instead of |
| 471 | // leaking / failing. |
| 472 | const created = yield* realtimeKit |
| 473 | .createPreset({ accountId, appId, ...desired }) |
| 474 | .pipe( |
| 475 | Effect.catchTag("RealtimeKitPresetExists", () => |
| 476 | Effect.succeed(undefined), |
| 477 | ), |
| 478 | ); |
| 479 | if (created) { |
| 480 | return toAttributes(created.data, accountId, appId); |
| 481 | } |
| 482 | const existing = yield* findByName(accountId, appId, name); |
| 483 | if (!existing?.id) { |
| 484 | // Conflict reported but the preset isn't visible yet — let the |
| 485 | // engine retry the reconcile rather than silently succeeding. |
| 486 | return yield* realtimeKit |
| 487 | .createPreset({ accountId, appId, ...desired }) |
| 488 | .pipe( |
| 489 | Effect.map((res) => toAttributes(res.data, accountId, appId)), |
| 490 | ); |
| 491 | } |
| 492 | yield* realtimeKit.patchPreset({ |
| 493 | accountId, |
| 494 | appId, |
| 495 | presetId: existing.id, |
| 496 | ...desired, |
| 497 | }); |
| 498 | const adopted = yield* realtimeKit.getPresetByIdPreset({ |
| 499 | accountId, |
| 500 | appId, |
| 501 | presetId: existing.id, |
| 502 | }); |
| 503 | return toAttributes(adopted.data, accountId, appId); |
| 504 | } |
| 505 | |
| 506 | // Sync — diff what the user asked for against observed cloud state. |
| 507 | // The API fills/echoes extra server-side fields (e.g. `simulcast`), |
| 508 | // so compare only the desired keys against the observed echo. |
| 509 | if (subsetEquals(desired, observed)) { |
| 510 | return toAttributes(observed, accountId, appId); |
| 511 | } |
| 512 | yield* realtimeKit.patchPreset({ |
| 513 | accountId, |
| 514 | appId, |
| 515 | presetId: observed.id, |
| 516 | ...desired, |
| 517 | }); |
| 518 | // The PATCH response omits the preset id — re-read for fresh state. |
| 519 | const fresh = yield* realtimeKit.getPresetByIdPreset({ |
| 520 | accountId, |
| 521 | appId, |
| 522 | presetId: observed.id, |
| 523 | }); |
| 524 | return toAttributes(fresh.data, accountId, appId); |
| 525 | }), |
| 526 | delete: Effect.fn(function* ({ output }) { |
| 527 | yield* realtimeKit |
| 528 | .deletePreset({ |
| 529 | accountId: output.accountId, |
| 530 | appId: output.appId, |
| 531 | presetId: output.presetId, |
| 532 | }) |
| 533 | .pipe(Effect.catchTag("RealtimeKitPresetNotFound", () => Effect.void)); |
| 534 | }), |
| 535 | // Presets are sub-resources of a RealtimeKit app. There is no |
| 536 | // account-wide preset enumeration API, so fan out: list every app in the |
| 537 | // account, then list every preset within each app. The per-app preset |
| 538 | // list returns only a slim `{ id, name }` shape, so each row is hydrated |
| 539 | // via `getPresetByIdPreset` into the full `read` Attributes shape. |
| 540 | list: Effect.fn(function* () { |
| 541 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 542 | const apps = yield* listAllApps(accountId); |
| 543 | const rows = yield* Effect.forEach( |
| 544 | apps, |
| 545 | (app) => |
| 546 | Effect.gen(function* () { |
| 547 | const appId = app.id; |
| 548 | if (!appId) return []; |
| 549 | const presets = yield* listAllPresets(accountId, appId); |
| 550 | const hydrated = yield* Effect.forEach( |
| 551 | presets, |
| 552 | (preset) => |
| 553 | preset.id |
| 554 | ? getPreset(accountId, appId, preset.id).pipe( |
| 555 | Effect.map((observed) => |
| 556 | observed |
| 557 | ? toAttributes(observed, accountId, appId) |
| 558 | : undefined, |
| 559 | ), |
| 560 | ) |
| 561 | : Effect.succeed(undefined), |
| 562 | { concurrency: 10 }, |
| 563 | ); |
| 564 | return hydrated.filter( |
| 565 | (row): row is PresetAttributes => row !== undefined, |
| 566 | ); |
| 567 | }), |
| 568 | { concurrency: 10 }, |
| 569 | ); |
| 570 | return rows.flat(); |
| 571 | }), |
| 572 | }); |
| 573 | |
| 574 | const LIST_PER_PAGE = 100; |
| 575 | |
| 576 | /** |
| 577 | * Exhaustively enumerate every RealtimeKit app in the account. The list op is |
| 578 | * not generated as a paginated method, so fetch the first page, derive the |
| 579 | * page count from `paging.totalCount`, then fan out the remaining pages. |
| 580 | */ |
| 581 | const listAllApps = (accountId: string) => |
| 582 | Effect.gen(function* () { |
| 583 | const first = yield* realtimeKit.getApp({ |
| 584 | accountId, |
| 585 | pageNo: 1, |
| 586 | perPage: LIST_PER_PAGE, |
| 587 | }); |
| 588 | const apps = (first.data ?? []).filter( |
| 589 | (a): a is NonNullable<typeof a> => a !== null, |
| 590 | ); |
| 591 | const total = first.paging?.totalCount ?? apps.length; |
| 592 | const pages = Math.ceil(total / LIST_PER_PAGE); |
| 593 | if (pages <= 1) return apps; |
| 594 | const rest = yield* Effect.forEach( |
| 595 | Array.from({ length: pages - 1 }, (_, i) => i + 2), |
| 596 | (pageNo) => |
| 597 | realtimeKit |
| 598 | .getApp({ accountId, pageNo, perPage: LIST_PER_PAGE }) |
| 599 | .pipe( |
| 600 | Effect.map((res) => |
| 601 | (res.data ?? []).filter( |
| 602 | (a): a is NonNullable<typeof a> => a !== null, |
| 603 | ), |
| 604 | ), |
| 605 | ), |
| 606 | { concurrency: 10 }, |
| 607 | ); |
| 608 | return [...apps, ...rest.flat()]; |
| 609 | }); |
| 610 | |
| 611 | /** |
| 612 | * Exhaustively enumerate every preset within a single app, paginating off |
| 613 | * `paging.totalCount` the same way as {@link listAllApps}. |
| 614 | */ |
| 615 | const listAllPresets = (accountId: string, appId: string) => |
| 616 | Effect.gen(function* () { |
| 617 | const first = yield* realtimeKit.getPreset({ |
| 618 | accountId, |
| 619 | appId, |
| 620 | pageNo: 1, |
| 621 | perPage: LIST_PER_PAGE, |
| 622 | }); |
| 623 | const presets = [...first.data]; |
| 624 | const total = first.paging?.totalCount ?? presets.length; |
| 625 | const pages = Math.ceil(total / LIST_PER_PAGE); |
| 626 | if (pages <= 1) return presets; |
| 627 | const rest = yield* Effect.forEach( |
| 628 | Array.from({ length: pages - 1 }, (_, i) => i + 2), |
| 629 | (pageNo) => |
| 630 | realtimeKit |
| 631 | .getPreset({ accountId, appId, pageNo, perPage: LIST_PER_PAGE }) |
| 632 | .pipe(Effect.map((res) => [...res.data])), |
| 633 | { concurrency: 10 }, |
| 634 | ); |
| 635 | return [...presets, ...rest.flat()]; |
| 636 | }); |
| 637 | |
| 638 | type ObservedPreset = realtimeKit.GetPresetByIdPresetResponse["data"]; |
| 639 | |
| 640 | /** |
| 641 | * Read a preset by id, mapping "gone" (`RealtimeKitPresetNotFound`, |
| 642 | * HTTP 404) to `undefined`. |
| 643 | */ |
| 644 | const getPreset = (accountId: string, appId: string, presetId: string) => |
| 645 | realtimeKit.getPresetByIdPreset({ accountId, appId, presetId }).pipe( |
| 646 | Effect.map((res) => res.data), |
| 647 | Effect.catchTag("RealtimeKitPresetNotFound", () => |
| 648 | Effect.succeed(undefined), |
| 649 | ), |
| 650 | ); |
| 651 | |
| 652 | /** |
| 653 | * Find a preset by exact name in the app's preset list. If several presets |
| 654 | * carry the same name, pick the oldest for determinism. |
| 655 | */ |
| 656 | const findByName = (accountId: string, appId: string, name: string) => |
| 657 | realtimeKit.getPreset({ accountId, appId, perPage: 100 }).pipe( |
| 658 | Effect.map((list) => |
| 659 | [...list.data] |
| 660 | .filter((p) => p.name === name) |
| 661 | .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")) |
| 662 | .at(0), |
| 663 | ), |
| 664 | ); |
| 665 | |
| 666 | const createPresetName = (id: string, name: string | undefined) => |
| 667 | Effect.gen(function* () { |
| 668 | return name ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 669 | }); |
| 670 | |
| 671 | const withUiDefaults = ( |
| 672 | ui: PresetUi | undefined, |
| 673 | ): PresetUi & { configDiff: unknown } => { |
| 674 | const base = ui ?? defaultRealtimeKitPresetUi(); |
| 675 | // `config_diff` is required by the API even though it's incidental — |
| 676 | // default it so users don't have to care. |
| 677 | return { ...base, configDiff: base.configDiff ?? {} }; |
| 678 | }; |
| 679 | |
| 680 | /** |
| 681 | * Fill `plugins.config` (required by the create API even though it's |
| 682 | * incidental) so users don't have to care. The return type intentionally |
| 683 | * stays inferred: `CreatePresetRequest` requires `plugins.config` to be |
| 684 | * present, and annotating `PresetPermissions` (where it is |
| 685 | * optional) would erase the guarantee the literal provides. |
| 686 | */ |
| 687 | const withPermissionsDefaults = ( |
| 688 | permissions: PresetPermissions | undefined, |
| 689 | ) => { |
| 690 | const base = permissions ?? defaultRealtimeKitPresetPermissions(); |
| 691 | return { |
| 692 | ...base, |
| 693 | plugins: { |
| 694 | ...base.plugins, |
| 695 | config: (base.plugins.config ?? {}) as unknown, |
| 696 | }, |
| 697 | }; |
| 698 | }; |
| 699 | |
| 700 | /** |
| 701 | * Deep "is `desired` a subset of `observed`" comparison. The RealtimeKit |
| 702 | * API echoes extra server-computed fields (e.g. `simulcast`) alongside what |
| 703 | * we sent, so a strict deep-equal would always report drift. |
| 704 | */ |
| 705 | const subsetEquals = (desired: unknown, observed: unknown): boolean => { |
| 706 | if ( |
| 707 | typeof desired === "object" && |
| 708 | desired !== null && |
| 709 | typeof observed === "object" && |
| 710 | observed !== null && |
| 711 | !Array.isArray(desired) && |
| 712 | !Array.isArray(observed) |
| 713 | ) { |
| 714 | return Object.entries(desired as Record<string, unknown>).every( |
| 715 | ([key, value]) => |
| 716 | value === undefined || |
| 717 | subsetEquals(value, (observed as Record<string, unknown>)[key]), |
| 718 | ); |
| 719 | } |
| 720 | if (Array.isArray(desired) && Array.isArray(observed)) { |
| 721 | return ( |
| 722 | desired.length === observed.length && |
| 723 | desired.every((value, i) => subsetEquals(value, observed[i])) |
| 724 | ); |
| 725 | } |
| 726 | return desired === observed; |
| 727 | }; |
| 728 | |
| 729 | const toAttributes = ( |
| 730 | preset: ObservedPreset, |
| 731 | accountId: string, |
| 732 | appId: string, |
| 733 | ): PresetAttributes => ({ |
| 734 | presetId: preset.id, |
| 735 | accountId, |
| 736 | appId, |
| 737 | name: preset.name, |
| 738 | // Distilled widens generated string enums to open unions (`string & {}`) |
| 739 | // and marks echoes readonly — re-shape into our prop types. |
| 740 | config: { |
| 741 | maxScreenshareCount: preset.config.maxScreenshareCount, |
| 742 | maxVideoStreams: { |
| 743 | desktop: preset.config.maxVideoStreams.desktop, |
| 744 | mobile: preset.config.maxVideoStreams.mobile, |
| 745 | }, |
| 746 | media: { |
| 747 | screenshare: { |
| 748 | frameRate: preset.config.media.screenshare.frameRate, |
| 749 | quality: preset.config.media.screenshare.quality as MediaQuality, |
| 750 | }, |
| 751 | video: { |
| 752 | frameRate: preset.config.media.video.frameRate, |
| 753 | quality: preset.config.media.video.quality as MediaQuality, |
| 754 | }, |
| 755 | ...(preset.config.media.audio |
| 756 | ? { |
| 757 | audio: { |
| 758 | ...(preset.config.media.audio.enableHighBitrate != null |
| 759 | ? { |
| 760 | enableHighBitrate: |
| 761 | preset.config.media.audio.enableHighBitrate, |
| 762 | } |
| 763 | : {}), |
| 764 | ...(preset.config.media.audio.enableStereo != null |
| 765 | ? { enableStereo: preset.config.media.audio.enableStereo } |
| 766 | : {}), |
| 767 | }, |
| 768 | } |
| 769 | : {}), |
| 770 | }, |
| 771 | viewType: preset.config.viewType as ViewType, |
| 772 | }, |
| 773 | ui: { |
| 774 | designTokens: { |
| 775 | borderRadius: preset.ui.designTokens.borderRadius as BorderRadius, |
| 776 | borderWidth: preset.ui.designTokens.borderWidth as BorderWidth, |
| 777 | colors: { |
| 778 | background: { ...preset.ui.designTokens.colors.background }, |
| 779 | brand: { ...preset.ui.designTokens.colors.brand }, |
| 780 | danger: preset.ui.designTokens.colors.danger, |
| 781 | success: preset.ui.designTokens.colors.success, |
| 782 | text: preset.ui.designTokens.colors.text, |
| 783 | textOnBrand: preset.ui.designTokens.colors.textOnBrand, |
| 784 | videoBg: preset.ui.designTokens.colors.videoBg, |
| 785 | warning: preset.ui.designTokens.colors.warning, |
| 786 | }, |
| 787 | logo: preset.ui.designTokens.logo ?? "", |
| 788 | spacingBase: preset.ui.designTokens.spacingBase, |
| 789 | theme: preset.ui.designTokens.theme as Theme, |
| 790 | }, |
| 791 | configDiff: preset.ui.configDiff ?? {}, |
| 792 | }, |
| 793 | permissions: preset.permissions |
| 794 | ? { |
| 795 | acceptWaitingRequests: preset.permissions.acceptWaitingRequests, |
| 796 | canAcceptProductionRequests: |
| 797 | preset.permissions.canAcceptProductionRequests, |
| 798 | canChangeParticipantPermissions: |
| 799 | preset.permissions.canChangeParticipantPermissions, |
| 800 | canEditDisplayName: preset.permissions.canEditDisplayName, |
| 801 | canLivestream: preset.permissions.canLivestream, |
| 802 | canRecord: preset.permissions.canRecord, |
| 803 | canSpotlight: preset.permissions.canSpotlight, |
| 804 | chat: { |
| 805 | private: { ...preset.permissions.chat.private }, |
| 806 | public: { ...preset.permissions.chat.public }, |
| 807 | }, |
| 808 | connectedMeetings: { ...preset.permissions.connectedMeetings }, |
| 809 | disableParticipantAudio: preset.permissions.disableParticipantAudio, |
| 810 | disableParticipantScreensharing: |
| 811 | preset.permissions.disableParticipantScreensharing, |
| 812 | disableParticipantVideo: preset.permissions.disableParticipantVideo, |
| 813 | hiddenParticipant: preset.permissions.hiddenParticipant, |
| 814 | kickParticipant: preset.permissions.kickParticipant, |
| 815 | media: { |
| 816 | audio: { |
| 817 | canProduce: preset.permissions.media.audio.canProduce as CanProduce, |
| 818 | }, |
| 819 | screenshare: { |
| 820 | canProduce: preset.permissions.media.screenshare |
| 821 | .canProduce as CanProduce, |
| 822 | }, |
| 823 | video: { |
| 824 | canProduce: preset.permissions.media.video.canProduce as CanProduce, |
| 825 | }, |
| 826 | }, |
| 827 | pinParticipant: preset.permissions.pinParticipant, |
| 828 | plugins: { |
| 829 | canClose: preset.permissions.plugins.canClose, |
| 830 | canEditConfig: preset.permissions.plugins.canEditConfig, |
| 831 | canStart: preset.permissions.plugins.canStart, |
| 832 | config: preset.permissions.plugins.config ?? {}, |
| 833 | }, |
| 834 | polls: { ...preset.permissions.polls }, |
| 835 | recorderType: preset.permissions.recorderType as RecorderType, |
| 836 | showParticipantList: preset.permissions.showParticipantList, |
| 837 | waitingRoomType: preset.permissions.waitingRoomType as WaitingRoomType, |
| 838 | ...(preset.permissions.isRecorder != null |
| 839 | ? { isRecorder: preset.permissions.isRecorder } |
| 840 | : {}), |
| 841 | } |
| 842 | : undefined, |
| 843 | }); |