diff --git a/packages/relay/src/cloudflare-adapter.ts b/packages/relay/src/cloudflare-adapter.ts index f19f6c00f..eae87a733 100644 --- a/packages/relay/src/cloudflare-adapter.ts +++ b/packages/relay/src/cloudflare-adapter.ts @@ -49,6 +49,52 @@ interface WebSocketWithAttachment extends WebSocket { deserializeAttachment(): unknown; } +function hasAttachmentMethods(ws: WebSocket): ws is WebSocketWithAttachment { + // Type-safe check for attachment methods - required for Cloudflare WebSocket hibernation API + // Use Reflect to check for methods without type assertions + return ( + "serializeAttachment" in ws && + "deserializeAttachment" in ws && + typeof Reflect.get(ws, "serializeAttachment") === "function" && + typeof Reflect.get(ws, "deserializeAttachment") === "function" + ); +} + +function deserializeAttachment(ws: WebSocket): unknown { + if (!hasAttachmentMethods(ws)) return null; + try { + return ws.deserializeAttachment(); + } catch { + return null; + } +} + +function serializeAttachment(ws: WebSocket, value: unknown): void { + if (!hasAttachmentMethods(ws)) { + throw new Error("WebSocket does not support attachments"); + } + ws.serializeAttachment(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function getGlobalWebSocketPair(): (new () => WebSocketPair) | undefined { + // Access WebSocketPair from global scope (Cloudflare Workers runtime) + // Use Reflect to access global property without type assertions + const WebSocketPair = Reflect.get(globalThis, "WebSocketPair") as unknown; + if (typeof WebSocketPair === "function") { + return WebSocketPair as new () => WebSocketPair; + } + return undefined; +} + interface Env { RELAY: DurableObjectNamespace; } @@ -91,9 +137,11 @@ export class RelayDurableObject { } private createWebSocketPair(): [WebSocket, WebSocket] { - const pair = new ( - globalThis as unknown as { WebSocketPair: new () => WebSocketPair } - ).WebSocketPair(); + const WebSocketPairCtor = getGlobalWebSocketPair(); + if (!WebSocketPairCtor) { + throw new Error("WebSocketPair not available in global scope"); + } + const pair: WebSocketPair = new WebSocketPairCtor(); return [pair[0], pair[1]]; } @@ -146,8 +194,9 @@ export class RelayDurableObject { private handleControlKeepalive(ws: WebSocket, message: string): void { try { - const parsed = JSON.parse(message) as unknown as { type?: unknown }; - if (parsed?.type !== "ping") return; + const parsed: unknown = JSON.parse(message); + const parsedRecord = isRecord(parsed) ? parsed : null; + if (parsedRecord?.type !== "ping") return; try { ws.send(JSON.stringify({ type: "pong", ts: Date.now() })); } catch { @@ -220,9 +269,8 @@ export class RelayDurableObject { const out = new Set(); for (const ws of this.state.getWebSockets("client")) { try { - const attachment = ( - ws as WebSocketWithAttachment - ).deserializeAttachment() as RelaySessionAttachment | null; + const attachmentRaw = deserializeAttachment(ws); + const attachment = isRecord(attachmentRaw) ? attachmentRaw : null; if ( attachment?.role === "client" && typeof attachment.connectionId === "string" && @@ -271,7 +319,7 @@ export class RelayDurableObject { connectionId: null, createdAt: Date.now(), }; - (server as WebSocketWithAttachment).serializeAttachment(attachment); + serializeAttachment(server, attachment); console.log(`[Relay DO] v1:${role} connected to session ${serverId}`); @@ -322,7 +370,7 @@ export class RelayDurableObject { connectionId: resolvedConnectionId || null, createdAt: Date.now(), }; - (server as WebSocketWithAttachment).serializeAttachment(attachment); + serializeAttachment(server, attachment); let roleSuffix = ""; if (isServerControl) { @@ -359,7 +407,8 @@ export class RelayDurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); - const role = url.searchParams.get("role") as ConnectionRole | null; + const roleRaw = url.searchParams.get("role"); + const role = roleRaw === "server" || roleRaw === "client" ? roleRaw : null; const serverId = url.searchParams.get("serverId"); const connectionIdRaw = url.searchParams.get("connectionId"); const connectionId = typeof connectionIdRaw === "string" ? connectionIdRaw.trim() : ""; @@ -388,15 +437,14 @@ export class RelayDurableObject { * Called when a WebSocket message is received (wakes from hibernation). */ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void { - const attachment = ( - ws as WebSocketWithAttachment - ).deserializeAttachment() as RelaySessionAttachment | null; - if (!attachment) { + const attachmentRaw = deserializeAttachment(ws); + if (!isRecord(attachmentRaw)) { console.error("[Relay DO] Message from WebSocket without attachment"); return; } + const attachment = attachmentRaw; - const version = attachment.version ?? LEGACY_RELAY_VERSION; + const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION; if (version === LEGACY_RELAY_VERSION) { const targetRole = attachment.role === "server" ? "client" : "server"; @@ -411,7 +459,8 @@ export class RelayDurableObject { return; } - const { role, connectionId } = attachment; + const role = getString(attachment, "role"); + const connectionId = getString(attachment, "connectionId"); if (!connectionId) { // Control channel: support simple app-level keepalive. if (typeof message === "string") { @@ -451,44 +500,46 @@ export class RelayDurableObject { * Called when a WebSocket closes (wakes from hibernation). */ webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): void { - const attachment = ( - ws as WebSocketWithAttachment - ).deserializeAttachment() as RelaySessionAttachment | null; - if (!attachment) return; + const attachmentRaw = deserializeAttachment(ws); + if (!isRecord(attachmentRaw)) return; + const attachment = attachmentRaw; - const version = attachment.version ?? LEGACY_RELAY_VERSION; + const version = getString(attachment, "version") ?? LEGACY_RELAY_VERSION; + const role = getString(attachment, "role"); + const connectionId = getString(attachment, "connectionId"); + const serverId = getString(attachment, "serverId"); console.log( - `[Relay DO] v${version}:${attachment.role}${attachment.connectionId ? `(${attachment.connectionId})` : ""} disconnected from session ${attachment.serverId} (${code}: ${reason})`, + `[Relay DO] v${version}:${role ?? "unknown"}${connectionId ? `(${connectionId})` : ""} disconnected from session ${serverId ?? "unknown"} (${code}: ${reason})`, ); if (version === LEGACY_RELAY_VERSION) { return; } - if (attachment.role === "client" && attachment.connectionId) { + if (role === "client" && connectionId) { const remainingClientSockets = this.state - .getWebSockets(`client:${attachment.connectionId}`) + .getWebSockets(`client:${connectionId}`) .some((socket) => socket !== ws); if (remainingClientSockets) { return; } - this.pendingFrames.delete(attachment.connectionId); + this.pendingFrames.delete(connectionId); // Last socket for this session closed: now clean up matching server-data socket. - for (const serverWs of this.state.getWebSockets(`server:${attachment.connectionId}`)) { + for (const serverWs of this.state.getWebSockets(`server:${connectionId}`)) { try { serverWs.close(1001, "Client disconnected"); } catch { // ignore } } - this.notifyControls({ type: "disconnected", connectionId: attachment.connectionId }); + this.notifyControls({ type: "disconnected", connectionId }); return; } - if (attachment.role === "server" && attachment.connectionId) { + if (role === "server" && connectionId) { // Force the client to reconnect and re-handshake when the daemon side drops. - for (const clientWs of this.state.getWebSockets(`client:${attachment.connectionId}`)) { + for (const clientWs of this.state.getWebSockets(`client:${connectionId}`)) { try { clientWs.close(1012, "Server disconnected"); } catch { @@ -502,10 +553,10 @@ export class RelayDurableObject { * Called on WebSocket error. */ webSocketError(ws: WebSocket, error: unknown): void { - const attachment = ( - ws as WebSocketWithAttachment - ).deserializeAttachment() as RelaySessionAttachment | null; - console.error(`[Relay DO] WebSocket error for ${attachment?.role ?? "unknown"}:`, error); + const attachmentRaw = deserializeAttachment(ws); + const attachment = isRecord(attachmentRaw) ? attachmentRaw : null; + const role = attachment ? getString(attachment, "role") : undefined; + console.error(`[Relay DO] WebSocket error for ${role ?? "unknown"}:`, error); } } diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts index 205c0785a..3a605b9bb 100644 --- a/packages/relay/src/crypto.ts +++ b/packages/relay/src/crypto.ts @@ -26,6 +26,15 @@ const NONCE_LENGTH = nacl.box.nonceLength; // 24 let prngReady = false; +interface GlobalWithCrypto { + crypto?: Crypto; +} + +function getGlobalCrypto(): Crypto | undefined { + const g = globalThis as GlobalWithCrypto; + return g.crypto; +} + function ensurePrng(): void { if (prngReady) return; @@ -37,7 +46,7 @@ function ensurePrng(): void { // fallthrough } - const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto; + const cryptoObj = getGlobalCrypto(); if (cryptoObj?.getRandomValues) { nacl.setPRNG((x, n) => { const buf = new Uint8Array(n); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index 3b1b6a447..2d2c59f1b 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -56,9 +56,25 @@ interface E2EEReadyMessage { type: "e2ee_ready"; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isE2EEHelloMessage(value: unknown): value is E2EEHelloMessage { + return ( + isRecord(value) && + value.type === "e2ee_hello" && + typeof value.key === "string" && + value.key.trim().length > 0 + ); +} + +function isE2EEReadyMessage(value: unknown): value is E2EEReadyMessage { + return isRecord(value) && value.type === "e2ee_ready"; +} + function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { - const parsedRecord = - parsed && typeof parsed === "object" ? (parsed as Record) : null; + const parsedRecord = isRecord(parsed) ? parsed : null; const rawType = parsedRecord?.type; function describeType(value: unknown): string { if (typeof value === "string") return value; @@ -77,6 +93,19 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { const HANDSHAKE_RETRY_MS = 1000; const MAX_PENDING_SENDS = 200; +interface TimeoutWithUnref { + unref(): void; +} + +function hasUnref(timeout: unknown): timeout is TimeoutWithUnref { + return ( + typeof timeout === "object" && + timeout !== null && + "unref" in timeout && + typeof (timeout as Record).unref === "function" + ); +} + /** * Creates an encrypted channel as the initiator (client). * @@ -137,7 +166,9 @@ export async function createClientChannel( sendHello(); }, HANDSHAKE_RETRY_MS); // Avoid keeping Node processes alive (e.g. tests) if the handshake is stuck. - (retry as unknown as { unref?: () => void }).unref?.(); + if (hasUnref(retry)) { + retry.unref(); + } return channel; } @@ -160,8 +191,8 @@ export async function createDaemonChannel( const shouldIgnorePostHelloPlaintext = (data: string | ArrayBuffer): boolean => { try { const text = typeof data === "string" ? data : new TextDecoder().decode(data); - const parsed = JSON.parse(text) as Partial; - return parsed.type === "e2ee_hello" || parsed.type === "e2ee_ready"; + const parsed: unknown = JSON.parse(text); + return isE2EEHelloMessage(parsed) || isE2EEReadyMessage(parsed); } catch { return false; } @@ -178,11 +209,12 @@ export async function createDaemonChannel( throw buildInvalidHelloError(helloText); } - const msg = parsed as Partial; - if (msg.type !== "e2ee_hello" || typeof msg.key !== "string" || !msg.key.trim()) { + if (!isE2EEHelloMessage(parsed)) { throw buildInvalidHelloError(helloText, parsed); } + const msg = parsed; + // Buffer any subsequent messages that arrive while we're doing async // WebCrypto work to derive the shared key. Without this, it's possible // for the next message (already encrypted) to be misinterpreted as a @@ -269,8 +301,8 @@ export class EncryptedChannel { if (this.state === "handshaking") { try { const text = typeof data === "string" ? data : new TextDecoder().decode(data); - const msg = JSON.parse(text) as Partial; - if (msg.type === "e2ee_ready") { + const parsed: unknown = JSON.parse(text); + if (isE2EEReadyMessage(parsed)) { this.state = "open"; this.events.onopen?.(); for (const cb of this.onOpenCallbacks) cb(); @@ -290,16 +322,16 @@ export class EncryptedChannel { try { const text = typeof data === "string" ? data : new TextDecoder().decode(data); if (text.trim().startsWith("{")) { - const parsed = JSON.parse(text) as Partial; + const parsed: unknown = JSON.parse(text); - if (parsed.type === "e2ee_hello" && typeof parsed.key === "string") { + if (isE2EEHelloMessage(parsed)) { if (this.options.daemonKeyPair) { await this.handleDaemonRehello(parsed.key); } return null; } - if (parsed.type === "e2ee_ready") { + if (isE2EEReadyMessage(parsed)) { return null; }