From 19107fceb715c656336f6f0ec3f0723f54636763 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 11 Jul 2026 16:25:14 +0200 Subject: [PATCH] ops(relay): add staging cutover proxy --- .github/workflows/deploy-relay.yml | 2 +- packages/relay/src/cutover-proxy.test.ts | 86 +++++++++++++++++++++++ packages/relay/src/cutover-proxy.ts | 19 +++++ packages/relay/src/live-relay.e2e.test.ts | 2 +- packages/relay/wrangler.staging.toml | 8 +++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 packages/relay/src/cutover-proxy.test.ts create mode 100644 packages/relay/src/cutover-proxy.ts create mode 100644 packages/relay/wrangler.staging.toml diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 15637840a7..4c50ed6918 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -27,6 +27,6 @@ jobs: run: npm run typecheck --workspace=@getpaseo/relay - name: Deploy worker - run: cd packages/relay && npx wrangler deploy + run: cd packages/relay && npx wrangler deploy -c wrangler.staging.toml env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/packages/relay/src/cutover-proxy.test.ts b/packages/relay/src/cutover-proxy.test.ts new file mode 100644 index 0000000000..210fe8d8be --- /dev/null +++ b/packages/relay/src/cutover-proxy.test.ts @@ -0,0 +1,86 @@ +import { createServer, type IncomingMessage, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createCutoverProxy } from "./cutover-proxy.js"; + +interface ReceivedRequest { + method: string; + path: string; + probe: string; +} + +class RecordingOrigin { + private readonly requests: ReceivedRequest[] = []; + private server: Server | null = null; + + async start(): Promise { + this.server = createServer((request, response) => { + this.requests.push(recordRequest(request)); + response.writeHead(202, { "content-type": "application/json", "x-relay-origin": "fly" }); + response.end(JSON.stringify({ status: "forwarded" })); + }); + await new Promise((resolve) => this.server!.listen(0, "127.0.0.1", resolve)); + const address = this.server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; + } + + received(): ReceivedRequest[] { + return this.requests; + } + + async close(): Promise { + const server = this.server; + this.server = null; + if (!server) return; + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} + +function recordRequest(request: IncomingMessage): ReceivedRequest { + return { + method: request.method ?? "", + path: request.url ?? "", + probe: String(request.headers["x-relay-probe"] ?? ""), + }; +} + +describe("cutover proxy", () => { + const origins: RecordingOrigin[] = []; + + afterEach(async () => { + await Promise.all(origins.splice(0).map((origin) => origin.close())); + }); + + it("forwards the request to the configured origin without changing its route", async () => { + const origin = new RecordingOrigin(); + origins.push(origin); + const originUrl = await origin.start(); + const proxy = createCutoverProxy(originUrl); + + const response = await proxy.fetch( + new Request("https://relay-staging.example/ws?serverId=srv_stage&role=server&v=2", { + headers: { "x-relay-probe": "staging" }, + }), + ); + + expect(origin.received()).toEqual([ + { + method: "GET", + path: "/ws?serverId=srv_stage&role=server&v=2", + probe: "staging", + }, + ]); + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ status: "forwarded" }); + expect(response.headers.get("x-relay-origin")).toBe("fly"); + }); +}); diff --git a/packages/relay/src/cutover-proxy.ts b/packages/relay/src/cutover-proxy.ts new file mode 100644 index 0000000000..840c5d6e3c --- /dev/null +++ b/packages/relay/src/cutover-proxy.ts @@ -0,0 +1,19 @@ +interface CutoverProxy { + fetch(request: Request): Promise; +} + +export function createCutoverProxy(origin: string): CutoverProxy { + const originUrl = new URL(origin); + + return { + async fetch(request: Request): Promise { + const upstreamUrl = new URL(request.url); + upstreamUrl.protocol = originUrl.protocol; + upstreamUrl.host = originUrl.host; + + return fetch(new Request(upstreamUrl, request)); + }, + }; +} + +export default createCutoverProxy("https://paseo-relay-next.fly.dev"); diff --git a/packages/relay/src/live-relay.e2e.test.ts b/packages/relay/src/live-relay.e2e.test.ts index 1526c6cd46..562c27f920 100644 --- a/packages/relay/src/live-relay.e2e.test.ts +++ b/packages/relay/src/live-relay.e2e.test.ts @@ -12,7 +12,7 @@ import { // This live test uses the hosted relay's real TLS endpoint. Self-hosted relay TLS // opt-in is covered at URL-building/integration level so the local E2E does not // need to provision trusted certificates. -const RELAY_BASE_URL = "wss://relay.paseo.sh"; +const RELAY_BASE_URL = process.env.PASEO_LIVE_RELAY_URL ?? "wss://relay.paseo.sh"; async function withRetry( fn: () => Promise, diff --git a/packages/relay/wrangler.staging.toml b/packages/relay/wrangler.staging.toml new file mode 100644 index 0000000000..70629af105 --- /dev/null +++ b/packages/relay/wrangler.staging.toml @@ -0,0 +1,8 @@ +name = "paseo-relay-cutover-staging" +main = "src/cutover-proxy.ts" +compatibility_date = "2024-12-01" +workers_dev = true +preview_urls = false + +[observability] +enabled = true