├── .gitignore
├── README.md
├── app.arc
├── app
├── entry.client.tsx
├── entry.server.tsx
├── root.tsx
├── routes
│ ├── _index.tsx
│ └── about.tsx
└── tsconfig.json
├── overview.png
├── package-lock.json
├── package.json
├── patches
└── @remix-run+dev+1.11.1.patch
├── public
└── favicon.ico
├── remix.config.mjs
├── src
├── http
│ ├── config.arc
│ └── index.mjs
├── shared
│ └── app.mjs
├── worker.mjs
└── ws
│ ├── connect
│ ├── .arc-config
│ └── index.mjs
│ ├── default
│ ├── .arc-config
│ └── index.mjs
│ └── disconnect
│ ├── .arc-config
│ └── index.mjs
└── wrangler.toml
/.gitignore:
--------------------------------------------------------------------------------
1 | .cache
2 | .wrangler
3 | node_modules
4 | *.pkg
5 | sam.json
6 | sam.yaml
7 | public/build
8 | src/shared/remix.*
9 | src/**/package.json
10 | src/**/package-lock.json
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # cf-lambda-streaming
2 |
3 | Enable your AWS Lambda hosted applications to stream responses via Cloudflare Workers.
4 |
5 | ## Overview
6 |
7 | HTTP request -> CF Worker -> CF DO configurable load balancer-> WebSocket -> AWS -> WebSocket -> CF DO -> CF Worker -> HTTP Response
8 |
9 | 
10 |
11 | ## Deploying
12 |
13 | Build the remix app:
14 |
15 | ```sh
16 | npm run build
17 | ```
18 |
19 | Deploy to AWS:
20 |
21 | ```sh
22 | npx arc deploy production
23 | ```
24 |
25 | Copy the HTTP and WS URL's printed at the end of the deployment into `wrangler.toml`
26 | with HTTP as `ORIGIN_URL` and WS as `SOCKET_URL`.
27 |
28 | Deploy to Cloudflare:
29 |
30 | ```sh
31 | npx wrangler publish
32 | ```
33 |
--------------------------------------------------------------------------------
/app.arc:
--------------------------------------------------------------------------------
1 | @app
2 | cf-lambda-streaming
3 |
4 | @http
5 | /*
6 | method any
7 | src src/http
8 |
9 | @ws
10 | # no further config required
11 |
12 | @shared
13 | # no further config required
14 |
15 | @static
16 | # no further config required
17 |
18 | # @aws
19 | # profile default
20 | # region us-west-1
21 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import { RemixBrowser } from "@remix-run/react";
2 | import { startTransition, StrictMode } from "react";
3 | import { hydrateRoot } from "react-dom/client";
4 |
5 | function hydrate() {
6 | startTransition(() => {
7 | hydrateRoot(
8 | document,
9 |
23 | Go to About page 24 |
25 |Count: {count}
26 |27 | 28 |
29 |LOADING...
33 |{resolved}
41 |Home {i}
49 | ))} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, useState } from "react"; 2 | import { defer } from "@remix-run/node"; 3 | import { Await, Link, useLoaderData } from "@remix-run/react"; 4 | 5 | export function loader() { 6 | return defer({ 7 | deferred: new Promise((resolve) => 8 | setTimeout(() => { 9 | resolve("I WAS DEFERRED!!!!!!"); 10 | }, 500) 11 | ), 12 | }); 13 | } 14 | 15 | export default function Deferred() { 16 | const { deferred } = useLoaderData(); 17 | const [count, setCount] = useState(0); 18 | 19 | return ( 20 |23 | Go to Home page 24 |
25 |Count: {count}
26 |27 | 28 |
29 |LOADING...
33 |{resolved}
41 |About {i}
49 | ))} 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "exclude": ["functions/**/*"], 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "module": "ESNext", 7 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 8 | "types": ["@cloudflare/workers-types"], 9 | "isolatedModules": true, 10 | "esModuleInterop": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "jsx": "react-jsx", 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./*"] 19 | }, 20 | 21 | // Remix takes care of building everything in the `app` directory. 22 | "noEmit": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/cf-lambda-streaming/5b6c439f9898143388e17c3fbc9a2bea3afe1377/overview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "cf-lambda-streaming", 4 | "type": "module", 5 | "scripts": { 6 | "build": "remix build", 7 | "dev": "npm run build && concurrently \"npm:dev:*\"", 8 | "dev:arc": "arc sandbox", 9 | "dev:remix": "remix watch", 10 | "dev:wrangler": "wrangler dev --local --env local --local-upstream localhost:3333", 11 | "postinstall": "patch-package" 12 | }, 13 | "devDependencies": { 14 | "@architect/architect": "^10.8.4", 15 | "@cloudflare/workers-types": "^4.20221111.1", 16 | "@remix-run/dev": "^1.11.1", 17 | "@types/react": "^18.0.27", 18 | "@types/react-dom": "^18.0.10", 19 | "aws-lambda": "^1.0.7", 20 | "concurrently": "^7.6.0", 21 | "patch-package": "^6.5.1", 22 | "wrangler": "^2.8.0" 23 | }, 24 | "dependencies": { 25 | "@architect/functions": "^5.3.3", 26 | "@remix-run/node": "^1.11.1", 27 | "@remix-run/react": "^1.11.1", 28 | "dog": "^1.1.3", 29 | "isbot": "^3.6.5", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "ws": "^8.12.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /patches/@remix-run+dev+1.11.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@remix-run/dev/dist/compiler/compilerServer.js b/node_modules/@remix-run/dev/dist/compiler/compilerServer.js 2 | index fa6bd05..7bd4325 100644 3 | --- a/node_modules/@remix-run/dev/dist/compiler/compilerServer.js 4 | +++ b/node_modules/@remix-run/dev/dist/compiler/compilerServer.js 5 | @@ -135,7 +135,7 @@ const createEsbuildConfig = (config, assetsManifestChannel, options) => { 6 | async function writeServerBuildResult(config, outputFiles) { 7 | await fse__namespace.ensureDir(path__namespace.dirname(config.serverBuildPath)); 8 | for (let file of outputFiles) { 9 | - if (file.path.endsWith(".js")) { 10 | + if (file.path.endsWith(".js") || file.path.endsWith(".mjs")) { 11 | // fix sourceMappingURL to be relative to current path instead of /build 12 | let filename = file.path.substring(file.path.lastIndexOf(path__namespace.sep) + 1); 13 | let escapedFilename = filename.replace(/\./g, "\\."); 14 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/cf-lambda-streaming/5b6c439f9898143388e17c3fbc9a2bea3afe1377/public/favicon.ico -------------------------------------------------------------------------------- /remix.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import("@remix-run/dev").AppConfig} */ 2 | export default { 3 | ignoredRouteFiles: ["**/.*"], 4 | serverDependenciesToBundle: [/~/], 5 | serverModuleFormat: "esm", 6 | publicPath: "/_static/build/", 7 | serverBuildPath: "src/shared/remix.mjs", 8 | future: { 9 | v2_routeConvention: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/http/config.arc: -------------------------------------------------------------------------------- 1 | @aws 2 | runtime nodejs18.x 3 | # memory 1152 4 | # timeout 30 5 | # concurrency 1 -------------------------------------------------------------------------------- /src/http/index.mjs: -------------------------------------------------------------------------------- 1 | import * as app from "@architect/shared/app.mjs"; 2 | 3 | /** @type {import("aws-lambda").APIGatewayProxyHandlerV2} */ 4 | export async function handler(event) { 5 | const request = createRequest(event); 6 | 7 | const response = await app.handler(request); 8 | 9 | return sendResponse(response); 10 | } 11 | 12 | async function sendResponse(response) { 13 | const cookies = []; 14 | // Arc/AWS API Gateway will send back set-cookies outside of response headers. 15 | for (let [key, values] of Object.entries(response.headers.entries())) { 16 | if (key.toLowerCase() === "set-cookie") { 17 | for (let value of values) { 18 | cookies.push(value); 19 | } 20 | } 21 | } 22 | 23 | if (cookies.length) { 24 | response.headers.delete("Set-Cookie"); 25 | } 26 | 27 | const contentType = response.headers.get("Content-Type"); 28 | const isBase64Encoded = isBinaryType(contentType); 29 | let body; 30 | 31 | if (response.body) { 32 | if (isBase64Encoded) { 33 | body = await readableStreamToString(response.body, "base64"); 34 | } else { 35 | body = await response.text(); 36 | } 37 | } 38 | 39 | return { 40 | statusCode: response.status, 41 | headers: Object.fromEntries(response.headers.entries()), 42 | cookies, 43 | body, 44 | isBase64Encoded, 45 | }; 46 | } 47 | 48 | /** 49 | * @param {import("aws-lambda").APIGatewayProxyEventV2} event 50 | */ 51 | function createRequest(event) { 52 | const host = event.headers["x-forwarded-host"] || event.headers.host; 53 | const search = event.rawQueryString.length ? `?${event.rawQueryString}` : ""; 54 | const scheme = process.env.ARC_SANDBOX ? "http" : "https"; 55 | const url = new URL(event.rawPath + search, `${scheme}://${host}`); 56 | const isFormData = event.headers["content-type"]?.includes( 57 | "multipart/form-data" 58 | ); 59 | 60 | const headers = new Headers(); 61 | for (let [header, value] of Object.entries(event.headers)) { 62 | if (value) { 63 | headers.append(header, value); 64 | } 65 | } 66 | if (event.cookies) { 67 | headers.append("Cookie", event.cookies.join("; ")); 68 | } 69 | 70 | const controller = new AbortController(); 71 | return new Request(url.href, { 72 | body: 73 | event.body && event.isBase64Encoded 74 | ? isFormData 75 | ? Buffer.from(event.body, "base64") 76 | : Buffer.from(event.body, "base64").toString() 77 | : event.body, 78 | headers, 79 | method: event.requestContext.http.method, 80 | signal: controller.signal, 81 | }); 82 | } 83 | 84 | async function readableStreamToString(stream, encoding) { 85 | let reader = stream.getReader(); 86 | let chunks = []; 87 | 88 | async function read() { 89 | let { done, value } = await reader.read(); 90 | 91 | if (done) { 92 | return; 93 | } else if (value) { 94 | chunks.push(value); 95 | } 96 | 97 | await read(); 98 | } 99 | 100 | await read(); 101 | 102 | return Buffer.concat(chunks).toString(encoding); 103 | } 104 | 105 | /** 106 | * Common binary MIME types 107 | * @see https://github.com/architect/functions/blob/45254fc1936a1794c185aac07e9889b241a2e5c6/src/http/helpers/binary-types.js 108 | */ 109 | const binaryTypes = [ 110 | "application/octet-stream", 111 | // Docs 112 | "application/epub+zip", 113 | "application/msword", 114 | "application/pdf", 115 | "application/rtf", 116 | "application/vnd.amazon.ebook", 117 | "application/vnd.ms-excel", 118 | "application/vnd.ms-powerpoint", 119 | "application/vnd.openxmlformats-officedocument.presentationml.presentation", 120 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 121 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 122 | // Fonts 123 | "font/otf", 124 | "font/woff", 125 | "font/woff2", 126 | // Images 127 | "image/avif", 128 | "image/bmp", 129 | "image/gif", 130 | "image/jpeg", 131 | "image/png", 132 | "image/tiff", 133 | "image/vnd.microsoft.icon", 134 | "image/webp", 135 | // Audio 136 | "audio/3gpp", 137 | "audio/aac", 138 | "audio/basic", 139 | "audio/mpeg", 140 | "audio/ogg", 141 | "audio/wav", 142 | "audio/webm", 143 | "audio/x-aiff", 144 | "audio/x-midi", 145 | "audio/x-wav", 146 | // Video 147 | "video/3gpp", 148 | "video/mp2t", 149 | "video/mpeg", 150 | "video/ogg", 151 | "video/quicktime", 152 | "video/webm", 153 | "video/x-msvideo", 154 | // Archives 155 | "application/java-archive", 156 | "application/vnd.apple.installer+xml", 157 | "application/x-7z-compressed", 158 | "application/x-apple-diskimage", 159 | "application/x-bzip", 160 | "application/x-bzip2", 161 | "application/x-gzip", 162 | "application/x-java-archive", 163 | "application/x-rar-compressed", 164 | "application/x-tar", 165 | "application/x-zip", 166 | "application/zip", 167 | ]; 168 | 169 | function isBinaryType(contentType) { 170 | if (!contentType) return false; 171 | const [test] = contentType.split(";"); 172 | return binaryTypes.includes(test); 173 | } 174 | -------------------------------------------------------------------------------- /src/shared/app.mjs: -------------------------------------------------------------------------------- 1 | import { createRequestHandler } from "@remix-run/node"; 2 | 3 | import * as build from "./remix.mjs"; 4 | 5 | const requestHandler = createRequestHandler(build); 6 | 7 | /** 8 | * @param {Request} request 9 | */ 10 | export async function handler(request) { 11 | return await requestHandler(request, process.env.NODE_ENV); 12 | } 13 | -------------------------------------------------------------------------------- /src/worker.mjs: -------------------------------------------------------------------------------- 1 | import { identify, Group, Replica } from "dog"; 2 | import isbot from "isbot"; 3 | isbot.exclude(["chrome-lighthouse"]); 4 | 5 | /** 6 | * @typedef {{ 7 | * ORIGIN_URL: string; 8 | * SOCKET_URL: string; 9 | * AWS_BRIDGE: import("@cloudflare/workers-types").DurableObjectNamespace 10 | * AWS_POOL: import("@cloudflare/workers-types").DurableObjectNamespace 11 | * }} Env 12 | */ 13 | 14 | export default { 15 | /** 16 | * @param {Request} request 17 | * @param {Env} env 18 | * @param {import("@cloudflare/workers-types").ExecutionContext} ctx 19 | * @returns {Response} 20 | */ 21 | async fetch(request, env, ctx) { 22 | ctx.passThroughOnException(); 23 | 24 | const url = new URL(request.url); 25 | if ( 26 | isbot(request.headers.get("User-Agent")) || 27 | (!(request.headers.get("Accept") || "").includes("text/html") && 28 | !url.searchParams.has("_data")) 29 | ) { 30 | return await fetch( 31 | new URL(url.pathname + url.search, env.ORIGIN_URL), 32 | request 33 | ); 34 | } 35 | 36 | const poolId = env.AWS_POOL.idFromName("singleton"); 37 | const replica = await identify(poolId, "AWS_POOL", { 38 | child: env.AWS_BRIDGE, 39 | parent: env.AWS_POOL, 40 | }); 41 | 42 | return await replica.fetch(request); 43 | }, 44 | }; 45 | 46 | export class AWSPool extends Group { 47 | limit = 10; 48 | 49 | /** 50 | * @param {Env} env 51 | */ 52 | link(env) { 53 | return { 54 | child: env.AWS_BRIDGE, 55 | self: env.AWS_POOL, 56 | }; 57 | } 58 | } 59 | 60 | export class AWSBridge extends Replica { 61 | /** 62 | * 63 | * @param {Env} env 64 | */ 65 | link(env) { 66 | return { 67 | parent: env.AWS_POOL, 68 | self: env.AWS_BRIDGE, 69 | }; 70 | } 71 | 72 | /** 73 | * 74 | * @param {import("@cloudflare/workers-types").DurableObjectState} state 75 | * @param {Env} env 76 | */ 77 | constructor(state, env) { 78 | super(state, env); 79 | 80 | this.state = state; 81 | 82 | this.state.blockConcurrencyWhile(async () => { 83 | console.log("Connecting to WebSocket..."); 84 | if (!this.ws) { 85 | let webSocketResponse = await fetch(env.SOCKET_URL, { 86 | headers: { 87 | Upgrade: "websocket", 88 | }, 89 | }); 90 | 91 | /** @type {WebSocket} */ 92 | this.ws = webSocketResponse.webSocket; 93 | if (!this.ws) { 94 | throw new Error("Server did not accept WebSocket"); 95 | } 96 | await this.ws.accept(); 97 | } 98 | }); 99 | } 100 | 101 | /** 102 | * @param {Request} request 103 | */ 104 | async receive(request) { 105 | const id = crypto.randomUUID(); 106 | this.ws.send( 107 | JSON.stringify({ 108 | id, 109 | headers: Array.from(request.headers.entries()), 110 | method: request.method, 111 | url: request.url, 112 | body: !!request.body, 113 | }) 114 | ); 115 | 116 | let resolveResponseInit; 117 | const responseInitCallback = (event) => { 118 | const responseInfo = JSON.parse(event.data); 119 | 120 | if (responseInfo.id !== id) { 121 | return; 122 | } 123 | 124 | resolveResponseInit({ 125 | headers: new Headers(responseInfo.headers), 126 | status: responseInfo.status, 127 | statusText: responseInfo.statusText, 128 | body: responseInfo.body, 129 | }); 130 | this.ws.removeEventListener("message", responseInitCallback); 131 | }; 132 | 133 | let responseInit = await new Promise((resolve) => { 134 | resolveResponseInit = resolve; 135 | this.ws.addEventListener("message", responseInitCallback); 136 | }); 137 | 138 | let body = null; 139 | if (responseInit.body) { 140 | body = new ReadableStream({ 141 | start: async (controller) => { 142 | const encoder = new TextEncoder(); 143 | this.ws.addEventListener("message", (event) => { 144 | const message = JSON.parse(event.data); 145 | if (message.id !== id) { 146 | return; 147 | } 148 | if (typeof message.body === "string") { 149 | controller.enqueue(encoder.encode(message.body)); 150 | } 151 | if (message.done) { 152 | controller.close(); 153 | } 154 | }); 155 | this.ws.addEventListener("close", () => { 156 | controller.error(new Error("WebSocket closed")); 157 | }); 158 | }, 159 | }); 160 | } 161 | 162 | return new Response(body, { 163 | headers: responseInit.headers, 164 | status: responseInit.status, 165 | statusText: responseInit.statusText, 166 | }); 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/ws/connect/.arc-config: -------------------------------------------------------------------------------- 1 | @aws 2 | runtime nodejs18.x 3 | # memory 1152 4 | # timeout 30 5 | # concurrency 1 6 | -------------------------------------------------------------------------------- /src/ws/connect/index.mjs: -------------------------------------------------------------------------------- 1 | export async function handler(event) { 2 | return { statusCode: 200 }; 3 | } 4 | -------------------------------------------------------------------------------- /src/ws/default/.arc-config: -------------------------------------------------------------------------------- 1 | @aws 2 | runtime nodejs18.x 3 | # memory 1152 4 | # timeout 30 5 | # concurrency 1 6 | -------------------------------------------------------------------------------- /src/ws/default/index.mjs: -------------------------------------------------------------------------------- 1 | import arc from "@architect/functions"; 2 | 3 | import * as app from "@architect/shared/app.mjs"; 4 | 5 | export async function handler(event) { 6 | const connectionId = event.requestContext.connectionId; 7 | const message = JSON.parse(event.body); 8 | 9 | const id = message.id; 10 | const headers = new Headers(message.headers); 11 | const request = new Request(message.url, { 12 | headers, 13 | method: message.method, 14 | }); 15 | 16 | const response = await app.handler(request); 17 | 18 | await arc.ws.send({ 19 | id: connectionId, 20 | payload: { 21 | id, 22 | headers: Array.from(response.headers.entries()), 23 | status: response.status, 24 | statusText: response.statusText, 25 | body: !!response.body, 26 | }, 27 | }); 28 | 29 | if (response.body) { 30 | const reader = response.body.getReader(); 31 | const decoder = new TextDecoder(); 32 | let { done, value } = await reader.read(); 33 | while (!done) { 34 | await arc.ws.send({ 35 | id: connectionId, 36 | payload: { 37 | id, 38 | body: decoder.decode(value, { stream: true }), 39 | }, 40 | }); 41 | ({ done, value } = await reader.read()); 42 | } 43 | 44 | await arc.ws.send({ 45 | id: connectionId, 46 | payload: { 47 | id, 48 | body: decoder.decode(), 49 | done: true, 50 | }, 51 | }); 52 | } else { 53 | await arc.ws.send({ 54 | id: connectionId, 55 | payload: { 56 | id, 57 | done: true, 58 | }, 59 | }); 60 | } 61 | 62 | return { statusCode: 200 }; 63 | } 64 | -------------------------------------------------------------------------------- /src/ws/disconnect/.arc-config: -------------------------------------------------------------------------------- 1 | @aws 2 | runtime nodejs18.x 3 | # memory 1152 4 | # timeout 30 5 | # concurrency 1 6 | -------------------------------------------------------------------------------- /src/ws/disconnect/index.mjs: -------------------------------------------------------------------------------- 1 | export async function handler(event) { 2 | return { statusCode: 200 }; 3 | } 4 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cf-lambda-streaming" 2 | main = "src/worker.mjs" 3 | compatibility_date = "2022-10-30" 4 | compatibility_flags = ["streams_enable_constructors"] 5 | 6 | [vars] 7 | ORIGIN_URL = "https://8w3tpor496.execute-api.us-west-2.amazonaws.com" 8 | SOCKET_URL = "https://lxuyiz6x19.execute-api.us-west-2.amazonaws.com/staging" 9 | 10 | [durable_objects] 11 | bindings = [ 12 | { name = "AWS_BRIDGE", class_name = "AWSBridge" }, 13 | { name = "AWS_POOL", class_name = "AWSPool" }, 14 | ] 15 | 16 | [[migrations]] 17 | tag = "v1" 18 | new_classes = ["AWSBridge", "AWSPool"] 19 | 20 | [env.local.vars] 21 | ORIGIN_URL = "http://localhost:3333" 22 | SOCKET_URL = "http://localhost:3333" 23 | 24 | [env.local.durable_objects] 25 | bindings = [ 26 | { name = "AWS_BRIDGE", class_name = "AWSBridge" }, 27 | { name = "AWS_POOL", class_name = "AWSPool" }, 28 | ] 29 | --------------------------------------------------------------------------------