1import type * as cf from "@cloudflare/workers-types";
2import * as Data from "effect/Data";
3import type * as Effect from "effect/Effect";
4import type { RuntimeContext } from "../../RuntimeContext.ts";
5import * as Binding from "../Workers/Binding.ts";
6import type { StreamBinding } from "./StreamBinding.ts";
7
8const TypeId = "Cloudflare.Stream.Stream" as const;
9type TypeId = typeof TypeId;
10
11/**
12 * Error produced by a {@link StreamClient} operation. Mirrors the runtime's
13 * `StreamBindingError` shape (`code` is the Cloudflare Stream error code,
14 * `statusCode` the HTTP status it maps to) when the failure crosses the
15 * binding boundary.
16 */
17export class StreamError extends Data.TaggedError("StreamError")<{
18 message: string;
19 code?: number;
20 statusCode?: number;
21 cause: unknown;
22}> {}
23
24/** An Effect produced by a {@link StreamClient} operation. */
25type StreamEffect<A> = Effect.Effect<A, StreamError, RuntimeContext>;
26
27/**
28 * A Cloudflare Stream binding for managing videos, captions, downloads and
29 * watermarks from Workers — a Worker-only binding with no backing cloud
30 * resource.
31 *
32 * `Stream` is a single value that is at once the `Binding.Service` tag, the
33 * callable that produces a {@link StreamBinding}, and the type. Declare it on a
34 * Worker's `env` (it flows through `InferEnv` → the runtime `StreamBinding`
35 * handle) or `yield*` it inside an Effect-native Worker to attach the binding
36 * and obtain the {@link StreamClient}.
37 *
38 * In `alchemy dev` the binding is emulated locally: uploads land in a local
39 * video store and each video's `preview` URL is served unmodified at
40 * `{devUrl}/cdn-cgi/mf/stream/<id>/watch`. The local store performs no
41 * transcoding (the `hlsPlaybackUrl`/`dashPlaybackUrl` point at a placeholder
42 * host), no signed URLs, and `createDirectUpload` is unsupported. Pipe the
43 * binding through `Alchemy.remote()` to proxy to the real Stream service
44 * instead.
45 *
46 * ### Effect-style Worker (recommended)
47 * **Example:** Upload a video by URL and read back its details
48 * ```typescript
49 * import * as Effect from "effect/Effect";
50 *
51 * Cloudflare.Worker(
52 * "StreamWorker",
53 * { main: import.meta.url },
54 * Effect.gen(function* () {
55 * const stream = yield* Cloudflare.Stream.Stream("STREAM");
56 *
57 * return {
58 * fetch: Effect.gen(function* () {
59 * const video = yield* stream.upload("https://example.com/video.mp4");
60 * const details = yield* stream.video(video.id).details();
61 * return yield* HttpServerResponse.json(details);
62 * }),
63 * };
64 * }).pipe(Effect.provide(Cloudflare.Stream.StreamBinding)),
65 * );
66 * ```
67 *
68 * ### Worker binding metadata
69 * **Example:** Declare the binding on `env`
70 * ```typescript
71 * export const Worker = Cloudflare.Worker("Worker", {
72 * main: "./src/worker.ts",
73 * env: { STREAM: Cloudflare.Stream.Stream() },
74 * });
75 *
76 * export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
77 * // { STREAM: StreamBinding }
78 * ```
79 *
80 * ### Local development
81 * **Example:** Proxy to the real Stream service in dev
82 * ```typescript
83 * // Default: videos land in the local video store under `alchemy dev`.
84 * // Alchemy.remote() opts the binding into the real Stream service
85 * // instead — in an Effect-native Worker:
86 * const stream = yield* Cloudflare.Stream.Stream("STREAM").pipe(Alchemy.remote());
87 *
88 * // or declared on an async Worker's env:
89 * env: { STREAM: Cloudflare.Stream.Stream("STREAM").pipe(Alchemy.remote()) }
90 * ```
91 *
92 * @see https://developers.cloudflare.com/stream/
93 *
94 * @binding
95 * @product Stream
96 * @category Media
97 */
98export interface Stream extends Binding.Service<Stream, TypeId, StreamClient> {
99 /**
100 * @param name Binding name (logical id) — the `env` key it resolves to.
101 * @default "STREAM"
102 */
103 (name?: string): StreamBinding;
104}
105
106export const Stream = Binding.Service<Stream>({
107 id: TypeId,
108 defaultName: "STREAM",
109 toWorkerBinding: (binding) => ({
110 type: "stream",
111 name: binding.name,
112 }),
113});
114
115export const isStream = (value: unknown): value is StreamBinding =>
116 Binding.isBinding(value) && value.kind === TypeId;
117
118/**
119 * Effect-native client for a Cloudflare Stream binding. Mirrors the runtime
120 * {@link cf.StreamBinding} handle: each operation returns an Effect tagged with
121 * {@link StreamError}; `video(id)` returns a pure per-video handle.
122 */
123export interface StreamClient {
124 /** Effect resolving to the raw Cloudflare Stream runtime binding. */
125 raw: Effect.Effect<cf.StreamBinding, never, RuntimeContext>;
126 /** Upload a new video from a URL. */
127 upload(
128 url: string,
129 params?: cf.StreamUrlUploadParams,
130 ): StreamEffect<cf.StreamVideo>;
131 /**
132 * Create a direct-creator upload (video uploads without an API key).
133 * Unsupported by the local dev simulator — fails with {@link StreamError}.
134 */
135 createDirectUpload(
136 params: cf.StreamDirectUploadCreateParams,
137 ): StreamEffect<cf.StreamDirectUpload>;
138 /** A handle scoped to a single video for per-video operations. */
139 video(id: string): StreamVideoClient;
140 /** Account-level video operations. */
141 videos: {
142 /** List videos. */
143 list(params?: cf.StreamVideosListParams): StreamEffect<cf.StreamVideo[]>;
144 };
145 /** Watermark profile operations. */
146 watermarks: {
147 /** Create a watermark profile from an image stream or URL. */
148 generate(
149 input: ReadableStream | string,
150 params: cf.StreamWatermarkCreateParams,
151 ): StreamEffect<cf.StreamWatermark>;
152 /** List watermark profiles. */
153 list(): StreamEffect<cf.StreamWatermark[]>;
154 /** Get a watermark profile. */
155 get(watermarkId: string): StreamEffect<cf.StreamWatermark>;
156 /** Delete a watermark profile. */
157 delete(watermarkId: string): StreamEffect<void>;
158 };
159}
160
161/** Effect-native handle for operations scoped to a single Stream video. */
162export interface StreamVideoClient {
163 /** The unique identifier for the video. */
164 readonly id: string;
165 /** Get the full video details. */
166 details(): StreamEffect<cf.StreamVideo>;
167 /** Update details for the video. */
168 update(params: cf.StreamUpdateVideoParams): StreamEffect<cf.StreamVideo>;
169 /** Delete the video and its copies. */
170 delete(): StreamEffect<void>;
171 /** Create a signed URL token for the video. */
172 generateToken(): StreamEffect<string>;
173 /** MP4 download operations for the video. */
174 downloads: {
175 /** Generate a download of the given type. */
176 generate(
177 downloadType?: cf.StreamDownloadType,
178 ): StreamEffect<cf.StreamDownloadGetResponse>;
179 /** Get the video's downloads. */
180 get(): StreamEffect<cf.StreamDownloadGetResponse>;
181 /** Delete a download of the given type. */
182 delete(downloadType?: cf.StreamDownloadType): StreamEffect<void>;
183 };
184 /** Caption operations for the video. */
185 captions: {
186 /** Upload a WebVTT caption file for a language. */
187 upload(
188 language: string,
189 input: ReadableStream,
190 ): StreamEffect<cf.StreamCaption>;
191 /** Generate a caption via AI for a language. */
192 generate(language: string): StreamEffect<cf.StreamCaption>;
193 /** List the video's captions, optionally filtered by language. */
194 list(language?: string): StreamEffect<cf.StreamCaption[]>;
195 /** Delete the caption for a language. */
196 delete(language: string): StreamEffect<void>;
197 };
198}