Skip to content

Commit bf4c9ab

Browse files
Support Images binding in getPlatformProxy() (#9954)
* Support Images binding in getPlatformProxy() * fix snapshot * fix snapshot * Update fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts Co-authored-by: Pete Bacon Darwin <[email protected]> --------- Co-authored-by: Pete Bacon Darwin <[email protected]>
1 parent a5d7b35 commit bf4c9ab

File tree

10 files changed

+142
-4
lines changed

10 files changed

+142
-4
lines changed

.changeset/red-wombats-chew.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"miniflare": patch
3+
"wrangler": patch
4+
---
5+
6+
Support Images binding in `getPlatformProxy()`

fixtures/get-platform-proxy/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"devDependencies": {
1111
"@cloudflare/workers-tsconfig": "workspace:*",
1212
"@cloudflare/workers-types": "^4.20250712.0",
13+
"@types/jest-image-snapshot": "^6.4.0",
14+
"jest-image-snapshot": "^6.4.0",
1315
"typescript": "catalog:default",
1416
"undici": "catalog:default",
1517
"vitest": "catalog:default",
Loading

fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import path from "path";
22
import { D1Database, R2Bucket } from "@cloudflare/workers-types";
3+
import { toMatchImageSnapshot } from "jest-image-snapshot";
34
import {
45
afterEach,
56
beforeEach,
@@ -13,6 +14,7 @@ import { getPlatformProxy } from "./shared";
1314
import type {
1415
Fetcher,
1516
Hyperdrive,
17+
ImagesBinding,
1618
KVNamespace,
1719
} from "@cloudflare/workers-types";
1820
import type { Unstable_DevWorker } from "wrangler";
@@ -28,6 +30,7 @@ type Env = {
2830
MY_D1: D1Database;
2931
MY_HYPERDRIVE: Hyperdrive;
3032
ASSETS: Fetcher;
33+
IMAGES: ImagesBinding;
3134
};
3235

3336
const wranglerConfigFilePath = path.join(__dirname, "..", "wrangler.jsonc");
@@ -199,6 +202,43 @@ describe("getPlatformProxy - env", () => {
199202
}
200203
});
201204

205+
it("correctly obtains functioning Image bindings", async () => {
206+
expect.extend({ toMatchImageSnapshot });
207+
208+
const { env, dispose } = await getPlatformProxy<Env>({
209+
configPath: wranglerConfigFilePath,
210+
});
211+
try {
212+
const { IMAGES } = env;
213+
const streams = (
214+
await fetch("https://playground.devprod.cloudflare.dev/flares.png")
215+
).body!.tee();
216+
217+
// @ts-expect-error The stream types aren't matching up properly?
218+
expect(await IMAGES.info(streams[0])).toMatchInlineSnapshot(`
219+
{
220+
"fileSize": 96549,
221+
"format": "image/png",
222+
"height": 1145,
223+
"width": 2048,
224+
}
225+
`);
226+
227+
// @ts-expect-error The stream types aren't matching up properly?
228+
const response = await env.IMAGES.input(streams[1])
229+
.transform({ rotate: 90 })
230+
.transform({ width: 128, height: 100 })
231+
.transform({ blur: 20 })
232+
.output({ format: "image/png" });
233+
234+
expect(
235+
Buffer.from(await response.response().arrayBuffer())
236+
).toMatchImageSnapshot();
237+
} finally {
238+
await dispose();
239+
}
240+
});
241+
202242
// Important: the hyperdrive values are passthrough ones since the workerd specific hyperdrive values only make sense inside
203243
// workerd itself and would simply not work in a node.js process
204244
it("correctly obtains passthrough Hyperdrive bindings", async () => {

fixtures/get-platform-proxy/wrangler.jsonc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"test": true,
1616
},
1717
},
18+
"images": {
19+
"binding": "IMAGES",
20+
},
1821
"kv_namespaces": [
1922
{
2023
"binding": "MY_KV",

packages/miniflare/src/plugins/core/proxy/client.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ import {
2525
} from "../../../workers";
2626
import { DECODER, SynchronousFetcher, SynchronousResponse } from "./fetch-sync";
2727
import { NODE_PLATFORM_IMPL } from "./types";
28-
import type { ServiceWorkerGlobalScope } from "@cloudflare/workers-types/experimental";
28+
import type {
29+
ImageDrawOptions,
30+
ImageOutputOptions,
31+
ImagesBinding,
32+
ImageTransform,
33+
ImageTransformationResult,
34+
ImageTransformer,
35+
ServiceWorkerGlobalScope,
36+
} from "@cloudflare/workers-types/experimental";
2937

3038
const kAddress = Symbol("kAddress");
3139
const kName = Symbol("kName");
@@ -282,11 +290,68 @@ class ProxyStubHandler<T extends object>
282290
});
283291
return this.#parseAsyncResponse(resPromise);
284292
} else {
293+
// See #createMediaProxy() for why this is special
294+
if (name === "ImagesBindingImpl") {
295+
return this.#createMediaProxy(target);
296+
}
285297
// Otherwise, return a `Proxy` for this target
286298
return this.bridge.getProxy(target);
287299
}
288300
},
289301
};
302+
/**
303+
* Images bindings are some of the most complex bindings from an API perspective, other than RPC. In particular, they expose a _synchronous_ API that accepts ReadableStream arguments.
304+
* Multiple synchronous APIs are chained together in a builder pattern (e.g. `await env.IMAGES.input(stream).transform(...).output(...)`) before the final `.output()` call is awaited.
305+
* This breaks our assumptions around functions that accept ReadableStream arguments always being async, and so doesn't work without some special casing.
306+
*
307+
* Ref: https://developers.cloudflare.com/images/transform-images/bindings/
308+
*/
309+
#createMediaProxy(target: NativeTarget) {
310+
type Operation = {
311+
type: "transform" | "draw";
312+
arguments: unknown[];
313+
};
314+
const transformer = (
315+
target: {
316+
input: (
317+
stream: ReadableStream,
318+
operations: Operation[],
319+
options: ImageOutputOptions
320+
) => ImageTransformationResult;
321+
},
322+
stream: ReadableStream,
323+
operations: Operation[]
324+
): ImageTransformer => {
325+
return {
326+
transform: (transform: ImageTransform): ImageTransformer => {
327+
return transformer(target, stream, [
328+
...operations,
329+
{ type: "transform", arguments: [transform] },
330+
]);
331+
},
332+
draw: (image: ImageTransformer, options?: ImageDrawOptions) => {
333+
return transformer(target, stream, [
334+
...operations,
335+
{ type: "draw", arguments: [image, options] },
336+
]);
337+
},
338+
output: async (options: ImageOutputOptions) => {
339+
// This signature doesn't exist on the production binding, but will be intercepted in the proxy server
340+
return await target.input(stream, operations, options);
341+
},
342+
};
343+
};
344+
const binding = {
345+
info: (stream: ReadableStream<Uint8Array>) => {
346+
// @ts-expect-error The stream types are mismatched
347+
return (this.bridge.getProxy(target) as ImagesBinding)["info"](stream);
348+
},
349+
input: (stream: ReadableStream<Uint8Array>) => {
350+
return transformer(this.bridge.getProxy(target), stream, []);
351+
},
352+
};
353+
return binding;
354+
}
290355

291356
constructor(
292357
readonly bridge: ProxyClientBridge,
@@ -552,7 +617,7 @@ class ProxyStubHandler<T extends object>
552617
this.#assertSafe();
553618

554619
const targetName = this.target[kName];
555-
// See `isFetcherFetch()` comment for why this special
620+
// See `isFetcherFetch()` comment for why this is special
556621
if (isFetcherFetch(targetName, key)) return this.#fetcherFetchCall(args);
557622

558623
const stringified = stringifyWithStreams(

packages/miniflare/src/workers/core/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,10 @@ export function isR2ObjectWriteHttpMetadata(targetName: string, key: string) {
8787
key === "writeHttpMetadata"
8888
);
8989
}
90+
91+
/**
92+
* See #createMediaProxy() comment for why this is special
93+
*/
94+
export function isImagesInput(targetName: string, key: string) {
95+
return targetName === "ImagesBindingImpl" && key === "input";
96+
}

packages/miniflare/src/workers/core/proxy.worker.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CoreBindings,
77
CoreHeaders,
88
isFetcherFetch,
9+
isImagesInput,
910
isR2ObjectWriteHttpMetadata,
1011
ProxyAddresses,
1112
ProxyOps,
@@ -305,7 +306,15 @@ export class ProxyServer implements DurableObject {
305306
}
306307
assert(Array.isArray(args));
307308
try {
308-
if (["RpcProperty", "RpcStub"].includes(func.constructor.name)) {
309+
// See #createMediaProxy() for why this is special
310+
if (isImagesInput(targetName, keyHeader)) {
311+
let transform = func.apply(target, [args[0]]);
312+
for (const operation of args[1]) {
313+
transform = transform[operation.type](...operation.arguments);
314+
}
315+
// We intentionally don't await this `output()` call so that it's treated as a regular promise
316+
result = transform.output(args[2]);
317+
} else if (["RpcProperty", "RpcStub"].includes(func.constructor.name)) {
309318
// let's resolve RpcPromise instances right away (to support serialization)
310319
result = await func(...args);
311320
} else {

packages/wrangler/src/api/integrations/platform/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ async function getMiniflareOptionsFromConfig(args: {
215215
services: bindings.services,
216216
serviceBindings: {},
217217
migrations: config.migrations,
218-
imagesLocalMode: false,
218+
imagesLocalMode: true,
219219
tails: [],
220220
containers: undefined,
221221
containerBuildId: undefined,

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)