INTEGRITY Документация

Тестирование Durable Objects

Используйте @cloudflare/vitest-plugin ↗ для написания тестов для ваших Durable Objects. Эта интеграция запускает тесты внутри среды выполнения Workers, предоставляя прямой доступ к привязкам и API Durable Object.

Предварительные требования

Установите Vitest и Workers Vitest integration в качестве dev-зависимостей:

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

Пример Durable Object

В этом примере тестируется простой Durable Object счётчика с хранилищем SQLite:

src/index.js
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.ts
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

Создайте vitest.config.ts файл, использующий cloudflareTest() плагин:

vitest.config.ts
import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vitest/config";

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

Убедитесь, что конфигурация Wrangler включает привязку Durable Object и миграцию SQLite:

{
  "name": "counter-worker",
  "main": "src/index.ts",
  // Set this to today's date
  "compatibility_date": "2026-08-28",
  "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-28"

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

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

Определение типов для тестов

Создайте test/tsconfig.json чтобы настроить TypeScript для тестов:

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

Создайте env.d.ts файл, чтобы типизировать тестовое окружение:

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

Написание тестов

Модульные тесты с прямым доступом к Durable Object

Вы можете получить stub для Durable Object напрямую из env объект, предоставленный cloudflare:workers:

test/counter.test.js
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.ts
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() чтобы протестировать HTTP-обработчик вашего Worker, который направляет запросы к Durable Objects:

test/integration.test.js
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.ts
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.js
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.ts
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

Durable Objects на основе SQLite прекрасно работают в тестах. SQL API доступен, если класс Durable Object настроен с использованием new_sqlite_classes в конфигурации Wrangler:

test/sqlite.test.js
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.ts
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.js
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.ts
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);
	});
});

Чтобы протестировать alarms, добавьте alarm() метод в ваш Durable Object:

src/index.js
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.ts
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, поддерживающие hibernation, не закрываются, а переводятся в спящий режим, и при вытеснении система ждёт до 30 секунд завершения выполняющихся запросов, прежде чем уничтожить экземпляр.

Следующий тест задаёт как состояние в памяти (cachedHits) и постоянное хранилище (значение счётчика), выгружает Durable Object из памяти и проверяет, что состояние в памяти очищено, а сохранённое значение счётчика сохраняется:

test/eviction.test.js
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.ts
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 при вытеснении

Вы можете управлять поведением WebSocket-соединений, поддерживающих гибернацию, при выгрузке Durable Object, передав options параметр:

В следующем примере используется Durable Object, который принимает соединения WebSocket с hibernatable WebSockets API:

src/websocket-server.js
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.ts
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
	}
}

Добавьте привязку и миграцию для Durable Object в конфигурации Wrangler, рядом с существующей 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.js
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.ts
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 интерфейс, обратитесь к справка по Test APIs.

Запуск тестов

Запустите тесты с помощью:

npx vitest

Или добавьте скрипт в свой package.json:

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