├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── README.md ├── config ├── cloudflare-env │ ├── .eslintrc.js │ ├── index.d.ts │ ├── package.json │ └── tsconfig.json ├── eslint-config-custom │ ├── index.js │ └── package.json └── tsconfig │ ├── README.md │ ├── base.json │ └── package.json ├── package-lock.json ├── package.json ├── packages ├── chat-room-do │ ├── .eslintrc.js │ ├── index.ts │ ├── package.json │ ├── tsconfig.json │ └── utils.ts ├── counter-do │ ├── .eslintrc.js │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── rate-limiter-do │ ├── .eslintrc.js │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── remix-app │ ├── .eslintrc.js │ ├── app │ │ ├── entry.client.tsx │ │ ├── entry.server.tsx │ │ ├── root.tsx │ │ ├── routes │ │ │ ├── index.tsx │ │ │ ├── join.ts │ │ │ ├── new.ts │ │ │ ├── room.$roomId.tsx │ │ │ └── room.$roomId.websocket.ts │ │ ├── session.server.ts │ │ └── utils.ts │ ├── build.d.ts │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── remix.config.js │ ├── tsconfig.json │ └── types │ │ ├── remix-env.d.ts │ │ └── wrangler-env.d.ts └── worker │ ├── .eslintrc.js │ ├── entry.worker.ts │ ├── package.json │ ├── tsconfig.json │ ├── types │ └── wrangler-env.d.ts │ └── wrangler.toml └── turbo.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ["custom"], 5 | settings: { 6 | next: { 7 | rootDir: ["apps/*/"], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | lint: 6 | name: ⬣ ESLint 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: 🛑 Cancel Previous Runs 10 | uses: styfle/cancel-workflow-action@0.9.1 11 | 12 | - name: ⬇️ Checkout repo 13 | uses: actions/checkout@v3 14 | 15 | - name: ⎔ Setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 16.7.0 19 | 20 | - name: 📥 Download deps 21 | uses: bahmutov/npm-install@v1 22 | 23 | - name: 🔬 Lint 24 | run: npm run lint 25 | 26 | typecheck: 27 | name: ʦ Typecheck 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: 🛑 Cancel Previous Runs 31 | uses: styfle/cancel-workflow-action@0.9.1 32 | 33 | - name: ⬇️ Checkout repo 34 | uses: actions/checkout@v3 35 | 36 | - name: ⎔ Setup node 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version: 16.7.0 40 | 41 | - name: 📥 Install turbo 42 | run: npm install -g turbo 43 | 44 | - name: 📥 Download deps 45 | uses: bahmutov/npm-install@v1 46 | 47 | - name: 🔎 Type check 48 | run: npm run typecheck 49 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🕊 Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | name: 🕊 Deploy 11 | steps: 12 | - name: 🛑 Cancel Previous Runs 13 | uses: styfle/cancel-workflow-action@0.9.1 14 | 15 | - name: ⬇️ Checkout repo 16 | uses: actions/checkout@v3 17 | 18 | - name: 📥 Install deps 19 | uses: bahmutov/npm-install@v1 20 | 21 | - name: 📦 Build 22 | run: npm run build 23 | 24 | - name: 🚀 Publish 25 | uses: cloudflare/wrangler-action@2.0.0 26 | with: 27 | apiToken: ${{ secrets.CF_API_TOKEN }} 28 | workingDirectory: "packages/worker" 29 | command: publish 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | # remix 36 | .cache 37 | 38 | # typescript 39 | tsconfig.tsbuildinfo 40 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.fileNesting.enabled": true, 3 | "explorer.fileNesting.patterns": { 4 | "*.ts": "${capture}.js", 5 | "*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts", 6 | "*.jsx": "${capture}.js", 7 | "*.tsx": "${capture}.ts", 8 | "tsconfig.json": "tsconfig.*.json", 9 | "package.json": "package-lock.json, remix.config.js, tsconfig.json, .eslintrc.js, .gitignore, .prettierignore, turbo.json, tsconfig.tsbuildinfo" 10 | }, 11 | "search.useGlobalIgnoreFiles": true, 12 | "files.exclude": { 13 | "**/.cache": true, 14 | "**/.turbo": true, 15 | "**/build": true, 16 | "**/node_modules": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix + Cloudflare Workers + DO + Turborepo 2 | 3 | A starter to get you up and going with Remix on Cloudflare with all the latest and greatest. 4 | 5 | ## What's inside? 6 | 7 | This repo uses [npm](https://www.npmjs.com/) as a package manager. It includes the following packages/apps: 8 | 9 | ### Packages 10 | 11 | - `packages/chat-room-do`: a Durable Object for chat rooms 12 | - `packages/counter-do`: a Durable Object for counting things 13 | - `packages/rate-limiter-do`: a Durable Object for limiting clients 14 | - `packages/remix-app`: a [Remix](https://remix.run/) application that makes up the public facing UX 15 | - `packages/worker`: a Cloudflare Worker that brings everything together for deployment 16 | - `config/cloudflare-env`: type definitions for bindings shared across the packages 17 | - `config/eslint-config-custom`: shared eslint config that includes `@remix-run/eslint-config` and `prettier` 18 | - `config/tsconfig`: base tsconfig that other packages inherit from 19 | 20 | Each `package/*` is 100% [TypeScript](https://www.typescriptlang.org/). 21 | 22 | ### Utilities 23 | 24 | This turborepo has some additional tools already setup for you: 25 | 26 | - [TypeScript](https://www.typescriptlang.org/) for static type checking 27 | - [ESLint](https://eslint.org/) for code linting 28 | - [Prettier](https://prettier.io) for code formatting 29 | - [Github Actions](https://github.com/features/actions) 30 | 31 | ## Setup 32 | 33 | Clone and install dependencies: 34 | 35 | ``` 36 | npm i 37 | ``` 38 | 39 | ### Build 40 | 41 | To build all apps and packages, run the following command: 42 | 43 | ``` 44 | npm run build 45 | ``` 46 | 47 | ### Develop 48 | 49 | To develop all apps and packages, run the following command: 50 | 51 | ``` 52 | npm run dev 53 | ``` 54 | 55 | ### Remote Caching 56 | 57 | Turborepo can use a technique known as [Remote Caching (Beta)](https://turborepo.org/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. 58 | 59 | By default, Turborepo will cache locally. To enable Remote Caching (Beta) you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup), then enter the following commands: 60 | 61 | ``` 62 | npx turbo login 63 | ``` 64 | 65 | This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). 66 | 67 | Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your turborepo: 68 | 69 | ``` 70 | npx turbo link 71 | ``` 72 | 73 | ## Useful Links 74 | 75 | Learn more about the power of Turborepo: 76 | 77 | - [Pipelines](https://turborepo.org/docs/core-concepts/pipelines) 78 | - [Caching](https://turborepo.org/docs/core-concepts/caching) 79 | - [Remote Caching (Beta)](https://turborepo.org/docs/core-concepts/remote-caching) 80 | - [Scoped Tasks](https://turborepo.org/docs/core-concepts/scopes) 81 | - [Configuration Options](https://turborepo.org/docs/reference/configuration) 82 | - [CLI Usage](https://turborepo.org/docs/reference/command-line-reference) 83 | -------------------------------------------------------------------------------- /config/cloudflare-env/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /config/cloudflare-env/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | __STATIC_CONTENT: KVNamespace; 3 | 4 | CHAT_ROOM: DurableObjectNamespace; 5 | COUNTER: DurableObjectNamespace; 6 | RATE_LIMITER: DurableObjectNamespace; 7 | 8 | SESSION_SECRET: string; 9 | } 10 | -------------------------------------------------------------------------------- /config/cloudflare-env/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-env", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "types": "index.ts", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "typecheck": "tsc -b" 9 | }, 10 | "devDependencies": { 11 | "@cloudflare/workers-types": "^3.10.0", 12 | "eslint": "^8.15.0", 13 | "eslint-config-custom": "*", 14 | "tsconfig": "*", 15 | "typescript": "^4.6.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /config/cloudflare-env/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /config/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint/conf/eslint-all")} */ 2 | let config = { 3 | extends: ["@remix-run/eslint-config", "prettier"], 4 | ignorePatterns: ["node_modules", "build"], 5 | settings: { 6 | files: ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"], 7 | }, 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /config/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@remix-run/eslint-config": "^1.4.3", 8 | "eslint-config-prettier": "^8.5.0" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /config/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # `tsconfig` 2 | 3 | These are base shared `tsconfig.json`s from which all other `tsconfig.json`'s inherit from. 4 | -------------------------------------------------------------------------------- /config/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /config/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json", 7 | "remix.json" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-cloudflare-worker-template", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "config/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "npx --yes turbo run build", 11 | "dev": "npx --yes turbo run dev --parallel", 12 | "lint": "npx --yes turbo run lint", 13 | "typecheck": "npx --yes turbo run typecheck", 14 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"" 15 | }, 16 | "devDependencies": { 17 | "prettier": "^2.6.2", 18 | "typescript": "^4.6.4" 19 | }, 20 | "engines": { 21 | "npm": ">=7.0.0", 22 | "node": ">=14.0.0" 23 | }, 24 | "packageManager": "npm@8.5.5" 25 | } 26 | -------------------------------------------------------------------------------- /packages/chat-room-do/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/chat-room-do/index.ts: -------------------------------------------------------------------------------- 1 | import Filter from "bad-words"; 2 | import { RateLimiterClient } from "rate-limiter-do"; 3 | 4 | import { handleErrors } from "./utils"; 5 | 6 | export interface Message { 7 | message: string; 8 | name: string; 9 | timestamp: number; 10 | } 11 | 12 | type Session = { 13 | webSocket: WebSocket; 14 | blockedMessages: string[]; 15 | quit?: boolean; 16 | name?: string; 17 | }; 18 | 19 | export default class ChatRoomDurableObject { 20 | private sessions: Session[] = []; 21 | private lastTimestamp: number = 0; 22 | private filter: Filter; 23 | 24 | constructor(private state: DurableObjectState, private env: Env) { 25 | this.filter = new Filter(); 26 | } 27 | 28 | async fetch(request: Request) { 29 | return handleErrors(request, async () => { 30 | let url = new URL(request.url); 31 | 32 | if (url.pathname === "/latest") { 33 | let storage = await this.state.storage.list({ 34 | reverse: true, 35 | limit: 100, 36 | }); 37 | let backlog = [...storage.values()]; 38 | return new Response(JSON.stringify(backlog)); 39 | } else if (url.pathname.startsWith("/websocket")) { 40 | if (request.headers.get("Upgrade") != "websocket") { 41 | return new Response("expected websocket", { status: 400 }); 42 | } 43 | 44 | // Get the client's IP address for use with the rate limiter. 45 | let ip = request.headers.get("CF-Connecting-IP"); 46 | if (!ip) throw new Error("No IP address"); 47 | 48 | // To accept the WebSocket request, we create a WebSocketPair (which is like a socketpair, 49 | // i.e. two WebSockets that talk to each other), we return one end of the pair in the 50 | // response, and we operate on the other end. Note that this API is not part of the 51 | // Fetch API standard; unfortunately, the Fetch API / Service Workers specs do not define 52 | // any way to act as a WebSocket server today. 53 | let pair = new WebSocketPair(); 54 | 55 | // We're going to take pair[1] as our end, and return pair[0] to the client. 56 | await this.handleSession(pair[1], ip); 57 | 58 | // Now we return the other end of the pair to the client. 59 | return new Response(null, { status: 101, webSocket: pair[0] }); 60 | } 61 | 62 | return new Response("Not found", { status: 404 }); 63 | }); 64 | } 65 | 66 | private async handleSession(webSocket: WebSocket, ip: string) { 67 | // Accept our end of the WebSocket. This tells the runtime that we'll be terminating the 68 | // WebSocket in JavaScript, not sending it elsewhere. 69 | // @ts-expect-error 70 | webSocket.accept(); 71 | 72 | // Set up our rate limiter client. 73 | let limiterId = this.env.RATE_LIMITER.idFromName(ip); 74 | let limiter = new RateLimiterClient( 75 | () => this.env.RATE_LIMITER.get(limiterId), 76 | (error) => { 77 | console.log(error); 78 | webSocket.close(1011, "Something went wrong"); 79 | } 80 | ); 81 | 82 | // Create our session and add it to the sessions list. 83 | // We don't send any messages to the client until it has sent us the initial user info 84 | // message. Until then, we will queue messages in `session.blockedMessages`. 85 | let session: Session = { webSocket, blockedMessages: [] }; 86 | this.sessions.push(session); 87 | 88 | // Load the last 100 messages from the chat history stored on disk, and send them to the 89 | // client. 90 | // let storage = await this.state.storage.list({ 91 | // reverse: true, 92 | // limit: 100, 93 | // }); 94 | // let backlog = [...storage.values()]; 95 | // backlog.reverse(); 96 | // backlog.forEach((value) => { 97 | // session.blockedMessages.push(JSON.stringify(value)); 98 | // }); 99 | 100 | let receivedUserInfo = false; 101 | webSocket.addEventListener("message", async (msg) => { 102 | try { 103 | if (session.quit) { 104 | // Whoops, when trying to send to this WebSocket in the past, it threw an exception and 105 | // we marked it broken. But somehow we got another message? I guess try sending a 106 | // close(), which might throw, in which case we'll try to send an error, which will also 107 | // throw, and whatever, at least we won't accept the message. (This probably can't 108 | // actually happen. This is defensive coding.) 109 | webSocket.close(1011, "WebSocket broken."); 110 | return; 111 | } 112 | 113 | // Check if the user is over their rate limit and reject the message if so. 114 | if (!limiter.checkLimit()) { 115 | webSocket.send( 116 | JSON.stringify({ 117 | error: "Your IP is being rate-limited, please try again later.", 118 | }) 119 | ); 120 | return; 121 | } 122 | 123 | // I guess we'll use JSON. 124 | let data = JSON.parse(msg.data); 125 | 126 | if (!receivedUserInfo) { 127 | // The first message the client sends is the user info message with their name. Save it 128 | // into their session object. 129 | session.name = "" + (data.name || "anonymous"); 130 | 131 | // Don't let people use ridiculously long names. (This is also enforced on the client, 132 | // so if they get here they are not using the intended client.) 133 | if (session.name.length > 32) { 134 | webSocket.send(JSON.stringify({ error: "Name too long." })); 135 | webSocket.close(1009, "Name too long."); 136 | return; 137 | } 138 | 139 | // Deliver all the messages we queued up since the user connected. 140 | session.blockedMessages.forEach((queued) => { 141 | webSocket.send(queued); 142 | }); 143 | session.blockedMessages = []; 144 | 145 | // Broadcast to all other connections that this user has joined. 146 | this.broadcast({ joined: session.name }); 147 | 148 | webSocket.send(JSON.stringify({ ready: true })); 149 | 150 | // Note that we've now received the user info message. 151 | receivedUserInfo = true; 152 | 153 | return; 154 | } 155 | 156 | // Construct sanitized message for storage and broadcast. 157 | data = { name: session.name, message: "" + data.message }; 158 | 159 | // Block people from sending overly long messages. This is also enforced on the client, 160 | // so to trigger this the user must be bypassing the client code. 161 | if (data.message.length > 256) { 162 | webSocket.send(JSON.stringify({ error: "Message too long." })); 163 | return; 164 | } 165 | 166 | // Filter out profanity as any public demo will bring degenerates. 167 | data.message = this.filter.clean(data.message); 168 | 169 | // Add timestamp. Here's where this.lastTimestamp comes in -- if we receive a bunch of 170 | // messages at the same time (or if the clock somehow goes backwards????), we'll assign 171 | // them sequential timestamps, so at least the ordering is maintained. 172 | data.timestamp = Math.max(Date.now(), this.lastTimestamp + 1); 173 | this.lastTimestamp = data.timestamp; 174 | 175 | // Broadcast the message to all other WebSockets. 176 | this.broadcast(data); 177 | 178 | // Save message. 179 | let key = new Date(data.timestamp).toISOString(); 180 | await this.state.storage.put(key, data); 181 | } catch (error) { 182 | console.log(error); 183 | webSocket.send(JSON.stringify({ error: "Something went wrong" })); 184 | } 185 | }); 186 | 187 | // On "close" and "error" events, remove the WebSocket from the sessions list and broadcast 188 | // a quit message. 189 | let closeOrErrorHandler = () => { 190 | session.quit = true; 191 | this.sessions = this.sessions.filter((member) => member !== session); 192 | if (session.name) { 193 | this.broadcast({ quit: session.name }); 194 | } 195 | }; 196 | webSocket.addEventListener("close", closeOrErrorHandler); 197 | webSocket.addEventListener("error", closeOrErrorHandler); 198 | } 199 | 200 | // broadcast() broadcasts a message to all clients. 201 | private broadcast(event: unknown) { 202 | // Apply JSON if we weren't given a string to start with. 203 | let message = typeof event !== "string" ? JSON.stringify(event) : event; 204 | 205 | // Iterate over all the sessions sending them messages. 206 | let quitters: Session[] = []; 207 | this.sessions = this.sessions.filter((session) => { 208 | if (session.name) { 209 | try { 210 | session.webSocket.send(message); 211 | return true; 212 | } catch (err) { 213 | // Whoops, this connection is dead. Remove it from the list and arrange to notify 214 | // everyone below. 215 | session.quit = true; 216 | quitters.push(session); 217 | return false; 218 | } 219 | } else { 220 | // This session hasn't sent the initial user info message yet, so we're not sending them 221 | // messages yet (no secret lurking!). Queue the message to be sent later. 222 | session.blockedMessages.push(message); 223 | return true; 224 | } 225 | }); 226 | 227 | quitters.forEach((quitter) => { 228 | if (quitter.name) { 229 | this.broadcast({ quit: quitter.name }); 230 | } 231 | }); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /packages/chat-room-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-room-do", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "index.ts", 6 | "types": "index.ts", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "typecheck": "tsc -b" 10 | }, 11 | "dependencies": { 12 | "bad-words": "^3.0.4" 13 | }, 14 | "devDependencies": { 15 | "@cloudflare/workers-types": "^3.10.0", 16 | "@types/bad-words": "^3.0.1", 17 | "cloudflare-env": "*", 18 | "eslint": "^8.15.0", 19 | "eslint-config-custom": "*", 20 | "rate-limiter-do": "*", 21 | "tsconfig": "*", 22 | "typescript": "^4.6.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/chat-room-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types", "cloudflare-env"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/chat-room-do/utils.ts: -------------------------------------------------------------------------------- 1 | export async function handleErrors( 2 | request: Request, 3 | func: () => Response | Promise 4 | ) { 5 | try { 6 | return await func(); 7 | } catch (err) { 8 | if (request.headers.get("Upgrade") == "websocket") { 9 | // Annoyingly, if we return an HTTP error in response to a WebSocket request, Chrome devtools 10 | // won't show us the response body! So... let's send a WebSocket response with an error 11 | // frame instead. 12 | let [client, server] = Object.values(new WebSocketPair()); 13 | // @ts-expect-error 14 | server.accept(); 15 | server.close(1011, "Uncaught exception during session setup"); 16 | return new Response(null, { status: 101, webSocket: client }); 17 | } else { 18 | return new Response("Uncaught exception", { status: 500 }); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/counter-do/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/counter-do/index.ts: -------------------------------------------------------------------------------- 1 | export default class CounterDurableObject { 2 | private value: number = 0; 3 | 4 | constructor(private state: DurableObjectState) { 5 | this.state.blockConcurrencyWhile(async () => { 6 | let storedValue = await this.state.storage.get("value"); 7 | this.value = storedValue || 0; 8 | }); 9 | } 10 | 11 | async fetch(request: Request) { 12 | let url = new URL(request.url); 13 | 14 | let value = this.value; 15 | switch (url.pathname) { 16 | case "/increment": 17 | ++value; 18 | break; 19 | case "/decrement": 20 | --value; 21 | break; 22 | case "/": 23 | // Just serve the current value. No storage calls needed! 24 | break; 25 | default: 26 | return new Response("Not found", { status: 404 }); 27 | } 28 | 29 | this.value = value; 30 | this.state.storage.put("value", value); 31 | 32 | return new Response(value.toString()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/counter-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-do", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "index.ts", 6 | "types": "index.ts", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "typecheck": "tsc -b" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/workers-types": "^3.10.0", 13 | "eslint": "^8.15.0", 14 | "eslint-config-custom": "*", 15 | "tsconfig": "*", 16 | "typescript": "^4.6.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/counter-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/rate-limiter-do/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/rate-limiter-do/index.ts: -------------------------------------------------------------------------------- 1 | export default class RateLimiter { 2 | private nextAllowedTime: number = 0; 3 | 4 | // Our protocol is: POST when the IP performs an action, or GET to simply read the current limit. 5 | // Either way, the result is the number of seconds to wait before allowing the IP to perform its 6 | // next action. 7 | async fetch(request: Request) { 8 | let now = Date.now() / 1000; 9 | 10 | this.nextAllowedTime = Math.max(now, this.nextAllowedTime); 11 | 12 | if (request.method == "POST") { 13 | // POST request means the user performed an action. 14 | // We allow one action per seconds. 15 | ++this.nextAllowedTime; 16 | } 17 | 18 | // Return the number of seconds that the client needs to wait. 19 | // 20 | // We provide a "grace" period of 20 seconds, meaning that the client can make 4-5 requests 21 | // in a quick burst before they start being limited. 22 | let cooldown = Math.max(0, this.nextAllowedTime - now - 20); 23 | return new Response(String(cooldown)); 24 | } 25 | } 26 | 27 | // RateLimiterClient implements rate limiting logic on the caller's side. 28 | export class RateLimiterClient { 29 | private inCooldown: boolean = false; 30 | private limiter: DurableObjectStub; 31 | 32 | constructor( 33 | private getLimiterStub: () => DurableObjectStub, 34 | private reportError: (error: unknown) => void 35 | ) { 36 | // Call the callback to get the initial stub. 37 | this.limiter = getLimiterStub(); 38 | } 39 | 40 | // Call checkLimit() when a message is received to decide if it should be blocked due to the 41 | // rate limit. Returns `true` if the message should be accepted, `false` to reject. 42 | checkLimit() { 43 | if (this.inCooldown) { 44 | return false; 45 | } 46 | this.inCooldown = true; 47 | this.callLimiter(); 48 | return true; 49 | } 50 | 51 | // callLimiter() is an internal method which talks to the rate limiter. 52 | async callLimiter() { 53 | try { 54 | let response; 55 | try { 56 | // Currently, fetch() needs a valid URL even though it's not actually going to the 57 | // internet. We may loosen this in the future to accept an arbitrary string. But for now, 58 | // we have to provide a dummy URL that will be ignored at the other end anyway. 59 | response = await this.limiter.fetch("https://.../", { 60 | method: "POST", 61 | }); 62 | } catch (err) { 63 | // `fetch()` threw an exception. This is probably because the limiter has been 64 | // disconnected. Stubs implement E-order semantics, meaning that calls to the same stub 65 | // are delivered to the remote object in order, until the stub becomes disconnected, after 66 | // which point all further calls fail. This guarantee makes a lot of complex interaction 67 | // patterns easier, but it means we must be prepared for the occasional disconnect, as 68 | // networks are inherently unreliable. 69 | // 70 | // Anyway, get a new limiter and try again. If it fails again, something else is probably 71 | // wrong. 72 | this.limiter = this.getLimiterStub(); 73 | response = await this.limiter.fetch("https://.../", { 74 | method: "POST", 75 | }); 76 | } 77 | 78 | // The response indicates how long we want to pause before accepting more requests. 79 | let cooldown = +(await response.text()); 80 | await new Promise((resolve) => setTimeout(resolve, cooldown * 1000)); 81 | 82 | // Done waiting. 83 | this.inCooldown = false; 84 | } catch (err) { 85 | this.reportError(err); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/rate-limiter-do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rate-limiter-do", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "index.ts", 6 | "types": "index.ts", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "typecheck": "tsc -b" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/workers-types": "^3.10.0", 13 | "cloudflare-env": "*", 14 | "eslint": "^8.15.0", 15 | "eslint-config-custom": "*", 16 | "tsconfig": "*", 17 | "typescript": "^4.6.4" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/rate-limiter-do/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types", "cloudflare-env"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/remix-app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/remix-app/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrateRoot } from "react-dom/client"; 2 | import { RemixBrowser } from "@remix-run/react"; 3 | 4 | hydrateRoot(document, ); 5 | -------------------------------------------------------------------------------- /packages/remix-app/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/cloudflare"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /packages/remix-app/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import type { LoaderFunction, MetaFunction } from "@remix-run/cloudflare"; 3 | import { json } from "@remix-run/cloudflare"; 4 | import { 5 | Link, 6 | Links, 7 | LiveReload, 8 | Meta, 9 | Outlet, 10 | Scripts, 11 | ScrollRestoration, 12 | useCatch, 13 | useMatches, 14 | } from "@remix-run/react"; 15 | 16 | export let meta: MetaFunction = () => ({ 17 | charset: "utf-8", 18 | title: "New Remix App", 19 | viewport: "width=device-width,initial-scale=1", 20 | }); 21 | 22 | type LoaderData = { 23 | loaderCalls: number; 24 | }; 25 | 26 | export let loader: LoaderFunction = async ({ context: { env } }) => { 27 | let counter = env.COUNTER.get(env.COUNTER.idFromName("root")); 28 | let counterResponse = await counter.fetch("https://.../increment"); 29 | let loaderCalls = Number.parseInt(await counterResponse.text()); 30 | 31 | return json({ loaderCalls }); 32 | }; 33 | 34 | function Document({ children }: PropsWithChildren<{}>) { 35 | let matches = useMatches(); 36 | let root = matches.find((match) => match.id === "root"); 37 | let data = root?.data as LoaderData | undefined; 38 | 39 | return ( 40 | 41 | 42 | 43 | 44 | 48 | 49 | 50 |
51 |

52 | Remix Chat 53 |

54 |

55 | This chat runs entirely on the edge, powered by Cloudflare Workers 56 | Durable Objects 57 |

58 |
59 | {children} 60 | {data && ( 61 | <> 62 |
63 |
root loader invocations: {data.loaderCalls}
64 | 65 | )} 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | } 73 | 74 | export default function App() { 75 | return ( 76 | 77 | 78 | 79 | ); 80 | } 81 | 82 | export function CatchBoundary() { 83 | let { status, statusText } = useCatch(); 84 | 85 | return ( 86 | 87 |
88 |

{status}

89 | {statusText &&

{statusText}

} 90 |
91 |
92 | ); 93 | } 94 | 95 | export function ErrorBoundary({ error }: { error: Error }) { 96 | console.log(error); 97 | 98 | return ( 99 | 100 |
101 |

Oops, looks like something went wrong 😭

102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "@remix-run/cloudflare"; 2 | import { json } from "@remix-run/cloudflare"; 3 | import { Form, useLoaderData } from "@remix-run/react"; 4 | 5 | import { getSession } from "~/session.server"; 6 | 7 | type LoaderData = { 8 | loaderCalls: number; 9 | username?: string; 10 | }; 11 | 12 | export let loader: LoaderFunction = async ({ context: { env }, request }) => { 13 | let sessionPromise = getSession(request, env); 14 | 15 | let counter = env.COUNTER.get(env.COUNTER.idFromName("index")); 16 | let loaderCalls = await counter 17 | .fetch("https://.../increment") 18 | .then((response) => response.text()) 19 | .then((text) => Number.parseInt(text, 10)); 20 | 21 | let session = await sessionPromise; 22 | let username = (session.get("username") || undefined) as string | undefined; 23 | 24 | return json({ loaderCalls, username }); 25 | }; 26 | 27 | export default function Index() { 28 | let { loaderCalls, username } = useLoaderData() as LoaderData; 29 | 30 | return ( 31 |
32 |
33 | 44 |
51 |

52 |

or

53 | 64 | 67 | 68 |
69 |
70 |

index loader invocations: {loaderCalls}

71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/join.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/cloudflare"; 2 | import { redirect } from "@remix-run/cloudflare"; 3 | 4 | import { commitSession, getSession } from "~/session.server"; 5 | import { normalizeRoomName } from "~/utils"; 6 | 7 | export let action: ActionFunction = async ({ context: { env }, request }) => { 8 | let formData = await request.formData(); 9 | let room = formData.get("room") || ""; 10 | let username = formData.get("username") || ""; 11 | 12 | if ( 13 | typeof room !== "string" || 14 | !room || 15 | typeof username !== "string" || 16 | !username 17 | ) { 18 | return redirect("/"); 19 | } 20 | 21 | try { 22 | let sessionPromise = getSession(request, env); 23 | 24 | let id = env.CHAT_ROOM.idFromName(normalizeRoomName(room)).toString(); 25 | 26 | let session = await sessionPromise; 27 | session.set("username", username); 28 | 29 | return redirect(`/room/${id}`, { 30 | headers: { 31 | "Set-Cookie": await commitSession(session, env), 32 | }, 33 | }); 34 | } catch (error) { 35 | return redirect("/"); 36 | } 37 | }; 38 | 39 | export let loader = () => redirect("/"); 40 | 41 | export default () => null; 42 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/new.ts: -------------------------------------------------------------------------------- 1 | import type { ActionFunction } from "@remix-run/cloudflare"; 2 | import { redirect } from "@remix-run/cloudflare"; 3 | 4 | import { commitSession, getSession } from "~/session.server"; 5 | 6 | export let action: ActionFunction = async ({ context: { env }, request }) => { 7 | try { 8 | let sessionPromise = getSession(request, env); 9 | 10 | let formData = await request.formData(); 11 | let username = formData.get("username") || ""; 12 | 13 | if (typeof username !== "string" || !username) { 14 | return redirect("/"); 15 | } 16 | 17 | let id = env.CHAT_ROOM.newUniqueId().toString(); 18 | 19 | let session = await sessionPromise; 20 | session.set("username", username); 21 | 22 | return redirect(`/room/${id}`, { 23 | headers: { 24 | "Set-Cookie": await commitSession(session, env), 25 | }, 26 | }); 27 | } catch (error) { 28 | return redirect("/"); 29 | } 30 | }; 31 | 32 | export let loader = () => redirect("/"); 33 | 34 | export default () => null; 35 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/room.$roomId.tsx: -------------------------------------------------------------------------------- 1 | import type { KeyboardEventHandler } from "react"; 2 | import { useEffect, useState } from "react"; 3 | import type { ActionFunction, LoaderFunction } from "@remix-run/cloudflare"; 4 | import { json, redirect } from "@remix-run/cloudflare"; 5 | import { Form, useLoaderData, useLocation } from "@remix-run/react"; 6 | import type { Message } from "chat-room-do"; 7 | 8 | import { commitSession, getSession } from "~/session.server"; 9 | 10 | type LoaderData = { 11 | loaderCalls: number; 12 | latestMessages: Message[]; 13 | roomId: string; 14 | username: string; 15 | }; 16 | 17 | export let action: ActionFunction = async ({ context: { env }, request }) => { 18 | let formData = await request.formData(); 19 | let username = formData.get("username") || ""; 20 | 21 | if (!username) { 22 | throw json(null, { status: 401 }); 23 | } 24 | 25 | let session = await getSession(request, env); 26 | session.set("username", username); 27 | 28 | let url = new URL(request.url); 29 | return redirect(url.pathname, { 30 | headers: { "Set-Cookie": await commitSession(session, env) }, 31 | }); 32 | }; 33 | 34 | export let loader: LoaderFunction = async ({ 35 | context: { env }, 36 | params: { roomId }, 37 | request, 38 | }) => { 39 | roomId = roomId?.trim(); 40 | let session = await getSession(request, env); 41 | let username = session.get("username") as string | undefined; 42 | 43 | if (!roomId) { 44 | return redirect("/"); 45 | } 46 | 47 | if (!username) { 48 | throw json(null, { status: 401 }); 49 | } 50 | 51 | let chatRoom = env.CHAT_ROOM.get(env.CHAT_ROOM.idFromString(roomId)); 52 | let latestMessages = chatRoom 53 | .fetch("https://.../latest") 54 | .then((response) => { 55 | if (response.status !== 200) { 56 | throw new Error( 57 | "Something went wrong loading latest messages\n" + response.text() 58 | ); 59 | } 60 | return response; 61 | }) 62 | .then((response) => response.json()); 63 | 64 | let counter = env.COUNTER.get(env.COUNTER.idFromName(`room.${roomId}`)); 65 | let loaderCalls = counter 66 | .fetch("https://.../increment") 67 | .then((response) => response.text()) 68 | .then((text) => Number.parseInt(text, 10)); 69 | 70 | return json({ 71 | roomId, 72 | loaderCalls: await loaderCalls, 73 | latestMessages: await latestMessages, 74 | username, 75 | }); 76 | }; 77 | 78 | export default function Room() { 79 | let { key: locationKey } = useLocation(); 80 | let { loaderCalls, latestMessages, roomId, username } = 81 | useLoaderData() as LoaderData; 82 | 83 | let [newMessages, setNewMessages] = useState([]); 84 | let [socket, setSocket] = useState(null); 85 | useEffect(() => { 86 | let hostname = window.location.host; 87 | if (!hostname) return; 88 | 89 | let socket = new WebSocket( 90 | `${ 91 | window.location.protocol.startsWith("https") ? "wss" : "ws" 92 | }://${hostname}/room/${roomId}/websocket` 93 | ); 94 | socket.addEventListener("open", () => { 95 | socket.send(JSON.stringify({ name: username })); 96 | }); 97 | 98 | socket.addEventListener("message", (event) => { 99 | let data = JSON.parse(event.data); 100 | if (data.error) { 101 | console.error(data.error); 102 | return; 103 | } else if (data.joined) { 104 | console.log(`${data.joined} joined`); 105 | } else if (data.quit) { 106 | console.log(`${data.quit} quit`); 107 | } else if (data.ready) { 108 | setSocket(socket); 109 | } else if (data.message) { 110 | setNewMessages((previousValue) => [data, ...previousValue]); 111 | } 112 | }); 113 | 114 | return () => { 115 | socket.close(); 116 | }; 117 | }, [roomId, username, locationKey, setNewMessages]); 118 | 119 | let handleKeyDown: KeyboardEventHandler = (event) => { 120 | if (event.key === "Enter") { 121 | event.preventDefault(); 122 | 123 | let input = event.currentTarget; 124 | let message = input.value; 125 | input.value = ""; 126 | if (socket) { 127 | socket.send(JSON.stringify({ message })); 128 | } 129 | } 130 | }; 131 | 132 | return ( 133 |
134 |
135 |
Room ID
136 |
{roomId}
137 |
Visits
138 |
{loaderCalls}
139 |
140 |
141 | 151 |
152 |
    153 | {newMessages.map((message) => ( 154 |
  • 155 | {message.name}: {message.message} 156 |
  • 157 | ))} 158 | {latestMessages.map((message) => ( 159 |
  • 160 | {message.name}: {message.message} 161 |
  • 162 | ))} 163 |
164 |
165 | ); 166 | } 167 | 168 | export function CatchBoundary() { 169 | return ( 170 |
171 |
172 | 173 | 174 |
175 |
176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/room.$roomId.websocket.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from "@remix-run/cloudflare"; 2 | 3 | export let loader: LoaderFunction = async ({ 4 | context: { env }, 5 | params: { roomId }, 6 | request, 7 | }) => { 8 | if (!roomId) { 9 | return new Response("Invalid room id", { status: 400 }); 10 | } 11 | let chatRoom = env.CHAT_ROOM.get(env.CHAT_ROOM.idFromString(roomId)); 12 | 13 | let url = new URL(request.url); 14 | return chatRoom.fetch(`${url.protocol}//.../websocket`, request); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/remix-app/app/session.server.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "@remix-run/cloudflare"; 2 | import { createCookieSessionStorage } from "@remix-run/cloudflare"; 3 | 4 | function getSessionStorage(env: Env) { 5 | if (!env.SESSION_SECRET) throw new Error("SESSION_SECRET is not defined"); 6 | 7 | return createCookieSessionStorage({ 8 | cookie: { 9 | httpOnly: true, 10 | name: "remix-cloudflare-worker-chat-room", 11 | path: "/", 12 | sameSite: "lax", 13 | secrets: [env.SESSION_SECRET], 14 | }, 15 | }); 16 | } 17 | 18 | export function commitSession(session: Session, env: Env) { 19 | let sessionStorage = getSessionStorage(env); 20 | 21 | return sessionStorage.commitSession(session); 22 | } 23 | 24 | export function destroySession(session: Session, env: Env) { 25 | let sessionStorage = getSessionStorage(env); 26 | 27 | return sessionStorage.destroySession(session); 28 | } 29 | 30 | export function getSession(requestOrCookie: Request | string | null, env: Env) { 31 | let cookie = 32 | typeof requestOrCookie === "string" 33 | ? requestOrCookie 34 | : requestOrCookie?.headers.get("Cookie"); 35 | 36 | let sessionStorage = getSessionStorage(env); 37 | 38 | return sessionStorage.getSession(cookie); 39 | } 40 | -------------------------------------------------------------------------------- /packages/remix-app/app/utils.ts: -------------------------------------------------------------------------------- 1 | export function normalizeRoomName(room: string) { 2 | return room 3 | .replace(/[^a-zA-Z0-9_-]/g, "") 4 | .replace(/_/g, "-") 5 | .toLowerCase(); 6 | } 7 | -------------------------------------------------------------------------------- /packages/remix-app/build.d.ts: -------------------------------------------------------------------------------- 1 | export * from "@remix-run/dev/server-build"; 2 | -------------------------------------------------------------------------------- /packages/remix-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-app", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "./build/index.js", 6 | "types": "./build.d.ts", 7 | "scripts": { 8 | "build": "remix build", 9 | "dev": "remix watch", 10 | "lint": "eslint .", 11 | "typecheck": "tsc -b" 12 | }, 13 | "dependencies": { 14 | "@remix-run/cloudflare": "nightly", 15 | "@remix-run/react": "nightly", 16 | "react": "^18.1.0", 17 | "react-dom": "^18.1.0" 18 | }, 19 | "devDependencies": { 20 | "@cloudflare/workers-types": "^3.10.0", 21 | "@remix-run/dev": "nightly", 22 | "@types/react": "^18.0.9", 23 | "@types/react-dom": "^18.0.4", 24 | "cloudflare-env": "*", 25 | "eslint": "^8.15.0", 26 | "eslint-config-custom": "*", 27 | "tsconfig": "*", 28 | "typescript": "^4.6.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/remix-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/remix-cloudflare-worker-template/7b1d310bdbce3392c2a1586ba7eb5fbc0fc4d768/packages/remix-app/public/favicon.ico -------------------------------------------------------------------------------- /packages/remix-app/remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("@remix-run/dev").AppConfig} */ 2 | let config = { 3 | serverBuildTarget: "cloudflare-workers", 4 | ignoredRouteFiles: ["**/.*"], 5 | devServerBroadcastDelay: 1000, 6 | }; 7 | 8 | module.exports = config; 9 | -------------------------------------------------------------------------------- /packages/remix-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 5 | "exclude": ["node_modules", "build", "public/build"], 6 | "compilerOptions": { 7 | "target": "ES2019", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "types": ["@cloudflare/workers-types", "cloudflare-env"], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "incremental": true, 16 | "esModuleInterop": true, 17 | "module": "esnext", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx", 21 | "baseUrl": ".", 22 | "paths": { 23 | "~/*": ["./app/*"] 24 | }, 25 | "moduleResolution": "node" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/remix-app/types/remix-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | interface LoadContext { 5 | env: Env; 6 | } 7 | 8 | declare var process: { 9 | env: { NODE_ENV: "development" | "production" }; 10 | }; 11 | 12 | declare module "@remix-run/cloudflare" { 13 | import type { DataFunctionArgs as RemixDataFunctionArgs } from "@remix-run/cloudflare"; 14 | export * from "@remix-run/cloudflare/index"; 15 | 16 | interface DataFunctionArgs extends Omit { 17 | context: LoadContext; 18 | } 19 | 20 | export interface ActionFunction { 21 | (args: DataFunctionArgs): null | Response | Promise; 22 | } 23 | 24 | export interface LoaderFunction { 25 | (args: DataFunctionArgs): null | Response | Promise; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/remix-app/types/wrangler-env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "__STATIC_CONTENT_MANIFEST" { 2 | const manifestJSON: string; 3 | export default manifestJSON; 4 | } 5 | -------------------------------------------------------------------------------- /packages/worker/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/worker/entry.worker.ts: -------------------------------------------------------------------------------- 1 | import { getAssetFromKV } from "@cloudflare/kv-asset-handler"; 2 | import { createRequestHandler } from "@remix-run/cloudflare"; 3 | import manifestJSON from "__STATIC_CONTENT_MANIFEST"; 4 | 5 | import * as build from "remix-app"; 6 | 7 | export { default as ChatRoomDurableObject } from "chat-room-do"; 8 | export { default as CounterDurableObject } from "counter-do"; 9 | export { default as RateLimiterDurableObject } from "rate-limiter-do"; 10 | 11 | let assetManifest = JSON.parse(manifestJSON); 12 | let handleRemixRequest = createRequestHandler(build, process.env.NODE_ENV); 13 | 14 | export default { 15 | async fetch( 16 | request: Request, 17 | env: Env, 18 | ctx: ExecutionContext 19 | ): Promise { 20 | try { 21 | let url = new URL(request.url); 22 | let ttl = url.pathname.startsWith("/build/") 23 | ? 60 * 60 * 24 * 365 // 1 year 24 | : 60 * 5; // 5 minutes 25 | return await getAssetFromKV( 26 | { 27 | request, 28 | waitUntil(promise) { 29 | return ctx.waitUntil(promise); 30 | }, 31 | }, 32 | { 33 | ASSET_NAMESPACE: env.__STATIC_CONTENT, 34 | ASSET_MANIFEST: assetManifest, 35 | cacheControl: { 36 | browserTTL: ttl, 37 | edgeTTL: ttl, 38 | }, 39 | } 40 | ); 41 | } catch (error) { 42 | // if (error instanceof MethodNotAllowedError) { 43 | // return new Response("Method not allowed", { status: 405 }); 44 | // } else if (!(error instanceof NotFoundError)) { 45 | // return new Response("An unexpected error occurred", { status: 500 }); 46 | // } 47 | } 48 | 49 | try { 50 | let loadContext: LoadContext = { env }; 51 | return await handleRemixRequest(request, loadContext); 52 | } catch (error) { 53 | console.log(error); 54 | return new Response("An unexpected error occurred", { status: 500 }); 55 | } 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /packages/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "wrangler dev --env dev", 7 | "lint": "eslint .", 8 | "typecheck": "tsc -b" 9 | }, 10 | "dependencies": { 11 | "@cloudflare/kv-asset-handler": "^0.2.0", 12 | "@remix-run/cloudflare": "^1.4.3", 13 | "chat-room-do": "*", 14 | "counter-do": "*", 15 | "rate-limiter-do": "*", 16 | "remix-app": "*" 17 | }, 18 | "devDependencies": { 19 | "@cloudflare/workers-types": "^3.10.0", 20 | "cloudflare-env": "*", 21 | "eslint": "^8.15.0", 22 | "eslint-config-custom": "*", 23 | "tsconfig": "*", 24 | "typescript": "^4.6.4", 25 | "wrangler": "^2.0.5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 5 | "exclude": ["node_modules", "build", "public/build"], 6 | "compilerOptions": { 7 | "target": "ES2019", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "types": [ 10 | "@cloudflare/workers-types", 11 | "cloudflare-env", 12 | "remix-app/types/remix-env" 13 | ], 14 | "allowJs": true, 15 | "skipLibCheck": true, 16 | "strict": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noEmit": true, 19 | "incremental": true, 20 | "esModuleInterop": true, 21 | "module": "esnext", 22 | "resolveJsonModule": true, 23 | "isolatedModules": true, 24 | "jsx": "react-jsx", 25 | "baseUrl": ".", 26 | "paths": { 27 | "~/*": ["./app/*"] 28 | }, 29 | "moduleResolution": "node" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/worker/types/wrangler-env.d.ts: -------------------------------------------------------------------------------- 1 | declare module "__STATIC_CONTENT_MANIFEST" { 2 | const manifestJSON: string; 3 | export default manifestJSON; 4 | } 5 | -------------------------------------------------------------------------------- /packages/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | account_id = "574fdb1eae7e80782a805c4b92f6b626" 2 | compatibility_date = "2022-05-14" 3 | name = "remix-cloudflare-worker-template" 4 | 5 | main = "entry.worker.ts" 6 | 7 | workers_dev = true 8 | 9 | [site] 10 | bucket = "../remix-app/public" 11 | 12 | [env.dev] 13 | vars = {SESSION_SECRET = "should-be-secure-in-prod"} 14 | 15 | [env.dev.durable_objects] 16 | bindings = [ 17 | {name = "CHAT_ROOM", class_name = "ChatRoomDurableObject"}, 18 | {name = "COUNTER", class_name = "CounterDurableObject"}, 19 | {name = "RATE_LIMITER", class_name = "RateLimiterDurableObject"}, 20 | ] 21 | 22 | [durable_objects] 23 | bindings = [ 24 | {name = "CHAT_ROOM", class_name = "ChatRoomDurableObject"}, 25 | {name = "COUNTER", class_name = "CounterDurableObject"}, 26 | {name = "RATE_LIMITER", class_name = "RateLimiterDurableObject"}, 27 | ] 28 | 29 | [[migrations]] 30 | new_classes = ["CounterDurableObject"] 31 | tag = "v1" 32 | 33 | [[migrations]] 34 | new_classes = ["ChatRoomDurableObject", "RateLimiterDurableObject"] 35 | tag = "v2" 36 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "pipeline": { 3 | "build": { 4 | "dependsOn": ["^build"], 5 | "outputs": ["build/**", "public/build/**", ".cache/**"] 6 | }, 7 | "dev": { 8 | "cache": false 9 | }, 10 | "lint": { 11 | "outputs": [] 12 | }, 13 | "typecheck": { 14 | "outputs": ["tsconfig.tsbuildinfo"] 15 | } 16 | } 17 | } 18 | --------------------------------------------------------------------------------