1import * as AWS from "@/AWS";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10import { HttpServerRequest } from "effect/http/HttpServerRequest";
11import * as HttpServerResponse from "effect/http/HttpServerResponse";
12
13// Deterministic bucket for async synthesis output. Polly writes the audio
14// with the CALLER's credentials, so the Lambda role is granted s3:PutObject
15// on it via the AWS.S3.PutObject binding below.
16export const BUCKET = "alchemy-test-polly-bindings";
17
18// Lexicon names must match [0-9A-Za-z]{1,20} — exactly 20 characters.
19export const LEXICON_NAME = "alchemyPollyBindings";
20
21/** Checked-in W3C PLS document (never generated at test time). */
22export const PLS_CONTENT = `<?xml version="1.0" encoding="UTF-8"?>
23<lexicon version="1.0"
24 xmlns="http://www.w3.org/2005/01/pronunciation-lexicon"
25 alphabet="ipa" xml:lang="en-US">
26 <lexeme><grapheme>IaE</grapheme><alias>infrastructure as effects</alias></lexeme>
27</lexicon>`;
28
29export const SPEECH_TEXT = "Alchemy turns IaE into deployed infrastructure.";
30
31export class PollyTestFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
32 "PollyTestFunction",
33) {}
34
35/**
36 * Shared infrastructure for the Polly bindings fixture: the async-synthesis
37 * output bucket and the pronunciation lexicon the routes reference by name.
38 */
39export class PollyFixtures extends Context.Service<
40 PollyFixtures,
41 {
42 bucket: AWS.S3.Bucket;
43 lexicon: AWS.Polly.Lexicon;
44 }
45>()("PollyFixtures") {}
46
47export const PollyFixturesLive = Layer.effect(
48 PollyFixtures,
49 Effect.gen(function* () {
50 const bucket = yield* AWS.S3.Bucket("PollyTaskOutput", {
51 bucketName: BUCKET,
52 forceDestroy: true,
53 });
54 const lexicon = yield* AWS.Polly.Lexicon("BindingsLexicon", {
55 lexiconName: LEXICON_NAME,
56 content: PLS_CONTENT,
57 });
58 return { bucket, lexicon };
59 }),
60);
61
62export default PollyTestFunction.make(
63 {
64 main: import.meta.url,
65 functionUrl: true,
66 timeout: Duration.minutes(2),
67 },
68 Effect.gen(function* () {
69 const { bucket, lexicon } = yield* PollyFixtures;
70
71 // --- bindings under test ---
72 const describeVoices = yield* AWS.Polly.DescribeVoices();
73 const listLexicons = yield* AWS.Polly.ListLexicons();
74 const getLexicon = yield* AWS.Polly.GetLexicon(lexicon);
75 const synthesizeSpeech = yield* AWS.Polly.SynthesizeSpeech();
76 const startSpeechSynthesisTask =
77 yield* AWS.Polly.StartSpeechSynthesisTask();
78 const getSpeechSynthesisTask = yield* AWS.Polly.GetSpeechSynthesisTask();
79 const listSpeechSynthesisTasks =
80 yield* AWS.Polly.ListSpeechSynthesisTasks();
81 const startSpeechSynthesisStream =
82 yield* AWS.Polly.StartSpeechSynthesisStream();
83 // Grants the Lambda role s3:PutObject on the output bucket — Polly
84 // writes the async synthesis result with the caller's credentials.
85 yield* AWS.S3.PutObject(bucket);
86
87 const bound = {
88 describeVoices,
89 listLexicons,
90 getLexicon,
91 synthesizeSpeech,
92 startSpeechSynthesisTask,
93 getSpeechSynthesisTask,
94 listSpeechSynthesisTasks,
95 startSpeechSynthesisStream,
96 };
97
98 return {
99 fetch: Effect.gen(function* () {
100 const request = yield* HttpServerRequest;
101 const url = new URL(request.originalUrl);
102 const pathname = url.pathname;
103
104 // Cheap readiness route — no AWS call.
105 if (request.method === "GET" && pathname === "/ping") {
106 return yield* HttpServerResponse.json({
107 ok: true,
108 bound: Object.keys(bound),
109 });
110 }
111
112 if (request.method === "GET" && pathname === "/voices") {
113 const result = yield* describeVoices({ LanguageCode: "en-US" });
114 return yield* HttpServerResponse.json({
115 count: (result.Voices ?? []).length,
116 voiceIds: (result.Voices ?? []).map((voice) => voice.Id),
117 });
118 }
119
120 if (request.method === "GET" && pathname === "/lexicons") {
121 const result = yield* listLexicons();
122 return yield* HttpServerResponse.json({
123 names: (result.Lexicons ?? []).flatMap((lexicon) =>
124 lexicon.Name ? [lexicon.Name] : [],
125 ),
126 });
127 }
128
129 if (request.method === "GET" && pathname === "/lexicon-content") {
130 const result = yield* getLexicon();
131 const content = result.Lexicon?.Content;
132 const xml =
133 content === undefined
134 ? undefined
135 : Redacted.isRedacted(content)
136 ? Redacted.value(content)
137 : content;
138 return yield* HttpServerResponse.json({
139 name: result.Lexicon?.Name ?? null,
140 containsAlias: xml?.includes("infrastructure as effects") ?? false,
141 lexemesCount: result.LexiconAttributes?.LexemesCount ?? null,
142 });
143 }
144
145 if (request.method === "POST" && pathname === "/stream") {
146 const result = yield* startSpeechSynthesisStream({
147 Engine: "generative",
148 OutputFormat: "mp3",
149 VoiceId: "Matthew",
150 ActionStream: Stream.make(
151 { TextEvent: { Text: SPEECH_TEXT } },
152 { CloseStreamEvent: {} },
153 ),
154 });
155 const events = Array.from(
156 yield* Stream.runCollect(result.EventStream!),
157 );
158 const audioBytes = events.reduce(
159 (total, event) =>
160 total + (event.AudioEvent?.AudioChunk?.length ?? 0),
161 0,
162 );
163 return yield* HttpServerResponse.json({
164 events: events.length,
165 audioBytes,
166 closed: events.some(
167 (event) => event.StreamClosedEvent !== undefined,
168 ),
169 });
170 }
171
172 if (request.method === "GET" && pathname === "/synthesize") {
173 const result = yield* synthesizeSpeech({
174 Engine: "neural",
175 OutputFormat: "mp3",
176 VoiceId: "Joanna",
177 Text: SPEECH_TEXT,
178 LexiconNames: [LEXICON_NAME],
179 });
180 const chunks = yield* Stream.runCollect(result.AudioStream!);
181 const byteLength = Array.from(chunks).reduce(
182 (total, chunk) => total + chunk.length,
183 0,
184 );
185 return yield* HttpServerResponse.json({
186 contentType: result.ContentType,
187 byteLength,
188 });
189 }
190
191 if (request.method === "POST" && pathname === "/task") {
192 const started = yield* startSpeechSynthesisTask({
193 Engine: "neural",
194 OutputFormat: "mp3",
195 OutputS3BucketName: BUCKET,
196 OutputS3KeyPrefix: "task-output/",
197 VoiceId: "Joanna",
198 Text: SPEECH_TEXT,
199 });
200 const taskId = started.SynthesisTask?.TaskId!;
201
202 // A freshly started task can briefly 404; retry the typed tag,
203 // then poll (bounded) until the task leaves the queue.
204 const task = yield* getSpeechSynthesisTask({ TaskId: taskId }).pipe(
205 Effect.retry({
206 while: (e) => e._tag === "SynthesisTaskNotFoundException",
207 schedule: Schedule.max([
208 Schedule.fixed("2 seconds"),
209 Schedule.recurs(5),
210 ]),
211 }),
212 Effect.repeat({
213 schedule: Schedule.spaced("2 seconds"),
214 until: (r): boolean =>
215 r.SynthesisTask?.TaskStatus === "completed" ||
216 r.SynthesisTask?.TaskStatus === "failed",
217 times: 25,
218 }),
219 );
220
221 const listed = yield* listSpeechSynthesisTasks({ MaxResults: 10 });
222
223 return yield* HttpServerResponse.json({
224 taskId,
225 status: task.SynthesisTask?.TaskStatus ?? null,
226 statusReason: task.SynthesisTask?.TaskStatusReason ?? null,
227 outputUri: task.SynthesisTask?.OutputUri ?? null,
228 listedCount: (listed.SynthesisTasks ?? []).length,
229 });
230 }
231
232 return yield* HttpServerResponse.json(
233 { error: "Not found", method: request.method, pathname },
234 { status: 404 },
235 );
236 }).pipe(
237 // Surface every failure (typed error or defect) to the test as JSON
238 // instead of an opaque 500 — the test asserts `error` is absent.
239 Effect.catchCause((cause) =>
240 HttpServerResponse.json({ error: Cause.pretty(cause) }),
241 ),
242 ),
243 };
244 }).pipe(
245 Effect.provide(
246 Layer.mergeAll(
247 AWS.Polly.DescribeVoicesHttp,
248 AWS.Polly.ListLexiconsHttp,
249 AWS.Polly.GetLexiconHttp,
250 AWS.Polly.SynthesizeSpeechHttp,
251 AWS.Polly.StartSpeechSynthesisTaskHttp,
252 AWS.Polly.GetSpeechSynthesisTaskHttp,
253 AWS.Polly.ListSpeechSynthesisTasksHttp,
254 AWS.Polly.StartSpeechSynthesisStreamHttp,
255 AWS.S3.PutObjectHttp,
256 PollyFixturesLive,
257 ),
258 ),
259 ),
260);