├── .env-template ├── .github └── workflows │ ├── ci.yml │ └── stage.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.cloudflare ├── README.cloudflare.md └── img │ ├── 1 - create pages.jpg │ ├── 10 - add namespace.jpg │ ├── 11 - account id.jpg │ ├── 12 - edit workers token.jpg │ ├── 13 - gh vars.jpg │ ├── 2 - direct upload.jpg │ ├── 3 - create project.jpg │ ├── 3a - name project.jpg │ ├── 4 - dashboard.jpg │ ├── 7 - create worker.jpg │ ├── 8 - name worker and deploy.jpg │ ├── 9 - create namespace.jpg │ └── settings add variables.jpg ├── README.md ├── README.project.md ├── README.sentry ├── README.sentry.md └── img │ ├── 1 create project.jpg │ ├── 2 create project.jpg │ ├── 3 select project.jpg │ ├── 4 click settings.jpg │ ├── 5 copy dsn.jpg │ └── 6 generate token.jpg ├── bin ├── cli.js └── update-json.js ├── client ├── .env-template ├── .gitignore ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── components │ │ ├── ui.modal.tsx │ │ └── ui.spinner.tsx │ ├── config.ts │ ├── example.test.tsx │ ├── example.tsx │ ├── index.css │ ├── main.tsx │ ├── server-types.d.ts │ ├── services │ │ ├── monitor.tsx │ │ └── monitor.use-monitor.ts │ ├── test │ │ └── setup.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.eslint.json ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.config.ts ├── e2e ├── e2e.spec.ts ├── eslint.config.js ├── package.json ├── tsconfig.eslint.json └── tsconfig.json ├── eslint.config.js ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── server ├── .editorconfig ├── .gitignore ├── eslint.config.js ├── package.json ├── src │ ├── index.test.ts │ ├── index.ts │ ├── test.ts │ ├── types.shared.ts │ ├── types.ts │ └── utils.ts ├── tsconfig.eslint.json ├── tsconfig.json ├── tsconfig.shared.json ├── vitest.config.ts └── wrangler.toml ├── tsconfig.eslint.json └── tsconfig.json /.env-template: -------------------------------------------------------------------------------- 1 | CLOUDFLARE_API_TOKEN= 2 | CLOUDFLARE_ACCOUNT_ID= -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | permissions: 11 | actions: write 12 | contents: read 13 | 14 | jobs: 15 | lint: 16 | name: ⬣ Lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v4 21 | 22 | - uses: pnpm/action-setup@v3 23 | name: Install pnpm 24 | with: 25 | version: 9 26 | run_install: false 27 | 28 | - name: ⎔ Setup node 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: 22.9.0 32 | 33 | - name: 📥 Install deps 34 | run: pnpm install 35 | 36 | - name: 🔬 Lint 37 | run: pnpm run lint 38 | 39 | typecheck: 40 | name: ʦ Types 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: ⬇️ Checkout repo 44 | uses: actions/checkout@v4 45 | 46 | - uses: pnpm/action-setup@v3 47 | name: Install pnpm 48 | with: 49 | version: 9 50 | run_install: false 51 | 52 | - name: ⎔ Setup node 53 | uses: actions/setup-node@v4 54 | with: 55 | node-version: 22.9.0 56 | 57 | - name: 📥 Install deps 58 | run: pnpm install 59 | 60 | - name: 🔎 Type check 61 | run: pnpm run typecheck 62 | 63 | test: 64 | name: ⚡ Test 65 | runs-on: ubuntu-latest 66 | steps: 67 | - name: ⬇️ Checkout repo 68 | uses: actions/checkout@v4 69 | 70 | - uses: pnpm/action-setup@v3 71 | name: Install pnpm 72 | with: 73 | version: 9 74 | run_install: false 75 | 76 | - name: ⎔ Setup node 77 | uses: actions/setup-node@v4 78 | with: 79 | node-version: 22.9.0 80 | 81 | - name: 📥 Install deps 82 | run: pnpm install 83 | 84 | - name: ⚡ Run vitest 85 | run: pnpm run test 86 | 87 | e2e: 88 | name: 🎭 E2E 89 | runs-on: ubuntu-latest 90 | steps: 91 | - name: ⬇️ Checkout repo 92 | uses: actions/checkout@v4 93 | 94 | - uses: pnpm/action-setup@v3 95 | name: Install pnpm 96 | with: 97 | version: 9 98 | run_install: false 99 | 100 | - name: ⎔ Setup node 101 | uses: actions/setup-node@v4 102 | with: 103 | node-version: 22.9.0 104 | 105 | - name: 📥 Install deps 106 | run: pnpm install 107 | 108 | - name: 🌐 Install browsers 109 | run: pnpm playwright install chromium 110 | 111 | - name: 🎭 Run e2e tests 112 | run: pnpm run e2e 113 | 114 | publish-client: 115 | name: 🚀 Publish client 116 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} 117 | needs: [lint, typecheck, test, e2e] 118 | runs-on: ubuntu-latest 119 | env: 120 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 121 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 122 | GITHUB_REF_NAME: ${{ github.ref_name }} 123 | GITHUB_SHA: ${{ github.sha }} 124 | steps: 125 | - name: ⬇️ Checkout repo 126 | uses: actions/checkout@v4 127 | 128 | - uses: pnpm/action-setup@v3 129 | name: Install pnpm 130 | with: 131 | version: 9 132 | run_install: false 133 | 134 | - name: ⎔ Setup node 135 | uses: actions/setup-node@v4 136 | with: 137 | node-version: 22.9.0 138 | 139 | - name: 📥 Install deps 140 | run: pnpm install 141 | 142 | - name: ⚙️ Build 143 | run: pnpm build 144 | 145 | - name: 🚀 Publish client 146 | run: pnpm run client deploy:prod 147 | 148 | publish-server: 149 | name: 🚀 Publish server 150 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} 151 | needs: [lint, typecheck, test, e2e] 152 | runs-on: ubuntu-latest 153 | env: 154 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 155 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 156 | GITHUB_REF_NAME: ${{ github.ref_name }} 157 | GITHUB_SHA: ${{ github.sha }} 158 | steps: 159 | - name: ⬇️ Checkout repo 160 | uses: actions/checkout@v4 161 | 162 | - uses: pnpm/action-setup@v3 163 | name: Install pnpm 164 | with: 165 | version: 9 166 | run_install: false 167 | 168 | - name: ⎔ Setup node 169 | uses: actions/setup-node@v4 170 | with: 171 | node-version: 22.9.0 172 | 173 | - name: 📥 Install deps 174 | run: pnpm install 175 | 176 | - name: ⚙️ Build 177 | run: pnpm build 178 | 179 | - name: 🚀 Publish server 180 | run: pnpm run server deploy:prod 181 | -------------------------------------------------------------------------------- /.github/workflows/stage.yml: -------------------------------------------------------------------------------- 1 | name: stage 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | target: 7 | description: 'Deploy target (client, server, or both)' 8 | required: true 9 | default: 'both' 10 | type: choice 11 | options: 12 | - client 13 | - server 14 | - both 15 | # note: push is required to register this workflow 16 | push: 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | cancel-in-progress: true 21 | 22 | permissions: 23 | actions: write 24 | contents: read 25 | 26 | jobs: 27 | deploy-client: 28 | name: 🚀 Deploy client to stage 29 | if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'client' || github.event.inputs.target == 'both') }} 30 | runs-on: ubuntu-latest 31 | env: 32 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 33 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 34 | GITHUB_REF_NAME: ${{ github.ref_name }} 35 | GITHUB_SHA: ${{ github.sha }} 36 | steps: 37 | - name: ⬇️ Checkout repo 38 | uses: actions/checkout@v4 39 | 40 | - uses: pnpm/action-setup@v3 41 | name: Install pnpm 42 | with: 43 | version: 9 44 | run_install: false 45 | 46 | - name: ⎔ Setup node 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: 22.9.0 50 | 51 | - name: 📥 Install deps 52 | run: pnpm install 53 | 54 | - name: ⚙️ Build 55 | run: pnpm build 56 | 57 | - name: 🚀 Deploy client to stage 58 | run: pnpm run client deploy:stage 59 | 60 | deploy-server: 61 | name: 🚀 Deploy server to stage 62 | if: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'server' || github.event.inputs.target == 'both') }} 63 | runs-on: ubuntu-latest 64 | env: 65 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 66 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 67 | GITHUB_REF_NAME: ${{ github.ref_name }} 68 | GITHUB_SHA: ${{ github.sha }} 69 | steps: 70 | - name: ⬇️ Checkout repo 71 | uses: actions/checkout@v4 72 | 73 | - uses: pnpm/action-setup@v3 74 | name: Install pnpm 75 | with: 76 | version: 9 77 | run_install: false 78 | 79 | - name: ⎔ Setup node 80 | uses: actions/setup-node@v4 81 | with: 82 | node-version: 22.9.0 83 | 84 | - name: 📥 Install deps 85 | run: pnpm install 86 | 87 | - name: ⚙️ Build 88 | run: pnpm build 89 | 90 | - name: 🚀 Deploy server to stage 91 | run: pnpm run server deploy:stage 92 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .pnpm-store 3 | .eslintcache 4 | repo.txt 5 | .env 6 | .DS_Store 7 | /test-results/ 8 | /playwright-report/ 9 | /blob-report/ 10 | /playwright/.cache/ 11 | *.tsbuildinfo -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "semi": true, 5 | "useTabs": false, 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [{ "mode": "auto" }], 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact" 8 | ], 9 | "typescript.tsdk": "node_modules/typescript/lib" 10 | } 11 | -------------------------------------------------------------------------------- /README.cloudflare/README.cloudflare.md: -------------------------------------------------------------------------------- 1 | 1. [Sign up for Cloudflare](https://www.cloudflare.com/) (they have a free tier) 2 | 3 | 2. Create a Pages app to host the React client. From the Compute - Workers & Pages screen, choose "Create". 4 | ![](./img/1%20-%20create%20pages.jpg) 5 | 6 | 3. Select the "Pages" tab and click "Get started" in the "Use direct upload" section. 7 | ![](./img/2%20-%20direct%20upload.jpg) 8 | 9 | 4. Name your app and click "Create". Note: You do not need to upload any assets and click "Deploy". The Wrangler CLI and GitHub actions will take care of that later. After creating, skip step 2 and go back to the overview screen. 10 | 11 | ![](./img/3a%20-%20name%20project.jpg) 12 | 13 | 5. Each time you add an app, you will see it appear in your "Workers & Pages" Overview: 14 | ![](./img/4%20-%20dashboard.jpg) 15 | 16 | 6. Add the following variables under "Variables and Secrets" so that the build command can run in the correct environment: 17 | | Type | Name | Value | 18 | | --- | --- | --- | 19 | | Plaintext | NODE_VERSION | 22.9.0 | 20 | | Plaintext | PNPM_VERSION | 9 | 21 | 22 | ![](./img/settings%20add%20variables.jpg) 23 | 24 | 7. Create a Workers app to host the prod server. From the Workers & Pages Overview page, click "Create" (like step 2), and then from the "Workers" tab, click "Get started" in the section "Start with Hello World!". 25 | ![](./img/7%20-%20create%20worker.jpg) 26 | 27 | 8. Name your worker and click "Deploy". Note: Ignore the `worker.js` code, as it will be overwritten by our app when we run our deploy script. 28 | ![](./img/8%20-%20name%20worker%20and%20deploy.jpg) 29 | 30 | 9. Add a KV namespace for your prod server storage. Navigate to Storage & Databases - KV, and click "Create". 31 | ![](./img/9%20-%20create%20namespace.jpg) 32 | 33 | 10. Enter a name for your prod server kv namespace, and click "Create". 34 | ![](./img/10%20-%20add%20namespace.jpg) 35 | 36 | 11. Repeat steps 7-10 for your stage server and kv storage. Note: It might be useful to use suffix `-stage` in your naming. 37 | 38 | 12. On the right hand side of the Compute - Workers & Pages Overview screen, you can find your Account ID, as well as a link to "Manage API tokens": 39 | ![](./img/11%20-%20account%20id.jpg) 40 | 41 | 13. Create an API token for "Edit Cloudflare Workers", using the provided template. 42 | ![](./img/12%20-%20edit%20workers%20token.jpg) 43 | 44 | 14. Add your Cloudflare API Token and Account ID in [.env](../.env). This will enable the Wrangler CLI to deploy the app locally. 45 | 46 | 15. Add your Cloudflare API Token and Account ID to your repository secrets in GitHub. This will enable deploy from GitHub actions. 47 | ![](./img/13%20-%20gh%20vars.jpg) 48 | 49 | 16. Configure `ENV` and `getServerUrl` in [client/src/config.ts](../client/src/config.ts). Note: This is just a suggestion, and you may choose another method of determining your app's runtime environment. 50 | 51 | 17. Update [server/wrangler.toml](../server/wrangler.toml) with your app names and IDs. Eg: 52 | 53 | ```diff 54 | [env.stage] 55 | - name = "todo-rename-stage" 56 | + name = "my-app-stage" 57 | workers_dev = true 58 | - vars = { ALLOWED_HOST = "todo:rename to stage host", ENV = "stage" } 59 | + vars = { ALLOWED_HOST = "my-app-stage.pages.dev", ENV = "stage" } 60 | [[env.stage.kv_namespaces]] 61 | binding = "DB" 62 | - id = "e0c5eee53ed34ff69c4d8303f818adca" 63 | + id = "fThh47jB971c4GP452h75cP7jqE499mL" 64 | ``` 65 | 66 | 18. Update the deploy scripts in [client/package.json](../client/package.json) to use your project name. Note: Pages allows a "Preview" branch, for all branches besides main. We use the same app name but provide `--branch "stage"` for our staging branch: 67 | 68 | ```diff 69 | - "deploy:stage": "pnpm wrangler pages deploy ./dist --project-name todo-rename --branch \"stage\"..., 70 | + "deploy:stage": "pnpm wrangler pages deploy ./dist --project-name my-app --branch \"stage\"..., 71 | - "deploy:prod": "pnpm wrangler pages deploy ./dist --project-name todo-rename", 72 | + "deploy:prod": "pnpm wrangler pages deploy ./dist --project-name my-app", 73 | ``` 74 | -------------------------------------------------------------------------------- /README.cloudflare/img/1 - create pages.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/1 - create pages.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/10 - add namespace.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/10 - add namespace.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/11 - account id.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/11 - account id.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/12 - edit workers token.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/12 - edit workers token.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/13 - gh vars.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/13 - gh vars.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/2 - direct upload.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/2 - direct upload.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/3 - create project.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/3 - create project.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/3a - name project.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/3a - name project.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/4 - dashboard.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/4 - dashboard.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/7 - create worker.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/7 - create worker.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/8 - name worker and deploy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/8 - name worker and deploy.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/9 - create namespace.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/9 - create namespace.jpg -------------------------------------------------------------------------------- /README.cloudflare/img/settings add variables.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codenickycode/create-react-spa-cloudflare/b3593ecbb47805144ece8bc9d284da6c3afcf669/README.cloudflare/img/settings add variables.jpg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create React SPA Cloudflare 2 | 3 | A starter project for building a pnpm monorepo with a client-side React SPA and a server-side Cloudflare Worker with KV storage. This project includes comprehensive testing, linting, and CI/CD setup, along with Sentry integration for error tracking. 4 | 5 | ## Features 6 | 7 | - [React](https://react.dev) SPA for the client-side application 8 | - [Tailwind CSS](https://tailwindcss.com/) for styling 9 | - [Tanstack Query](https://tanstack.com/query/latest) for client http request state management 10 | - [Hono](https://hono.dev/) server-side api framework 11 | - [Prettier](https://prettier.io/) for code formatting 12 | - [ESLint](https://eslint.org/) for linting 13 | - [Vitest](https://vitest.dev/) for unit testing 14 | - [Playwright](https://playwright.dev/) for end-to-end testing 15 | - [TypeScript](https://www.typescriptlang.org/) for type checking 16 | - [Cloudflare](https://cloudflare.com) Pages for hosting the client, Worker with KV storage for hosting the server 17 | - [GitHub](https://github.com) workflows for CI and staging deployment 18 | - [Sentry](https://sentry.io/) integration for client-side error tracking 19 | - [pnpm](https://pnpm.io) for performant monorepo package management 20 | 21 | ## Installation 22 | 23 | ### Pre-requisites 24 | 25 | 1. [git](https://git-scm.com/downloads) 26 | 2. [pnpm](https://pnpm.io/installation) v9 and above 27 | 3. [node](https://nodejs.org/en/download/package-manager/current) v22.9.0 and above 28 | 29 | ### Install 30 | 31 | 1. Ensure you're using the correct version of Node: 32 | ```sh 33 | nvm use 22.9.0 34 | ``` 35 | 2. If necessary, install the correct version of pnpm: 36 | ```sh 37 | npm i -g pnpm@9 38 | ``` 39 | 3. Run the installation script: 40 | ```sh 41 | pnpm create react-spa-cloudflare@latest my-app 42 | ``` 43 | 4. Check the console output for any warnings. The command will succeed unless the initial download fails. 44 | 45 | ## Getting Started 46 | 47 | 1. Navigate to your project directory and start the development server: 48 | 49 | ```sh 50 | cd my-app 51 | pnpm run dev 52 | ``` 53 | 54 | 2. Navigate to the app at http://localhost:5173 55 | 3. You should see some hello text, components, and verified server connection 56 | 4. Follow your project's README for further setup instructions 57 | 58 | ## Debug 59 | 60 | ### ENOENT Error 61 | 62 | If you encounter an ENOENT error when running the create command, make sure to include a version: 63 | 64 | ```sh 65 | # ❌ Wrong! It's missing a version 66 | pnpm create react-spa-cloudflare 67 | 68 | # ✅ Correct! 69 | pnpm create react-spa-cloudflare@latest 70 | ``` 71 | -------------------------------------------------------------------------------- /README.project.md: -------------------------------------------------------------------------------- 1 | # React SPA Cloudflare 2 | 3 | This project is a pnpm monorepo with a client-side React SPA and a server-side Cloudflare Worker with KV storage. This project includes comprehensive testing, linting, and CI/CD setup, along with Sentry integration for error tracking. 4 | 5 | ## Features 6 | 7 | - [React](https://react.dev) SPA for the client-side application 8 | - [Tailwind CSS](https://tailwindcss.com/) for styling 9 | - [Tanstack Query](https://tanstack.com/query/latest) for client http request state management 10 | - [Hono](https://hono.dev/) server-side api framework 11 | - [Prettier](https://prettier.io/) for code formatting 12 | - [ESLint](https://eslint.org/) for linting 13 | - [Vitest](https://vitest.dev/) for unit testing 14 | - [Playwright](https://playwright.dev/) for end-to-end testing 15 | - [TypeScript](https://www.typescriptlang.org/) for type checking 16 | - [Cloudflare](https://cloudflare.com) Pages for hosting the client, Worker with KV storage for hosting the server 17 | - [GitHub](https://github.com) workflows for CI and staging deployment 18 | - [Sentry](https://sentry.io/) integration for client-side error tracking 19 | - [pnpm](https://pnpm.io) for performant monorepo package management 20 | 21 | ## Getting Started 22 | 23 | ### Pre-requisites 24 | 25 | 1. [pnpm](https://pnpm.io/installation) v9 and above 26 | 2. [node](https://nodejs.org/en/download/package-manager/current) v22.9.0 and above 27 | 28 | ### Installation 29 | 30 | 1. Ensure you're using the correct version of Node: 31 | 32 | ```sh 33 | nvm use 22.9.0 34 | ``` 35 | 36 | 2. If necessary, install the correct version of pnpm: 37 | 38 | ```sh 39 | npm i -g pnpm@9 40 | ``` 41 | 42 | 3. Install dependencies 43 | 44 | ```sh 45 | pnpm i 46 | ``` 47 | 48 | ### Deployment Configuration 49 | 50 | 1. Follow instructions in [README.cloudflare](./README.cloudflare/README.cloudflare.md) 51 | 52 | 2. Follow instructions in [README.sentry](./README.sentry/README.sentry.md) 53 | 54 | ### Deploying a branch to stage 55 | 56 | 1. Click the "Actions" tab 57 | 2. Select the "stage" workflow 58 | 3. Open the dropdown for "Run workflow" and select the branch you wish to deploy 59 | 4. Choose your deploy target (client, server, both) 60 | 5. Click "Run workflow" 61 | 62 | The client app will deploy to the preview url, and the server will deploy to your staging worker. 63 | 64 | ## Scripts 65 | 66 | - `pnpm run dev`: Start the development server 67 | - `pnpm run lint`: Run ESLint 68 | - `pnpm run test`: Run unit tests with Vitest 69 | - `pnpm run typecheck`: Run TypeScript type checking 70 | - `pnpm run format`: Format code with Prettier 71 | - `pnpm run e2e`: Run end-to-end tests with Playwright 72 | 73 | Some convenience scripts for shortcuts: 74 | 75 | - `pnpm run clean`: Execute a clean install of package dependencies 76 | - `pnpm run client 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@create-react-spa-cloudflare/client", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "build": "pnpm run build:server-types && tsc -b && vite build", 7 | "build:server-types": "tsc -p ../server/tsconfig.shared.json --declaration --emitDeclarationOnly --outFile ./src/server-types.d.ts", 8 | "build:test": "tsc -b && E2E=true vite build", 9 | "deploy:stage": "pnpm wrangler pages deploy ./dist --project-name todo-rename --branch \"stage\" --commit-hash \"$GITHUB_SHA\" --commit-message \"stage deployment\"", 10 | "deploy:prod": "pnpm wrangler pages deploy ./dist --project-name todo-rename", 11 | "dev": "vite --host 0.0.0.0", 12 | "lint": "eslint .", 13 | "test": "vitest run", 14 | "test:watch": "vitest", 15 | "preview": "vite preview", 16 | "typecheck": "pnpm exec tsc --build" 17 | }, 18 | "dependencies": { 19 | "@sentry/react": "^9.16.1", 20 | "@sentry/vite-plugin": "^3.4.0", 21 | "@tanstack/react-query": "^5.75.5", 22 | "classnames": "^2.5.1", 23 | "react": "^19.1.0", 24 | "react-dom": "^19.1.0" 25 | }, 26 | "devDependencies": { 27 | "@tailwindcss/postcss": "^4.1.5", 28 | "@testing-library/jest-dom": "^6.6.3", 29 | "@testing-library/react": "^16.3.0", 30 | "@types/react": "^19.1.3", 31 | "@types/react-dom": "^19.1.3", 32 | "@vitejs/plugin-react-swc": "^3.9.0", 33 | "eslint-plugin-react-hooks": "^5.2.0", 34 | "eslint-plugin-react-refresh": "^0.4.20", 35 | "jsdom": "^26.1.0", 36 | "postcss": "^8.5.3", 37 | "tailwindcss": "^4.1.5", 38 | "vite": "^6.3.5", 39 | "vitest": "^3.1.3" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /client/src/components/ui.modal.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import type { ReactNode } from 'react'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | interface ModalProps { 6 | isOpen: boolean; 7 | onClose: () => void; 8 | children: ReactNode; 9 | className?: string; 10 | closeOnEsc?: boolean; 11 | } 12 | 13 | export const Modal = ({ 14 | isOpen, 15 | onClose, 16 | children, 17 | closeOnEsc = true, 18 | className, 19 | }: ModalProps) => { 20 | const [isAnimating, setIsAnimating] = useState(false); 21 | 22 | // Closes modal on 'esc'. This needs to be smarter if you will have more than 23 | // one modal open at a time. We do not currently have that requirement. 24 | useEffect(() => { 25 | const closeModal = (e: KeyboardEvent) => { 26 | if (closeOnEsc && e.key === 'Escape') { 27 | onClose(); 28 | } 29 | }; 30 | document.addEventListener('keydown', closeModal); 31 | return () => { 32 | document.removeEventListener('keydown', closeModal); 33 | }; 34 | }, [closeOnEsc, onClose]); 35 | 36 | useEffect(() => { 37 | if (isOpen) { 38 | setIsAnimating(true); 39 | } 40 | }, [isOpen]); 41 | 42 | const handleAnimationEnd = () => { 43 | if (!isOpen) { 44 | setIsAnimating(false); 45 | } 46 | }; 47 | 48 | if (!isOpen && !isAnimating) { 49 | return null; 50 | } 51 | 52 | return ( 53 |
61 |
e.stopPropagation()} 70 | > 71 | 77 | {children} 78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/components/ui.spinner.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import type { ReactNode } from 'react'; 3 | 4 | interface SpinnerProps { 5 | isSpinning: boolean; 6 | children: ReactNode; 7 | } 8 | 9 | export const Spinner = ({ isSpinning, children }: SpinnerProps) => { 10 | return ( 11 | 12 | {isSpinning && ( 13 | 14 | 15 | 16 | )} 17 | 23 | {children} 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/src/config.ts: -------------------------------------------------------------------------------- 1 | export const ENV = window.location.hostname.includes('stage.') 2 | ? 'stage' 3 | : window.location.hostname.includes('todo: change to prod domain') 4 | ? 'prod' 5 | : window.location.port === '4173' 6 | ? 'test' 7 | : 'dev'; 8 | 9 | export const getServerUrl = () => { 10 | switch (ENV) { 11 | case 'stage': 12 | return 'todo: change to stage url'; 13 | case 'prod': 14 | return 'todo: change to prod url'; 15 | case 'test': 16 | return 'http://localhost:8788'; 17 | case 'dev': 18 | default: 19 | return 'http://localhost:8787'; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /client/src/example.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest'; 2 | import { 3 | render, 4 | screen, 5 | fireEvent, 6 | waitForElementToBeRemoved, 7 | } from '@testing-library/react'; 8 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 9 | import { Example } from './example'; 10 | 11 | // Mock only the react-query hook 12 | vi.mock('@tanstack/react-query', async (importOriginal) => { 13 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 14 | const mod = await importOriginal(); 15 | return { 16 | ...mod, 17 | useQuery: vi.fn().mockReturnValue({ data: null }), 18 | }; 19 | }); 20 | 21 | describe('Example Component', () => { 22 | const queryClient = new QueryClient(); 23 | 24 | const renderComponent = () => 25 | render( 26 | 27 | 28 | , 29 | ); 30 | 31 | it('renders the component with correct initial state', () => { 32 | renderComponent(); 33 | 34 | expect(screen.getByText('Hello from React!')).toBeDefined(); 35 | expect(screen.getByText('Server connection: ❌')).toBeDefined(); 36 | expect(screen.getByText('spinner example')).toBeDefined(); 37 | expect(screen.getByText('show modal')).toBeDefined(); 38 | expect(screen.queryByText('I am a modal!')).toBeNull(); 39 | }); 40 | 41 | it('opens the modal when the button is clicked', () => { 42 | renderComponent(); 43 | 44 | const showModalButton = screen.getByRole('button', { name: 'show modal' }); 45 | fireEvent.click(showModalButton); 46 | 47 | expect(screen.getByText('I am a modal!')).toBeDefined(); 48 | }); 49 | 50 | it('closes the modal when clicking outside', () => { 51 | renderComponent(); 52 | 53 | const showModalButton = screen.getByRole('button', { name: 'show modal' }); 54 | fireEvent.click(showModalButton); 55 | 56 | expect(screen.getByText('I am a modal!')).toBeDefined(); 57 | 58 | // Simulate clicking outside the modal 59 | fireEvent.mouseDown(document); 60 | 61 | waitForElementToBeRemoved(screen.queryByText('I am a modal!')); 62 | }); 63 | 64 | it('renders the spinner', () => { 65 | renderComponent(); 66 | 67 | const spinnerText = screen.getByText('spinner example'); 68 | expect(spinnerText).toBeDefined(); 69 | 70 | // Check if the spinner is actually spinning 71 | // This assumes the Spinner component adds a class or attribute when spinning 72 | const spinnerParent = spinnerText.closest('[aria-busy="true"]'); 73 | expect(spinnerParent).toBeDefined(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /client/src/example.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Spinner } from './components/ui.spinner'; 3 | import { Modal } from './components/ui.modal'; 4 | import { useQuery } from '@tanstack/react-query'; 5 | import { getServerUrl } from './config'; 6 | import { hc } from 'hono/client'; 7 | import { useMonitor } from './services/monitor.use-monitor'; 8 | import type { ServerApi } from 'types.shared'; 9 | 10 | const serverUrl = getServerUrl(); 11 | 12 | const { $get } = hc(serverUrl)['index']; 13 | 14 | const useServerOkQuery = () => { 15 | const { captureException } = useMonitor(); 16 | return useQuery({ 17 | queryKey: ['hello-from-server'], 18 | queryFn: async () => { 19 | return $get() 20 | .then(async (res) => { 21 | if (!res.ok) { 22 | throw res; 23 | } 24 | return res.text(); 25 | }) 26 | .catch((error) => { 27 | captureException(error); 28 | throw error; 29 | }); 30 | }, 31 | }); 32 | }; 33 | 34 | export const Example = () => { 35 | const [isModalOpen, setIsModalOpen] = useState(false); 36 | const serverOkQuery = useServerOkQuery(); 37 | return ( 38 |
39 |

Hello from React!

40 |
41 |

Server connection: {serverOkQuery.isSuccess ? '✅' : '❌'}

42 |
43 |
44 |

45 | Spinner: spinner example 46 |

47 |
48 |
49 | 55 | setIsModalOpen(false)}> 56 |

I am a modal!

57 |
58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @theme { 4 | --animate-fade-in: fadeIn 0.3s ease-out forwards; 5 | --animate-fade-out: fadeOut 0.3s ease-out forwards; 6 | --animate-scale-in: scaleIn 0.3s ease-out forwards; 7 | --animate-scale-out: scaleOut 0.3s ease-out forwards; 8 | 9 | --font-sans: Ubuntu, sans-serif; 10 | 11 | @keyframes fadeIn { 12 | 0% { 13 | opacity: 0; 14 | } 15 | 100% { 16 | opacity: 1; 17 | } 18 | } 19 | @keyframes fadeOut { 20 | 0% { 21 | opacity: 1; 22 | } 23 | 100% { 24 | opacity: 0; 25 | } 26 | } 27 | @keyframes scaleIn { 28 | 0% { 29 | transform: scale(0.95); 30 | opacity: 0; 31 | } 32 | 100% { 33 | transform: scale(1); 34 | opacity: 1; 35 | } 36 | } 37 | @keyframes scaleOut { 38 | 0% { 39 | transform: scale(1); 40 | opacity: 1; 41 | } 42 | 100% { 43 | transform: scale(0.95); 44 | opacity: 0; 45 | } 46 | } 47 | } 48 | 49 | /* 50 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 51 | so we've added these compatibility styles to make sure everything still 52 | looks the same as it did with Tailwind CSS v3. 53 | 54 | If we ever want to remove these styles, we need to add an explicit border 55 | color utility to any element that depends on these defaults. 56 | */ 57 | @layer base { 58 | *, 59 | ::after, 60 | ::before, 61 | ::backdrop, 62 | ::file-selector-button { 63 | border-color: var(--color-gray-200, currentcolor); 64 | } 65 | } 66 | 67 | @layer base { 68 | html { 69 | font-family: Ubuntu, system-ui, sans-serif; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 5 | import { MonitorProvider } from './services/monitor'; 6 | import { Example } from './example'; 7 | 8 | const queryClient = new QueryClient(); 9 | 10 | ReactDOM.createRoot(document.getElementById('root')!).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | ); 19 | 20 | console.log(`branch: ${import.meta.env.GIT_BRANCH}`); 21 | console.log(`commit: ${import.meta.env.GIT_SHA}`); 22 | console.log(`e2e: ${import.meta.env.E2E || 'false'}`); 23 | -------------------------------------------------------------------------------- /client/src/server-types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "types" { 2 | import type { KVNamespace } from '@cloudflare/workers-types'; 3 | import type { Context as HonoContext } from 'hono'; 4 | export interface Env { 5 | ALLOWED_HOST: string; 6 | DB: KVNamespace; 7 | LOCAL_DB: KVNamespace; 8 | TEST_DB: KVNamespace; 9 | ENV: 'dev' | 'prod' | 'stage' | 'test'; 10 | GITHUB_REF_NAME: string; 11 | GITHUB_SHA: string; 12 | } 13 | export type Context = HonoContext<{ 14 | Bindings: Env; 15 | }>; 16 | } 17 | declare module "test" { 18 | import type { Env } from "types"; 19 | export const testRoute: import("hono/hono-base").HonoBase<{ 20 | Bindings: Env; 21 | }, { 22 | "/reset": { 23 | $post: { 24 | input: {}; 25 | output: {}; 26 | outputFormat: string; 27 | status: import("hono/utils/http-status").StatusCode; 28 | }; 29 | }; 30 | }, "/">; 31 | } 32 | declare module "index" { 33 | import type { Env } from "types"; 34 | const app: import("hono/hono-base").HonoBase<{ 35 | Bindings: Env; 36 | }, ({ 37 | "*": {}; 38 | } & { 39 | "/": { 40 | $get: { 41 | input: {}; 42 | output: "ok"; 43 | outputFormat: "text"; 44 | status: 200; 45 | }; 46 | }; 47 | }) | import("hono/types").MergeSchemaPath<{ 48 | "/reset": { 49 | $post: { 50 | input: {}; 51 | output: {}; 52 | outputFormat: string; 53 | status: import("hono/utils/http-status").StatusCode; 54 | }; 55 | }; 56 | }, "/test">, "/">; 57 | export default app; 58 | } 59 | declare module "types.shared" { 60 | import type app from "index"; 61 | export type ServerApi = typeof app; 62 | } 63 | -------------------------------------------------------------------------------- /client/src/services/monitor.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import { lazy, Suspense } from 'react'; 3 | import { MonitorContext } from './monitor.use-monitor'; 4 | import { ENV, getServerUrl } from '../config'; 5 | 6 | const SENTRY_DSN = 'todo: replace with sentry dsn'; 7 | 8 | const SentryLazy = lazy(() => 9 | import('@sentry/react').then((SentryReact) => { 10 | SentryReact.init({ 11 | dsn: SENTRY_DSN, 12 | environment: ENV, 13 | integrations: [ 14 | SentryReact.browserTracingIntegration(), 15 | SentryReact.replayIntegration(), 16 | ], 17 | tracesSampleRate: 1.0, 18 | tracePropagationTargets: ['localhost', getServerUrl()], 19 | // limit replay sampling 20 | replaysSessionSampleRate: ENV === 'prod' ? 0.1 : 0, 21 | replaysOnErrorSampleRate: ENV === 'prod' ? 1.0 : 0, 22 | }); 23 | console.log('monitor initialized'); 24 | const SentryProvider = ({ children }: { children: ReactNode }) => { 25 | return ( 26 | 27 | 30 | {children} 31 | 32 | 33 | ); 34 | }; 35 | return { 36 | default: SentryProvider, 37 | }; 38 | }), 39 | ); 40 | 41 | export const MonitorProvider = ({ children }: { children: ReactNode }) => { 42 | return ( 43 | {children}}> 44 | {children} 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /client/src/services/monitor.use-monitor.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export const MonitorContext = createContext<{ 4 | captureException: (exception: unknown) => string; 5 | } | null>(null); 6 | 7 | export const useMonitor = () => { 8 | const ctx = useContext(MonitorContext); 9 | if (!ctx) { 10 | return { 11 | captureException: () => { 12 | console.warn( 13 | 'tried to capture exception before monitor was initialized', 14 | ); 15 | }, 16 | }; 17 | } 18 | return ctx; 19 | }; 20 | -------------------------------------------------------------------------------- /client/src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly GIT_BRANCH: string; 5 | readonly GIT_SHA: string; 6 | readonly E2E: string; 7 | } 8 | 9 | interface ImportMeta { 10 | readonly env: ImportMetaEnv; 11 | } 12 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "useDefineForClassFields": true, 8 | "allowImportingTsExtensions": true, 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "jsx": "react-jsx", 13 | "types": ["vitest/globals"] 14 | }, 15 | "include": ["src", "../server/src/*"] 16 | } 17 | -------------------------------------------------------------------------------- /client/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "files": [], 4 | "references": [ 5 | { 6 | "path": "./tsconfig.app.json" 7 | }, 8 | { 9 | "path": "./tsconfig.node.json" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sentryVitePlugin } from '@sentry/vite-plugin'; 2 | import type { PluginOption } from 'vite'; 3 | import { defineConfig, loadEnv } from 'vite'; 4 | import react from '@vitejs/plugin-react-swc'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(({ mode }) => { 8 | const env = loadEnv(mode, process.cwd(), ''); 9 | return { 10 | plugins: [ 11 | react() as PluginOption[], 12 | sentryVitePlugin({ 13 | org: 'todo:replace', 14 | project: 'todo:replace', 15 | authToken: env.SENTRY_AUTH_TOKEN, 16 | }), 17 | ], 18 | define: { 19 | 'import.meta.env.GIT_BRANCH': JSON.stringify(process.env.GITHUB_REF_NAME), 20 | 'import.meta.env.GIT_SHA': JSON.stringify(process.env.GITHUB_SHA), 21 | 'import.meta.env.E2E': JSON.stringify(process.env.E2E), 22 | }, 23 | build: { 24 | sourcemap: true, 25 | }, 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /client/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, mergeConfig } from 'vitest/config'; 2 | import viteConfig from './vite.config'; 3 | 4 | export default defineConfig((configEnv) => 5 | mergeConfig( 6 | viteConfig(configEnv), 7 | defineConfig({ 8 | test: { 9 | globals: true, 10 | environment: 'jsdom', 11 | setupFiles: './src/test/setup.ts', 12 | }, 13 | }), 14 | ), 15 | ); 16 | -------------------------------------------------------------------------------- /e2e/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.use({ 4 | actionTimeout: 5000, 5 | // add a user agent so server doesn't think playwright is a bot 🤖 6 | userAgent: 7 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36', 8 | }); 9 | 10 | test.beforeEach(async () => { 11 | try { 12 | await fetch('http://localhost:8788/test/reset', { method: 'POST' }); 13 | console.log('Successfully reset test db'); 14 | } catch (error) { 15 | console.error('Failed to reset test db', error); 16 | } 17 | }); 18 | 19 | test('smoke test', async ({ page }) => { 20 | await page.goto('/'); 21 | await expect(page).toHaveTitle('todo: rename me'); 22 | }); 23 | -------------------------------------------------------------------------------- /e2e/eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import globals from 'globals'; 5 | import rootConfig from '../eslint.config.js'; 6 | import tsEslintParser from '@typescript-eslint/parser'; 7 | 8 | // mimic CommonJS variables -- not needed if using CommonJS 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | export default [ 13 | ...rootConfig, 14 | { 15 | // Note: there should be no other properties in this object 16 | ignores: ['eslint.config.js'], 17 | }, 18 | { 19 | languageOptions: { 20 | ecmaVersion: 2022, 21 | sourceType: 'module', 22 | globals: { ...globals.browser }, 23 | parser: tsEslintParser, 24 | parserOptions: { 25 | project: './tsconfig.eslint.json', 26 | tsconfigRootDir: __dirname, 27 | }, 28 | }, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@create-react-spa-cloudflare/e2e", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "lint": "eslint .", 7 | "typecheck": "pnpm exec tsc --build" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /e2e/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noUnusedLocals": true, 5 | "noUnusedParameters": true 6 | }, 7 | "include": ["."] 8 | } 9 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import js from '@eslint/js'; 5 | import tsEslint from 'typescript-eslint'; 6 | import tsEslintParser from '@typescript-eslint/parser'; 7 | 8 | // mimic CommonJS variables -- not needed if using CommonJS 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | 12 | export default [ 13 | js.configs.recommended, 14 | ...tsEslint.configs.recommended, 15 | { 16 | // Note: there should be no other properties in this object 17 | ignores: ['dist', 'eslint.config.js'], 18 | }, 19 | { 20 | languageOptions: { 21 | ecmaVersion: 2022, 22 | sourceType: 'module', 23 | parser: tsEslintParser, 24 | parserOptions: { 25 | project: './tsconfig.eslint.json', 26 | tsconfigRootDir: __dirname, 27 | }, 28 | }, 29 | rules: { 30 | 'default-case': 'error', 31 | '@typescript-eslint/consistent-type-exports': 'warn', 32 | '@typescript-eslint/consistent-type-imports': 'warn', 33 | '@typescript-eslint/no-unused-vars': 'warn', 34 | '@typescript-eslint/no-unused-expressions': [ 35 | 'warn', 36 | { allowShortCircuit: true }, 37 | ], 38 | }, 39 | settings: { 40 | 'max-warnings': 0, 41 | }, 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-spa-cloudflare", 3 | "type": "module", 4 | "version": "0.0.26", 5 | "description": "Starter package for a React SPA with Cloudflare Pages, Workers, and KV", 6 | "bin": "./bin/cli.js", 7 | "scripts": { 8 | "preinstall": "npx only-allow pnpm", 9 | "client": "pnpm --filter client", 10 | "server": "pnpm --filter server", 11 | "build": "pnpm -r build", 12 | "clean": "rm -rf node_modules && pnpm -r exec rm -rf node_modules && pnpm i", 13 | "dev": "pnpm -r dev", 14 | "e2e": "pnpm exec playwright test", 15 | "e2e:ui": "pnpm exec playwright test --ui", 16 | "format": "pnpm exec prettier --write **/*.{js,ts,tsx,json,css,md}", 17 | "lint": "pnpm -r lint", 18 | "test": "pnpm -r test", 19 | "test:watch": "pnpm -r test:watch", 20 | "typecheck": "pnpm -r typecheck" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "spa", 25 | "cloudflare" 26 | ], 27 | "author": "@codenickycode", 28 | "license": "MIT", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/codenickycode/create-react-spa-cloudflare.git" 32 | }, 33 | "dependencies": { 34 | "hono": "^4.7.8" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.26.0", 38 | "@playwright/test": "^1.52.0", 39 | "@types/eslint": "^9.6.1", 40 | "@types/node": "^22.7.4", 41 | "@typescript-eslint/parser": "^8.32.0", 42 | "eslint": "^9.26.0", 43 | "globals": "^16.1.0", 44 | "prettier": "^3.5.3", 45 | "typescript": "^5.8.3", 46 | "typescript-eslint": "^8.32.0", 47 | "wrangler": "^4.14.3" 48 | }, 49 | "engines": { 50 | "node": ">=22.9.0", 51 | "pnpm": ">=9" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | export default defineConfig({ 14 | testDir: './e2e', 15 | /* Fail the build on CI if you accidentally left test.only in the source code */ 16 | forbidOnly: !!process.env.CI, 17 | fullyParallel: false, // because we are testing a clean db each time 18 | retries: process.env.CI ? 2 : 0, 19 | workers: 1, 20 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 21 | reporter: 'html', 22 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 23 | use: { 24 | /* Base URL to use in actions like `await page.goto('/')`. */ 25 | baseURL: 'http://localhost:4173', 26 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 27 | trace: 'on-first-retry', 28 | }, 29 | 30 | /* Configure projects for major browsers */ 31 | projects: [ 32 | { 33 | name: 'Desktop Chrome', 34 | use: { ...devices['Desktop Chrome'] }, 35 | }, 36 | { 37 | name: 'Mobile Chrome', 38 | use: { ...devices['Pixel 5'] }, 39 | }, 40 | ], 41 | 42 | /* Run your local dev server before starting the tests */ 43 | webServer: { 44 | command: 45 | 'pnpm run server dev:test & pnpm run client build:test && pnpm run client preview', 46 | url: 'http://localhost:4173', 47 | reuseExistingServer: !process.env.CI, 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'client' 3 | - 'e2e' 4 | - 'server' 5 | shared-workspace-lockfile: true 6 | -------------------------------------------------------------------------------- /server/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.yml] 12 | indent_style = space 13 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .wrangler/ 172 | -------------------------------------------------------------------------------- /server/eslint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import rootConfig from '../eslint.config.js'; 5 | import tsEslintParser from '@typescript-eslint/parser'; 6 | 7 | // mimic CommonJS variables -- not needed if using CommonJS 8 | const __filename = fileURLToPath(import.meta.url); 9 | const __dirname = path.dirname(__filename); 10 | 11 | export default [ 12 | ...rootConfig, 13 | { 14 | // Note: there should be no other properties in this object 15 | ignores: ['eslint.config.js'], 16 | }, 17 | { 18 | languageOptions: { 19 | ecmaVersion: 2022, 20 | sourceType: 'module', 21 | parser: tsEslintParser, 22 | parserOptions: { 23 | project: './tsconfig.eslint.json', 24 | tsconfigRootDir: __dirname, 25 | }, 26 | }, 27 | }, 28 | ]; 29 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@create-react-spa-cloudflare/server", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "deploy:stage": "pnpm wrangler deploy --minify src/index.ts --env stage --var GITHUB_REF_NAME:$GITHUB_REF_NAME --var GITHUB_SHA:$GITHUB_SHA", 7 | "deploy:prod": "pnpm wrangler deploy --minify src/index.ts --env prod --var GITHUB_REF_NAME:$GITHUB_REF_NAME --var GITHUB_SHA:$GITHUB_SHA", 8 | "dev": "pnpm wrangler dev --live-reload src/index.ts --env dev", 9 | "dev:test": "pnpm wrangler dev --live-reload src/index.ts --env test --port 8788", 10 | "lint": "eslint .", 11 | "test": "vitest run", 12 | "test:watch": "vitest", 13 | "typecheck": "pnpm exec tsc --build" 14 | }, 15 | "dependencies": { 16 | "@hono/zod-validator": "^0.5.0", 17 | "zod": "^3.24.4" 18 | }, 19 | "devDependencies": { 20 | "@cloudflare/vitest-pool-workers": "^0.8.26", 21 | "@cloudflare/workers-types": "^4.20250507.0", 22 | "vitest": "3.1.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { testClient } from 'hono/testing'; 3 | import app from './index'; 4 | 5 | const mockEnv = { 6 | ALLOWED_HOST: '*', 7 | ENV: 'test', 8 | }; 9 | 10 | describe('GET /', () => { 11 | it('should return status 200', async () => { 12 | const response = await testClient(app, mockEnv).index.$get(); 13 | expect(response.status).toBe(200); 14 | }); 15 | it('should return "ok" text', async () => { 16 | const response = await testClient(app, mockEnv).index.$get(); 17 | expect(await response.text()).toBe('ok'); 18 | }); 19 | }); 20 | 21 | describe('Not Found', () => { 22 | it('should return status 404', async () => { 23 | const response = await app.request('/foo', { method: 'GET' }, mockEnv); 24 | expect(response.status).toBe(404); 25 | }); 26 | it('should return "Not Found" message', async () => { 27 | const response = await app.request('/foo', { method: 'GET' }, mockEnv); 28 | expect(await response.json()).toEqual({ message: 'Not Found' }); 29 | }); 30 | }); 31 | 32 | describe.each(['stage', 'prod'])('when in %s', (ENV) => { 33 | it('should allow specified host, ignoring subdomains (since our preview branches have hash subdomains)', async () => { 34 | const response = await testClient(app, { 35 | ...mockEnv, 36 | ENV, 37 | ALLOWED_HOST: 'foo.com', 38 | }).index.$get(undefined, { 39 | headers: { referer: 'https://lj98w4f.foo.com' }, 40 | }); 41 | expect(response.status).toBe(200); 42 | }); 43 | it('should allow any referer when env.ALLOWED_HOST is "*"', async () => { 44 | const response = await testClient(app, { 45 | ...mockEnv, 46 | ENV, 47 | ALLOWED_HOST: '*', 48 | }).index.$get(undefined, { 49 | headers: { referer: 'https://foo.com' }, 50 | }); 51 | expect(response.status).toBe(200); 52 | }); 53 | it('should not allow any referer when env.ALLOWED_HOST is set', async () => { 54 | const response = await testClient(app, { 55 | ...mockEnv, 56 | ENV, 57 | ALLOWED_HOST: 'bar.com', 58 | }).index.$get(undefined, { 59 | headers: { referer: 'https://foo.com' }, 60 | }); 61 | expect(response.status).toBe(403); 62 | }); 63 | }); 64 | 65 | describe('unsupported method', () => { 66 | it.each(['PUT', 'PATCH', 'DELETE'])( 67 | '%s should return a 404', 68 | async (method) => { 69 | const response = await app.request('/', { method }, mockEnv); 70 | expect(response.status).toBe(404); 71 | }, 72 | ); 73 | it.each(['PUT', 'PATCH', 'DELETE'])( 74 | '%s should return "Not Found"', 75 | async (method) => { 76 | const response = await app.request('/', { method }, mockEnv); 77 | expect(await response.json()).toEqual({ message: 'Not Found' }); 78 | }, 79 | ); 80 | }); 81 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { cors } from 'hono/cors'; 3 | import { HTTPException } from 'hono/http-exception'; 4 | import { testRoute } from './test'; 5 | import type { Env } from './types'; 6 | 7 | const app = new Hono<{ Bindings: Env }>() 8 | .use('*', async (c, next) => { 9 | await next(); 10 | c.res.headers.set('X-Git-Branch', c.env.GITHUB_REF_NAME || ''); 11 | c.res.headers.set('X-Git-Commit', c.env.GITHUB_SHA || ''); 12 | }) 13 | .use('*', async (c, next) => { 14 | const allowedHost = c.env.ALLOWED_HOST; 15 | const origin = 16 | allowedHost === '*' ? '*' : new URL(c.req.header('referer') || '').origin; 17 | if (origin.endsWith(allowedHost)) { 18 | return cors({ 19 | origin, 20 | allowMethods: ['GET', 'POST', 'OPTIONS'], 21 | allowHeaders: ['Content-Type', 'baggage', 'sentry-trace'], 22 | exposeHeaders: ['Content-Type'], 23 | })(c, next); 24 | } 25 | // If referer is not allowed, fail the request 26 | throw new HTTPException(403, { message: 'Forbidden' }); 27 | }) 28 | .get('/', async (c) => c.text('ok', 200)) 29 | .notFound(() => { 30 | throw new HTTPException(404, { message: 'Not Found' }); 31 | }) 32 | .onError((err, c) => { 33 | console.error(err); 34 | if (err instanceof HTTPException) { 35 | return c.json({ message: err.message }, err.status); 36 | } 37 | return c.json({ message: 'Unknown server error', cause: err }, 500); 38 | }) 39 | .route('/test', testRoute); 40 | 41 | export default app; 42 | -------------------------------------------------------------------------------- /server/src/test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono'; 2 | import { HTTPException } from 'hono/http-exception'; 3 | import type { Env } from './types'; 4 | 5 | export const testRoute = new Hono<{ Bindings: Env }>() 6 | .post('/reset', async (c) => { 7 | if (c.env.ENV !== 'test') { 8 | console.warn(`cannot reset db in env: ${c.env.ENV}`); 9 | throw new HTTPException(404, { message: 'Not Found' }); 10 | } 11 | // explicitly use c.env.TEST_DB and not getDb() to be sure we are only 12 | // adjusting the local test db binding 13 | await resetDb(c.env.TEST_DB); 14 | return new Response('ok', { status: 200 }); 15 | }) 16 | .notFound(() => { 17 | throw new HTTPException(404, { message: 'Not Found' }); 18 | }); 19 | 20 | const resetDb = async (testDb: Env['TEST_DB']) => { 21 | await testDb.delete('highScore'); 22 | }; 23 | -------------------------------------------------------------------------------- /server/src/types.shared.ts: -------------------------------------------------------------------------------- 1 | import type app from './index'; 2 | 3 | export type ServerApi = typeof app; 4 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { KVNamespace } from '@cloudflare/workers-types'; 2 | import type { Context as HonoContext } from 'hono'; 3 | 4 | export interface Env { 5 | ALLOWED_HOST: string; 6 | DB: KVNamespace; 7 | LOCAL_DB: KVNamespace; 8 | TEST_DB: KVNamespace; 9 | ENV: 'dev' | 'prod' | 'stage' | 'test'; 10 | GITHUB_REF_NAME: string; 11 | GITHUB_SHA: string; 12 | } 13 | 14 | export type Context = HonoContext<{ 15 | Bindings: Env; 16 | }>; 17 | -------------------------------------------------------------------------------- /server/src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { KVNamespace } from '@cloudflare/workers-types'; 2 | import type { Context } from './types'; 3 | 4 | export const getDb = (c: Context): KVNamespace => { 5 | switch (c.env.ENV) { 6 | case 'dev': 7 | return c.env.LOCAL_DB; 8 | case 'test': 9 | return c.env.TEST_DB; 10 | default: 11 | return c.env.DB; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /server/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "@cloudflare/workers-types", 6 | "@cloudflare/workers-types/experimental", 7 | "@cloudflare/vitest-pool-workers" 8 | ], 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /server/tsconfig.shared.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "types": [ 6 | "@cloudflare/workers-types", 7 | "@cloudflare/workers-types/experimental", 8 | "@cloudflare/vitest-pool-workers" 9 | ], 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true 12 | }, 13 | "include": ["src/types.shared.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; 2 | 3 | export default defineWorkersConfig({ 4 | test: { 5 | poolOptions: { 6 | workers: { 7 | wrangler: { configPath: './wrangler.toml' }, 8 | miniflare: { 9 | kvNamespaces: ['TEST_DB'], 10 | }, 11 | }, 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /server/wrangler.toml: -------------------------------------------------------------------------------- 1 | main = "dist/index.js" 2 | 3 | compatibility_date = "2024-08-26" 4 | compatibility_flags = [ "nodejs_compat" ] 5 | 6 | [env.dev] 7 | vars = { ALLOWED_HOST = "*", ENV = "dev" } 8 | [[env.dev.kv_namespaces]] 9 | binding = "LOCAL_DB" 10 | id = "local-db" 11 | 12 | [env.test] 13 | vars = { ALLOWED_HOST = "*", ENV = "test" } 14 | [[env.test.kv_namespaces]] 15 | binding = "TEST_DB" 16 | id = "test-db" 17 | 18 | [env.stage] 19 | name = "todo-rename-stage" 20 | workers_dev = true 21 | vars = { ALLOWED_HOST = "todo:rename to stage host", ENV = "stage" } 22 | [[env.stage.kv_namespaces]] 23 | binding = "DB" 24 | id = "e0c5eee53ed34ff69c4d8303f818adca" 25 | 26 | [env.prod] 27 | name = "todo-rename-prod" 28 | workers_dev = true 29 | vars = { ALLOWED_HOST = "todo:rename to prod host", ENV = "prod" } 30 | [[env.prod.kv_namespaces]] 31 | binding = "DB" 32 | id = "2431c6957e9e4a7cb4fa61f284793b98" 33 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["eslint.config.js", "playwright.config.ts", "bin"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "lib": ["ES2022"], 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "moduleResolution": "bundler", 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "sourceMap": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true 17 | } 18 | } 19 | --------------------------------------------------------------------------------