1import * as mediapackagev2 from "@distilled.cloud/aws/mediapackagev2";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import { toWireSeconds } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 listAllChannelGroups,
15 listChannelEndpoints,
16 listGroupChannels,
17 matchesDesired,
18 policiesEqual,
19 retryWhileMpConflict,
20 syncMpTags,
21 toMpTagRecord,
22} from "./internal.ts";
23
24export interface OriginEndpointProps {
25 /**
26 * Name of the channel group the endpoint belongs to. Changing it replaces
27 * the endpoint.
28 */
29 channelGroupName: string;
30 /**
31 * Name of the channel the endpoint packages content from. Changing it
32 * replaces the endpoint.
33 */
34 channelName: string;
35 /**
36 * Name of the origin endpoint. Must be unique within the channel and match
37 * `^[a-zA-Z0-9_-]+$`. If omitted, a unique name is generated. Changing the
38 * name replaces the endpoint.
39 */
40 originEndpointName?: string;
41 /**
42 * The container format packaged for the endpoint's manifests: `TS`,
43 * `CMAF`, or `ISM`. Immutable — changing it replaces the endpoint.
44 */
45 containerType: mediapackagev2.ContainerType;
46 /**
47 * Segment settings: duration, name, SCTE-35 ad-marker filtering, and DRM
48 * encryption (SPEKE key provider).
49 */
50 segment?: mediapackagev2.Segment;
51 /**
52 * Optional description of the origin endpoint (up to 1024 characters).
53 */
54 description?: string;
55 /**
56 * The size of the window (1 minute - 14 days, e.g. `"1 hour"` or
57 * `Duration.hours(1)`) from which viewers can start over or catch up on
58 * previously streamed content. Sent to the API in whole seconds.
59 */
60 startoverWindow?: Duration.Input;
61 /**
62 * HLS manifest configurations served by the endpoint.
63 */
64 hlsManifests?: mediapackagev2.CreateHlsManifestConfiguration[];
65 /**
66 * Low-latency HLS manifest configurations served by the endpoint.
67 */
68 lowLatencyHlsManifests?: mediapackagev2.CreateLowLatencyHlsManifestConfiguration[];
69 /**
70 * DASH manifest configurations served by the endpoint.
71 */
72 dashManifests?: mediapackagev2.CreateDashManifestConfiguration[];
73 /**
74 * Microsoft Smooth Streaming (MSS) manifest configurations served by the
75 * endpoint. Requires the `ISM` container type.
76 */
77 mssManifests?: mediapackagev2.CreateMssManifestConfiguration[];
78 /**
79 * Conditions (stale manifest, missing DRM key, ...) under which the
80 * endpoint deliberately serves errors, for testing player behavior.
81 */
82 forceEndpointErrorConfiguration?: mediapackagev2.ForceEndpointErrorConfiguration;
83 /**
84 * The separator (`UNDERSCORE` or `HYPHEN`) inserted into manifest and
85 * segment URIs.
86 */
87 uriSeparator?: mediapackagev2.UriSeparator;
88 /**
89 * IAM resource policy (JSON) attached to the origin endpoint, controlling
90 * which principals may retrieve content from it
91 * (`mediapackagev2:GetObject` / `mediapackagev2:GetHeadObject`). Omitting
92 * it removes any existing policy.
93 */
94 policy?: string;
95 /**
96 * CDN authorization for the endpoint policy: the Secrets Manager secrets
97 * holding the CDN identifier and the role MediaPackage assumes to read
98 * them. Only applied together with {@link OriginEndpointProps.policy}.
99 */
100 cdnAuthConfiguration?: mediapackagev2.CdnAuthConfiguration;
101 /**
102 * User-defined tags for the origin endpoint. Merged with internal Alchemy
103 * tags.
104 */
105 tags?: Record<string, string>;
106}
107
108interface ManifestRef {
109 /** Name of the manifest. */
110 manifestName: string;
111 /** Playback URL of the manifest on the group's egress domain. */
112 url: string;
113}
114
115export interface OriginEndpoint extends Resource<
116 "AWS.MediaPackageV2.OriginEndpoint",
117 OriginEndpointProps,
118 {
119 /** Name of the channel group the endpoint belongs to. */
120 channelGroupName: string;
121 /** Name of the channel the endpoint packages content from. */
122 channelName: string;
123 /** Name of the origin endpoint. */
124 originEndpointName: string;
125 /** ARN of the origin endpoint. */
126 originEndpointArn: string;
127 /** Output container type (`TS`, `CMAF`, or `ISM`). */
128 containerType: string;
129 /** HLS manifests served by the endpoint. */
130 hlsManifests: ManifestRef[];
131 /** Low-latency HLS manifests served by the endpoint. */
132 lowLatencyHlsManifests: ManifestRef[];
133 /** DASH manifests served by the endpoint. */
134 dashManifests: ManifestRef[];
135 /** Microsoft Smooth Streaming manifests served by the endpoint. */
136 mssManifests: ManifestRef[];
137 },
138 never,
139 Providers
140> {}
141
142/**
143 * An AWS Elemental MediaPackage v2 origin endpoint — the output side of a
144 * channel. The endpoint packages the channel's ingested content into HLS,
145 * low-latency HLS, DASH, and/or MSS manifests and serves them to downstream
146 * devices (players or CDNs) on the channel group's egress domain.
147 *
148 * ### Creating an Origin Endpoint
149 * **Example:** HLS Endpoint on a Channel
150 * ```typescript
151 * import * as MediaPackageV2 from "alchemy/AWS/MediaPackageV2";
152 *
153 * const group = yield* MediaPackageV2.ChannelGroup("Live");
154 * const channel = yield* MediaPackageV2.Channel("Feed", {
155 * channelGroupName: group.channelGroupName,
156 * });
157 * const endpoint = yield* MediaPackageV2.OriginEndpoint("Playback", {
158 * channelGroupName: group.channelGroupName,
159 * channelName: channel.channelName,
160 * containerType: "TS",
161 * hlsManifests: [{ ManifestName: "index" }],
162 * });
163 * ```
164 *
165 * **Example:** CMAF Endpoint with DASH and Low-Latency HLS
166 * ```typescript
167 * const endpoint = yield* MediaPackageV2.OriginEndpoint("Playback", {
168 * channelGroupName: group.channelGroupName,
169 * channelName: channel.channelName,
170 * containerType: "CMAF",
171 * segment: { SegmentDurationSeconds: 4 },
172 * dashManifests: [{ ManifestName: "dash" }],
173 * lowLatencyHlsManifests: [{ ManifestName: "ll-hls" }],
174 * });
175 * ```
176 *
177 * ### Startover Window
178 * **Example:** Allow viewers to catch up on the last hour
179 * ```typescript
180 * const endpoint = yield* MediaPackageV2.OriginEndpoint("Playback", {
181 * channelGroupName: group.channelGroupName,
182 * channelName: channel.channelName,
183 * containerType: "TS",
184 * startoverWindow: "1 hour",
185 * hlsManifests: [{ ManifestName: "index" }],
186 * });
187 * ```
188 *
189 * ### Resource Policy
190 * **Example:** Restrict playback to a CDN principal
191 * ```typescript
192 * const endpoint = yield* MediaPackageV2.OriginEndpoint("Playback", {
193 * channelGroupName: group.channelGroupName,
194 * channelName: channel.channelName,
195 * containerType: "TS",
196 * hlsManifests: [{ ManifestName: "index" }],
197 * policy: JSON.stringify({
198 * Version: "2012-10-17",
199 * Statement: [{
200 * Effect: "Allow",
201 * Principal: { AWS: "arn:aws:iam::111122223333:root" },
202 * Action: ["mediapackagev2:GetObject", "mediapackagev2:GetHeadObject"],
203 * Resource: "arn:aws:mediapackagev2:us-east-1:111122223333:channelGroup/live/channel/feed/originEndpoint/playback",
204 * }],
205 * }),
206 * });
207 * ```
208 *
209 * ### Playback URLs
210 * **Example:** Read the served manifest URLs
211 * ```typescript
212 * const playbackUrl = endpoint.hlsManifests.map((m) => m.url);
213 * ```
214 *
215 * @resource
216 */
217export const OriginEndpoint = Resource<OriginEndpoint>(
218 "AWS.MediaPackageV2.OriginEndpoint",
219);
220
221export const OriginEndpointProvider = () =>
222 Provider.effect(
223 OriginEndpoint,
224 Effect.gen(function* () {
225 const createName = Effect.fn(function* (
226 id: string,
227 props: { originEndpointName?: string },
228 ) {
229 return (
230 props.originEndpointName ??
231 (yield* createPhysicalName({ id, maxLength: 256 }))
232 );
233 });
234
235 const manifestRefs = (
236 manifests: { ManifestName: string; Url: string }[] | undefined,
237 ): ManifestRef[] =>
238 (manifests ?? []).map((m) => ({
239 manifestName: m.ManifestName,
240 url: m.Url,
241 }));
242
243 const toAttrs = (endpoint: {
244 Arn: string;
245 ChannelGroupName: string;
246 ChannelName: string;
247 OriginEndpointName: string;
248 ContainerType: mediapackagev2.ContainerType;
249 HlsManifests?: mediapackagev2.GetHlsManifestConfiguration[];
250 LowLatencyHlsManifests?: mediapackagev2.GetLowLatencyHlsManifestConfiguration[];
251 DashManifests?: mediapackagev2.GetDashManifestConfiguration[];
252 MssManifests?: mediapackagev2.GetMssManifestConfiguration[];
253 }) => ({
254 channelGroupName: endpoint.ChannelGroupName,
255 channelName: endpoint.ChannelName,
256 originEndpointName: endpoint.OriginEndpointName,
257 originEndpointArn: endpoint.Arn,
258 containerType: endpoint.ContainerType,
259 hlsManifests: manifestRefs(endpoint.HlsManifests),
260 lowLatencyHlsManifests: manifestRefs(endpoint.LowLatencyHlsManifests),
261 dashManifests: manifestRefs(endpoint.DashManifests),
262 mssManifests: manifestRefs(endpoint.MssManifests),
263 });
264
265 /** Get an origin endpoint; typed not-found → undefined. */
266 const getEndpoint = Effect.fn(function* (
267 channelGroupName: string,
268 channelName: string,
269 originEndpointName: string,
270 ) {
271 return yield* mediapackagev2
272 .getOriginEndpoint({
273 ChannelGroupName: channelGroupName,
274 ChannelName: channelName,
275 OriginEndpointName: originEndpointName,
276 })
277 .pipe(
278 Effect.catchTag("ResourceNotFoundException", () =>
279 Effect.succeed(undefined),
280 ),
281 );
282 });
283
284 return {
285 stables: [
286 "channelGroupName",
287 "channelName",
288 "originEndpointName",
289 "originEndpointArn",
290 "containerType",
291 ],
292
293 diff: Effect.fn(function* ({ id, olds, news }) {
294 if (!isResolved(news)) return undefined;
295 // Group, channel, name, and container type are the endpoint's
296 // identity.
297 if (olds.channelGroupName !== news.channelGroupName) {
298 return { action: "replace" } as const;
299 }
300 if (olds.channelName !== news.channelName) {
301 return { action: "replace" } as const;
302 }
303 const oldName = yield* createName(id, olds);
304 const newName = yield* createName(id, news);
305 if (oldName !== newName) return { action: "replace" } as const;
306 if (olds.containerType !== news.containerType) {
307 return { action: "replace" } as const;
308 }
309 }),
310
311 read: Effect.fn(function* ({ id, olds, output }) {
312 const channelGroupName =
313 output?.channelGroupName ?? olds?.channelGroupName;
314 const channelName = output?.channelName ?? olds?.channelName;
315 if (channelGroupName === undefined || channelName === undefined) {
316 return undefined;
317 }
318 const name =
319 output?.originEndpointName ?? (yield* createName(id, olds ?? {}));
320 const endpoint = yield* getEndpoint(
321 channelGroupName,
322 channelName,
323 name,
324 );
325 if (endpoint === undefined) return undefined;
326 const attrs = toAttrs(endpoint);
327 return (yield* hasAlchemyTags(id, toMpTagRecord(endpoint.Tags)))
328 ? attrs
329 : Unowned(attrs);
330 }),
331
332 reconcile: Effect.fn(function* ({ id, news, output, session }) {
333 const internalTags = yield* createInternalTags(id);
334 const desiredTags = { ...internalTags, ...news.tags };
335 const channelGroupName = news.channelGroupName;
336 const channelName = news.channelName;
337 // The wire field is whole seconds.
338 const startoverWindowSeconds = toWireSeconds(news.startoverWindow);
339 const name =
340 output?.originEndpointName ?? (yield* createName(id, news));
341
342 // 1. Observe — cloud state is authoritative; output is an id cache.
343 let endpoint = yield* getEndpoint(
344 channelGroupName,
345 channelName,
346 name,
347 );
348
349 // 2. Ensure — create if missing; a Conflict means a peer created it
350 // concurrently, so fall through to observing the winner.
351 if (endpoint === undefined) {
352 endpoint = yield* mediapackagev2
353 .createOriginEndpoint({
354 ChannelGroupName: channelGroupName,
355 ChannelName: channelName,
356 OriginEndpointName: name,
357 ContainerType: news.containerType,
358 Segment: news.segment,
359 Description: news.description,
360 StartoverWindowSeconds: startoverWindowSeconds,
361 HlsManifests: news.hlsManifests,
362 LowLatencyHlsManifests: news.lowLatencyHlsManifests,
363 DashManifests: news.dashManifests,
364 MssManifests: news.mssManifests,
365 ForceEndpointErrorConfiguration:
366 news.forceEndpointErrorConfiguration,
367 UriSeparator: news.uriSeparator,
368 Tags: desiredTags,
369 })
370 .pipe(
371 Effect.catchTag("ConflictException", () =>
372 mediapackagev2.getOriginEndpoint({
373 ChannelGroupName: channelGroupName,
374 ChannelName: channelName,
375 OriginEndpointName: name,
376 }),
377 ),
378 );
379 } else {
380 // 3. Sync — the update is a full PUT of the mutable aspects, so
381 // apply it only when the observed state has drifted from the
382 // desired subset. Manifest arrays are compared by length and
383 // by the fields the desired config specifies, so server-side
384 // defaults never register as drift.
385 const desired = {
386 Segment: news.segment,
387 Description: news.description ?? "",
388 StartoverWindowSeconds: startoverWindowSeconds,
389 HlsManifests: news.hlsManifests ?? [],
390 LowLatencyHlsManifests: news.lowLatencyHlsManifests ?? [],
391 DashManifests: news.dashManifests ?? [],
392 MssManifests: news.mssManifests ?? [],
393 ForceEndpointErrorConfiguration:
394 news.forceEndpointErrorConfiguration,
395 UriSeparator: news.uriSeparator,
396 };
397 const observed = {
398 Segment: endpoint.Segment,
399 Description: endpoint.Description ?? "",
400 StartoverWindowSeconds: endpoint.StartoverWindowSeconds,
401 HlsManifests: endpoint.HlsManifests ?? [],
402 LowLatencyHlsManifests: endpoint.LowLatencyHlsManifests ?? [],
403 DashManifests: endpoint.DashManifests ?? [],
404 MssManifests: endpoint.MssManifests ?? [],
405 ForceEndpointErrorConfiguration:
406 endpoint.ForceEndpointErrorConfiguration,
407 UriSeparator: endpoint.UriSeparator,
408 };
409 if (!matchesDesired(desired, observed)) {
410 endpoint = yield* mediapackagev2.updateOriginEndpoint({
411 ChannelGroupName: channelGroupName,
412 ChannelName: channelName,
413 OriginEndpointName: name,
414 ContainerType: news.containerType,
415 Segment: news.segment,
416 Description: news.description,
417 StartoverWindowSeconds: startoverWindowSeconds,
418 HlsManifests: news.hlsManifests,
419 LowLatencyHlsManifests: news.lowLatencyHlsManifests,
420 DashManifests: news.dashManifests,
421 MssManifests: news.mssManifests,
422 ForceEndpointErrorConfiguration:
423 news.forceEndpointErrorConfiguration,
424 UriSeparator: news.uriSeparator,
425 });
426 }
427 }
428
429 // 3b. Sync tags — diff against OBSERVED cloud tags.
430 yield* syncMpTags(
431 endpoint.Arn,
432 toMpTagRecord(endpoint.Tags),
433 desiredTags,
434 );
435
436 // 3c. Sync the resource policy — observe the live policy (absent
437 // policy is the typed not-found) and apply only the delta.
438 const observedPolicy = yield* mediapackagev2
439 .getOriginEndpointPolicy({
440 ChannelGroupName: channelGroupName,
441 ChannelName: channelName,
442 OriginEndpointName: name,
443 })
444 .pipe(
445 Effect.map((response) => ({
446 policy: response.Policy as string | undefined,
447 cdnAuth: response.CdnAuthConfiguration,
448 })),
449 Effect.catchTag("ResourceNotFoundException", () =>
450 Effect.succeed({
451 policy: undefined as string | undefined,
452 cdnAuth: undefined as
453 | mediapackagev2.CdnAuthConfiguration
454 | undefined,
455 }),
456 ),
457 );
458 if (news.policy !== undefined) {
459 const cdnDrift =
460 news.cdnAuthConfiguration === undefined
461 ? observedPolicy.cdnAuth !== undefined
462 : !matchesDesired(
463 news.cdnAuthConfiguration,
464 observedPolicy.cdnAuth,
465 );
466 if (
467 !policiesEqual(observedPolicy.policy, news.policy) ||
468 cdnDrift
469 ) {
470 yield* mediapackagev2.putOriginEndpointPolicy({
471 ChannelGroupName: channelGroupName,
472 ChannelName: channelName,
473 OriginEndpointName: name,
474 Policy: news.policy,
475 CdnAuthConfiguration: news.cdnAuthConfiguration,
476 });
477 }
478 } else if (observedPolicy.policy !== undefined) {
479 yield* mediapackagev2.deleteOriginEndpointPolicy({
480 ChannelGroupName: channelGroupName,
481 ChannelName: channelName,
482 OriginEndpointName: name,
483 });
484 }
485
486 yield* session.note(name);
487 return toAttrs(endpoint);
488 }),
489
490 delete: Effect.fn(function* ({ output }) {
491 // MediaPackage v2 deletes are idempotent (deleting a missing
492 // endpoint succeeds); a Conflict from a concurrent mutation is
493 // transient.
494 yield* mediapackagev2
495 .deleteOriginEndpoint({
496 ChannelGroupName: output.channelGroupName,
497 ChannelName: output.channelName,
498 OriginEndpointName: output.originEndpointName,
499 })
500 .pipe(retryWhileMpConflict);
501 }),
502
503 // Origin endpoints are keyed by their parent channel, so enumerate
504 // groups → channels → endpoints.
505 list: () =>
506 Effect.gen(function* () {
507 const groups = yield* listAllChannelGroups();
508 const channels = yield* Effect.forEach(
509 groups,
510 (group) => listGroupChannels(group.ChannelGroupName),
511 { concurrency: 5 },
512 ).pipe(Effect.map((nested) => nested.flat()));
513 const items = yield* Effect.forEach(
514 channels,
515 (channel) =>
516 listChannelEndpoints(
517 channel.ChannelGroupName,
518 channel.ChannelName,
519 ),
520 { concurrency: 5 },
521 ).pipe(Effect.map((nested) => nested.flat()));
522 // Hydrate each item via get so the attributes carry the manifest
523 // URLs; an endpoint can vanish between enumeration and hydration.
524 const endpoints = yield* Effect.forEach(
525 items,
526 (item) =>
527 getEndpoint(
528 item.ChannelGroupName,
529 item.ChannelName,
530 item.OriginEndpointName,
531 ).pipe(
532 Effect.map((endpoint) =>
533 endpoint === undefined ? undefined : toAttrs(endpoint),
534 ),
535 ),
536 { concurrency: 5 },
537 );
538 return endpoints.filter(Predicate.isNotUndefined);
539 }),
540 };
541 }),
542 );