1import * as qbusiness from "@distilled.cloud/aws/qbusiness";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 type Tags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export type WebExperienceStatus = qbusiness.WebExperienceStatus;
18
19export interface WebExperienceProps {
20 /**
21 * The identifier of the Amazon Q Business application the web experience
22 * fronts. Changing it replaces the web experience.
23 */
24 applicationId: string;
25 /**
26 * The title displayed in the web experience header.
27 */
28 title?: string;
29 /**
30 * The subtitle displayed under the title.
31 */
32 subtitle?: string;
33 /**
34 * A custom welcome message displayed when a conversation starts.
35 */
36 welcomeMessage?: string;
37 /**
38 * Whether sample chat prompts are shown to end users.
39 */
40 samplePromptsControlMode?: qbusiness.WebExperienceSamplePromptsControlMode;
41 /**
42 * Website domains allowed to embed the web experience.
43 */
44 origins?: string[];
45 /**
46 * ARN of the IAM role the web experience assumes to talk to the
47 * application on behalf of end users.
48 */
49 roleArn?: string;
50 /**
51 * External identity-provider settings (SAML or OIDC) for the web
52 * experience.
53 */
54 identityProviderConfiguration?: qbusiness.IdentityProviderConfiguration;
55 /**
56 * Which browser extensions (Chrome, Firefox) may connect to the web
57 * experience.
58 */
59 browserExtensionConfiguration?: qbusiness.BrowserExtensionConfiguration;
60 /**
61 * Custom logo/font/CSS customization for the web experience.
62 */
63 customizationConfiguration?: qbusiness.CustomizationConfiguration;
64 /**
65 * Tags to associate with the web experience.
66 */
67 tags?: Record<string, string>;
68}
69
70export interface WebExperience extends Resource<
71 "AWS.QBusiness.WebExperience",
72 WebExperienceProps,
73 {
74 /**
75 * Service-assigned unique identifier of the web experience (unique
76 * within its application).
77 */
78 webExperienceId: string;
79 /**
80 * The identifier of the application the web experience belongs to.
81 */
82 applicationId: string;
83 /**
84 * ARN of the web experience.
85 */
86 webExperienceArn: string;
87 /**
88 * The AWS-hosted URL end users open to chat with the application.
89 */
90 defaultEndpoint: string | undefined;
91 /**
92 * Current lifecycle status of the web experience.
93 */
94 status: WebExperienceStatus | undefined;
95 /**
96 * Current tags reported for the web experience.
97 */
98 tags: Record<string, string>;
99 },
100 never,
101 Providers
102> {}
103
104/**
105 * An Amazon Q Business web experience — the hosted chat UI end users open
106 * to converse with an application.
107 *
108 * ### Creating Web Experiences
109 * **Example:** Basic Web Experience
110 * ```typescript
111 * import * as AWS from "alchemy/AWS";
112 *
113 * const web = yield* AWS.QBusiness.WebExperience("Chat", {
114 * applicationId: app.applicationId,
115 * title: "Company Assistant",
116 * welcomeMessage: "Ask me anything about our docs.",
117 * });
118 * ```
119 *
120 * **Example:** Embeddable Web Experience
121 * ```typescript
122 * const web = yield* AWS.QBusiness.WebExperience("Chat", {
123 * applicationId: app.applicationId,
124 * origins: ["https://intranet.example.com"],
125 * samplePromptsControlMode: "ENABLED",
126 * });
127 * ```
128 *
129 * @resource
130 */
131export const WebExperience = Resource<WebExperience>(
132 "AWS.QBusiness.WebExperience",
133);
134
135const fetchTags = Effect.fn(function* (arn: string) {
136 const response = yield* qbusiness
137 .listTagsForResource({ resourceARN: arn })
138 .pipe(
139 Effect.catchTag("ResourceNotFoundException", () =>
140 Effect.succeed(undefined),
141 ),
142 );
143 return Object.fromEntries(
144 (response?.tags ?? []).map((tag) => [tag.key, tag.value]),
145 );
146});
147
148interface WebExperienceState {
149 attrs: WebExperience["Attributes"];
150 described: qbusiness.GetWebExperienceResponse;
151}
152
153const readWebExperienceById = Effect.fn(function* (
154 applicationId: string,
155 webExperienceId: string,
156) {
157 const described = yield* qbusiness
158 .getWebExperience({ applicationId, webExperienceId })
159 .pipe(
160 Effect.catchTag("ResourceNotFoundException", () =>
161 Effect.succeed(undefined),
162 ),
163 );
164 if (!described || described.status === "DELETING") return undefined;
165 const arn = described.webExperienceArn;
166 if (arn === undefined) return undefined;
167 const state: WebExperienceState = {
168 described,
169 attrs: {
170 webExperienceId: described.webExperienceId ?? webExperienceId,
171 applicationId: described.applicationId ?? applicationId,
172 webExperienceArn: arn,
173 defaultEndpoint: described.defaultEndpoint,
174 status: described.status,
175 tags: yield* fetchTags(arn),
176 },
177 };
178 return state;
179});
180
181/**
182 * A web experience still transitioning toward a settled status — retried by
183 * {@link waitForWebExperienceSettled}'s bounded schedule.
184 */
185class WebExperienceNotReady extends Data.TaggedError("WebExperienceNotReady")<{
186 readonly webExperienceId: string;
187 readonly status: string | undefined;
188}> {}
189
190/**
191 * A web experience whose asynchronous provisioning converged to the
192 * terminal `FAILED` status.
193 */
194export class WebExperienceProvisioningFailed extends Data.TaggedError(
195 "WebExperienceProvisioningFailed",
196)<{
197 readonly webExperienceId: string;
198 readonly message: string | undefined;
199}> {}
200
201// Explicitly-typed retry wrapper — an inline `Effect.retry` in provider
202// lifecycle code leaks `Retry.Return`'s conditional type into declaration
203// emit and widens the provider layer to `unknown` for every consumer of
204// `AWS.providers()`.
205const retryWhileNotReady = <A, E extends { readonly _tag: string }, R>(
206 self: Effect.Effect<A, E, R>,
207): Effect.Effect<A, E, R> =>
208 Effect.retry(self, {
209 while: (e) => e._tag === "WebExperienceNotReady",
210 // Web experience provisioning is fast; poll every 5s up to ~5 min.
211 schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(60)]),
212 });
213
214// A web experience without identity-provider auth settles in
215// PENDING_AUTH_CONFIG rather than ACTIVE — both are converged states.
216const waitForWebExperienceSettled = (
217 applicationId: string,
218 webExperienceId: string,
219 target: "SETTLED" | "DELETED",
220) =>
221 retryWhileNotReady(
222 Effect.gen(function* () {
223 const described = yield* qbusiness
224 .getWebExperience({ applicationId, webExperienceId })
225 .pipe(
226 Effect.catchTag("ResourceNotFoundException", () =>
227 Effect.succeed(undefined),
228 ),
229 );
230 if (target === "DELETED") {
231 if (described === undefined) return;
232 return yield* Effect.fail(
233 new WebExperienceNotReady({
234 webExperienceId,
235 status: described.status,
236 }),
237 );
238 }
239 if (
240 described?.status === "ACTIVE" ||
241 described?.status === "PENDING_AUTH_CONFIG"
242 ) {
243 return;
244 }
245 if (described?.status === "FAILED") {
246 return yield* Effect.fail(
247 new WebExperienceProvisioningFailed({
248 webExperienceId,
249 message: described.error?.errorMessage,
250 }),
251 );
252 }
253 return yield* Effect.fail(
254 new WebExperienceNotReady({
255 webExperienceId,
256 status: described?.status,
257 }),
258 );
259 }),
260 );
261
262export const WebExperienceProvider = () =>
263 Provider.effect(
264 WebExperience,
265 Effect.gen(function* () {
266 return {
267 stables: ["webExperienceId", "applicationId", "webExperienceArn"],
268 // Keyed by a parent application; cannot be enumerated account-wide
269 // without iterating every application — treated as a sub-resource per
270 // the factory list() convention.
271 list: () => Effect.succeed([]),
272 read: Effect.fn(function* ({ id, olds, output }) {
273 const applicationId = output?.applicationId ?? olds?.applicationId;
274 // Web experience summaries carry no display name, so there is no
275 // name-based fallback — without a cached id the resource is
276 // treated as missing.
277 if (
278 applicationId === undefined ||
279 output?.webExperienceId === undefined
280 ) {
281 return undefined;
282 }
283 const state = yield* readWebExperienceById(
284 applicationId,
285 output.webExperienceId,
286 );
287 if (!state) return undefined;
288 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
289 ? state.attrs
290 : Unowned(state.attrs);
291 }),
292 diff: Effect.fn(function* ({ news, olds }) {
293 if (!isResolved(news)) return;
294 if (olds === undefined) return;
295 // The parent application is fixed at creation.
296 if (olds.applicationId !== news.applicationId) {
297 return { action: "replace" } as const;
298 }
299 }),
300 reconcile: Effect.fn(function* ({ id, news, output, session }) {
301 if (!news) {
302 return yield* Effect.fail(
303 new Error("QBusiness WebExperience requires props"),
304 );
305 }
306 const applicationId = news.applicationId;
307 const internalTags = yield* createInternalTags(id);
308 const desiredTags = { ...internalTags, ...news.tags };
309
310 // Observe — the cached id is the only handle (summaries carry no
311 // display name to search by).
312 let state = output?.webExperienceId
313 ? yield* readWebExperienceById(
314 applicationId,
315 output.webExperienceId,
316 )
317 : undefined;
318
319 // Ensure — create if missing, then wait for a settled status.
320 if (state === undefined) {
321 const created = yield* qbusiness.createWebExperience({
322 applicationId,
323 title: news.title,
324 subtitle: news.subtitle,
325 welcomeMessage: news.welcomeMessage,
326 samplePromptsControlMode: news.samplePromptsControlMode,
327 origins: news.origins,
328 roleArn: news.roleArn,
329 identityProviderConfiguration: news.identityProviderConfiguration,
330 browserExtensionConfiguration: news.browserExtensionConfiguration,
331 customizationConfiguration: news.customizationConfiguration,
332 tags: Object.entries(desiredTags).map(([key, value]) => ({
333 key,
334 value,
335 })),
336 });
337 if (!created.webExperienceId) {
338 return yield* Effect.fail(
339 new Error(
340 `CreateWebExperience for '${id}' returned no webExperienceId`,
341 ),
342 );
343 }
344 yield* session.note(
345 `Creating web experience (${created.webExperienceId})...`,
346 );
347 yield* waitForWebExperienceSettled(
348 applicationId,
349 created.webExperienceId,
350 "SETTLED",
351 );
352 state = yield* readWebExperienceById(
353 applicationId,
354 created.webExperienceId,
355 );
356 if (state === undefined) {
357 return yield* Effect.fail(
358 new Error(`failed to read created web experience for '${id}'`),
359 );
360 }
361 }
362
363 // Sync mutable settings via UpdateWebExperience — only when
364 // drifted.
365 const described = state.described;
366 const needsUpdate =
367 (news.title ?? "") !== (described.title ?? "") ||
368 (news.subtitle ?? "") !== (described.subtitle ?? "") ||
369 (news.welcomeMessage ?? "") !== (described.welcomeMessage ?? "") ||
370 (news.samplePromptsControlMode !== undefined &&
371 news.samplePromptsControlMode !==
372 described.samplePromptsControlMode) ||
373 (news.roleArn !== undefined &&
374 news.roleArn !== described.roleArn) ||
375 JSON.stringify(news.origins ?? []) !==
376 JSON.stringify(described.origins ?? []) ||
377 news.identityProviderConfiguration !== undefined ||
378 news.browserExtensionConfiguration !== undefined ||
379 news.customizationConfiguration !== undefined;
380 if (needsUpdate) {
381 yield* qbusiness.updateWebExperience({
382 applicationId,
383 webExperienceId: state.attrs.webExperienceId,
384 title: news.title,
385 subtitle: news.subtitle,
386 welcomeMessage: news.welcomeMessage,
387 samplePromptsControlMode: news.samplePromptsControlMode,
388 origins: news.origins,
389 roleArn: news.roleArn,
390 identityProviderConfiguration: news.identityProviderConfiguration,
391 browserExtensionConfiguration: news.browserExtensionConfiguration,
392 customizationConfiguration: news.customizationConfiguration,
393 });
394 yield* waitForWebExperienceSettled(
395 applicationId,
396 state.attrs.webExperienceId,
397 "SETTLED",
398 );
399 yield* session.note(`Updated web experience ${id}`);
400 }
401
402 // Sync tags — diff against observed cloud tags.
403 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
404 if (removed.length > 0) {
405 yield* qbusiness.untagResource({
406 resourceARN: state.attrs.webExperienceArn,
407 tagKeys: removed,
408 });
409 }
410 if (upsert.length > 0) {
411 yield* qbusiness.tagResource({
412 resourceARN: state.attrs.webExperienceArn,
413 tags: upsert.map(({ Key, Value }) => ({
414 key: Key,
415 value: Value,
416 })),
417 });
418 }
419
420 yield* session.note(state.attrs.webExperienceArn);
421
422 const final = yield* readWebExperienceById(
423 applicationId,
424 state.attrs.webExperienceId,
425 );
426 if (!final) {
427 return yield* Effect.fail(
428 new Error(`failed to read reconciled web experience for '${id}'`),
429 );
430 }
431 return final.attrs;
432 }),
433 delete: Effect.fn(function* ({ output }) {
434 yield* qbusiness
435 .deleteWebExperience({
436 applicationId: output.applicationId,
437 webExperienceId: output.webExperienceId,
438 })
439 .pipe(
440 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
441 );
442 yield* waitForWebExperienceSettled(
443 output.applicationId,
444 output.webExperienceId,
445 "DELETED",
446 );
447 }),
448 };
449 }),
450 );