Skip to content

Instantly share code, notes, and snippets.

@mkornatz
Last active March 7, 2024 11:02
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mkornatz/d7daca0203260340ffff7e85399a48db to your computer and use it in GitHub Desktop.
Save mkornatz/d7daca0203260340ffff7e85399a48db to your computer and use it in GitHub Desktop.
Cloudflare Workers CORS Proxy (supports websockets)
// We support the GET, POST, HEAD, and OPTIONS methods from any origin,
// and allow any header on requests. These headers must be present
// on all responses to all CORS preflight requests. In practice, this means
// all responses to OPTIONS requests.
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,HEAD,POST,OPTIONS",
"Access-Control-Max-Age": "86400",
}
// The URL for the remote third party API you want to fetch from
// but does not implement CORS
const API_URL = "https://SERVICE_NAME.datahub.figment.io"
const WS_API_URL = "wss://WEBSOCKET_SERVICE_NAME.datahub.figment.io"
const API_AUTH_KEY = "YOUR_API_KEY_HERE"
// List all of the domains here that you want to be able to access this proxy
const ALLOWED_DOMAINS = [
'my.example.com'
]
/**
* Receives a HTTP request, proxies the request, and returns the response. If the request is a websocket requests,
* it hands the request off to a separate handler for creating a websocket proxy.
* @param {Request} request
* @returns {Promise<Response>}
*/
async function handleRequest(request) {
const { url, headers } = request
const { host, pathname } = new URL(url)
const dhURL = API_URL + pathname
const request_origin = headers.get("Origin")
const origin = request_origin ? new URL(request_origin) : { host: headers.get('Host') }
if (ALLOWED_DOMAINS.includes(origin.host)){
let response
// Websocket requests are identified with an "Upgrade:websocket" HTTP header
const upgradeHeader = request.headers.get("Upgrade")
if (upgradeHeader && upgradeHeader === "websocket") {
const dataHubWebsocketURL = WS_API_URL + pathname
response = await handleWebsocketRequest(dataHubWebsocketURL)
} else {
dataHubRequest = new Request(dhURL, request)
dataHubRequest.headers.set("Authorization", API_AUTH_KEY);
dataHubRequest.headers.set("Origin", new URL(dhURL).origin)
response = await fetch(dataHubRequest)
// Recreate the response so we can modify the headers
response = new Response(response.body, response)
}
// Set CORS headers
response.headers.set("Access-Control-Allow-Origin", headers.get("Origin"))
// Append to/Add Vary header so browser will cache response correctly
response.headers.append("Vary", "Origin")
return response
}
else {
return new Response("Not Found for " + host, { status: 404 })
}
}
/**
* Receives a HTTP request and replies with a websocket proxy
* @param {Request} request
* @returns {Promise<Response>}
*/
async function handleWebsocketRequest(dataHubWebsocketURL) {
// Establish the websocket connection to DataHub
const dataHubResponse = await fetch(dataHubWebsocketURL, { headers: { "Upgrade": "websocket", "Authorization": API_AUTH_KEY } })
if (dataHubResponse.status !== 101) {
return new Response(null, {
status: dataHubResponse.status,
statusText: dataHubResponse.statusText
})
}
const dataHubSocket = dataHubResponse.webSocket
dataHubSocket.accept()
// Create a client/server to act as the proxy layer
const proxyWebSocketPair = new WebSocketPair()
const [client, server] = Object.values(proxyWebSocketPair)
// tell the Workers runtime that it should listen for WebSocket data and keep the connection open with client
server.accept()
// Any messages from the client are forwarded to the DataHub socket
server.addEventListener("message", event => {
dataHubSocket.send(event.data)
})
// Any messages coming from DataHub are forwarded back to the client
dataHubSocket.addEventListener("message", event => {
server.send(event.data)
})
const response = new Response(null, {
status: 101,
webSocket: client
})
return response
}
/**
* Responds with an uncaught error.
* @param {Error} error
* @returns {Response}
*/
function handleError(error) {
console.error('Uncaught error:', error)
const { stack } = error
return new Response(stack || error, {
status: 500,
headers: {
'Content-Type': 'text/plain;charset=UTF-8'
}
})
}
function handleOptions(request) {
// Make sure the necessary headers are present
// for this to be a valid pre-flight request
let headers = request.headers;
if (
headers.get("Origin") !== null &&
headers.get("Access-Control-Request-Method") !== null &&
headers.get("Access-Control-Request-Headers") !== null
){
// Handle CORS pre-flight request.
// If you want to check or reject the requested method + headers
// you can do that here.
let respHeaders = {
...corsHeaders,
// Allow all future content Request headers to go back to browser
// such as Authorization (Bearer) or X-Client-Name-Version
"Access-Control-Allow-Headers": request.headers.get("Access-Control-Request-Headers"),
}
return new Response(null, {
headers: respHeaders,
})
}
else {
// Handle standard OPTIONS request.
// If you want to allow other HTTP Methods, you can do that here.
return new Response(null, {
headers: {
Allow: "GET, HEAD, POST, OPTIONS",
},
})
}
}
addEventListener("fetch", event => {
const request = event.request
if (request.method === "OPTIONS") {
// Handle CORS preflight requests
event.respondWith(handleOptions(request))
}
else if(
request.method === "GET" ||
request.method === "HEAD" ||
request.method === "POST"
){
// Handle requests to the API server
event.respondWith(handleRequest(request))
}
else {
event.respondWith(
new Response(null, {
status: 405,
statusText: "Method Not Allowed",
}),
)
}
})
@kentonv
Copy link

