Editor/Core/RelayWorkerSource.cs
namespace TeamCreate;

/// <summary>
/// The relay Worker, exactly as it is in Tools/RelayWorker/src/index.js. The editor uploads this text to the person's own Cloudflare account when they
/// press Set up relay. Generated by `npm run embed` in Tools/RelayWorker; do not edit by hand. The test suite compares it with the file and fails on a difference.
/// </summary>
public static class RelayWorkerSource
{
    public const string Text = """
// Collaboration relay as a Cloudflare Worker.
//
// It speaks exactly the protocol Tools/Relay (the ASP.NET relay) speaks, so the editor library does not know or care which one it is dialling. The point of
// having this one is that it needs no server: `wrangler deploy` puts it on Cloudflare's free plan, and every install of the library can share it.
//
// What it does: pairs one host with its guests inside a Durable Object (one object per room) and forwards opaque bytes. What it cannot do: read them. Everything
// between the two editors is AES-256-GCM sealed under a key derived from the invite secret, which never reaches this code. The room id and the two proofs it does
// see are one-way derivations of that secret.
//
// Wire contract (see Libraries/collaboration/Editor/Core/RelayProtocol.cs):
//   - The socket is opened at /relay?room=<32 hex>. The room id rides in the address so the right Durable Object can be chosen before any message.
//   - First message, text JSON: a host hello {t:"host",v:1,room,host,guest,key?} or a guest hello {t:"guest",v:1,room,proof}. Reply {t:"ok",v:1} or an error.
//   - A GUEST'S socket then carries raw stream bytes; the relay wraps each chunk for the host as [type=1][channel u32 BE][payload].
//   - The HOST'S socket carries frames: Data(1) to a guest's channel, Open(2) sent by the relay, Close(3) either way.

import { DurableObject } from "cloudflare:workers";

const PROTOCOL_VERSION = 1;
const MAX_CHUNK_BYTES = 16 * 1024;
const MAX_FRAME_BYTES = MAX_CHUNK_BYTES + 5;
const MAX_CONTROL_CHARS = 1024;
const MAX_KEY_CHARS = 128;
const MAX_GUESTS_PER_ROOM = 16;
const HELLO_TIMEOUT_MS = 10_000;
const BYTES_PER_SECOND = 8 * 1024 * 1024;
const BURST_BYTES = 16 * 1024 * 1024;

const FRAME_DATA = 1;
const FRAME_OPEN = 2;
const FRAME_CLOSE = 3;

const HEX32 = /^[0-9a-f]{32}$/;
const HEX64 = /^[0-9a-f]{64}$/;
const encoder = new TextEncoder();

export default {
	async fetch(request, env) {
		const url = new URL(request.url);
		if (url.pathname === "/") {
			return new Response("Collaboration relay is running. Editors connect to /relay; this page is only here so you can check the address.\n", {
				headers: { "content-type": "text/plain; charset=utf-8" },
			});
		}
		if (url.pathname === "/health") {
			return Response.json({ status: "ok", service: "collab-relay", protocol: PROTOCOL_VERSION });
		}
		if (url.pathname !== "/relay") {
			return new Response("Not found.", { status: 404 });
		}
		if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
			return new Response("Expected a WebSocket upgrade.", { status: 426 });
		}
		const room = url.searchParams.get("room");
		if (!room || !HEX32.test(room)) {
			return new Response("Missing or malformed room.", { status: 400 });
		}
		const stub = env.ROOMS.get(env.ROOMS.idFromName(room));
		return stub.fetch(request);
	},
};

function ok() {
	return JSON.stringify({ t: "ok", v: PROTOCOL_VERSION });
}

function errorMessage(code, message) {
	return JSON.stringify({ t: "error", v: PROTOCOL_VERSION, code, message });
}

function buildFrame(type, channel, payload) {
	const body = payload || new Uint8Array(0);
	const frame = new Uint8Array(5 + body.length);
	frame[0] = type;
	new DataView(frame.buffer).setUint32(1, channel, false);
	frame.set(body, 5);
	return frame;
}

// Equal-length comparison that does not stop at the first difference.
function sameText(a, b) {
	if (typeof a !== "string" || typeof b !== "string" || a.length !== b.length) {
		return false;
	}
	let diff = 0;
	for (let i = 0; i < a.length; i++) {
		diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
	}
	return diff === 0;
}

function sameBytes(a, b) {
	if (a.length !== b.length) {
		return false;
	}
	let diff = 0;
	for (let i = 0; i < a.length; i++) {
		diff |= a[i] ^ b[i];
	}
	return diff === 0;
}

export class Room extends DurableObject {
	constructor(ctx, env) {
		super(ctx, env);
		// Byte budgets live in memory only: they matter while sockets are busy, and a socket that is busy keeps the object awake.
		this.budgets = new WeakMap();
	}

	async fetch(request) {
		const room = new URL(request.url).searchParams.get("room");
		const pair = new WebSocketPair();
		const [client, server] = Object.values(pair);
		// Hibernation: the object sleeps between messages, so an idle room costs nothing while its sockets stay open.
		this.ctx.acceptWebSocket(server);
		server.serializeAttachment({ role: "pending", room, at: Date.now() });
		if ((await this.ctx.storage.getAlarm()) == null) {
			await this.ctx.storage.setAlarm(Date.now() + HELLO_TIMEOUT_MS);
		}
		return new Response(null, { status: 101, webSocket: client });
	}

	// Tells sockets that connected but never said hello that they are refused, so a stranger cannot sit on the room silently. The refusal message goes out at
	// once; the runtime delivers the close frame of a socket closed from an alarm about ten seconds after the object next goes idle, which costs nothing (an
	// idle hibernating socket is free and counts toward no limit), so the wait is documented rather than fought.
	async alarm() {
		const now = Date.now();
		let waiting = 0;
		for (const ws of this.ctx.getWebSockets()) {
			const att = ws.deserializeAttachment();
			if (att && att.role === "pending") {
				if (now - att.at >= HELLO_TIMEOUT_MS) {
					this.refuse(ws, "bad-hello", "No hello arrived in time.");
				} else {
					waiting++;
				}
			}
		}
		if (waiting > 0) {
			await this.ctx.storage.setAlarm(Date.now() + 2000);
		}
	}

	async webSocketMessage(ws, message) {
		const att = ws.deserializeAttachment() || {};
		if (att.role === "pending") {
			return this.onHello(ws, att, message);
		}
		if (typeof message === "string") {
			return this.close(ws, 1003, "binary only");
		}
		const bytes = new Uint8Array(message);
		if (!this.charge(ws, bytes.length)) {
			return this.refuse(ws, "limit", "This connection exceeded its bandwidth limit.");
		}
		if (att.role === "host") {
			return this.onHostFrame(ws, bytes);
		}
		if (att.role === "guest") {
			return this.onGuestBytes(ws, att, bytes);
		}
	}

	async webSocketClose(ws) {
		await this.departed(ws);
	}

	async webSocketError(ws) {
		await this.departed(ws);
	}

	// ---- hello -------------------------------------------------------------------------------------------------------

	async onHello(ws, att, message) {
		if (typeof message !== "string") {
			return this.refuse(ws, "bad-hello", "The first message must be a text hello.");
		}
		if (message.length > MAX_CONTROL_CHARS) {
			return this.refuse(ws, "bad-hello", "The control message is longer than a relay accepts.");
		}
		let hello;
		try {
			hello = JSON.parse(message);
		} catch {
			return this.refuse(ws, "bad-hello", "The control message is not valid JSON.");
		}
		if (!hello || typeof hello !== "object" || typeof hello.t !== "string" || !hello.t) {
			return this.refuse(ws, "bad-hello", "The control message has no type.");
		}
		if (hello.v !== PROTOCOL_VERSION) {
			return this.refuse(ws, "version", `This relay speaks protocol ${PROTOCOL_VERSION}; update the collaboration library.`);
		}
		if (typeof hello.room !== "string" || !HEX32.test(hello.room)) {
			return this.refuse(ws, "bad-hello", "The room identifier is malformed.");
		}
		if (hello.room !== att.room) {
			return this.refuse(ws, "bad-hello", "The room identifier does not match the address.");
		}
		if (hello.t === "host") {
			return this.onHostHello(ws, att, hello);
		}
		if (hello.t === "guest") {
			return this.onGuestHello(ws, att, hello);
		}
		return this.refuse(ws, "bad-hello", "The first message must be a host or guest hello.");
	}

	async onHostHello(ws, att, hello) {
		if (typeof hello.host !== "string" || !HEX64.test(hello.host) || typeof hello.guest !== "string" || !HEX64.test(hello.guest)) {
			return this.refuse(ws, "bad-hello", "The host proofs are malformed.");
		}
		if (hello.key != null && (typeof hello.key !== "string" || hello.key.length > MAX_KEY_CHARS)) {
			return this.refuse(ws, "bad-hello", "The relay access key is too long.");
		}
		if (!(await this.keyAccepted(hello.key))) {
			return this.refuse(ws, "bad-key", "The access key was not accepted.");
		}
		const stored = await this.ctx.storage.get("room");
		if (stored && !sameText(stored.host, hello.host)) {
			return this.refuse(ws, "room-taken", "A different host owns this room.");
		}
		// The same host returning after a blip: its old socket may not have noticed yet, so the newcomer wins. The old socket's guests are closed with
		// it, because the new connection has no record of their channels and could not speak to them.
		if (stored) {
			for (const other of this.ctx.getWebSockets()) {
				const a = other.deserializeAttachment();
				if (a && a.role === "host" && other !== ws) {
					this.close(other, 1000, "replaced");
				}
				if (a && a.role === "guest") {
					this.close(other, 1001, "host restarted");
				}
			}
		}
		const hostToken = crypto.randomUUID();
		await this.ctx.storage.put("room", { host: hello.host, guest: hello.guest, hostToken, next: stored ? stored.next : 0 });
		ws.serializeAttachment({ role: "host", room: att.room, hostToken });
		ws.send(ok());
	}

	async onGuestHello(ws, att, hello) {
		if (typeof hello.proof !== "string" || !HEX64.test(hello.proof)) {
			return this.refuse(ws, "bad-hello", "The guest proof is malformed.");
		}
		const room = await this.ctx.storage.get("room");
		const host = room ? this.findHost(room.hostToken) : null;
		if (!room || !host) {
			return this.refuse(ws, "no-host", "The host is not connected.");
		}
		if (!sameText(room.guest, hello.proof)) {
			return this.refuse(ws, "bad-proof", "The proof was not accepted.");
		}
		let guests = 0;
		for (const other of this.ctx.getWebSockets()) {
			const a = other.deserializeAttachment();
			if (a && a.role === "guest") {
				guests++;
			}
		}
		if (guests >= MAX_GUESTS_PER_ROOM) {
			return this.refuse(ws, "full", "The room is full.");
		}
		const channel = room.next + 1;
		room.next = channel;
		await this.ctx.storage.put("room", room);
		ws.serializeAttachment({ role: "guest", room: att.room, channel });
		ws.send(ok());
		host.send(buildFrame(FRAME_OPEN, channel));
	}

	async keyAccepted(presented) {
		const configured = this.env.RELAY_ACCESS_KEYS;
		if (!configured || !configured.trim()) {
			return true;
		}
		if (!presented) {
			return false;
		}
		const keys = configured.split(/[,;]/).map((k) => k.trim()).filter(Boolean);
		// Hash both sides to a fixed length so the comparison reveals neither the key's length nor its prefix.
		const candidate = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(presented)));
		let accepted = false;
		for (const key of keys) {
			const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(key)));
			accepted = sameBytes(candidate, digest) || accepted;
		}
		return accepted;
	}

	// ---- forwarding --------------------------------------------------------------------------------------------------

	onHostFrame(ws, bytes) {
		if (bytes.length < 5 || bytes.length > MAX_FRAME_BYTES || bytes[0] < FRAME_DATA || bytes[0] > FRAME_CLOSE) {
			return this.close(ws, 1002, "bad frame");
		}
		const type = bytes[0];
		const channel = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1, false);
		const payload = bytes.subarray(5);
		// Open and Close carry no payload; a frame that does is malformed rather than ignorable.
		if (type !== FRAME_DATA && payload.length !== 0) {
			return this.close(ws, 1002, "bad frame");
		}
		const guest = this.findGuest(channel);
		if (!guest) {
			return;
		}
		if (type === FRAME_DATA) {
			guest.send(payload);
		} else if (type === FRAME_CLOSE) {
			// Marked first, so the guest's own close event does not send the host a second Close for a channel it already closed.
			guest.serializeAttachment({ ...guest.deserializeAttachment(), closedByHost: true });
			this.close(guest, 1000, "closed by host");
		}
	}

	async onGuestBytes(ws, att, bytes) {
		if (bytes.length > MAX_CHUNK_BYTES) {
			return this.close(ws, 1009, "chunk too big");
		}
		const room = await this.ctx.storage.get("room");
		const host = room ? this.findHost(room.hostToken) : null;
		if (!host) {
			return this.refuse(ws, "no-host", "The host is not connected.");
		}
		host.send(buildFrame(FRAME_DATA, att.channel, bytes));
	}

	async departed(ws) {
		const att = ws.deserializeAttachment();
		if (!att) {
			return;
		}
		if (att.role === "host") {
			const room = await this.ctx.storage.get("room");
			// Only the CURRENT host ending the room ends it; a replaced socket closing later must not tear down its successor's room.
			if (room && room.hostToken === att.hostToken) {
				await this.ctx.storage.deleteAll();
				for (const other of this.ctx.getWebSockets()) {
					const a = other.deserializeAttachment();
					if (a && a.role === "guest") {
						this.close(other, 1001, "host left");
					}
				}
			}
		} else if (att.role === "guest" && !att.closedByHost) {
			const room = await this.ctx.storage.get("room");
			const host = room ? this.findHost(room.hostToken) : null;
			if (host) {
				host.send(buildFrame(FRAME_CLOSE, att.channel));
			}
		}
	}

	// ---- helpers -----------------------------------------------------------------------------------------------------

	findHost(hostToken) {
		for (const ws of this.ctx.getWebSockets()) {
			const a = ws.deserializeAttachment();
			if (a && a.role === "host" && a.hostToken === hostToken) {
				return ws;
			}
		}
		return null;
	}

	findGuest(channel) {
		for (const ws of this.ctx.getWebSockets()) {
			const a = ws.deserializeAttachment();
			if (a && a.role === "guest" && a.channel === channel) {
				return ws;
			}
		}
		return null;
	}

	charge(ws, bytes) {
		const now = Date.now();
		let budget = this.budgets.get(ws);
		if (!budget) {
			budget = { tokens: BURST_BYTES, at: now };
			this.budgets.set(ws, budget);
		}
		budget.tokens = Math.min(BURST_BYTES, budget.tokens + ((now - budget.at) / 1000) * BYTES_PER_SECOND) - bytes;
		budget.at = now;
		return budget.tokens >= 0;
	}

	refuse(ws, code, message) {
		try {
			ws.send(errorMessage(code, message));
		} catch {
			// The socket may already be gone; there is nobody left to tell.
		}
		this.close(ws, 1008, code);
	}

	close(ws, code, reason) {
		try {
			ws.close(code, reason);
		} catch {
			// Already closing.
		}
	}
}
""";
}