diff --git a/.changeset/gentle-teams-cut.md b/.changeset/gentle-teams-cut.md new file mode 100644 index 000000000000..aee3579640a0 --- /dev/null +++ b/.changeset/gentle-teams-cut.md @@ -0,0 +1,6 @@ +--- +"miniflare": minor +"wrangler": minor +--- + +Add media binding support diff --git a/packages/miniflare/src/plugins/index.ts b/packages/miniflare/src/plugins/index.ts index 7114acb208f7..d956fec9dafb 100644 --- a/packages/miniflare/src/plugins/index.ts +++ b/packages/miniflare/src/plugins/index.ts @@ -24,6 +24,7 @@ import { HELLO_WORLD_PLUGIN, HELLO_WORLD_PLUGIN_NAME } from "./hello-world"; import { HYPERDRIVE_PLUGIN, HYPERDRIVE_PLUGIN_NAME } from "./hyperdrive"; import { IMAGES_PLUGIN, IMAGES_PLUGIN_NAME } from "./images"; import { KV_PLUGIN, KV_PLUGIN_NAME } from "./kv"; +import { MEDIA_PLUGIN, MEDIA_PLUGIN_NAME } from "./media"; import { MTLS_PLUGIN, MTLS_PLUGIN_NAME } from "./mtls"; import { PIPELINE_PLUGIN, PIPELINES_PLUGIN_NAME } from "./pipelines"; import { QUEUES_PLUGIN, QUEUES_PLUGIN_NAME } from "./queues"; @@ -63,6 +64,7 @@ export const PLUGINS = { [MTLS_PLUGIN_NAME]: MTLS_PLUGIN, [HELLO_WORLD_PLUGIN_NAME]: HELLO_WORLD_PLUGIN, [WORKER_LOADER_PLUGIN_NAME]: WORKER_LOADER_PLUGIN, + [MEDIA_PLUGIN_NAME]: MEDIA_PLUGIN, }; export type Plugins = typeof PLUGINS; @@ -124,7 +126,8 @@ export type WorkerOptions = z.input & z.input & z.input & z.input & - z.input; + z.input & + z.input; export type SharedOptions = z.input & z.input & @@ -197,3 +200,4 @@ export * from "./vpc-services"; export * from "./mtls"; export * from "./hello-world"; export * from "./worker-loader"; +export * from "./media"; diff --git a/packages/miniflare/src/plugins/media/index.ts b/packages/miniflare/src/plugins/media/index.ts new file mode 100644 index 000000000000..ce60decfc980 --- /dev/null +++ b/packages/miniflare/src/plugins/media/index.ts @@ -0,0 +1,103 @@ +import assert from "node:assert"; +import BINDING from "worker:media/binding"; +import { z } from "zod"; +import { + getUserBindingServiceName, + Plugin, + ProxyNodeBinding, + remoteProxyClientWorker, + RemoteProxyConnectionString, +} from "../shared"; + +export const MEDIA_PLUGIN_NAME = "media"; + +const MediaSchema = z.object({ + binding: z.string(), + remoteProxyConnectionString: z.custom(), +}); + +export const MediaOptionsSchema = z.object({ + media: MediaSchema.optional(), +}); + +export const MEDIA_PLUGIN: Plugin = { + options: MediaOptionsSchema, + async getBindings(options) { + if (!options.media) { + return []; + } + + assert( + options.media.remoteProxyConnectionString, + "Media only supports running remotely" + ); + + return [ + { + name: options.media.binding, + service: { + name: getUserBindingServiceName( + MEDIA_PLUGIN_NAME, + options.media.binding, + options.media.remoteProxyConnectionString + ), + }, + }, + ]; + }, + getNodeBindings(options: z.infer) { + if (!options.media) { + return {}; + } + return { + [options.media.binding]: new ProxyNodeBinding(), + }; + }, + async getServices({ options }) { + if (!options.media) { + return []; + } + + return [ + { + name: getUserBindingServiceName( + MEDIA_PLUGIN_NAME, + options.media.binding, + options.media.remoteProxyConnectionString + ), + worker: { + compatibilityDate: "2025-01-01", + modules: [ + { + name: "index.worker.js", + esModule: BINDING(), + }, + ], + bindings: [ + { + name: "remote", + service: { + name: getUserBindingServiceName( + `${MEDIA_PLUGIN_NAME}:remote`, + options.media.binding, + options.media.remoteProxyConnectionString + ), + }, + }, + ], + }, + }, + { + name: getUserBindingServiceName( + `${MEDIA_PLUGIN_NAME}:remote`, + options.media.binding, + options.media.remoteProxyConnectionString + ), + worker: remoteProxyClientWorker( + options.media.remoteProxyConnectionString, + options.media.binding + ), + }, + ]; + }, +}; diff --git a/packages/miniflare/src/workers/media/binding.worker.ts b/packages/miniflare/src/workers/media/binding.worker.ts new file mode 100644 index 000000000000..2bb50df0fd6d --- /dev/null +++ b/packages/miniflare/src/workers/media/binding.worker.ts @@ -0,0 +1,104 @@ +import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; + +type Env = { + remote: Fetcher; +}; + +type FitOption = "contain" | "cover" | "scale-down"; +type FormatOption = "jpg" | "png" | "m4a"; +type ModeOption = "video" | "spritesheet" | "frame" | "audio"; + +type MediaTransformationInputOptions = { + fit?: FitOption; + width?: number; + height?: number; +}; + +type MediaTransformationOutputOptions = { + mode?: ModeOption; + audio?: boolean; + time?: string; + duration?: string; + format?: FormatOption; + imageCount?: number; +}; + +export default class MediaBinding extends WorkerEntrypoint { + async input(media: ReadableStream): Promise { + return new MediaTransformer(this.env.remote, media); + } +} + +class MediaTransformer extends RpcTarget { + constructor( + private remote: Fetcher, + private input: ReadableStream + ) { + super(); + } + + transform( + options: MediaTransformationInputOptions + ): MediaTransformationGenerator { + return new MediaTransformationGenerator(this.remote, this.input, options); + } +} + +class MediaTransformationGenerator extends RpcTarget { + constructor( + private remote: Fetcher, + private input: ReadableStream, + private inputOptions: MediaTransformationInputOptions + ) { + super(); + } + + async output( + outputOptions: MediaTransformationOutputOptions + ): Promise { + const resp = await this.remote.fetch(`http://example.com`, { + body: this.input, + method: "POST", + headers: { + "x-cf-media-input-options": JSON.stringify(this.inputOptions), + "x-cf-media-output-options": JSON.stringify(outputOptions), + }, + }); + + const contentType = resp.headers.get("x-cf-media-content-type") as string; + + return new MediaTransformationResult( + resp.body as ReadableStream, + contentType + ); + } +} + +class MediaTransformationResult extends RpcTarget { + constructor( + private responseStream: ReadableStream, + private responseContentType: string + ) { + super(); + } + + media() { + const [stream1, stream2] = this.responseStream.tee(); + this.responseStream = stream1; + return stream2; + } + + response() { + const [stream1, stream2] = this.responseStream.tee(); + this.responseStream = stream1; + return new Response(stream2, { + headers: { + "Content-Type": this.contentType(), + }, + }); + } + + contentType() { + return this.responseContentType; + } +} diff --git a/packages/wrangler/e2e/dev-with-resources.test.ts b/packages/wrangler/e2e/dev-with-resources.test.ts index bb5269e15d4c..d716ee35e395 100644 --- a/packages/wrangler/e2e/dev-with-resources.test.ts +++ b/packages/wrangler/e2e/dev-with-resources.test.ts @@ -870,6 +870,36 @@ describe.sequential.each(RUNTIMES)("Bindings: $flags", ({ runtime, flags }) => { }); }); + it.skipIf(!CLOUDFLARE_ACCOUNT_ID)("exposes Media bindings", async () => { + await helper.seed({ + "wrangler.toml": dedent` + name = "my-media-demo" + main = "src/index.ts" + compatibility_date = "2025-09-06" + + [media] + binding = "MEDIA" + remote = true + `, + "src/index.ts": dedent` + export default { + async fetch(request, env, ctx) { + if (env.MEDIA === undefined) { + return new Response("env.MEDIA is undefined"); + } + + return new Response("env.MEDIA is available"); + } + } + `, + }); + const worker = helper.runLongLived(`wrangler dev`); + const { url } = await worker.waitForReady(); + const res = await fetch(url); + + await expect(res.text()).resolves.toBe("env.MEDIA is available"); + }); + // TODO(soon): implement E2E tests for other bindings it.skipIf(isLocal).todo("exposes send email bindings"); it.skipIf(isLocal).todo("exposes browser bindings"); diff --git a/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts b/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts index 5a4f386f7460..20678fc8c5e4 100644 --- a/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts +++ b/packages/wrangler/e2e/remote-binding/miniflare-remote-resources.test.ts @@ -285,6 +285,24 @@ const testCases: TestCase[] = [ }), matches: [expect.stringContaining(`image/avif`)], }, + { + name: "Media", + scriptPath: "media.js", + remoteProxySessionConfig: [ + { + MEDIA: { + type: "media", + }, + }, + ], + miniflareConfig: (connection) => ({ + media: { + binding: "MEDIA", + remoteProxyConnectionString: connection, + }, + }), + matches: [expect.stringContaining(`image/jpeg`)], + }, { name: "Dispatch Namespace", scriptPath: "dispatch-namespace.js", @@ -550,7 +568,7 @@ async function runTestCase( ); const mf = new Miniflare({ - compatibilityDate: "2025-01-01", + compatibilityDate: "2025-09-06", // @ts-expect-error TS doesn't like the spreading of miniflareConfig modules: true, scriptPath: path.resolve(helper.tmpPath, testCase.scriptPath), diff --git a/packages/wrangler/e2e/remote-binding/workers/media.js b/packages/wrangler/e2e/remote-binding/workers/media.js new file mode 100644 index 000000000000..9082d08a1440 --- /dev/null +++ b/packages/wrangler/e2e/remote-binding/workers/media.js @@ -0,0 +1,16 @@ +export default { + async fetch(request, env) { + const image = await fetch( + // There's nothing special about this video—it's just a random publicly accessible mp4 + // from the media transformations blog post + "https://pub-d9fcbc1abcd244c1821f38b99017347f.r2.dev/aus-mobile.mp4 " + ); + + const contentType = await env.MEDIA.input(image.body) + .transform({ width: 10 }) + .output({ mode: "frame", format: "image/jpeg" }) + .contentType(); + + return new Response(contentType); + }, +}; diff --git a/packages/wrangler/src/__tests__/config/configuration.test.ts b/packages/wrangler/src/__tests__/config/configuration.test.ts index 711182351ede..605e80759245 100644 --- a/packages/wrangler/src/__tests__/config/configuration.test.ts +++ b/packages/wrangler/src/__tests__/config/configuration.test.ts @@ -159,6 +159,7 @@ describe("normalizeAndValidateConfig()", () => { observability: undefined, compliance_region: undefined, images: undefined, + media: undefined, } satisfies Config); expect(diagnostics.hasErrors()).toBe(false); expect(diagnostics.hasWarnings()).toBe(false); @@ -2172,6 +2173,69 @@ describe("normalizeAndValidateConfig()", () => { }); }); + // Media + describe("[media]", () => { + it("should error if media is an array", () => { + const { diagnostics } = normalizeAndValidateConfig( + { media: [] } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field \\"media\\" should be an object but got []." + `); + }); + + it("should error if media is a string", () => { + const { diagnostics } = normalizeAndValidateConfig( + { media: "BAD" } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field \\"media\\" should be an object but got \\"BAD\\"." + `); + }); + + it("should error if media is a number", () => { + const { diagnostics } = normalizeAndValidateConfig( + { media: 999 } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field \\"media\\" should be an object but got 999." + `); + }); + + it("should error if media is null", () => { + const { diagnostics } = normalizeAndValidateConfig( + { media: null } as unknown as RawConfig, + undefined, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - The field \\"media\\" should be an object but got null." + `); + }); + }); + // Worker Version Metadata describe("[version_metadata]", () => { it("should error if version_metadata is an array", () => { diff --git a/packages/wrangler/src/__tests__/type-generation.test.ts b/packages/wrangler/src/__tests__/type-generation.test.ts index f46da3f68288..5d6658df7dea 100644 --- a/packages/wrangler/src/__tests__/type-generation.test.ts +++ b/packages/wrangler/src/__tests__/type-generation.test.ts @@ -213,6 +213,9 @@ const bindingsConfigMock: Omit< images: { binding: "IMAGES_BINDING", }, + media: { + binding: "MEDIA_BINDING", + }, version_metadata: { binding: "VERSION_METADATA_BINDING", }, @@ -525,6 +528,7 @@ describe("generate types", () => { BROWSER_BINDING: Fetcher; AI_BINDING: Ai; IMAGES_BINDING: ImagesBinding; + MEDIA_BINDING: MediaBinding; VERSION_METADATA_BINDING: WorkerVersionMetadata; ASSETS_BINDING: Fetcher; PIPELINE: import(\\"cloudflare:pipelines\\").Pipeline; @@ -629,6 +633,7 @@ describe("generate types", () => { BROWSER_BINDING: Fetcher; AI_BINDING: Ai; IMAGES_BINDING: ImagesBinding; + MEDIA_BINDING: MediaBinding; VERSION_METADATA_BINDING: WorkerVersionMetadata; ASSETS_BINDING: Fetcher; PIPELINE: import(\\"cloudflare:pipelines\\").Pipeline; @@ -797,6 +802,7 @@ describe("generate types", () => { BROWSER_BINDING: Fetcher; AI_BINDING: Ai; IMAGES_BINDING: ImagesBinding; + MEDIA_BINDING: MediaBinding; VERSION_METADATA_BINDING: WorkerVersionMetadata; ASSETS_BINDING: Fetcher; PIPELINE: import(\\"cloudflare:pipelines\\").Pipeline; diff --git a/packages/wrangler/src/api/remoteBindings/index.ts b/packages/wrangler/src/api/remoteBindings/index.ts index ffeb6b1dae42..b6953c9ca590 100644 --- a/packages/wrangler/src/api/remoteBindings/index.ts +++ b/packages/wrangler/src/api/remoteBindings/index.ts @@ -20,8 +20,8 @@ export function pickRemoteBindings( ): Record { return Object.fromEntries( Object.entries(bindings ?? {}).filter(([, binding]) => { - if (binding.type === "ai") { - // AI is always remote + if (binding.type === "ai" || binding.type === "media") { + // AI and 'media' bindings are always remote return true; } diff --git a/packages/wrangler/src/api/startDevWorker/types.ts b/packages/wrangler/src/api/startDevWorker/types.ts index 46c27adc9e50..99df995d6fcd 100644 --- a/packages/wrangler/src/api/startDevWorker/types.ts +++ b/packages/wrangler/src/api/startDevWorker/types.ts @@ -21,6 +21,7 @@ import type { CfImagesBinding, CfKvNamespace, CfLogfwdrBinding, + CfMediaBinding, CfModule, CfMTlsCertificate, CfPipeline, @@ -310,6 +311,7 @@ export type Binding = | ({ type: "ratelimit" } & NameOmit) | ({ type: "worker_loader" } & BindingOmit) | ({ type: "vpc_service" } & BindingOmit) + | ({ type: "media" } & BindingOmit) | { type: `unsafe_${string}` } | { type: "assets" }; diff --git a/packages/wrangler/src/api/startDevWorker/utils.ts b/packages/wrangler/src/api/startDevWorker/utils.ts index c804f2bae407..f292fff085f6 100644 --- a/packages/wrangler/src/api/startDevWorker/utils.ts +++ b/packages/wrangler/src/api/startDevWorker/utils.ts @@ -303,6 +303,11 @@ export function convertCfWorkerInitBindingsToBindings( } break; } + case "media": { + const { binding, ...x } = info; + output[binding] = { type: "media", ...x }; + break; + } default: { assertNever(type); } @@ -363,6 +368,7 @@ export async function convertBindingsToCfWorkerInitBindings( unsafe_hello_world: undefined, ratelimits: undefined, worker_loaders: undefined, + media: undefined, }; const fetchers: Record = {}; @@ -503,7 +509,9 @@ export async function convertBindingsToCfWorkerInitBindings( bindings.worker_loaders.push({ ...omitType(binding), binding: name }); } else if (binding.type === "vpc_service") { bindings.vpc_services ??= []; - bindings.vpc_services.push({ ...omitType(binding), binding: name }); + bindings.vpc_services.push({ ...binding, binding: name }); + } else if (binding.type === "media") { + bindings.media = { ...binding, binding: name }; } else if (isUnsafeBindingType(binding.type)) { bindings.unsafe ??= { bindings: [], diff --git a/packages/wrangler/src/config/config.ts b/packages/wrangler/src/config/config.ts index 4d0c6b611d04..05b05d402390 100644 --- a/packages/wrangler/src/config/config.ts +++ b/packages/wrangler/src/config/config.ts @@ -332,6 +332,7 @@ export const defaultWranglerConfig: Config = { analytics_engine_datasets: [], ai: undefined, images: undefined, + media: undefined, version_metadata: undefined, unsafe_hello_world: [], ratelimits: [], diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index 75823272744b..9780482d4710 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -1012,6 +1012,23 @@ export interface EnvironmentNonInheritable { } | undefined; + /** + * Binding to Cloudflare Media Transformations + * + * NOTE: This field is not automatically inherited from the top level environment, + * and so must be specified in every named environment. + * + * @default {} + * @nonInheritable + */ + media: + | { + binding: string; + /** Whether the Media binding should be remote or not */ + remote?: boolean; + } + | undefined; + /** * Binding to the Worker Version's metadata */ diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index 6faf3e0659a3..f3a4f8137be6 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -80,6 +80,7 @@ type ValidKeys = Exclude< | "durable_objects" | "version_metadata" | "images" + | "media" | "unsafe" | "ratelimits" | "workflows" diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index 402b664f35f2..d56a2259645c 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -1437,6 +1437,16 @@ function normalizeAndValidateEnvironment( validateNamedSimpleBinding(envName), undefined ), + media: notInheritable( + diagnostics, + topLevelEnv, + rawConfig, + rawEnv, + envName, + "media", + validateNamedSimpleBinding(envName), + undefined + ), pipelines: notInheritable( diagnostics, topLevelEnv, @@ -2420,6 +2430,7 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => { "pipeline", "worker_loader", "vpc_service", + "media", ]; if (safeBindings.includes(value.type)) { diff --git a/packages/wrangler/src/deployment-bundle/bindings.ts b/packages/wrangler/src/deployment-bundle/bindings.ts index b468fce35ccb..edb974e0e426 100644 --- a/packages/wrangler/src/deployment-bundle/bindings.ts +++ b/packages/wrangler/src/deployment-bundle/bindings.ts @@ -83,6 +83,7 @@ export function getBindings( ratelimits: config?.ratelimits, worker_loaders: config?.worker_loaders, vpc_services: config?.vpc_services, + media: config?.media, }; } diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index 5e974b586106..279e14056a8d 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -67,6 +67,7 @@ export type WorkerMetadataBinding = | { type: "version_metadata"; name: string } | { type: "data_blob"; name: string; part: string } | { type: "kv_namespace"; name: string; namespace_id: string; raw?: boolean } + | { type: "media"; name: string } | { type: "send_email"; name: string; @@ -588,6 +589,13 @@ export function createWorkerUploadForm( }); } + if (bindings.media !== undefined) { + metadataBindings.push({ + name: bindings.media.binding, + type: "media", + }); + } + if (bindings.version_metadata !== undefined) { metadataBindings.push({ name: bindings.version_metadata.binding, diff --git a/packages/wrangler/src/deployment-bundle/worker.ts b/packages/wrangler/src/deployment-bundle/worker.ts index 8dc6ac581a1b..4fb25cf9c682 100644 --- a/packages/wrangler/src/deployment-bundle/worker.ts +++ b/packages/wrangler/src/deployment-bundle/worker.ts @@ -144,6 +144,14 @@ export interface CfImagesBinding { remote?: boolean; } +/** + * A binding to Cloudflare Media Transformations + */ +export interface CfMediaBinding { + binding: string; + remote?: boolean; +} + /** * A binding to the Worker Version's metadata */ @@ -428,6 +436,7 @@ export interface CfWorkerInit { unsafe_hello_world: CfHelloWorld[] | undefined; ratelimits: CfRateLimit[] | undefined; worker_loaders: CfWorkerLoader[] | undefined; + media: CfMediaBinding | undefined; }; containers?: { class_name: string }[]; diff --git a/packages/wrangler/src/dev.ts b/packages/wrangler/src/dev.ts index df187eb278ff..cc8c098bf7a5 100644 --- a/packages/wrangler/src/dev.ts +++ b/packages/wrangler/src/dev.ts @@ -687,6 +687,7 @@ export function getBindings( unsafe_hello_world: configParam.unsafe_hello_world, ratelimits: configParam.ratelimits, worker_loaders: configParam.worker_loaders, + media: configParam.media, }; return bindings; diff --git a/packages/wrangler/src/dev/miniflare/index.ts b/packages/wrangler/src/dev/miniflare/index.ts index 19b8277e2485..50898de25663 100644 --- a/packages/wrangler/src/dev/miniflare/index.ts +++ b/packages/wrangler/src/dev/miniflare/index.ts @@ -422,6 +422,7 @@ type WorkerOptionsBindings = Pick< | "workerLoaders" | "unsafeBindings" | "additionalUnboundDurableObjects" + | "media" >; type MiniflareBindingsConfig = Pick< @@ -534,6 +535,10 @@ export function buildMiniflareBindingOptions( warnOrError("ai", bindings.ai.remote, "always-remote"); } + if (bindings.media && remoteBindingsEnabled) { + warnOrError("media", bindings.media.remote, "always-remote"); + } + if (bindings.mtls_certificates && remoteBindingsEnabled) { for (const mtls of bindings.mtls_certificates) { warnOrError("mtls_certificates", mtls.remote, "always-remote"); @@ -765,6 +770,13 @@ export function buildMiniflareBindingOptions( : undefined, } : undefined, + media: + bindings.media && remoteBindingsEnabled && remoteProxyConnectionString + ? { + binding: bindings.media.binding, + remoteProxyConnectionString, + } + : undefined, browserRendering: bindings.browser?.binding ? { binding: bindings.browser.binding, diff --git a/packages/wrangler/src/secret/index.ts b/packages/wrangler/src/secret/index.ts index 099732875d7d..fd0d64b72fd2 100644 --- a/packages/wrangler/src/secret/index.ts +++ b/packages/wrangler/src/secret/index.ts @@ -113,6 +113,7 @@ async function createDraftWorker({ }, unsafe_hello_world: [], worker_loaders: [], + media: undefined, }, modules: [], migrations: undefined, diff --git a/packages/wrangler/src/type-generation/index.ts b/packages/wrangler/src/type-generation/index.ts index 5eabf4138878..51a68234f719 100644 --- a/packages/wrangler/src/type-generation/index.ts +++ b/packages/wrangler/src/type-generation/index.ts @@ -351,6 +351,7 @@ export async function generateEnvTypes( ratelimits: config.ratelimits, worker_loaders: config.worker_loaders, vpc_services: config.vpc_services, + media: config.media, }; const entrypointFormat = entrypoint?.format ?? "modules"; @@ -614,6 +615,13 @@ export async function generateEnvTypes( ]); } + if (configToDTS.media) { + envTypeStructure.push([ + constructTypeKey(configToDTS.media.binding), + "MediaBinding", + ]); + } + if (configToDTS.version_metadata) { envTypeStructure.push([ configToDTS.version_metadata.binding, diff --git a/packages/wrangler/src/utils/map-worker-metadata-bindings.ts b/packages/wrangler/src/utils/map-worker-metadata-bindings.ts index a416e2f9f319..934ce19e7f4e 100644 --- a/packages/wrangler/src/utils/map-worker-metadata-bindings.ts +++ b/packages/wrangler/src/utils/map-worker-metadata-bindings.ts @@ -117,6 +117,13 @@ export async function mapWorkerMetadataBindings( }; } break; + case "media": + { + configObj.media = { + binding: binding.name, + }; + } + break; case "r2_bucket": { configObj.r2_buckets = [ diff --git a/packages/wrangler/src/utils/print-bindings.ts b/packages/wrangler/src/utils/print-bindings.ts index 56bcc8f6a80d..336a99054079 100644 --- a/packages/wrangler/src/utils/print-bindings.ts +++ b/packages/wrangler/src/utils/print-bindings.ts @@ -27,6 +27,7 @@ export const friendlyBindingNames: Record< browser: "Browser", ai: "AI", images: "Images", + media: "Media", version_metadata: "Worker Version Metadata", unsafe: "Unsafe Metadata", vars: "Environment Variable", @@ -110,6 +111,7 @@ export function printBindings( ratelimits, assets, unsafe_hello_world, + media, } = bindings; if (data_blobs !== undefined && Object.keys(data_blobs).length > 0) { @@ -476,6 +478,21 @@ export function printBindings( }); } + if (media !== undefined) { + output.push({ + name: media.binding, + type: friendlyBindingNames.media, + value: undefined, + mode: getMode({ + isSimulatedLocally: getFlag("REMOTE_BINDINGS") + ? media.remote === true || media.remote === undefined + ? false + : undefined + : false, + }), + }); + } + if (ai !== undefined) { output.push({ name: ai.binding, diff --git a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts b/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts index c9820e4610e9..85e38358d67f 100644 --- a/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts +++ b/packages/wrangler/templates/remoteBindings/ProxyServerWorker.ts @@ -9,6 +9,39 @@ class BindingNotFoundError extends Error { } } +/** + * Here be dragons! capnweb does not currently support ReadableStreams, which Media + * bindings use for input. As such, Media Bindings cannot be directly used via capnweb, + * and need to be special cased. + */ + +function isSpecialCaseMediaBindingRequest(headers: Headers): boolean { + return headers.has("x-cf-media-input-options"); +} +async function evaluateMediaBinding( + headers: Headers, + stream: ReadableStream, + binding: MediaBinding +): Promise { + const inputOptions = JSON.parse( + headers.get("x-cf-media-input-options") as string + ); + const outputOptions = JSON.parse( + headers.get("x-cf-media-output-options") as string + ); + + const result = await binding + .input(stream) + .transform(inputOptions) + .output(outputOptions); + + return new Response(await result.media(), { + headers: { + "x-cf-media-content-type": await result.contentType(), + }, + }); +} + /** * For most bindings, we expose them as * - RPC stubs directly to capnweb, or @@ -114,6 +147,13 @@ export default { originalHeaders.set(name, value); } } + if (isSpecialCaseMediaBindingRequest(originalHeaders)) { + return evaluateMediaBinding( + originalHeaders, + request.body as ReadableStream, + fetcher as unknown as MediaBinding + ); + } return fetcher.fetch( request.headers.get("MF-URL") ?? "http://example.com",