kentonv commented Jul 29, 2022

Hi there, I came across this while investigating some production issues.

This code is much more expensive than necessary when handling WebSockets.

There is no need to add event listeners to handle each WebSocket message individually. You can just take the webSocket you receive from the server and pass it on to the client. Doing so will be much more efficient, as the Workers Runtime will then be able to pump raw bytes from network connection to network connection without actually parsing out each message and without calling back into JavaScript. The optimization will also save you money: https://blog.cloudflare.com/workers-optimization-reduces-your-bill/

I also notice the code here does not handle close or error events, so when one side of the connection shuts down, the other side might be left open and hanging. This would also be fixed by passing along the whole WebSocket object directly.

@mkornatz
Copy link
Author

Thanks @kentonv! I developed this only as an example. So, it's not optimized for either cost or performance. I'm glad to hear your input on improving it. That makes a lot of sense. I appreciate the feedback.

@shaunco
Copy link

shaunco commented Aug 12, 2023

There is no need to add event listeners to handle each WebSocket message individually. You can just take the webSocket you receive from the server and pass it on to the client. Doing so will be much more efficient, as the Workers Runtime will then be able to pump raw bytes from network connection to network connection without actually parsing out each message and without calling back into JavaScript. The optimization will also save you money: https://blog.cloudflare.com/workers-optimization-reduces-your-bill/

