Skip to content

Commit

Permalink
Add basic support for wrappedBindings and RPC entrypoints to Minifl…
Browse files Browse the repository at this point in the history
…are's Magic Proxy (#5599)

* [miniflare] fix: add support for wrapped bindings in magic proxy

* [wrangler] chore: add an e2e test for AI bindings with `getPlatformProxy()`

* [miniflare] fix: add partial support for RPC in magic proxy

* skip `getPlatformProxy()` wrangler e2e

---------

Co-authored-by: Dario Piotrowicz <dario@cloudflare.com>
  • Loading branch information
penalosa and dario-piotrowicz committed Apr 15, 2024
1 parent 8f470d9 commit c9f081a
Show file tree
Hide file tree
Showing 26 changed files with 406 additions and 70 deletions.
48 changes: 48 additions & 0 deletions .changeset/quick-parents-float.md
@@ -0,0 +1,48 @@
---
"miniflare": patch
---

fix: add support for wrapped bindings in magic proxy

currently `Miniflare#getBindings()` does not return proxies to provided `wrappedBindings`, make sure that appropriate proxies are instead returned

Example:

```ts
import { Miniflare } from "miniflare";

const mf = new Miniflare({
workers: [
{
wrappedBindings: {
Greeter: {
scriptName: "impl",
},
},
modules: true,
script: `export default { fetch(){ return new Response(''); } }`,
},
{
modules: true,
name: "impl",
script: `
class Greeter {
sayHello(name) {
return "Hello " + name;
}
}
export default function (env) {
return new Greeter();
}
`,
},
],
});

const { Greeter } = await mf.getBindings();

console.log(Greeter.sayHello("world")); // <--- prints 'Hello world'

await mf.dispose();
```
51 changes: 51 additions & 0 deletions .changeset/wild-shoes-love.md
@@ -0,0 +1,51 @@
---
"miniflare": patch
---

fix: add support for RPC in magic proxy

currently `Miniflare#getBindings()` does not return valid proxies to provided `serviceBindings` using RPC, make sure that appropriate proxies are instead returned

Example:

```ts
import { Miniflare } from "miniflare";

const mf = new Miniflare({
workers: [
{
modules: true,
script: `export default { fetch() { return new Response(''); } }`,
serviceBindings: {
SUM: {
name: "sum-worker",
entrypoint: "SumEntrypoint",
},
},
},
{
modules: true,
name: "sum-worker",
script: `
import { WorkerEntrypoint } from 'cloudflare:workers';
export default { fetch() { return new Response(''); } }
export class SumEntrypoint extends WorkerEntrypoint {
sum(args) {
return args.reduce((a, b) => a + b);
}
}
`,
},
],
});

const { SUM } = await mf.getBindings();

const numbers = [1, 2, 3];

console.log(`The sum of ${numbers.join(", ")} is ${await SUM.sum(numbers)}`); // <--- prints 'The sum of 1, 2, 3 is 6'

await mf.dispose();
```
2 changes: 2 additions & 0 deletions .github/workflows/e2e.yml
Expand Up @@ -75,6 +75,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.TEST_CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.TEST_CLOUDFLARE_ACCOUNT_ID }}
WRANGLER: wrangler
WRANGLER_IMPORT: ${{ github.workspace }}/packages/wrangler/wrangler-dist/cli.js
NODE_OPTIONS: "--max_old_space_size=8192"
WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/

Expand All @@ -85,6 +86,7 @@ jobs:
CLOUDFLARE_API_TOKEN: ${{ secrets.TEST_CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.TEST_CLOUDFLARE_ACCOUNT_ID }}
WRANGLER: pnpm --silent --package ${{ steps.find-wrangler.outputs.dir}} dlx wrangler
WRANGLER_IMPORT: ${{ github.workspace }}/packages/wrangler/wrangler-dist/cli.js
NODE_OPTIONS: "--max_old_space_size=8192"
WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/

Expand Down
79 changes: 60 additions & 19 deletions fixtures/get-platform-proxy/tests/get-platform-proxy.env.test.ts
Expand Up @@ -6,10 +6,19 @@ import {
Fetcher,
R2Bucket,
} from "@cloudflare/workers-types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { unstable_dev } from "wrangler";
import { getPlatformProxy } from "./shared";
import type { KVNamespace } from "@cloudflare/workers-types";
import type { NamedEntrypoint } from "../workers/rpc-worker";
import type { KVNamespace, Rpc, Service } from "@cloudflare/workers-types";
import type { UnstableDevWorker } from "wrangler";

