feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RelayDurableObject } from "./cloudflare-adapter.js";
|
||||
import relayWorker, { RelayDurableObject } from "./cloudflare-adapter.js";
|
||||
|
||||
type MockSocket = WebSocket & {
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
@@ -42,6 +42,57 @@ function createMockState() {
|
||||
};
|
||||
}
|
||||
|
||||
async function withMockWebSocketPair(
|
||||
run: (sockets: { clientWs: MockSocket; serverWs: MockSocket }) => Promise<void> | void
|
||||
): Promise<void> {
|
||||
const serverWs = createMockSocket();
|
||||
const clientWs = createMockSocket();
|
||||
const WebSocketPairMock = class {
|
||||
[index: number]: WebSocket;
|
||||
constructor() {
|
||||
this[0] = clientWs as unknown as WebSocket;
|
||||
this[1] = serverWs as unknown as WebSocket;
|
||||
}
|
||||
};
|
||||
|
||||
const previousPair = (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair;
|
||||
(globalThis as unknown as { WebSocketPair: unknown }).WebSocketPair = WebSocketPairMock;
|
||||
try {
|
||||
await run({ clientWs, serverWs });
|
||||
} finally {
|
||||
if (previousPair === undefined) {
|
||||
delete (globalThis as unknown as { WebSocketPair?: unknown }).WebSocketPair;
|
||||
} else {
|
||||
(globalThis as unknown as { WebSocketPair: unknown }).WebSocketPair = previousPair;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("RelayDurableObject versioning", () => {
|
||||
it("accepts legacy v1 client sockets without clientId", async () => {
|
||||
const { state } = createMockState();
|
||||
await withMockWebSocketPair(async () => {
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=1", {
|
||||
headers: {
|
||||
Upgrade: "websocket",
|
||||
},
|
||||
});
|
||||
await relay.fetch(req).catch(() => undefined);
|
||||
expect(state.acceptWebSocket).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects v2 client sockets when clientId is missing", async () => {
|
||||
const { state } = createMockState();
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
const req = new Request("https://relay.test/ws?role=client&serverId=srv_test&v=2");
|
||||
const response = await relay.fetch(req);
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe("Missing clientId parameter");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -93,4 +144,119 @@ describe("RelayDurableObject control nudge/reset behavior", () => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(control.close).toHaveBeenCalledWith(1011, "Control unresponsive");
|
||||
});
|
||||
|
||||
it("does not replace existing client sockets for the same clientId", async () => {
|
||||
const existingClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId: "clt_same_session",
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const { state, setTagSockets } = createMockState();
|
||||
setTagSockets("client:clt_same_session", [existingClient]);
|
||||
setTagSockets("client", [existingClient]);
|
||||
|
||||
await withMockWebSocketPair(async () => {
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
const req = new Request(
|
||||
"https://relay.test/ws?role=client&serverId=srv_test&clientId=clt_same_session&v=2",
|
||||
{
|
||||
headers: {
|
||||
Upgrade: "websocket",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await relay.fetch(req).catch(() => undefined);
|
||||
expect(existingClient.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps server data socket alive while at least one client socket remains", () => {
|
||||
const clientId = "clt_multi";
|
||||
const disconnectedClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const stillConnectedClient = createMockSocket({
|
||||
version: "2",
|
||||
role: "client",
|
||||
clientId,
|
||||
serverId: "srv_test",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
const serverData = createMockSocket();
|
||||
const control = createMockSocket();
|
||||
const { state, setTagSockets } = createMockState();
|
||||
|
||||
setTagSockets("server-control", [control]);
|
||||
setTagSockets(`server:${clientId}`, [serverData]);
|
||||
setTagSockets("client", [stillConnectedClient]);
|
||||
setTagSockets(`client:${clientId}`, [stillConnectedClient]);
|
||||
|
||||
const relay = new RelayDurableObject(state as any);
|
||||
relay.webSocketClose(
|
||||
disconnectedClient as unknown as WebSocket,
|
||||
1001,
|
||||
"Client disconnected",
|
||||
true
|
||||
);
|
||||
|
||||
expect(serverData.close).not.toHaveBeenCalled();
|
||||
expect(control.send).not.toHaveBeenCalledWith(
|
||||
JSON.stringify({ type: "client_disconnected", clientId })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay worker endpoint routing", () => {
|
||||
it("routes missing v to legacy v1 isolated DO ids", async () => {
|
||||
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
|
||||
const get = vi.fn(() => ({ fetch }));
|
||||
const idFromName = vi.fn(() => ({ toString: () => "id" }));
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.test/ws?serverId=srv_test&role=server"),
|
||||
{ RELAY: { idFromName, get } } as any
|
||||
);
|
||||
|
||||
expect(idFromName).toHaveBeenCalledWith("relay-v1:srv_test");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await expect(response.text()).resolves.toBe("ok:1");
|
||||
});
|
||||
|
||||
it("routes v=2 to v2 isolated DO ids", async () => {
|
||||
const fetch = vi.fn(async (request: Request) => new Response(`ok:${new URL(request.url).searchParams.get("v")}`));
|
||||
const get = vi.fn(() => ({ fetch }));
|
||||
const idFromName = vi.fn(() => ({ toString: () => "id" }));
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=2"),
|
||||
{ RELAY: { idFromName, get } } as any
|
||||
);
|
||||
|
||||
expect(idFromName).toHaveBeenCalledWith("relay-v2:srv_test");
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
await expect(response.text()).resolves.toBe("ok:2");
|
||||
});
|
||||
|
||||
it("rejects invalid v values", async () => {
|
||||
const fetch = vi.fn();
|
||||
const get = vi.fn(() => ({ fetch }));
|
||||
const idFromName = vi.fn(() => ({ toString: () => "id" }));
|
||||
|
||||
const response = await relayWorker.fetch(
|
||||
new Request("https://relay.test/ws?serverId=srv_test&role=server&v=nope"),
|
||||
{ RELAY: { idFromName, get } } as any
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.text()).resolves.toBe("Invalid v parameter (expected 1 or 2)");
|
||||
expect(idFromName).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user