@kentonv - Can you clarify what the code here should look like in the case of a forward/reverse websocket proxy? The blog you referenced simply calls return await fetch(request) without ever setting up the WebSocket pair or calling client.accept(), whereas https://blog.cloudflare.com/introducing-websockets-in-workers/ shows creating the WebSocketPair and then putting webSocket: client into the Response object. So, I suppose a few questions:

  1. If you take an inbound Request that has an Upgrade: websocket header, and simply do:
    const url = new URL(request.url)
    const params = new URLSearchParams(url.search)
    url.hostname = 'newhostname.com'
    url.search = params.toString()
    const headers = new Headers(request.headers)
    headers.set('host', 'newhostname.com')
    return fetch(
       new Request(url.toString(), {
         body: request.body,
         method: request.method,
         headers: headers,
       })
    
    does fetch internally handle all the WebSocket upgrade client/server setup (Upgrade header from the request was passed along), return the 101, and offload the remainder of the request lifetime or does fetch need to be called like in this code and then the WebSocket client be passed back to a 101 response to establish the offloaded WebSocket ("You can just take the webSocket you receive from the server and pass it on to the client")? ... assuming no need for the worker to see messages/close/errors/etc
  2. Shouldn't the blog post you referenced not use await in the WebSocket fetch call? Cloudflare docs, other blog posts, and examples make it seem that having await here prevents the request offloading that the blog article touts.

@kentonv
Copy link

kentonv commented Aug 12, 2023

The blog you referenced simply calls return await fetch(request) without ever setting up the WebSocket pair or calling client.accept()

That's correct. Simply return await fetch(request) is all you need to proxy any kind of HTTP request/response, including WebSockets.

Shouldn't the blog post you referenced not use await in the WebSocket fetch call? Cloudflare docs, other blog posts, and examples make it seem that having await here prevents the request offloading that the blog article touts.

It doesn't make a difference.

Usually, in an async function, return await foo() and return foo() are equivalent. Either way, the promise returned by the async function itself will not resolve until foo() completes, and then will resolve to the same value foo() produced.

There is a difference if the statement happens to be wrapped in try/catch. In this case, return await foo() causes the await to happen inside the try, so any asynchronously-thrown exceptions are caught. return foo() would return the promise immediately, terminating the try block, and then the async function machinery would wait for the promise to resolve later. This is kind of wonky unintended consequences of the way async/await and Promises work under the hood. In general, it's safer to use return await foo() rather than return foo() to avoid any confusion.

Note that awaiting a fetch() only waits for the response headers to be returned. It does not wait for the body to complete. The optimization in my blog post applies to streaming the body, after response headers have passed through. So, awaiting the fetch does not harm the optimization.


Unrelated to WebSockets, but note that if you just want to change the URL but keep the rest of the request the same, then you should NOT do this:

// BAD: Does not forward all properties!
return fetch(
   new Request(newUrl, {
     body: request.body,
     method: request.method,
     headers: headers,
   })

Instead, do this:

return fetch(new Request(newUrl, request))

Or equivalenlty:

return fetch(newUrl, request)

This ensures that all properties of Request are forwarded, not just url, body, method, and headers.

Note if you need to modify headers (in addition to URL), you can do:

request = new Request(newUrl, request);
request.headers.set("foo", "bar");
return fetch(request);

Or:

request = new Request(newUrl, request);
request = new Request(request, {headers: newHeaders});
return fetch(request);

(But none of this affects whether a WebSocket in the response can be proxied through -- in all cases, it'll work.)

@shaunco
Copy link

shaunco commented Aug 13, 2023

Amazing! Thank you @kentonv! Really interesting note about the try/catch, but given that fetch only waits for headers, I will just always use await.

@qya
Copy link

qya commented Oct 25, 2023

Did still work on new version of cloudflare workers ? when i tried proxy wss its returned 500 Internal Server Error
Realtime logs response :
{ "outcome": "exception", "scriptName": "testwss", "diagnosticsChannelEvents": [], "exceptions": [ { "name": "TypeError", "message": "Fetch API cannot load: wss://ws.postman-echo.com/raw", "timestamp": 1698224478809 } ], "logs": [], "eventTimestamp": 1698224478806 }

@mkornatz
Copy link
Author

@qya, I have not kept this up-to-date since I developed the initial version back in Oct 2021. So, I can't guarantee that it works with the latest CF workers. This was meant to be an example that I shared with some other developers at the time.

I suspect it should mostly still work, but I don't have availability to troubleshoot for you. If you're able to resolve your issue, feel free to share in the comments for other folks.

@kentonv
Copy link

kentonv commented Oct 30, 2023

@qya The URL should use https: rather than wss:. There used to be a bug in Workers where unrecognized protocols would be silently interpreted as HTTP, but this was fixed (with a compatibility flag) a couple years ago.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment