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.
}
}
}
""";
}