1import * as AWS from "@/AWS";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13const failureJson = (failure: { readonly _tag: string }) => ({
14 errorTag: failure._tag,
15 errorMessage:
16 "message" in failure && typeof failure.message === "string"
17 ? failure.message
18 : undefined,
19});
20
21export class KinesisVideoTestFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
22 "KinesisVideoTestFunction",
23) {}
24
25export default KinesisVideoTestFunction.make(
26 {
27 main,
28 functionUrl: true,
29 // every route fans out to GetDataEndpoint/GetSignalingChannelEndpoint
30 // plus the data-plane call; the 3s default is too tight
31 timeout: Duration.seconds(30),
32 },
33 Effect.gen(function* () {
34 const stream = yield* AWS.KinesisVideo.Stream("FixtureStream", {
35 mediaType: "video/h264",
36 dataRetention: "24 hours",
37 });
38 const channel = yield* AWS.KinesisVideo.SignalingChannel("FixtureChannel");
39
40 const getHls = yield* AWS.KinesisVideo.GetHLSStreamingSessionURL(stream);
41 const getDash = yield* AWS.KinesisVideo.GetDASHStreamingSessionURL(stream);
42 const getClip = yield* AWS.KinesisVideo.GetClip(stream);
43 const getImages = yield* AWS.KinesisVideo.GetImages(stream);
44 const listFragments = yield* AWS.KinesisVideo.ListFragments(stream);
45 const getFragmentMedia =
46 yield* AWS.KinesisVideo.GetMediaForFragmentList(stream);
47 const getIceServers = yield* AWS.KinesisVideo.GetIceServerConfig(channel);
48 const getMedia = yield* AWS.KinesisVideo.GetMedia(stream);
49 const joinStorage = yield* AWS.KinesisVideo.JoinStorageSession(channel);
50 const joinStorageAsViewer =
51 yield* AWS.KinesisVideo.JoinStorageSessionAsViewer(channel);
52 const sendAlexaOffer =
53 yield* AWS.KinesisVideo.SendAlexaOfferToMaster(channel);
54
55 return {
56 fetch: Effect.gen(function* () {
57 const request = yield* HttpServerRequest;
58 const pathname = new URL(request.originalUrl).pathname;
59
60 if (request.method === "GET" && pathname === "/info") {
61 return yield* HttpServerResponse.json({ ok: true });
62 }
63
64 if (request.method === "GET" && pathname === "/hls") {
65 // The fixture stream has no ingested media, so LIVE playback
66 // deterministically fails with a typed error from the archived
67 // media data plane — reaching it at all proves endpoint discovery,
68 // IAM, and the signed call.
69 const result = yield* Effect.result(getHls({ PlaybackMode: "LIVE" }));
70 if (Result.isSuccess(result)) {
71 return yield* HttpServerResponse.json({
72 url: result.success.HLSStreamingSessionURL,
73 });
74 }
75 return yield* HttpServerResponse.json(failureJson(result.failure));
76 }
77
78 if (request.method === "GET" && pathname === "/dash") {
79 // Same shape as /hls — the empty stream deterministically returns
80 // the archived-media data plane's typed no-fragments error.
81 const result = yield* Effect.result(
82 getDash({ PlaybackMode: "LIVE" }),
83 );
84 if (Result.isSuccess(result)) {
85 return yield* HttpServerResponse.json({
86 url: result.success.DASHStreamingSessionURL,
87 });
88 }
89 return yield* HttpServerResponse.json({
90 ...failureJson(result.failure),
91 });
92 }
93
94 if (request.method === "GET" && pathname === "/fragments") {
95 // ListFragments on an empty (but retained) stream succeeds with an
96 // empty page — a full data-plane round-trip. A FragmentSelector is
97 // required whenever no NextToken is passed.
98 const now = yield* Effect.sync(() => Date.now());
99 const result = yield* Effect.result(
100 listFragments({
101 FragmentSelector: {
102 FragmentSelectorType: "SERVER_TIMESTAMP",
103 TimestampRange: {
104 StartTimestamp: new Date(now - 60_000),
105 EndTimestamp: new Date(now),
106 },
107 },
108 }),
109 );
110 if (Result.isSuccess(result)) {
111 return yield* HttpServerResponse.json({
112 ok: true,
113 count: (result.success.Fragments ?? []).length,
114 });
115 }
116 return yield* HttpServerResponse.json({
117 ok: false,
118 ...failureJson(result.failure),
119 });
120 }
121
122 if (request.method === "GET" && pathname === "/clip") {
123 const now = yield* Effect.sync(() => Date.now());
124 const result = yield* Effect.result(
125 getClip({
126 ClipFragmentSelector: {
127 FragmentSelectorType: "SERVER_TIMESTAMP",
128 TimestampRange: {
129 StartTimestamp: new Date(now - 60_000),
130 EndTimestamp: new Date(now),
131 },
132 },
133 }),
134 );
135 if (Result.isSuccess(result)) {
136 return yield* HttpServerResponse.json({
137 ok: true,
138 contentType: result.success.ContentType,
139 });
140 }
141 return yield* HttpServerResponse.json({
142 ok: false,
143 ...failureJson(result.failure),
144 });
145 }
146
147 if (request.method === "GET" && pathname === "/images") {
148 const now = yield* Effect.sync(() => Date.now());
149 const result = yield* Effect.result(
150 getImages({
151 ImageSelectorType: "SERVER_TIMESTAMP",
152 StartTimestamp: new Date(now - 60_000),
153 EndTimestamp: new Date(now),
154 SamplingInterval: 3000,
155 Format: "JPEG",
156 }),
157 );
158 if (Result.isSuccess(result)) {
159 return yield* HttpServerResponse.json({
160 ok: true,
161 count: (result.success.Images ?? []).length,
162 });
163 }
164 return yield* HttpServerResponse.json({
165 ok: false,
166 ...failureJson(result.failure),
167 });
168 }
169
170 if (request.method === "GET" && pathname === "/fragment-media") {
171 // A syntactically-valid but nonexistent fragment number — the
172 // empty stream deterministically returns the typed no-fragment
173 // error, proving the signed data-plane call.
174 const result = yield* Effect.result(
175 getFragmentMedia({
176 Fragments: ["91343852333181432392682062607743920994"],
177 }),
178 );
179 if (Result.isSuccess(result)) {
180 return yield* HttpServerResponse.json({
181 ok: true,
182 contentType: result.success.ContentType,
183 });
184 }
185 return yield* HttpServerResponse.json({
186 ok: false,
187 ...failureJson(result.failure),
188 });
189 }
190
191 if (request.method === "GET" && pathname === "/join-storage") {
192 // The fixture channel has no media storage configured, so the
193 // WEBRTC endpoint discovery (or the join call itself) fails with a
194 // typed error — reaching it proves IAM + endpoint resolution.
195 const result = yield* Effect.result(joinStorage());
196 return yield* HttpServerResponse.json(
197 Result.isSuccess(result)
198 ? { ok: true }
199 : { ok: false, ...failureJson(result.failure) },
200 );
201 }
202
203 if (request.method === "GET" && pathname === "/join-storage-viewer") {
204 const result = yield* Effect.result(
205 joinStorageAsViewer({ clientId: "alchemy-test-viewer" }),
206 );
207 return yield* HttpServerResponse.json(
208 Result.isSuccess(result)
209 ? { ok: true }
210 : { ok: false, ...failureJson(result.failure) },
211 );
212 }
213
214 if (request.method === "GET" && pathname === "/alexa-offer") {
215 // No master peer is connected to the fixture channel, so the
216 // service either rejects the junk SDP payload with a typed
217 // InvalidArgumentException or holds the offer for redelivery until
218 // our bounded timeout — both prove endpoint discovery, IAM, and
219 // the signed signaling call.
220 const result = yield* Effect.result(
221 sendAlexaOffer({
222 SenderClientId: "alchemy-test-alexa",
223 // base64("v=0") — a minimal, syntactically-plausible SDP stub
224 MessagePayload: "dj0w",
225 }).pipe(Effect.timeoutOption("5 seconds")),
226 );
227 if (Result.isSuccess(result)) {
228 return yield* HttpServerResponse.json(
229 Option.isNone(result.success)
230 ? { ok: true, timedOut: true }
231 : {
232 ok: true,
233 answered: result.success.value.Answer !== undefined,
234 },
235 );
236 }
237 return yield* HttpServerResponse.json({
238 ok: false,
239 ...failureJson(result.failure),
240 });
241 }
242
243 if (request.method === "GET" && pathname === "/ice") {
244 const result = yield* Effect.result(
245 getIceServers({ ClientId: "alchemy-test" }),
246 );
247 if (Result.isFailure(result)) {
248 return yield* HttpServerResponse.json({
249 ok: false,
250 ...failureJson(result.failure),
251 });
252 }
253 return yield* HttpServerResponse.json({
254 servers: (result.success.IceServerList ?? []).map((server) => ({
255 uris: server.Uris ?? [],
256 hasCredentials:
257 server.Username !== undefined && server.Password !== undefined,
258 ttl: server.Ttl,
259 })),
260 });
261 }
262
263 if (request.method === "GET" && pathname === "/media") {
264 // The empty stream never produces fragments; the connection to the
265 // per-stream media endpoint either opens (headers arrive) or idles
266 // until the bounded timeout. Both prove the data-plane call was
267 // accepted — auth/endpoint failures surface as typed errors.
268 const result = yield* Effect.result(
269 getMedia({
270 StartSelector: { StartSelectorType: "EARLIEST" },
271 }).pipe(Effect.timeoutOption("5 seconds")),
272 );
273 if (Result.isSuccess(result)) {
274 if (Option.isNone(result.success)) {
275 return yield* HttpServerResponse.json({
276 ok: true,
277 timedOut: true,
278 });
279 }
280 return yield* HttpServerResponse.json({
281 ok: true,
282 contentType: result.success.value.ContentType,
283 });
284 }
285 return yield* HttpServerResponse.json({
286 ok: false,
287 ...failureJson(result.failure),
288 });
289 }
290
291 return yield* HttpServerResponse.json(
292 { error: "Not found" },
293 { status: 404 },
294 );
295 }).pipe(Effect.orDie),
296 };
297 }).pipe(
298 Effect.provide(
299 Layer.mergeAll(
300 AWS.KinesisVideo.GetHLSStreamingSessionURLHttp,
301 AWS.KinesisVideo.GetDASHStreamingSessionURLHttp,
302 AWS.KinesisVideo.GetClipHttp,
303 AWS.KinesisVideo.GetImagesHttp,
304 AWS.KinesisVideo.ListFragmentsHttp,
305 AWS.KinesisVideo.GetMediaForFragmentListHttp,
306 AWS.KinesisVideo.GetIceServerConfigHttp,
307 AWS.KinesisVideo.SendAlexaOfferToMasterHttp,
308 AWS.KinesisVideo.GetMediaHttp,
309 AWS.KinesisVideo.JoinStorageSessionHttp,
310 AWS.KinesisVideo.JoinStorageSessionAsViewerHttp,
311 ),
312 ),
313 ),
314);