Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/gentle-teams-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"miniflare": minor
"wrangler": minor
---

Add media binding support
6 changes: 5 additions & 1 deletion packages/miniflare/src/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -124,7 +126,8 @@ export type WorkerOptions = z.input<typeof CORE_PLUGIN.options> &
z.input<typeof VPC_SERVICES_PLUGIN.options> &
z.input<typeof MTLS_PLUGIN.options> &
z.input<typeof HELLO_WORLD_PLUGIN.options> &
z.input<typeof WORKER_LOADER_PLUGIN.options>;
z.input<typeof WORKER_LOADER_PLUGIN.options> &
z.input<typeof MEDIA_PLUGIN.options>;

export type SharedOptions = z.input<typeof CORE_PLUGIN.sharedOptions> &
z.input<typeof CACHE_PLUGIN.sharedOptions> &
Expand Down Expand Up @@ -197,3 +200,4 @@ export * from "./vpc-services";
export * from "./mtls";
export * from "./hello-world";
export * from "./worker-loader";
export * from "./media";
103 changes: 103 additions & 0 deletions packages/miniflare/src/plugins/media/index.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteProxyConnectionString>(),
});

export const MediaOptionsSchema = z.object({
media: MediaSchema.optional(),
});

export const MEDIA_PLUGIN: Plugin<typeof MediaOptionsSchema> = {
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<typeof MediaOptionsSchema>) {
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
),
},
];
},
};
104 changes: 104 additions & 0 deletions packages/miniflare/src/workers/media/binding.worker.ts
Original file line number Diff line number Diff line change
@@ -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<Env> {
async input(media: ReadableStream<Uint8Array>): Promise<MediaTransformer> {
return new MediaTransformer(this.env.remote, media);
}
}

class MediaTransformer extends RpcTarget {
constructor(
private remote: Fetcher,
private input: ReadableStream<Uint8Array>
) {
super();
}

transform(
options: MediaTransformationInputOptions
): MediaTransformationGenerator {
return new MediaTransformationGenerator(this.remote, this.input, options);
}
}

class MediaTransformationGenerator extends RpcTarget {
constructor(
private remote: Fetcher,
private input: ReadableStream<Uint8Array>,
private inputOptions: MediaTransformationInputOptions
) {
super();
}

async output(
outputOptions: MediaTransformationOutputOptions
): Promise<MediaTransformationResult> {
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<Uint8Array>,
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;
}
}
30 changes: 30 additions & 0 deletions packages/wrangler/e2e/dev-with-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,24 @@ const testCases: TestCase<string>[] = [
}),
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",
Expand Down Expand Up @@ -550,7 +568,7 @@ async function runTestCase<T>(
);

const mf = new Miniflare({
compatibilityDate: "2025-01-01",
compatibilityDate: "2025-09-06",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that this is when the media binding became available in production?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct

// @ts-expect-error TS doesn't like the spreading of miniflareConfig
modules: true,
scriptPath: path.resolve(helper.tmpPath, testCase.scriptPath),
Expand Down
16 changes: 16 additions & 0 deletions packages/wrangler/e2e/remote-binding/workers/media.js
Original file line number Diff line number Diff line change
@@ -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);
},
};
Loading
Loading