Skip to content

Commit

Permalink
feature: add support for JSRPC (#5916)
Browse files Browse the repository at this point in the history
* feature: add support for JSRPC

* Add messaging + example test to clarify `using`

---------

Co-authored-by: bcoll <bcoll@cloudflare.com>
Co-authored-by: Samuel Macleod <smacleod@cloudflare.com>
  • Loading branch information
3 people authored May 28, 2024
1 parent 1f3acc6 commit e42f320
Show file tree
Hide file tree
Showing 30 changed files with 1,104 additions and 234 deletions.
5 changes: 5 additions & 0 deletions .changeset/dry-buttons-deliver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/vitest-pool-workers": minor
---

feature: add support for JSRPC
7 changes: 6 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@
"<TYPES>"
],
"importOrderTypeScriptVersion": "5.4.5",
"importOrderParserPlugins": ["typescript", "jsx", "decorators"]
"importOrderParserPlugins": [
"typescript",
"jsx",
"decorators",
"explicitResourceManagement"
]
}
1 change: 1 addition & 0 deletions fixtures/vitest-pool-workers-examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This directory contains example projects tested with `@cloudflare/vitest-pool-wo
| [🤹 request-mocking](request-mocking) | Tests using declarative/imperative outbound request mocks |
| [🔌 multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks |
| [⚙️ web-assembly](web-assembly) | Tests importing WebAssembly modules |
| [🤯 rpc](rpc) | Tests using named entrypoints, Durable Objects and RPC |
| [🤷 misc](misc) | Tests for other assorted Vitest features |

[^1]: When using `SELF` for integration tests, your worker code runs in the same context as the test runner. This means you can use global mocks to control your worker, but also means your worker uses the same subtly different module resolution behaviour provided by Vite. Usually this isn't a problem, but if you'd like to run your worker in a fresh environment that's as close to production as possible, using an auxiliary worker may be a good idea. Note this prevents global mocks from controlling your worker, and requires you to build your worker ahead-of-time. This means your tests won't re-run automatically if you change your worker's source code, but could be useful if you have a complicated build process (e.g. full-stack framework).
2 changes: 1 addition & 1 deletion fixtures/vitest-pool-workers-examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"jose": "^5.2.2",
"miniflare": "workspace:*",
"toucan-js": "^3.3.1",
"typescript": "^5.3.3",
"typescript": "^5.4.3",
"vitest": "1.5.0",
"wrangler": "workspace:*"
}
Expand Down
8 changes: 8 additions & 0 deletions fixtures/vitest-pool-workers-examples/rpc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 🤯 rpc

This Worker defines `WorkerEntrypoint` default and named exports. It also defines Durable Objects subclassing `DurableObject`. All of these classes define properties and methods that can be called over RPC. Integration tests dispatch events, access properties and call methods using the `SELF` helper from the `cloudflare:test` module.

| Test | Overview |
| --------------------------------------------------------- | -------------------------------------------- |
| [integration-self.test.ts](test/integration-self.test.ts) | Integration test using `SELF` |
| [unit.test.ts](test/unit.test.ts) | Unit tests calling exported classes directly |
6 changes: 6 additions & 0 deletions fixtures/vitest-pool-workers-examples/rpc/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
interface Env {
KV_NAMESPACE: KVNamespace;
TEST_OBJECT: DurableObjectNamespace<import("./index").TestObject>;
TEST_NAMED_HANDLER: Service;
TEST_NAMED_ENTRYPOINT: Service<import("./index").TestNamedEntrypoint>;
}
154 changes: 154 additions & 0 deletions fixtures/vitest-pool-workers-examples/rpc/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { DurableObject, RpcTarget, WorkerEntrypoint } from "cloudflare:workers";

export class Counter extends RpcTarget {
#value: number;

constructor(value: number) {
super();
this.#value = value;
}

get value() {
return this.#value;
}

increment(by = 1) {
return (this.#value += by);
}

clone() {
return new Counter(this.#value);
}

asObject() {
return { val: this.#value };
}
}

export class TestObject extends DurableObject<Env> {
#value: number = 0;

constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
void ctx.blockConcurrencyWhile(async () => {
this.#value = (await ctx.storage.get("count")) ?? 0;
});
}

get value() {
return this.#value;
}

increment(by = 1) {
this.#value += by;
void this.ctx.storage.put("count", this.#value);
return this.#value;
}

scheduleReset(afterMillis: number) {
void this.ctx.storage.setAlarm(Date.now() + afterMillis);
}

async fetch(request: Request) {
return Response.json({
source: "TestObject",
method: request.method,
url: request.url,
ctxWaitUntil: typeof this.ctx.waitUntil,
envKeys: Object.keys(this.env).sort(),
});
}

getCounter() {
return new Counter(0);
}

getObject() {
return { hello: "world" };
}

alarm() {
this.#value = 0;
void this.ctx.storage.put("count", this.#value);
}

instanceProperty = "👻";
}

export const testNamedHandler = <ExportedHandler<Env>>{
fetch(request, env, ctx) {
return Response.json({
source: "testNamedHandler",
method: request.method,
url: request.url,
ctxWaitUntil: typeof ctx.waitUntil,
envKeys: Object.keys(env).sort(),
});
},
};

export class TestNamedEntrypoint extends WorkerEntrypoint<Env> {
fetch(request: Request) {
return Response.json({
source: "TestNamedEntrypoint",
method: request.method,
url: request.url,
ctxWaitUntil: typeof this.ctx.waitUntil,
envKeys: Object.keys(this.env).sort(),
});
}

ping() {
return "pong";
}

getCounter() {
return new Counter(0);
}
}

export class TestSuperEntrypoint extends WorkerEntrypoint<Env> {
superMethod() {
return "🦸";
}
}

let lastController: ScheduledController | undefined;
export default class TestDefaultEntrypoint extends TestSuperEntrypoint {
async fetch(request: Request) {
return Response.json({
source: "TestDefaultEntrypoint",
method: request.method,
url: request.url,
ctxWaitUntil: typeof this.ctx.waitUntil,
envKeys: Object.keys(this.env).sort(),
});
}

async scheduled(controller: ScheduledController) {
lastController = controller;
}

get lastControllerCron() {
return lastController?.cron;
}

sum(...args: number[]) {
return args.reduce((acc, value) => acc + value, 0);
}

backgroundWrite(key: string, value: string) {
this.ctx.waitUntil(this.env.KV_NAMESPACE.put(key, value));
}

async read(key: string) {
return this.env.KV_NAMESPACE.get(key);
}

createCounter(value = 0) {
return new Counter(value);
}

instanceProperty = "👻";
instanceMethod = () => "👻";
}
4 changes: 4 additions & 0 deletions fixtures/vitest-pool-workers-examples/rpc/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.workerd.json",
"include": ["./**/*.ts"]
}
7 changes: 7 additions & 0 deletions fixtures/vitest-pool-workers-examples/rpc/test/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare module "cloudflare:test" {
// Controls the type of `import("cloudflare:test").env`
interface ProvidedEnv extends Env {}

// Ensure RPC properties and methods can be accessed with `SELF`
export const SELF: Service<import("../src/index").default>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { env, SELF } from "cloudflare:test";
import { expect, it, vi } from "vitest";

it("dispatches fetch event", async () => {
const response = await SELF.fetch("https://example.com/");
expect(await response.json()).toMatchInlineSnapshot(`
{
"ctxWaitUntil": "function",
"envKeys": [
"KV_NAMESPACE",
"TEST_NAMED_ENTRYPOINT",
"TEST_NAMED_HANDLER",
"TEST_OBJECT",
],
"method": "GET",
"source": "TestDefaultEntrypoint",
"url": "https://example.com/",
}
`);
});

it("dispatches scheduled event and accesses property with rpc", async () => {
await SELF.scheduled({ cron: "* * * * 30" });
const lastControllerCron = await SELF.lastControllerCron;
expect(lastControllerCron).toBe("* * * * 30");
});

it("calls multi-argument methods with rpc", async () => {
const result = await SELF.sum(1, 2, 3);
expect(result).toBe(6);
});

it("calls methods using ctx and env with rpc", async () => {
expect(await env.KV_NAMESPACE.get("key")).toBe(null);
await SELF.backgroundWrite("key", "value");
await vi.waitUntil(
async () => (await env.KV_NAMESPACE.get("key")) === "value"
);
});

it("calls async methods with rpc", async () => {
await env.KV_NAMESPACE.put("key", "value");
expect(await SELF.read("key")).toBe("value");
});

it("calls methods with rpc and pipelining", async () => {
const result = await SELF.createCounter(5).clone().increment(3);
expect(result).toBe(8);
});

it("can access methods from superclass", async () => {
const result = await SELF.superMethod();
expect(result).toBe("🦸");
});
it("cannot access instance properties or methods", async () => {
await expect(async () => await SELF.instanceProperty).rejects
.toThrowErrorMatchingInlineSnapshot(`
[TypeError: The RPC receiver's prototype does not implement "instanceProperty", but the receiver instance does.
Only properties and methods defined on the prototype can be accessed over RPC.
Ensure properties are declared like \`get instanceProperty() { ... }\` instead of \`instanceProperty = ...\`,
and methods are declared like \`instanceProperty() { ... }\` instead of \`instanceProperty = () => { ... }\`.]
`);
await expect(async () => await SELF.instanceMethod()).rejects
.toThrowErrorMatchingInlineSnapshot(`
[TypeError: The RPC receiver's prototype does not implement "instanceMethod", but the receiver instance does.
Only properties and methods defined on the prototype can be accessed over RPC.
Ensure properties are declared like \`get instanceMethod() { ... }\` instead of \`instanceMethod = ...\`,
and methods are declared like \`instanceMethod() { ... }\` instead of \`instanceMethod = () => { ... }\`.]
`);
});
it("cannot access non-existent properties or methods", async () => {
await expect(
// @ts-expect-error intentionally testing incorrect types
async () => await SELF.nonExistentProperty
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TypeError: The RPC receiver does not implement "nonExistentProperty".]`
);
await expect(
// @ts-expect-error intentionally testing incorrect types
async () => await SELF.nonExistentMethod()
).rejects.toThrowErrorMatchingInlineSnapshot(
`[TypeError: The RPC receiver does not implement "nonExistentMethod".]`
);
});
4 changes: 4 additions & 0 deletions fixtures/vitest-pool-workers-examples/rpc/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.workerd-test.json",
"include": ["./**/*.ts", "../src/env.d.ts"]
}
Loading

0 comments on commit e42f320

Please sign in to comment.