type Env = {
Expand All @@ -19,6 +28,7 @@ type Env = {
MY_DEV_VAR: string;
MY_SERVICE_A: Fetcher;
MY_SERVICE_B: Fetcher;
MY_RPC: Service;
MY_KV: KVNamespace;
MY_DO_A: DurableObjectNamespace;
MY_DO_B: DurableObjectNamespace;
Expand All @@ -36,18 +46,13 @@ describe("getPlatformProxy - bindings", () => {
vi.spyOn(console, "log").mockImplementation(() => {});
});

// Note: we're skipping the service workers and durable object tests
// so there's no need to start separate workers right now, the
// following beforeAll and afterAll should be un-commented when
// we reenable the tests

// beforeAll(async () => {
// devWorkers = await startWorkers();
// });
beforeAll(async () => {
devWorkers = await startWorkers();
});

// afterAll(async () => {
// await Promise.allSettled(devWorkers.map((i) => i.stop()));
// });
afterAll(async () => {
await Promise.allSettled(devWorkers.map((i) => i.stop()));
});

describe("var bindings", () => {
it("correctly obtains var bindings from both wrangler.toml and .dev.vars", async () => {
Expand Down Expand Up @@ -87,7 +92,10 @@ describe("getPlatformProxy - bindings", () => {
const { MY_KV } = env;
expect(MY_KV).not.toEqual("my-dev-kv");
["get", "delete", "list", "put", "getWithMetadata"].every(
(methodName) => expect(typeof MY_KV[methodName]).toBe("function")
(methodName) =>
expect(
typeof (MY_KV as unknown as Record<string, unknown>)[methodName]
).toBe("function")
);
} finally {
await dispose();
Expand Down Expand Up @@ -134,9 +142,7 @@ describe("getPlatformProxy - bindings", () => {
}
});

// Note: the following test is skipped due to flakiness caused by the local registry not working reliably
// when we run all our fixtures together (possibly because of race condition issues)
it.skip("provides service bindings to external local workers", async () => {
it("provides service bindings to external local workers", async () => {
const { env, dispose } = await getPlatformProxy<Env>({
configPath: wranglerTomlFilePath,
});
Expand All @@ -149,6 +155,43 @@ describe("getPlatformProxy - bindings", () => {
}
});

type EntrypointService = Service<
Omit<NamedEntrypoint, "getCounter"> & Rpc.WorkerEntrypointBranded
> & {
getCounter: () => Promise<
Promise<{
value: Promise<number>;
increment: (amount: number) => Promise<number>;
}>
>;
};

describe("provides rpc service bindings to external local workers", () => {
let rpc: EntrypointService;
beforeEach(async () => {
const { env, dispose } = await getPlatformProxy<Env>({
configPath: wranglerTomlFilePath,
});
rpc = env.MY_RPC as unknown as EntrypointService;
return dispose;
});
it("can call RPC methods directly", async () => {
expect(await rpc.sum([1, 2, 3])).toMatchInlineSnapshot(`6`);
});
it("can call RPC methods returning a Response", async () => {
const resp = await rpc.asJsonResponse([1, 2, 3]);
expect(resp.status).toMatchInlineSnapshot(`200`);
expect(await resp.text()).toMatchInlineSnapshot(`"[1,2,3]"`);
});
it("can obtain and interact with RpcStubs", async () => {
const counter = await rpc.getCounter();
expect(await counter.value).toMatchInlineSnapshot(`0`);
expect(await counter.increment(4)).toMatchInlineSnapshot(`4`);
expect(await counter.increment(8)).toMatchInlineSnapshot(`12`);
expect(await counter.value).toMatchInlineSnapshot(`12`);
});
});

it("correctly obtains functioning KV bindings", async () => {
const { env, dispose } = await getPlatformProxy<Env>({
configPath: wranglerTomlFilePath,
Expand All @@ -164,8 +207,6 @@ describe("getPlatformProxy - bindings", () => {
await dispose();
});

// Note: the following test is skipped due to flakiness caused by the local registry not working reliably
// when we run all our fixtures together (possibly because of race condition issues)
it.skip("correctly obtains functioning DO bindings (provided by external local workers)", async () => {
const { env, dispose } = await getPlatformProxy<Env>({
configPath: wranglerTomlFilePath,
Expand Down
7 changes: 7 additions & 0 deletions fixtures/get-platform-proxy/tests/tsconfig.json
@@ -0,0 +1,7 @@
{
"extends": "@cloudflare/workers-tsconfig/tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["*.ts"]
}
11 changes: 2 additions & 9 deletions fixtures/get-platform-proxy/tsconfig.json
@@ -1,16 +1,9 @@
{
"include": [
"workers/hello-worker-a",
"module-worker-b",
"service-worker-a",
"module-worker-c",
"module-worker-d",
"pages-functions-app"
],
"include": ["workers/*/*.ts"],
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"types": ["@cloudflare/workers-types"]
"types": ["@cloudflare/workers-types/experimental"]
}
}
37 changes: 37 additions & 0 deletions fixtures/get-platform-proxy/workers/rpc-worker/index.ts
@@ -0,0 +1,37 @@
import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers";

export default {
async fetch(request: Request, env: Record<string, unknown>, ctx: unknown) {
throw new Error(
"Worker only used for RPC calls, there's no default fetch handler"
);
},
};

export class NamedEntrypoint extends WorkerEntrypoint {
sum(args: number[]): number {
return args.reduce((a, b) => a + b);
}
asJsonResponse(args: unknown): {
status: number;
text: () => Promise<string>;
} {
return Response.json(args);
}
getCounter() {
return new Counter();
}
}

class Counter extends RpcTarget {
#value = 0;

increment(amount: number) {
this.#value += amount;
return this.#value;
}

get value() {
return this.#value;
}
}
@@ -0,0 +1 @@
name = "rpc-worker"
3 changes: 2 additions & 1 deletion fixtures/get-platform-proxy/wrangler.toml
Expand Up @@ -4,7 +4,8 @@ compatibility_date = "2023-11-21"

services = [
{ binding = "MY_SERVICE_A", service = "hello-worker-a" },
{ binding = "MY_SERVICE_B", service = "hello-worker-b" }
{ binding = "MY_SERVICE_B", service = "hello-worker-b" },
{ binding = "MY_RPC", service = "rpc-worker", entrypoint = "NamedEntrypoint" }
]

[vars]
Expand Down
2 changes: 1 addition & 1 deletion packages/miniflare/package.json
Expand Up @@ -87,7 +87,7 @@
"prettier": "^3.0.3",
"rimraf": "^3.0.2",
"source-map": "^0.6.0",
"typescript": "~5.0.4",
"typescript": "^5.4.5",
"which": "^2.0.2"
},
"engines": {
Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/http/request.ts
Expand Up @@ -44,6 +44,7 @@ export class Request<
// JSDoc comment so retained when bundling types with api-extractor
/** @ts-expect-error `clone` is actually defined as a method internally */
clone(): Request<CfType> {
// @ts-ignore
const request = super.clone() as Request<CfType>;
// Update prototype so cloning a clone clones `cf`
Object.setPrototypeOf(request, Request.prototype);
Expand Down
2 changes: 2 additions & 0 deletions packages/miniflare/src/http/response.ts
Expand Up @@ -61,6 +61,7 @@ export class Response extends BaseResponse {
get status() {
// When passing a WebSocket, we validate that the passed status was actually
// 101, but we can't store this because `undici` rightfully complains.
// @ts-ignore
return this[kWebSocket] ? 101 : super.status;
}

Expand All @@ -74,6 +75,7 @@ export class Response extends BaseResponse {
if (this[kWebSocket]) {
throw new TypeError("Cannot clone a response to a WebSocket handshake.");
}
// @ts-ignore
const response = super.clone() as Response;
Object.setPrototypeOf(response, Response.prototype);
return response;
Expand Down
9 changes: 9 additions & 0 deletions packages/miniflare/src/plugins/core/index.ts
Expand Up @@ -465,6 +465,14 @@ export const CORE_PLUGIN: Plugin<
])
);
}
if (options.wrappedBindings !== undefined) {
bindingEntries.push(
...Object.keys(options.wrappedBindings).map((name) => [
name,
kProxyNodeBinding,
])
);
}

return Object.fromEntries(await Promise.all(bindingEntries));
},
Expand Down Expand Up @@ -742,6 +750,7 @@ export function getGlobalServices({
"nodejs_compat",
"service_binding_extra_handlers",
"brotli_content_encoding",
"rpc",
],
bindings: serviceEntryBindings,
durableObjectNamespaces: [
Expand Down

0 comments on commit c9f081a

Please sign in to comment.