跳转到内容
搜索文档

测试 Durable Objects

使用 Workers Vitest 集成为 Durable Objects 编写测试。

最后更新 查看 MarkdownAgent 设置

使用 @cloudflare/vitest-pool-workers 包为 Durable Objects 编写测试。此集成在 Workers 运行时内运行测试,使您能够直接访问 Durable Object 绑定(binding)和 API。

前提条件

将 Vitest 和 Workers Vitest 集成安装为开发依赖:

npm i -D vitest@^4.1.0 @cloudflare/vitest-pool-workers
pnpm add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers
yarn add -D vitest@^4.1.0 @cloudflare/vitest-pool-workers

示例 Durable Object

此示例测试带有 SQLite 存储的简单计数器 Durable Object:

src/index.jsjs
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);

		ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS counters (
					name TEXT PRIMARY KEY,
					value INTEGER NOT NULL DEFAULT 0
				)
			`);
		});
	}

	// In-memory only. This field lives on the instance and is not persisted
	// to storage, so it is reset whenever the Durable Object is evicted and
	// reconstructed.
	cachedHits = 0;

	recordHit() {
		return ++this.cachedHits;
	}

	getHits() {
		return this.cachedHits;
	}

	async increment(name = "default") {
		this.ctx.storage.sql.exec(
			`INSERT INTO counters (name, value) VALUES (?, 1)
			 ON CONFLICT(name) DO UPDATE SET value = value + 1`,
			name,
		);
		const result = this.ctx.storage.sql
			.exec("SELECT value FROM counters WHERE name = ?", name)
			.one();
		return result.value;
	}

	async getCount(name = "default") {
		const result = this.ctx.storage.sql
			.exec("SELECT value FROM counters WHERE name = ?", name)
			.toArray();
		return result[0]?.value ?? 0;
	}

	async reset(name = "default") {
		this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name);
	}
}

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		const counterId = url.searchParams.get("id") ?? "default";

		const id = env.COUNTER.idFromName(counterId);
		const stub = env.COUNTER.get(id);

		if (request.method === "POST") {
			const count = await stub.increment();
			return Response.json({ count });
		}

		const count = await stub.getCount();
		return Response.json({ count });
	},
};
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export interface Env {
	COUNTER: DurableObjectNamespace<Counter>;
}

export class Counter extends DurableObject<Env> {
	constructor(ctx: DurableObjectState, env: Env) {
		super(ctx, env);

		ctx.blockConcurrencyWhile(async () => {
			this.ctx.storage.sql.exec(`
				CREATE TABLE IF NOT EXISTS counters (
					name TEXT PRIMARY KEY,
					value INTEGER NOT NULL DEFAULT 0
				)
			`);
		});
	}

	// In-memory only. This field lives on the instance and is not persisted
	// to storage, so it is reset whenever the Durable Object is evicted and
	// reconstructed.
	cachedHits = 0;

	recordHit(): number {
		return ++this.cachedHits;
	}

	getHits(): number {
		return this.cachedHits;
	}

	async increment(name: string = "default"): Promise<number> {
		this.ctx.storage.sql.exec(
			`INSERT INTO counters (name, value) VALUES (?, 1)
			 ON CONFLICT(name) DO UPDATE SET value = value + 1`,
			name
		);
		const result = this.ctx.storage.sql
			.exec<{ value: number }>("SELECT value FROM counters WHERE name = ?", name)
			.one();
		return result.value;
	}

	async getCount(name: string = "default"): Promise<number> {
		const result = this.ctx.storage.sql
			.exec<{ value: number }>("SELECT value FROM counters WHERE name = ?", name)
			.toArray();
		return result[0]?.value ?? 0;
	}

	async reset(name: string = "default"): Promise<void> {
		this.ctx.storage.sql.exec("DELETE FROM counters WHERE name = ?", name);
	}
}

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const url = new URL(request.url);
		const counterId = url.searchParams.get("id") ?? "default";

		const id = env.COUNTER.idFromName(counterId);
		const stub = env.COUNTER.get(id);

		if (request.method === "POST") {
			const count = await stub.increment();
			return Response.json({ count });
		}

		const count = await stub.getCount();
		return Response.json({ count });
	},
};

配置 Vitest

创建使用 cloudflareTest() 插件的 vitest.config.ts 文件:

vitest.config.tsts
import { cloudflareTest } from "@cloudflare/vitest-pool-workers";
import { defineConfig } from "vitest/config";

export default defineConfig({
	plugins: [
		cloudflareTest({
			wrangler: { configPath: "./wrangler.jsonc" },
		}),
	],
});

确保 Wrangler 配置包含 Durable Object 绑定(binding)和 SQLite 迁移:

{
  "name": "counter-worker",
  "main": "src/index.ts",
  // Set this to today's date
  "compatibility_date": "2026-08-17",
  "durable_objects": {
    "bindings": [
      { "name": "COUNTER", "class_name": "Counter" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["Counter"] }
  ]
}
name = "counter-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"

[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"

[[migrations]]
tag = "v1"
new_sqlite_classes = [ "Counter" ]

为测试定义类型

创建 test/tsconfig.json 以配置测试的 TypeScript:

test/tsconfig.jsonjsonc
{
	"extends": "../tsconfig.json",
	"compilerOptions": {
		"moduleResolution": "bundler",
		"types": ["@cloudflare/vitest-pool-workers/types"]
	},
	"include": ["./**/*.ts", "../src/worker-configuration.d.ts"]
}

创建 env.d.ts 文件以键入测试环境:

test/env.d.tsts
declare module "cloudflare:workers" {
	interface ProvidedEnv extends Env {}
}

编写测试

直接访问 Durable Object 的单元测试

您可以直接从 cloudflare:workers 提供的 env 对象获取 Durable Object stub:

test/counter.test.jsjs
import { env } from "cloudflare:workers";
import { describe, it, expect, beforeEach } from "vitest";

describe("Counter Durable Object", () => {
	it("should increment the counter", async () => {
		const id = env.COUNTER.idFromName("test-counter");
		const stub = env.COUNTER.get(id);

		// Call RPC methods directly on the stub
		const count1 = await stub.increment();
		expect(count1).toBe(1);

		const count2 = await stub.increment();
		expect(count2).toBe(2);

		const count3 = await stub.increment();
		expect(count3).toBe(3);
	});

	it("should persist storage within a test file", async () => {
		const id = env.COUNTER.idFromName("test-counter");
		const stub = env.COUNTER.get(id);

		expect(await stub.getCount()).toBe(3);
	});

	it("should reset a counter", async () => {
		const id = env.COUNTER.idFromName("test-counter");
		const stub = env.COUNTER.get(id);

		await stub.increment("my-counter");
		await stub.increment("my-counter");
		expect(await stub.getCount("my-counter")).toBe(2);

		await stub.reset("my-counter");
		expect(await stub.getCount("my-counter")).toBe(0);
	});

	it("should isolate different Durable Object instances", async () => {
		const id1 = env.COUNTER.idFromName("counter-1");
		const id2 = env.COUNTER.idFromName("counter-2");

		const stub1 = env.COUNTER.get(id1);
		const stub2 = env.COUNTER.get(id2);

		await stub1.increment();
		await stub1.increment();
		await stub2.increment();

		// Each Durable Object instance has its own storage
		expect(await stub1.getCount()).toBe(2);
		expect(await stub2.getCount()).toBe(1);
	});
});
test/counter.test.tsts
import { env } from "cloudflare:workers";
import { describe, it, expect, beforeEach } from "vitest";

describe("Counter Durable Object", () => {
	it("should increment the counter", async () => {
		const id = env.COUNTER.idFromName("test-counter");
		const stub = env.COUNTER.get(id);

		// Call RPC methods directly on the stub
		const count1 = await stub.increment();
		expect(count1).toBe(1);

		const count2 = await stub.increment();
		expect(count2).toBe(2);

		const count3 = await stub.increment();
		expect(count3).toBe(3);
	});

	it("should persist storage within a test file", async () => {
		const id = env.COUNTER.idFromName("test-counter");
		const stub = env.COUNTER.get(id);

		expect(await stub.getCount()).toBe(3);
	});

	it("should reset a counter", async () => {
		const id = env.COUNTER.idFromName("test-counter");
		const stub = env.COUNTER.get(id);

		await stub.increment("my-counter");
		await stub.increment("my-counter");
		expect(await stub.getCount("my-counter")).toBe(2);

		await stub.reset("my-counter");
		expect(await stub.getCount("my-counter")).toBe(0);
	});

	it("should isolate different Durable Object instances", async () => {
		const id1 = env.COUNTER.idFromName("counter-1");
		const id2 = env.COUNTER.idFromName("counter-2");

		const stub1 = env.COUNTER.get(id1);
		const stub2 = env.COUNTER.get(id2);

		await stub1.increment();
		await stub1.increment();
		await stub2.increment();

		// Each Durable Object instance has its own storage
		expect(await stub1.getCount()).toBe(2);
		expect(await stub2.getCount()).toBe(1);
	});
});

使用 exports 的集成测试

使用 exports.default.fetch() 测试 Worker 的 HTTP 处理程序,该处理程序将请求路由到 Durable Objects:

test/integration.test.jsjs
import { exports } from "cloudflare:workers";
import { describe, it, expect } from "vitest";

describe("Counter Worker integration", () => {
	it("should increment via HTTP POST", async () => {
		const response = await exports.default.fetch(
			"http://example.com?id=http-test",
			{
				method: "POST",
			},
		);

		expect(response.status).toBe(200);
		const data = await response.json();
		expect(data.count).toBe(1);
	});

	it("should get count via HTTP GET", async () => {
		// First increment the counter
		await exports.default.fetch("http://example.com?id=get-test", {
			method: "POST",
		});
		await exports.default.fetch("http://example.com?id=get-test", {
			method: "POST",
		});

		// Then get the count
		const response = await exports.default.fetch(
			"http://example.com?id=get-test",
		);
		const data = await response.json();
		expect(data.count).toBe(2);
	});

	it("should use different counters for different IDs", async () => {
		await exports.default.fetch("http://example.com?id=counter-a", {
			method: "POST",
		});
		await exports.default.fetch("http://example.com?id=counter-a", {
			method: "POST",
		});
		await exports.default.fetch("http://example.com?id=counter-b", {
			method: "POST",
		});

		const responseA = await exports.default.fetch(
			"http://example.com?id=counter-a",
		);
		const responseB = await exports.default.fetch(
			"http://example.com?id=counter-b",
		);

		const dataA = await responseA.json();
		const dataB = await responseB.json();

		expect(dataA.count).toBe(2);
		expect(dataB.count).toBe(1);
	});
});
test/integration.test.tsts
import { exports } from "cloudflare:workers";
import { describe, it, expect } from "vitest";

describe("Counter Worker integration", () => {
	it("should increment via HTTP POST", async () => {
		const response = await exports.default.fetch("http://example.com?id=http-test", {
			method: "POST",
		});

		expect(response.status).toBe(200);
		const data = await response.json<{ count: number }>();
		expect(data.count).toBe(1);
	});

	it("should get count via HTTP GET", async () => {
		// First increment the counter
		await exports.default.fetch("http://example.com?id=get-test", { method: "POST" });
		await exports.default.fetch("http://example.com?id=get-test", { method: "POST" });

		// Then get the count
		const response = await exports.default.fetch("http://example.com?id=get-test");
		const data = await response.json<{ count: number }>();
		expect(data.count).toBe(2);
	});

	it("should use different counters for different IDs", async () => {
		await exports.default.fetch("http://example.com?id=counter-a", { method: "POST" });
		await exports.default.fetch("http://example.com?id=counter-a", { method: "POST" });
		await exports.default.fetch("http://example.com?id=counter-b", { method: "POST" });

		const responseA = await exports.default.fetch("http://example.com?id=counter-a");
		const responseB = await exports.default.fetch("http://example.com?id=counter-b");

		const dataA = await responseA.json<{ count: number }>();
		const dataB = await responseB.json<{ count: number }>();

		expect(dataA.count).toBe(2);
		expect(dataB.count).toBe(1);
	});
});

直接访问 Durable Object 内部

使用 runInDurableObject() 直接访问实例属性和存储。这对于验证内部状态或测试私有方法很有用:

test/direct-access.test.jsjs
import { env } from "cloudflare:workers";
import { runInDurableObject, listDurableObjectIds } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { Counter } from "../src";

describe("Direct Durable Object access", () => {
	it("can access instance internals and storage", async () => {
		const id = env.COUNTER.idFromName("direct-test");
		const stub = env.COUNTER.get(id);

		// First, interact normally via RPC
		await stub.increment();
		await stub.increment();

		// Then use runInDurableObject to inspect internals
		await runInDurableObject(stub, async (instance, state) => {
			// Access the exact same class instance
			expect(instance).toBeInstanceOf(Counter);

			// Access storage directly for verification
			const result = state.storage.sql
				.exec("SELECT value FROM counters WHERE name = ?", "default")
				.one();
			expect(result.value).toBe(2);
		});
	});

	it("can list all Durable Object IDs in a namespace", async () => {
		// Create some Durable Objects
		const id1 = env.COUNTER.idFromName("list-test-1");
		const id2 = env.COUNTER.idFromName("list-test-2");

		await env.COUNTER.get(id1).increment();
		await env.COUNTER.get(id2).increment();

		// List all IDs in the namespace
		const ids = await listDurableObjectIds(env.COUNTER);
		expect(ids.length).toBeGreaterThanOrEqual(2);
		expect(ids.some((id) => id.equals(id1))).toBe(true);
		expect(ids.some((id) => id.equals(id2))).toBe(true);
	});
});
test/direct-access.test.tsts
import { env } from "cloudflare:workers";
import {
	runInDurableObject,
	listDurableObjectIds,
} from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { Counter } from "../src";

describe("Direct Durable Object access", () => {
	it("can access instance internals and storage", async () => {
		const id = env.COUNTER.idFromName("direct-test");
		const stub = env.COUNTER.get(id);

		// First, interact normally via RPC
		await stub.increment();
		await stub.increment();

		// Then use runInDurableObject to inspect internals
		await runInDurableObject(stub, async (instance: Counter, state) => {
			// Access the exact same class instance
			expect(instance).toBeInstanceOf(Counter);

			// Access storage directly for verification
			const result = state.storage.sql
				.exec<{ value: number }>(
					"SELECT value FROM counters WHERE name = ?",
					"default"
				)
				.one();
			expect(result.value).toBe(2);
		});
	});

	it("can list all Durable Object IDs in a namespace", async () => {
		// Create some Durable Objects
		const id1 = env.COUNTER.idFromName("list-test-1");
		const id2 = env.COUNTER.idFromName("list-test-2");

		await env.COUNTER.get(id1).increment();
		await env.COUNTER.get(id2).increment();

		// List all IDs in the namespace
		const ids = await listDurableObjectIds(env.COUNTER);
		expect(ids.length).toBeGreaterThanOrEqual(2);
		expect(ids.some((id) => id.equals(id1))).toBe(true);
		expect(ids.some((id) => id.equals(id2))).toBe(true);
	});
});

测试 SQLite 存储

SQLite 支持的 Durable Objects 在测试中无缝工作。当 Durable Object 类在 Wrangler 配置中使用 new_sqlite_classes 配置时,SQL API 可用:

test/sqlite.test.jsjs
import { env } from "cloudflare:workers";
import { runInDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("SQLite in Durable Objects", () => {
	it("can query and verify SQLite storage", async () => {
		const id = env.COUNTER.idFromName("sqlite-test");
		const stub = env.COUNTER.get(id);

		// Increment the counter a few times via RPC
		await stub.increment("page-views");
		await stub.increment("page-views");
		await stub.increment("api-calls");

		// Verify the data directly in SQLite
		await runInDurableObject(stub, async (instance, state) => {
			// Query the database directly
			const rows = state.storage.sql
				.exec("SELECT name, value FROM counters ORDER BY name")
				.toArray();

			expect(rows).toEqual([
				{ name: "api-calls", value: 1 },
				{ name: "page-views", value: 2 },
			]);

			// Check database size is non-zero
			expect(state.storage.sql.databaseSize).toBeGreaterThan(0);
		});
	});
});
test/sqlite.test.tsts
import { env } from "cloudflare:workers";
import { runInDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("SQLite in Durable Objects", () => {
	it("can query and verify SQLite storage", async () => {
		const id = env.COUNTER.idFromName("sqlite-test");
		const stub = env.COUNTER.get(id);

		// Increment the counter a few times via RPC
		await stub.increment("page-views");
		await stub.increment("page-views");
		await stub.increment("api-calls");

		// Verify the data directly in SQLite
		await runInDurableObject(stub, async (instance, state) => {
			// Query the database directly
			const rows = state.storage.sql
				.exec<{ name: string; value: number }>("SELECT name, value FROM counters ORDER BY name")
				.toArray();

			expect(rows).toEqual([
				{ name: "api-calls", value: 1 },
				{ name: "page-views", value: 2 },
			]);

			// Check database size is non-zero
			expect(state.storage.sql.databaseSize).toBeGreaterThan(0);
		});
	});
});

测试 alarm

使用 runDurableObjectAlarm() 立即触发计划 alarm,无需等待计时器。这使您能够同步测试 alarm 处理程序:

test/alarm.test.jsjs
import { env } from "cloudflare:workers";
import { runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { Counter } from "../src";

describe("Durable Object alarms", () => {
	it("can trigger alarms immediately", async () => {
		const id = env.COUNTER.idFromName("alarm-test");
		const stub = env.COUNTER.get(id);

		// Increment counter and schedule a reset alarm
		await stub.increment();
		await stub.increment();
		expect(await stub.getCount()).toBe(2);

		// Schedule an alarm (in a real app, this might be hours in the future)
		await runInDurableObject(stub, async (instance, state) => {
			await state.storage.setAlarm(Date.now() + 60_000); // 1 minute from now
		});

		// Immediately execute the alarm without waiting
		const alarmRan = await runDurableObjectAlarm(stub);
		expect(alarmRan).toBe(true); // Alarm was scheduled and executed

		// Verify the alarm handler ran (assuming it resets the counter)
		// Note: You'll need an alarm() method in your Durable Object that handles resets
		// expect(await stub.getCount()).toBe(0);

		// Trying to run the alarm again returns false (no alarm scheduled)
		const alarmRanAgain = await runDurableObjectAlarm(stub);
		expect(alarmRanAgain).toBe(false);
	});
});
test/alarm.test.tsts
import { env } from "cloudflare:workers";
import {
	runInDurableObject,
	runDurableObjectAlarm,
} from "cloudflare:test";
import { describe, it, expect } from "vitest";
import { Counter } from "../src";

describe("Durable Object alarms", () => {
	it("can trigger alarms immediately", async () => {
		const id = env.COUNTER.idFromName("alarm-test");
		const stub = env.COUNTER.get(id);

		// Increment counter and schedule a reset alarm
		await stub.increment();
		await stub.increment();
		expect(await stub.getCount()).toBe(2);

		// Schedule an alarm (in a real app, this might be hours in the future)
		await runInDurableObject(stub, async (instance, state) => {
			await state.storage.setAlarm(Date.now() + 60_000); // 1 minute from now
		});

		// Immediately execute the alarm without waiting
		const alarmRan = await runDurableObjectAlarm(stub);
		expect(alarmRan).toBe(true); // Alarm was scheduled and executed

		// Verify the alarm handler ran (assuming it resets the counter)
		// Note: You'll need an alarm() method in your Durable Object that handles resets
		// expect(await stub.getCount()).toBe(0);

		// Trying to run the alarm again returns false (no alarm scheduled)
		const alarmRanAgain = await runDurableObjectAlarm(stub);
		expect(alarmRanAgain).toBe(false);
	});
});

要测试 alarm,请在 Durable Object 中添加 alarm() 方法:

src/index.jsjs
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
	// ... other methods ...

	async alarm() {
		// This method is called when the alarm fires
		// Reset all counters
		this.ctx.storage.sql.exec("DELETE FROM counters");
	}

	async scheduleReset(afterMs) {
		await this.ctx.storage.setAlarm(Date.now() + afterMs);
	}
}
src/index.tsts
import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
	// ... other methods ...

	async alarm() {
		// This method is called when the alarm fires
		// Reset all counters
		this.ctx.storage.sql.exec("DELETE FROM counters");
	}

	async scheduleReset(afterMs: number) {
		await this.ctx.storage.setAlarm(Date.now() + afterMs);
	}
}

测试驱逐

使用 evictDurableObject() 在测试期间驱逐 Durable Object 实例。驱逐会拆除实例以重置其内存状态。这使您能够测试 Durable Object 在被驱逐后如何从存储恢复状态。

默认情况下,可休眠 WebSocket 会被休眠而非关闭,驱逐最多等待 30 秒让进行中的请求排空,然后才拆除实例。

以下测试同时设置内存状态(cachedHits)和 durable storage(计数器值),驱逐 Durable Object,并验证内存状态被清除而存储的计数得以保留:

test/eviction.test.jsjs
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("Durable Object eviction", () => {
	it("wipes in-memory state but preserves storage across eviction", async () => {
		const id = env.COUNTER.idFromName("evict-test");
		const stub = env.COUNTER.get(id);

		// Persist a value to SQLite storage
		await stub.increment();
		await stub.increment();
		expect(await stub.getCount()).toBe(2);

		// Set in-memory only state, which is not persisted to storage
		await stub.recordHit();
		await stub.recordHit();
		expect(await stub.getHits()).toBe(2);

		// Evict the Durable Object. The in-memory instance is torn down,
		// but durable storage is preserved.
		await evictDurableObject(stub);

		// In-memory state is wiped: the reconstructed instance starts fresh
		expect(await stub.getHits()).toBe(0);

		// Durable storage survives: the persisted count is read back
		expect(await stub.getCount()).toBe(2);
	});
});
test/eviction.test.tsts
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("Durable Object eviction", () => {
	it("wipes in-memory state but preserves storage across eviction", async () => {
		const id = env.COUNTER.idFromName("evict-test");
		const stub = env.COUNTER.get(id);

		// Persist a value to SQLite storage
		await stub.increment();
		await stub.increment();
		expect(await stub.getCount()).toBe(2);

		// Set in-memory only state, which is not persisted to storage
		await stub.recordHit();
		await stub.recordHit();
		expect(await stub.getHits()).toBe(2);

		// Evict the Durable Object. The in-memory instance is torn down,
		// but durable storage is preserved.
		await evictDurableObject(stub);

		// In-memory state is wiped: the reconstructed instance starts fresh
		expect(await stub.getHits()).toBe(0);

		// Durable storage survives: the persisted count is read back
		expect(await stub.getCount()).toBe(2);
	});
});

测试跨驱逐的 WebSocket 行为

您可以通过传递 options 参数控制 Durable Object 被驱逐时可休眠 WebSocket 的行为:

  • { webSockets: "hibernate" }(默认)休眠 WebSocket,使其在驱逐后可以恢复。
  • { webSockets: "close" } 在驱逐期间关闭 WebSocket。

以下示例使用接受 WebSocket 连接的 Durable Object,采用可休眠 WebSocket API

src/websocket-server.jsjs
import { DurableObject } from "cloudflare:workers";

export class WebSocketServer extends DurableObject {
	async fetch(request) {
		const [client, server] = Object.values(new WebSocketPair());

		// Accept the WebSocket as hibernatable so it can survive eviction
		this.ctx.acceptWebSocket(server);

		return new Response(null, { status: 101, webSocket: client });
	}

	webSocketMessage(ws, message) {
		// Echo the received message back to the client
		ws.send(message);
	}

	webSocketClose(ws, code, reason, wasClean) {
		// Handle WebSocket close events
	}
}
src/websocket-server.tsts
import { DurableObject } from "cloudflare:workers";

export class WebSocketServer extends DurableObject<Env> {
	async fetch(request: Request): Promise<Response> {
		const [client, server] = Object.values(new WebSocketPair());

		// Accept the WebSocket as hibernatable so it can survive eviction
		this.ctx.acceptWebSocket(server);

		return new Response(null, { status: 101, webSocket: client });
	}

	webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
		// Echo the received message back to the client
		ws.send(message);
	}

	webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
		// Handle WebSocket close events
	}
}

在 Wrangler 配置中为 Durable Object 添加绑定(binding)和迁移,与现有 COUNTER 绑定一起:

{
  "durable_objects": {
    "bindings": [
      { "name": "WEBSOCKET_SERVER", "class_name": "WebSocketServer" }
    ]
  },
  "migrations": [
    { "tag": "v2", "new_sqlite_classes": ["WebSocketServer"] }
  ]
}
[[durable_objects.bindings]]
name = "WEBSOCKET_SERVER"
class_name = "WebSocketServer"

[[migrations]]
tag = "v2"
new_sqlite_classes = [ "WebSocketServer" ]

使用默认选项时,可休眠 WebSocket 在驱逐后保持打开,因此消息之后仍可往返。传递 { webSockets: "close" } 会改为关闭它们:

test/eviction-websockets.test.jsjs
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("WebSocket eviction behavior", () => {
	it("hibernates WebSockets across eviction by default", async () => {
		const id = env.WEBSOCKET_SERVER.idFromName("ws-test");
		const stub = env.WEBSOCKET_SERVER.get(id);

		const response = await stub.fetch("https://example.com", {
			headers: { Upgrade: "websocket" },
		});
		const socket = response.webSocket;
		if (!socket) throw new Error("Expected WebSocket response");
		socket.accept();

		// Hibernatable WebSockets are hibernated, not closed
		await evictDurableObject(stub);

		// Messages still round-trip after eviction wakes the Durable Object
		const message = new Promise((resolve) => {
			socket.addEventListener("message", (event) => {
				resolve(event.data);
			});
		});
		socket.send("after-eviction");
		expect(await message).toBe("after-eviction");
		socket.close(1000, "done");
	});

	it("closes WebSockets when requested", async () => {
		const id = env.WEBSOCKET_SERVER.idFromName("ws-close-test");
		const stub = env.WEBSOCKET_SERVER.get(id);

		const response = await stub.fetch("https://example.com", {
			headers: { Upgrade: "websocket" },
		});
		const socket = response.webSocket;
		if (!socket) throw new Error("Expected WebSocket response");
		socket.accept();

		const closed = new Promise((resolve) => {
			socket.addEventListener("close", (event) => resolve(event));
		});

		// Close WebSockets instead of hibernating them
		await evictDurableObject(stub, { webSockets: "close" });
		expect(await closed).toBeDefined();
	});
});
test/eviction-websockets.test.tsts
import { env } from "cloudflare:workers";
import { evictDurableObject } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("WebSocket eviction behavior", () => {
	it("hibernates WebSockets across eviction by default", async () => {
		const id = env.WEBSOCKET_SERVER.idFromName("ws-test");
		const stub = env.WEBSOCKET_SERVER.get(id);

		const response = await stub.fetch("https://example.com", {
			headers: { Upgrade: "websocket" },
		});
		const socket = response.webSocket;
		if (!socket) throw new Error("Expected WebSocket response");
		socket.accept();

		// Hibernatable WebSockets are hibernated, not closed
		await evictDurableObject(stub);

		// Messages still round-trip after eviction wakes the Durable Object
		const message = new Promise<string>((resolve) => {
			socket.addEventListener("message", (event) => {
				resolve(event.data as string);
			});
		});
		socket.send("after-eviction");
		expect(await message).toBe("after-eviction");
		socket.close(1000, "done");
	});

	it("closes WebSockets when requested", async () => {
		const id = env.WEBSOCKET_SERVER.idFromName("ws-close-test");
		const stub = env.WEBSOCKET_SERVER.get(id);

		const response = await stub.fetch("https://example.com", {
			headers: { Upgrade: "websocket" },
		});
		const socket = response.webSocket;
		if (!socket) throw new Error("Expected WebSocket response");
		socket.accept();

		const closed = new Promise<CloseEvent>((resolve) => {
			socket.addEventListener("close", (event) => resolve(event));
		});

		// Close WebSockets instead of hibernating them
		await evictDurableObject(stub, { webSockets: "close" });
		expect(await closed).toBeDefined();
	});
});

要一次驱逐所有当前运行的 Durable Objects(例如,在不删除持久化数据的情况下在测试之间重置状态),请使用 evictAllDurableObjects()

import { evictAllDurableObjects } from "cloudflare:test";
import { afterEach } from "vitest";

afterEach(async () => {
	await evictAllDurableObjects();
});

有关驱逐辅助函数的更多详情,包括 DurableObjectEvictionOptions 接口,请参阅测试 API 参考

运行测试

使用以下命令运行测试:

npx vitest

或在 package.json 中添加脚本:

{
	"scripts": {
		"test": "vitest"
	}
}

相关资源

这篇文档对您有帮助吗?