1import * as ivschat from "@distilled.cloud/aws/ivschat";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 retryWhileHandlerPermissionPropagating,
13 retryWhileThrottled,
14 syncIvsChatTags,
15 toTagRecord,
16} from "./internal.ts";
17
18export interface RoomMessageReviewHandler {
19 /**
20 * ARN of the Lambda function that reviews messages before they are
21 * delivered to the room.
22 */
23 uri?: string;
24 /**
25 * What happens to a message if the review handler errors or times out:
26 * `ALLOW` delivers it, `DENY` drops it.
27 * @default "ALLOW"
28 */
29 fallbackResult?: "ALLOW" | "DENY";
30}
31
32/**
33 * The binding contract of a room: the message review event source
34 * (`IVSChat.onReviewMessage`) contributes the reviewing Lambda's ARN as the
35 * room's `messageReviewHandler`, which the provider merges with
36 * `props.messageReviewHandler` and syncs onto the room.
37 */
38export interface RoomBinding {
39 /** Review handler injected by `IVSChat.onReviewMessage`. */
40 messageReviewHandler?: RoomMessageReviewHandler;
41}
42
43export interface RoomProps {
44 /**
45 * Name of the room (not unique). If omitted, a deterministic physical
46 * name is generated. Room names are mutable — changing the name
47 * updates the room in place.
48 */
49 roomName?: string;
50 /**
51 * Maximum number of messages per second that can be sent to the room
52 * (`1` - `10`).
53 * @default 10
54 */
55 maximumMessageRatePerSecond?: number;
56 /**
57 * Maximum number of characters in a single message (`1` - `500`).
58 * @default 500
59 */
60 maximumMessageLength?: number;
61 /**
62 * A Lambda-backed handler that reviews (and can modify or deny)
63 * messages before delivery. Prefer wiring it through
64 * `IVSChat.onReviewMessage` — the event source also creates the invoke
65 * Permission and registers the runtime handler.
66 * @default no review handler
67 */
68 messageReviewHandler?: RoomMessageReviewHandler;
69 /**
70 * ARNs or IDs of `LoggingConfiguration`s that record the room's chat
71 * messages.
72 */
73 loggingConfigurationIdentifiers?: string[];
74 /**
75 * Tags to apply to the room. Merged with internal Alchemy tags.
76 */
77 tags?: Record<string, string>;
78}
79
80export interface Room extends Resource<
81 "AWS.IVSChat.Room",
82 RoomProps,
83 {
84 /**
85 * The room's physical name.
86 */
87 roomName: string;
88 /**
89 * ARN of the room.
90 */
91 roomArn: string;
92 /**
93 * Unique ID of the room.
94 */
95 roomId: string;
96 },
97 RoomBinding,
98 Providers
99> {}
100
101/**
102 * An Amazon IVS Chat room — a virtual space where chat participants
103 * exchange messages over WebSocket connections.
104 *
105 * Clients connect with chat tokens minted at runtime via
106 * `CreateChatToken`; message rate/length limits, a Lambda review handler,
107 * and logging configurations are all managed on the room.
108 * ### Creating Rooms
109 * **Example:** Basic Room
110 * ```typescript
111 * import * as IVSChat from "alchemy/AWS/IVSChat";
112 *
113 * const room = yield* IVSChat.Room("LiveChat");
114 * ```
115 *
116 * **Example:** Room with Message Limits
117 * ```typescript
118 * const room = yield* IVSChat.Room("LiveChat", {
119 * maximumMessageRatePerSecond: 5,
120 * maximumMessageLength: 200,
121 * });
122 * ```
123 *
124 * ### Logging
125 * **Example:** Room with Chat Logging
126 * ```typescript
127 * const logging = yield* IVSChat.LoggingConfiguration("ChatLogs", {
128 * destinationConfiguration: {
129 * cloudWatchLogs: { logGroupName: logGroup.logGroupName },
130 * },
131 * });
132 * const room = yield* IVSChat.Room("LiveChat", {
133 * loggingConfigurationIdentifiers: [logging.loggingConfigurationArn],
134 * });
135 * ```
136 *
137 * ### Message Review
138 * **Example:** Review Messages with a Lambda Handler
139 * ```typescript
140 * // inside a Lambda Function's effect — the handler reviews every message
141 * // sent to the room before delivery (allow / modify / deny)
142 * const room = yield* IVSChat.Room("LiveChat");
143 * yield* IVSChat.onReviewMessage(room, (event) =>
144 * Effect.succeed(
145 * event.Content.includes("banned-word")
146 * ? { ReviewResult: "DENY", Attributes: { Reason: "moderated" } }
147 * : undefined,
148 * ),
149 * );
150 * // on the Function effect:
151 * // .pipe(Effect.provide(Lambda.RoomMessageReviewEventSource))
152 * ```
153 *
154 * @resource
155 */
156export const Room = Resource<Room>("AWS.IVSChat.Room");
157
158/**
159 * Raised when the IVS Chat API returns a room missing its ARN, ID, or
160 * name.
161 */
162export class IvsChatRoomIncomplete extends Data.TaggedError(
163 "IvsChatRoomIncomplete",
164)<{ message: string }> {}
165
166/**
167 * Two different Lambda functions were registered as the same room's message
168 * review handler — IVS Chat supports exactly one handler per room.
169 */
170export class ConflictingRoomMessageReviewHandler extends Data.TaggedError(
171 "ConflictingRoomMessageReviewHandler",
172)<{ readonly uris: readonly string[] }> {}
173
174/**
175 * The room's desired `messageReviewHandler`: `props.messageReviewHandler`
176 * merged with the handler contributed through the binding contract
177 * (`IVSChat.onReviewMessage`). Fails when two different handler URIs are
178 * declared. Returns `undefined` when no handler is desired (which clears
179 * any associated handler on update).
180 */
181const resolveMessageReviewHandler = Effect.fn(function* (
182 news: RoomProps,
183 bindings: ReadonlyArray<RoomBinding | { data?: RoomBinding }>,
184) {
185 const contributions = [
186 news.messageReviewHandler,
187 ...bindings.map(
188 (binding) =>
189 (binding as { data?: RoomBinding }).data?.messageReviewHandler ??
190 (binding as RoomBinding).messageReviewHandler,
191 ),
192 ].filter((handler) => handler !== undefined);
193 let resolved: RoomMessageReviewHandler | undefined;
194 for (const handler of contributions) {
195 if (
196 resolved?.uri !== undefined &&
197 handler.uri !== undefined &&
198 resolved.uri !== handler.uri
199 ) {
200 return yield* Effect.fail(
201 new ConflictingRoomMessageReviewHandler({
202 uris: [resolved.uri, handler.uri],
203 }),
204 );
205 }
206 resolved = { ...resolved, ...handler };
207 }
208 return resolved;
209});
210
211type RoomState = {
212 arn?: string | undefined;
213 id?: string | undefined;
214 name?: string | undefined;
215 maximumMessageRatePerSecond?: number | undefined;
216 maximumMessageLength?: number | undefined;
217 messageReviewHandler?: ivschat.MessageReviewHandler | undefined;
218 tags?: { [key: string]: string | undefined } | undefined;
219 loggingConfigurationIdentifiers?: string[] | undefined;
220};
221
222export const RoomProvider = () =>
223 Provider.effect(
224 Room,
225 Effect.gen(function* () {
226 const toName = (id: string, props: RoomProps) =>
227 props.roomName
228 ? Effect.succeed(props.roomName)
229 : createPhysicalName({ id, maxLength: 128 });
230
231 const toAttrs = Effect.fn(function* (room: RoomState) {
232 if (!room.arn || !room.id || !room.name) {
233 return yield* Effect.fail(
234 new IvsChatRoomIncomplete({
235 message: "IVS Chat room is missing its ARN, ID, or name",
236 }),
237 );
238 }
239 return {
240 roomName: room.name,
241 roomArn: room.arn,
242 roomId: room.id,
243 };
244 });
245
246 const getByIdentifier = Effect.fn(function* (identifier: string) {
247 return yield* ivschat.getRoom({ identifier }).pipe(
248 retryWhileThrottled,
249 Effect.catchTag("ResourceNotFoundException", () =>
250 Effect.succeed(undefined),
251 ),
252 );
253 });
254
255 /** `ListRooms` filters by name server-side; match exactly. */
256 const findByName = Effect.fn(function* (name: string) {
257 const summaries = yield* ivschat.listRooms.pages({ name }).pipe(
258 Stream.runCollect,
259 Effect.map((chunk) =>
260 Array.from(chunk).flatMap((page) => page.rooms),
261 ),
262 retryWhileThrottled,
263 );
264 const match = summaries.find((s) => s.name === name && s.arn);
265 return match?.arn ? yield* getByIdentifier(match.arn) : undefined;
266 });
267
268 return {
269 stables: ["roomArn", "roomId"],
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const room = output?.roomArn
273 ? yield* getByIdentifier(output.roomArn)
274 : yield* findByName(yield* toName(id, olds ?? {}));
275 if (room === undefined) return undefined;
276 const attrs = yield* toAttrs(room);
277 return (yield* hasAlchemyTags(id, toTagRecord(room.tags)))
278 ? attrs
279 : Unowned(attrs);
280 }),
281
282 reconcile: Effect.fn(function* ({
283 id,
284 news,
285 output,
286 session,
287 bindings,
288 }) {
289 const name = yield* toName(id, news);
290 const internalTags = yield* createInternalTags(id);
291 const desiredTags = { ...internalTags, ...news.tags };
292 const messageReviewHandler = yield* resolveMessageReviewHandler(
293 news,
294 bindings,
295 );
296
297 // 1. Observe.
298 let observed: RoomState | undefined = output?.roomArn
299 ? yield* getByIdentifier(output.roomArn)
300 : yield* findByName(name);
301
302 // 2. Ensure — create if missing.
303 if (observed === undefined) {
304 observed = yield* ivschat
305 .createRoom({
306 name,
307 maximumMessageRatePerSecond: news.maximumMessageRatePerSecond,
308 maximumMessageLength: news.maximumMessageLength,
309 messageReviewHandler,
310 loggingConfigurationIdentifiers:
311 news.loggingConfigurationIdentifiers,
312 tags: desiredTags,
313 })
314 .pipe(
315 retryWhileThrottled,
316 retryWhileHandlerPermissionPropagating,
317 );
318 }
319 const arn = observed.arn;
320 if (arn === undefined) {
321 return yield* Effect.fail(
322 new IvsChatRoomIncomplete({
323 message: "IVS Chat CreateRoom returned no room ARN",
324 }),
325 );
326 }
327
328 // 3. Sync — every room setting is mutable via UpdateRoom; apply
329 // only the drifted fields.
330 const patch: Partial<ivschat.UpdateRoomRequest> = {};
331 if (observed.name !== name) patch.name = name;
332 if (
333 news.maximumMessageRatePerSecond !== undefined &&
334 observed.maximumMessageRatePerSecond !==
335 news.maximumMessageRatePerSecond
336 ) {
337 patch.maximumMessageRatePerSecond =
338 news.maximumMessageRatePerSecond;
339 }
340 if (
341 news.maximumMessageLength !== undefined &&
342 observed.maximumMessageLength !== news.maximumMessageLength
343 ) {
344 patch.maximumMessageLength = news.maximumMessageLength;
345 }
346 // Handler sync: `uri: ""` disassociates, and the API defaults
347 // fallbackResult to ALLOW — compare normalized values so a
348 // no-handler desire converges from either direction.
349 const observedHandlerUri = observed.messageReviewHandler?.uri ?? "";
350 const desiredHandlerUri = messageReviewHandler?.uri ?? "";
351 const observedFallback =
352 observed.messageReviewHandler?.fallbackResult ?? "ALLOW";
353 const desiredFallback =
354 messageReviewHandler?.fallbackResult ?? "ALLOW";
355 if (
356 observedHandlerUri !== desiredHandlerUri ||
357 (desiredHandlerUri !== "" && observedFallback !== desiredFallback)
358 ) {
359 patch.messageReviewHandler = messageReviewHandler ?? { uri: "" };
360 }
361 if (
362 news.loggingConfigurationIdentifiers !== undefined &&
363 JSON.stringify(observed.loggingConfigurationIdentifiers ?? []) !==
364 JSON.stringify(news.loggingConfigurationIdentifiers)
365 ) {
366 patch.loggingConfigurationIdentifiers =
367 news.loggingConfigurationIdentifiers;
368 }
369 if (Object.keys(patch).length > 0) {
370 yield* ivschat
371 .updateRoom({ identifier: arn, ...patch })
372 .pipe(
373 retryWhileThrottled,
374 retryWhileHandlerPermissionPropagating,
375 );
376 }
377
378 // 3b. Sync tags — diff against OBSERVED cloud tags.
379 yield* syncIvsChatTags(arn, desiredTags);
380
381 // 4. Return fresh attributes.
382 const final = yield* getByIdentifier(arn);
383 if (final === undefined) {
384 return yield* Effect.fail(
385 new IvsChatRoomIncomplete({
386 message: `IVS Chat room '${arn}' vanished during reconcile`,
387 }),
388 );
389 }
390 yield* session.note(arn);
391 return yield* toAttrs(final);
392 }),
393
394 delete: Effect.fn(function* ({ output }) {
395 yield* ivschat.deleteRoom({ identifier: output.roomArn }).pipe(
396 retryWhileThrottled,
397 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
398 );
399 }),
400
401 list: () =>
402 ivschat.listRooms.pages({}).pipe(
403 Stream.runCollect,
404 Effect.map((chunk) =>
405 Array.from(chunk).flatMap((page) => page.rooms),
406 ),
407 Effect.flatMap(
408 Effect.forEach(
409 (summary) =>
410 toAttrs(summary).pipe(
411 // Tolerate a malformed/deleted summary — drop it.
412 Effect.catchTag("IvsChatRoomIncomplete", () =>
413 Effect.succeed(undefined),
414 ),
415 ),
416 { concurrency: 5 },
417 ),
418 ),
419 Effect.map((items) => items.filter((item) => item !== undefined)),
420 ),
421 };
422 }),
423 );