├── .editorconfig ├── .env ├── .eslintrc.cjs ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml └── workflows │ ├── conventional-commits.yml │ └── main.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .vscode ├── extensions.json ├── react.code-snippets └── settings.json ├── .yarn ├── releases │ └── yarn-4.1.0.cjs └── sdks │ ├── eslint │ ├── bin │ │ └── eslint.js │ ├── lib │ │ ├── api.js │ │ └── unsupported-api.js │ └── package.json │ ├── integrations.yml │ ├── prettier │ ├── bin │ │ └── prettier.cjs │ ├── index.cjs │ └── package.json │ └── typescript │ ├── bin │ ├── tsc │ └── tsserver │ ├── lib │ ├── tsc.js │ ├── tsserver.js │ ├── tsserverlibrary.js │ └── typescript.js │ └── package.json ├── .yarnrc.yml ├── LICENSE ├── README.md ├── app ├── README.md ├── components │ ├── button-color-scheme.tsx │ ├── button-login.tsx │ ├── button-user-avatar.tsx │ ├── error.tsx │ ├── index.ts │ ├── layout.tsx │ ├── logo.tsx │ ├── navigation.tsx │ ├── sidebar.tsx │ └── toolbar.tsx ├── core │ ├── auth.ts │ ├── example.test.ts │ ├── firebase.ts │ ├── page.ts │ ├── store.ts │ └── theme.ts ├── global.d.ts ├── icons │ ├── anonymous.tsx │ ├── apple.tsx │ ├── facebook.tsx │ ├── google.tsx │ └── index.ts ├── index.html ├── index.tsx ├── package.json ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ └── site.manifest ├── routes │ ├── dashboard.tsx │ ├── index.tsx │ ├── login.tsx │ ├── messages.tsx │ ├── privacy.tsx │ ├── tasks.tsx │ └── terms.tsx ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── db ├── README.md ├── firestore.indexes.json ├── firestore.rules ├── index.ts ├── models │ ├── index.ts │ └── workspace.ts ├── package.json ├── scripts │ └── seed.ts ├── seeds │ ├── 01-users.ts │ └── 02-workspaces.ts └── tsconfig.json ├── edge ├── README.md ├── core │ ├── app.ts │ ├── email.ts │ └── manifest.ts ├── global.d.ts ├── index.ts ├── package.json ├── routes │ ├── api-login.ts │ ├── api-swapi.ts │ ├── assets.ts │ ├── echo.test.ts │ ├── echo.ts │ └── firebase.ts ├── tsconfig.json ├── vite.config.ts └── wrangler.toml ├── package.json ├── scripts ├── bundle-yarn.js ├── package.json ├── post-install.js ├── start.js ├── tsconfig.json ├── utils.js └── wrangler.js ├── server ├── Dockerfile ├── README.md ├── app.test.ts ├── app.ts ├── core │ ├── auth.test.ts │ ├── auth.ts │ ├── env.ts │ ├── firestore.ts │ ├── logging.ts │ ├── openai.ts │ ├── trpc.ts │ └── utils.ts ├── global.d.ts ├── index.ts ├── package.json ├── routes │ ├── index.ts │ ├── workspace.test.ts │ └── workspace.ts ├── start.ts ├── test │ └── context.ts ├── tsconfig.json ├── types.ts └── vite.config.ts ├── tsconfig.base.json ├── tsconfig.json ├── vitest.config.ts ├── vitest.workspace.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in 2 | # this file, please see the EditorConfig documentation: 3 | # https://editorconfig.org/ 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | # https://vitejs.dev/guide/env-and-mode.html#env-files 3 | # 4 | # TIP: Feel free to personalize these settings in your `.env.local` file that 5 | # is not tracked by source control, giving you the liberty to tweak 6 | # settings in your local environment worry-free! Happy coding! 🚀 7 | 8 | # Web application settings 9 | APP_ENV=local 10 | APP_NAME=Acme Co. 11 | APP_HOSTNAME=localhost 12 | APP_ORIGIN=http://localhost:5173 13 | API_ORIGIN=https://api-mcfytwakla-uc.a.run.app 14 | APP_STORAGE_BUCKET=example.com 15 | 16 | # Google Cloud 17 | # https://console.cloud.google.com/ 18 | GOOGLE_CLOUD_PROJECT=kriasoft 19 | GOOGLE_CLOUD_REGION=us-central1 20 | GOOGLE_CLOUD_DATABASE="(default)" 21 | GOOGLE_CLOUD_CREDENTIALS={"type":"service_account","project_id":"example","private_key_id":"xxx","private_key":"-----BEGIN PRIVATE KEY-----\nxxxxx\n-----END PRIVATE KEY-----\n","client_email":"application@exmaple.iam.gserviceaccount.com","client_id":"xxxxx","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/application%40example.iam.gserviceaccount.com"} 22 | 23 | # Firebase 24 | # https://console.firebase.google.com/ 25 | FIREBASE_APP_ID=1:736557952746:web:b5ee23841e24c0b883b193 26 | FIREBASE_API_KEY=AIzaSyAZDmdeRWvlYgZpwm6LBCkYJM6ySIMF2Hw 27 | FIREBASE_AUTH_DOMAIN=kriasoft.web.app 28 | 29 | # OpenAI 30 | # https://platform.openai.com/ 31 | OPENAI_ORGANIZATION=xxxxx 32 | OPENAI_API_KEY=xxxxx 33 | 34 | # Cloudflare 35 | # https://dash.cloudflare.com/ 36 | # https://developers.cloudflare.com/api/tokens/create 37 | CLOUDFLARE_ACCOUNT_ID= 38 | CLOUDFLARE_ZONE_ID= 39 | CLOUDFLARE_API_TOKEN= 40 | 41 | # Google Analytics (v4) 42 | # https://console.firebase.google.com/ 43 | # https://firebase.google.com/docs/analytics/get-started?platform=web 44 | GA_MEASUREMENT_ID=G-XXXXXXXX 45 | 46 | # SendGrid 47 | # https://app.sendgrid.com/settings/api_keys 48 | SENDGRID_API_KEY=xxxxx 49 | FROM_EMAIL=hello@example.com 50 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | /** 5 | * ESLint configuration. 6 | * 7 | * @see https://eslint.org/docs/user-guide/configuring 8 | * @type {import("eslint").Linter.Config} 9 | */ 10 | module.exports = { 11 | root: true, 12 | 13 | env: { 14 | es6: true, 15 | }, 16 | 17 | extends: [ 18 | "eslint:recommended", 19 | "plugin:import/recommended", 20 | "plugin:import/typescript", 21 | "prettier", 22 | ], 23 | 24 | parserOptions: { 25 | ecmaVersion: 2022, 26 | sourceType: "module", 27 | }, 28 | 29 | overrides: [ 30 | { 31 | files: ["*.ts", "*.tsx"], 32 | parser: "@typescript-eslint/parser", 33 | extends: [ 34 | "plugin:@typescript-eslint/recommended", 35 | "plugin:react/recommended", 36 | "plugin:react-hooks/recommended", 37 | ], 38 | rules: { 39 | "react/no-children-prop": "off", 40 | "react/react-in-jsx-scope": "off", 41 | }, 42 | plugins: ["@typescript-eslint"], 43 | parserOptions: { 44 | warnOnUnsupportedTypeScriptVersion: true, 45 | }, 46 | }, 47 | { 48 | files: [".eslintrc.cjs", "*/vite.config.ts", "scripts/**/*.js"], 49 | env: { node: true }, 50 | }, 51 | { 52 | files: ["*.cjs"], 53 | parserOptions: { sourceType: "script" }, 54 | }, 55 | ], 56 | 57 | ignorePatterns: [ 58 | "/.cache", 59 | "/.git", 60 | "/.husky", 61 | "/.yarn", 62 | "/*/dist", 63 | "/app/queries", 64 | ], 65 | 66 | settings: { 67 | "import/resolver": { 68 | typescript: { 69 | project: [ 70 | "app/tsconfig.json", 71 | "edge/tsconfig.json", 72 | "scripts/tsconfig.json", 73 | ], 74 | }, 75 | }, 76 | "import/core-modules": ["__STATIC_CONTENT_MANIFEST"], 77 | react: { 78 | version: "detect", 79 | }, 80 | }, 81 | }; 82 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically normalize line endings for all text-based files 2 | # https://git-scm.com/docs/gitattributes#_end_of_line_conversion 3 | 4 | * text=auto 5 | 6 | # For the following file types, normalize line endings to LF on 7 | # checkin and prevent conversion to CRLF when they are checked out 8 | # (this is required in order to prevent newline related issues like, 9 | # for example, after the build script is run) 10 | 11 | .* text eol=lf 12 | *.css text eol=lf 13 | *.html text eol=lf 14 | *.js text eol=lf 15 | *.json text eol=lf 16 | *.md text eol=lf 17 | *.sh text eol=lf 18 | *.ts text eol=lf 19 | *.txt text eol=lf 20 | *.xml text eol=lf 21 | 22 | /.yarn/** linguist-vendored 23 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to React Starter Kit 2 | 3 | ♥ [React Starter Kit](https://github.com/kriasoft/react-starter-kit) and 4 | want to get involved? Thanks! There are plenty of ways you can help! 5 | 6 | Please take a moment to review this document in order to make the contribution 7 | process easy and effective for everyone involved. 8 | 9 | Following these guidelines helps to communicate that you respect the time of 10 | the developers managing and developing this open source project. In return, 11 | they should reciprocate that respect in addressing your issue or assessing 12 | patches and features. 13 | 14 | ## Using the issue tracker 15 | 16 | The [issue tracker](https://github.com/kriasoft/react-starter-kit/issues) is 17 | the preferred channel for [bug reports](#bugs), [features requests](#features) 18 | and [submitting pull requests](#pull-requests), but please respect the following 19 | restrictions: 20 | 21 | - Please **do not** use the issue tracker for personal support requests (use 22 | [Stack Overflow](https://stackoverflow.com/questions/tagged/react-starter-kit), 23 | [Gitter](https://gitter.im/kriasoft/react-starter-kit), 24 | [HackHands](https://hackhands.com/koistya) or 25 | [Codementor](https://www.codementor.io/koistya)). 26 | 27 | - Please **do not** derail or troll issues. Keep the discussion on topic and 28 | respect the opinions of others. 29 | 30 | 31 | 32 | ## Bug reports 33 | 34 | A bug is a _demonstrable problem_ that is caused by the code in the repository. 35 | Good bug reports are extremely helpful - thank you! 36 | 37 | Guidelines for bug reports: 38 | 39 | 1. **Use the GitHub issue search** — check if the issue has already been 40 | reported. 41 | 42 | 2. **Check if the issue has been fixed** — try to reproduce it using the 43 | latest `main` or development branch in the repository. 44 | 45 | 3. **Isolate the problem** — ideally create fork of this repo with an 46 | example of how to reproduce the problem. 47 | 48 | A good bug report shouldn't leave others needing to chase you up for more 49 | information. Please try to be as detailed as possible in your report. What is 50 | your environment? What steps will reproduce the issue? What would you expect 51 | to be the outcome? All these details will help people to fix any potential bugs. 52 | 53 | Example: 54 | 55 | > Short and descriptive example bug report title 56 | > 57 | > A summary of the issue and the Node.js/OS environment in which it occurs. If 58 | > suitable, include the steps required to reproduce the bug. 59 | > 60 | > 1. This is the first step 61 | > 2. This is the second step 62 | > 3. Further steps, etc. 63 | > 64 | > `` - a link to the reduced test case 65 | > 66 | > Any other information you want to share that is relevant to the issue being 67 | > reported. This might include the lines of code that you have identified as 68 | > causing the bug, and potential solutions (and your opinions on their 69 | > merits). 70 | 71 | 72 | 73 | ## Feature requests 74 | 75 | Feature requests are welcome. But take a moment to find out whether your idea 76 | fits with the scope and aims of the project. It's up to _you_ to make a strong 77 | case to convince the project's developers of the merits of this feature. Please 78 | provide as much detail and context as possible. 79 | 80 | 81 | 82 | ## Pull requests 83 | 84 | Good pull requests - patches, improvements, new features - are a fantastic 85 | help. They should remain focused in scope and avoid containing unrelated 86 | commits. 87 | 88 | **Please ask first** before embarking on any significant pull request (e.g. 89 | implementing features, refactoring code, porting to a different language), 90 | otherwise you risk spending a lot of time working on something that the 91 | project's developers might not want to merge into the project. 92 | 93 | Please adhere to the coding conventions used throughout a project (indentation, 94 | accurate comments, etc.) and any other requirements (such as test coverage). 95 | 96 | Adhering to the following process is the best way to get your work 97 | included in the project: 98 | 99 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your 100 | fork, and configure the remotes: 101 | 102 | ```bash 103 | # Clone your fork of the repo into the current directory 104 | git clone https://github.com//react-starter-kit.git 105 | # Navigate to the newly cloned directory 106 | cd react-starter-kit 107 | # Assign the original repo to a remote called "upstream" 108 | git remote add upstream https://github.com/kriasoft/react-starter-kit.git 109 | ``` 110 | 111 | 2. If you cloned a while ago, get the latest changes from upstream: 112 | 113 | ```bash 114 | git checkout main 115 | git pull upstream main 116 | ``` 117 | 118 | 3. Create a new topic branch (off the main project development branch) to 119 | contain your feature, change, or fix: 120 | 121 | ```bash 122 | git checkout -b 123 | ``` 124 | 125 | 4. Commit your changes in logical chunks. Please adhere to these [git commit 126 | message guidelines](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 127 | or your code is unlikely be merged into the main project. Use Git's 128 | [interactive rebase](https://help.github.com/articles/about-git-rebase/) 129 | feature to tidy up your commits before making them public. 130 | 131 | 5. Locally merge (or rebase) the upstream development branch into your topic branch: 132 | 133 | ```bash 134 | git pull [--rebase] upstream main 135 | ``` 136 | 137 | 6. Push your topic branch up to your fork: 138 | 139 | ```bash 140 | git push origin 141 | ``` 142 | 143 | 7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) 144 | with a clear title and description. 145 | 146 | **IMPORTANT**: By submitting a patch, you agree to allow the project 147 | owners to license your work under the terms of the [MIT License](LICENSE.txt). 148 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kriasoft 4 | -------------------------------------------------------------------------------- /.github/workflows/conventional-commits.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow 2 | # https://help.github.com/actions 3 | # https://www.conventionalcommits.org 4 | 5 | name: "Conventional Commits" 6 | 7 | on: 8 | pull_request_target: 9 | types: 10 | - opened 11 | - edited 12 | - synchronize 13 | 14 | permissions: 15 | pull-requests: read 16 | 17 | jobs: 18 | lint: 19 | name: "Lint PR Title" 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: amannn/action-semantic-pull-request@v5 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow 2 | # https://help.github.com/actions 3 | 4 | name: CI/CD 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | tags: 10 | - "v*" 11 | pull_request: 12 | branches: [main] 13 | schedule: 14 | - cron: "0 7 * * *" 15 | workflow_dispatch: 16 | inputs: 17 | environment: 18 | description: "Environment" 19 | type: environment 20 | default: "test" 21 | required: true 22 | 23 | env: 24 | NODE_VERSION: 20.5.x 25 | VERSION: ${{ github.event.pull_request.number }} 26 | HUSKY: 0 27 | 28 | jobs: 29 | build: 30 | name: "Build" 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | # Configure Node.js and install NPM dependencies 36 | - uses: actions/setup-node@v4 37 | with: { node-version: "${{ env.NODE_VERSION }}", cache: "yarn" } 38 | - run: yarn install 39 | 40 | # Analyze code for potential problems 41 | - run: yarn prettier --check . 42 | if: ${{ github.event_name == 'pull_request' }} 43 | - run: yarn lint 44 | if: ${{ github.event_name == 'pull_request' }} 45 | - run: yarn tsc --build 46 | - run: yarn workspace app test 47 | if: ${{ github.event_name == 'pull_request' }} 48 | # - run: yarn workspace edge test 49 | # if: ${{ github.event_name == 'pull_request' }} 50 | 51 | # Compile and save build artifacts 52 | - run: yarn build 53 | - uses: actions/upload-artifact@v4 54 | with: { name: "build", path: "app/dist\nedge/dist\n" } 55 | 56 | deploy: 57 | name: "Deploy" 58 | runs-on: ubuntu-latest 59 | needs: [build] 60 | environment: 61 | name: ${{ inputs.environment || 'test' }} 62 | url: ${{ inputs.environment == 'prod' && 'https://example.com' || format('https://{0}.example.com', inputs.environment || 'test') }} 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: actions/setup-node@v4 66 | with: { node-version: "${{ env.NODE_VERSION }}", cache: "yarn" } 67 | - run: yarn install 68 | - uses: actions/download-artifact@v4 69 | with: { name: "build" } 70 | - run: yarn workspace edge deploy --env=${{ inputs.environment || 'test' }} 71 | if: ${{ false }} 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Include your project-specific ignores in this file 2 | # Read about how to use .gitignore: https://help.github.com/articles/ignoring-files 3 | 4 | # Compiled output 5 | /*/dist/ 6 | 7 | # Yarn package manager with PnP 8 | # https://yarnpkg.com/getting-started/qa/#which-files-should-be-gitignored 9 | .pnp.* 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/sdks 15 | !.yarn/versions 16 | node_modules 17 | 18 | # Logs 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Cache 23 | /.cache 24 | .eslintcache 25 | 26 | # Testing 27 | /coverage 28 | *.lcov 29 | 30 | # Environment variables 31 | .env.*.local 32 | .env.local 33 | 34 | # Visual Studio Code 35 | # https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore 36 | .vscode/* 37 | !.vscode/settings.json 38 | !.vscode/tasks.json 39 | !.vscode/launch.json 40 | !.vscode/extensions.json 41 | !.vscode/react.code-snippets 42 | 43 | # WebStorm 44 | .idea 45 | 46 | # macOS 47 | # https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 48 | .DS_Store 49 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn prettier --check . 5 | yarn lint 6 | yarn tsc --build 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Compiled & generated output 2 | /*/dist/ 3 | /app/queries/ 4 | 5 | # Cache 6 | /.cache 7 | 8 | # Yarn 9 | /.yarn 10 | /.pnp.* 11 | 12 | # TypeScript 13 | /tsconfig.base.json 14 | 15 | # Misc 16 | /.husky 17 | *.hbs 18 | 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "eamodio.gitlens", 6 | "editorconfig.editorconfig", 7 | "esbenp.prettier-vscode", 8 | "github.copilot", 9 | "github.vscode-github-actions", 10 | "graphql.vscode-graphql", 11 | "mikestead.dotenv", 12 | "streetsidesoftware.code-spell-checker", 13 | "vscode-icons-team.vscode-icons", 14 | "zixuanchen.vitest-explorer" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/react.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "React": { 3 | "scope": "javascriptreact,typescriptreact", 4 | "prefix": "react", 5 | "body": [ 6 | "import { ${2:Box}, ${2}Props } from \"@mui/joy\";", 7 | "", 8 | "export function ${1:${TM_FILENAME_BASE/(^|-)(.)/${2:/upcase}/g}}(props: ${1}Props): JSX.Element {", 9 | " const { sx, ...other } = props;", 10 | "", 11 | " return (", 12 | " <${2} sx={{ ...sx }} {...other}>$0", 13 | " );", 14 | "}", 15 | "", 16 | "export type ${1}Props = Omit<${2}Props, \"children\">;", 17 | "", 18 | ], 19 | "description": "React Component", 20 | }, 21 | "ReactRoute": { 22 | "scope": "javascriptreact,typescriptreact", 23 | "prefix": "route", 24 | "body": [ 25 | "import { ${2:Box}, ${2}Props } from \"@mui/joy\";", 26 | "", 27 | "export const Component = function ${1:${TM_FILENAME_BASE/(^|-)(.)/${2:/upcase}/g}}(): JSX.Element {", 28 | " return (", 29 | " <${2}>$0", 30 | " );", 31 | "}", 32 | "", 33 | ], 34 | "description": "React Route Component", 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "editor.tabSize": 2, 8 | "eslint.nodePath": ".yarn/sdks", 9 | "eslint.runtime": "node", 10 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 11 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 12 | "typescript.enablePromptUseWorkspaceTsdk": true, 13 | "vitest.commandLine": "yarn vitest", 14 | "files.associations": { 15 | ".env.*.local": "properties", 16 | ".env.*": "properties" 17 | }, 18 | "files.exclude": { 19 | "**/.cache": true, 20 | "**/.DS_Store": true, 21 | "**/.editorconfig": true, 22 | "**/.eslintcache": true, 23 | "**/.git": true, 24 | "**/.gitattributes": true, 25 | "**/.husky": true, 26 | "**/.pnp.*": true, 27 | "**/.prettierignore": true, 28 | "**/node_modules": true, 29 | "**/yarn.lock": true 30 | }, 31 | "search.exclude": { 32 | "**/dist/": true, 33 | "**/.pnp.*": true, 34 | "**/.yarn": true, 35 | "**/yarn-error.log": true, 36 | "**/yarn.lock": true 37 | }, 38 | "terminal.integrated.env.linux": { 39 | "CACHE_DIR": "${workspaceFolder}/.cache" 40 | }, 41 | "terminal.integrated.env.osx": { 42 | "CACHE_DIR": "${workspaceFolder}/.cache" 43 | }, 44 | "terminal.integrated.env.windows": { 45 | "CACHE_DIR": "${workspaceFolder}\\.cache" 46 | }, 47 | "cSpell.ignoreWords": [ 48 | "browserslist", 49 | "cloudfunctions", 50 | "corejs", 51 | "corepack", 52 | "endregion", 53 | "entrypoint", 54 | "envalid", 55 | "envars", 56 | "eslintcache", 57 | "esmodules", 58 | "esnext", 59 | "execa", 60 | "firebaseapp", 61 | "firestore", 62 | "globby", 63 | "hono", 64 | "identitytoolkit", 65 | "jamstack", 66 | "kriasoft", 67 | "localforage", 68 | "miniflare", 69 | "nodenext", 70 | "notistack", 71 | "oidc", 72 | "openai", 73 | "pathinfo", 74 | "pino", 75 | "pnpify", 76 | "reactstarter", 77 | "refetch", 78 | "refetchable", 79 | "relyingparty", 80 | "sendgrid", 81 | "signup", 82 | "sourcemap", 83 | "spdx", 84 | "swapi", 85 | "trpc", 86 | "tslib", 87 | "typechecking", 88 | "vite", 89 | "vitest", 90 | "webflow", 91 | "yarnpkg", 92 | "yarnrc" 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/bin/eslint.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/bin/eslint.js your application uses 20 | module.exports = absRequire(`eslint/bin/eslint.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/unsupported-api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint/use-at-your-own-risk 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint/use-at-your-own-risk your application uses 20 | module.exports = absRequire(`eslint/use-at-your-own-risk`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.56.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "eslint": "./bin/eslint.js" 8 | }, 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": "./lib/api.js", 12 | "./use-at-your-own-risk": "./lib/unsupported-api.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/bin/prettier.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier/bin/prettier.cjs 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier/bin/prettier.cjs your application uses 20 | module.exports = absRequire(`prettier/bin/prettier.cjs`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier your application uses 20 | module.exports = absRequire(`prettier`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "3.1.1-sdk", 4 | "main": "./index.cjs", 5 | "type": "commonjs", 6 | "bin": "./bin/prettier.cjs" 7 | } 8 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserver.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserver.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // 2021-10-08: VSCode changed the format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | // 2022-04-06: VSCode changed the format in 1.66. 69 | // Before | ^/zip//c:/foo/bar.zip/package.json 70 | // After | ^/zip/c:/foo/bar.zip/package.json 71 | // 72 | // 2022-05-06: VSCode changed the format in 1.68 73 | // Before | ^/zip/c:/foo/bar.zip/package.json 74 | // After | ^/zip//c:/foo/bar.zip/package.json 75 | // 76 | case `vscode <1.61`: { 77 | str = `^zip:${str}`; 78 | } break; 79 | 80 | case `vscode <1.66`: { 81 | str = `^/zip/${str}`; 82 | } break; 83 | 84 | case `vscode <1.68`: { 85 | str = `^/zip${str}`; 86 | } break; 87 | 88 | case `vscode`: { 89 | str = `^/zip/${str}`; 90 | } break; 91 | 92 | // To make "go to definition" work, 93 | // We have to resolve the actual file system path from virtual path 94 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 95 | case `coc-nvim`: { 96 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 97 | str = resolve(`zipfile:${str}`); 98 | } break; 99 | 100 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 101 | // We have to resolve the actual file system path from virtual path, 102 | // everything else is up to neovim 103 | case `neovim`: { 104 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 105 | str = `zipfile://${str}`; 106 | } break; 107 | 108 | default: { 109 | str = `zip:${str}`; 110 | } break; 111 | } 112 | } else { 113 | str = str.replace(/^\/?/, process.platform === `win32` ? `` : `/`); 114 | } 115 | } 116 | 117 | return str; 118 | } 119 | 120 | function fromEditorPath(str) { 121 | switch (hostInfo) { 122 | case `coc-nvim`: { 123 | str = str.replace(/\.zip::/, `.zip/`); 124 | // The path for coc-nvim is in format of //zipfile://.yarn/... 125 | // So in order to convert it back, we use .* to match all the thing 126 | // before `zipfile:` 127 | return process.platform === `win32` 128 | ? str.replace(/^.*zipfile:\//, ``) 129 | : str.replace(/^.*zipfile:/, ``); 130 | } break; 131 | 132 | case `neovim`: { 133 | str = str.replace(/\.zip::/, `.zip/`); 134 | // The path for neovim is in format of zipfile:////.yarn/... 135 | return str.replace(/^zipfile:\/\//, ``); 136 | } break; 137 | 138 | case `vscode`: 139 | default: { 140 | return str.replace(/^\^?(zip:|\/zip(\/ts-nul-authority)?)\/+/, process.platform === `win32` ? `` : `/`) 141 | } break; 142 | } 143 | } 144 | 145 | // Force enable 'allowLocalPluginLoads' 146 | // TypeScript tries to resolve plugins using a path relative to itself 147 | // which doesn't work when using the global cache 148 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 149 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 150 | // TypeScript already does local loads and if this code is running the user trusts the workspace 151 | // https://github.com/microsoft/vscode/issues/45856 152 | const ConfiguredProject = tsserver.server.ConfiguredProject; 153 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 154 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 155 | this.projectService.allowLocalPluginLoads = true; 156 | return originalEnablePluginsWithOptions.apply(this, arguments); 157 | }; 158 | 159 | // And here is the point where we hijack the VSCode <-> TS communications 160 | // by adding ourselves in the middle. We locate everything that looks 161 | // like an absolute path of ours and normalize it. 162 | 163 | const Session = tsserver.server.Session; 164 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 165 | let hostInfo = `unknown`; 166 | 167 | Object.assign(Session.prototype, { 168 | onMessage(/** @type {string | object} */ message) { 169 | const isStringMessage = typeof message === 'string'; 170 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 171 | 172 | if ( 173 | parsedMessage != null && 174 | typeof parsedMessage === `object` && 175 | parsedMessage.arguments && 176 | typeof parsedMessage.arguments.hostInfo === `string` 177 | ) { 178 | hostInfo = parsedMessage.arguments.hostInfo; 179 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK) { 180 | const [, major, minor] = (process.env.VSCODE_IPC_HOOK.match( 181 | // The RegExp from https://semver.org/ but without the caret at the start 182 | /(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/ 183 | ) ?? []).map(Number) 184 | 185 | if (major === 1) { 186 | if (minor < 61) { 187 | hostInfo += ` <1.61`; 188 | } else if (minor < 66) { 189 | hostInfo += ` <1.66`; 190 | } else if (minor < 68) { 191 | hostInfo += ` <1.68`; 192 | } 193 | } 194 | } 195 | } 196 | 197 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 198 | return typeof value === 'string' ? fromEditorPath(value) : value; 199 | }); 200 | 201 | return originalOnMessage.call( 202 | this, 203 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 204 | ); 205 | }, 206 | 207 | send(/** @type {any} */ msg) { 208 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 209 | return typeof value === `string` ? toEditorPath(value) : value; 210 | }))); 211 | } 212 | }); 213 | 214 | return tsserver; 215 | }; 216 | 217 | if (existsSync(absPnpApiPath)) { 218 | if (!process.versions.pnp) { 219 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 220 | require(absPnpApiPath).setup(); 221 | } 222 | } 223 | 224 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 225 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 226 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = createRequire(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript your application uses 20 | module.exports = absRequire(`typescript`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.3.3-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "tsc": "./bin/tsc", 8 | "tsserver": "./bin/tsserver" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | compressionLevel: mixed 2 | 3 | enableGlobalCache: true 4 | 5 | nodeLinker: pnp 6 | 7 | packageExtensions: 8 | local-pkg@*: 9 | dependencies: 10 | happy-dom: ^13.3.8 11 | 12 | yarnPath: .yarn/releases/yarn-4.1.0.cjs 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-present Konstantin Tarkus, Kriasoft (hello@kriasoft.com) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Starter Kit 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | The web's most popular Jamstack front-end template for building web applications with 10 | [React](https://react.dev/). 11 | 12 | ## Features 13 | 14 | - Optimized for serverless deployment to CDN edge locations (Cloudflare Workers) 15 | - HTML page rendering (SSR) at CDN edge locations, all ~100 points on Lighthouse 16 | - Hot module replacement during local development using React Refetch 17 | - Pre-configured with CSS-in-JS styling using Emotion.js 18 | - Pre-configured with code quality tools: ESLint, Prettier, TypeScript, Vitest, etc. 19 | - Pre-configured with VSCode code snippets and other VSCode settings 20 | - The ongoing design and development is supported by these wonderful companies: 21 | 22 |      23 | 24 | --- 25 | 26 | This project was bootstrapped with [React Starter Kit](https://github.com/kriasoft/react-starter-kit). 27 | Be sure to join our [Discord channel](https://discord.com/invite/2nKEnKq) for assistance. 28 | 29 | ## Directory Structure 30 | 31 | `├──`[`.github`](.github) — GitHub configuration including CI/CD workflows
32 | `├──`[`.vscode`](.vscode) — VSCode settings including code snippets, recommended extensions etc.
33 | `├──`[`app`](./app) — Web application front-end built with [React](https://react.dev/) and [Joy UI](https://mui.com/joy-ui/getting-started/)
34 | `├──`[`db`](./db) — Firestore database schema, seed data, and admin tools
35 | `├──`[`edge`](./edge) — Cloudflare Workers (CDN) edge endpoint
36 | `├──`[`env`](./env) — Application settings, API keys, etc.
37 | `├──`[`scripts`](./scripts) — Automation scripts such as `yarn deploy`
38 | `├──`[`server`](./server) — Node.js application server built with [tRPC](https://trpc.io/)
39 | `├──`[`tsconfig.base.json`](./tsconfig.base.json) — The common/shared TypeScript configuration
40 | `└──`[`tsconfig.json`](./tsconfig.json) — The root TypeScript configuration
41 | 42 | ## Tech Stack 43 | 44 | - [React](https://react.dev/), [React Router](https://reactrouter.com/), [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/), [Joy UI](https://mui.com/joy-ui/getting-started/), [Firebase Authentication](https://firebase.google.com/docs/auth) 45 | - [Cloudflare Workers](https://workers.cloudflare.com/), [Vite](https://vitejs.dev/), [Vitest](https://vitejs.dev/), 46 | [TypeScript](https://www.typescriptlang.org/), [ESLint](https://eslint.org/), [Prettier](https://prettier.io/), [Yarn](https://yarnpkg.com/) with PnP 47 | 48 | ## Requirements 49 | 50 | - [Node.js](https://nodejs.org/) v18+ with [Corepack](https://nodejs.org/api/corepack.html) (`$ corepack enable`) 51 | - [VS Code](https://code.visualstudio.com/) editor with [recommended extensions](.vscode/extensions.json) 52 | - Optionally [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) 53 | and [Reactime](https://chrome.google.com/webstore/detail/reactime/cgibknllccemdnfhfpmjhffpjfeidjga?hl=en) browser extensions 54 | 55 | ## Getting Started 56 | 57 | [Generate](https://github.com/kriasoft/react-starter-kit/generate) a new project 58 | from this template, clone it, install project dependencies, update the 59 | environment variables found in [`env/*.env`](./env/), and start hacking: 60 | 61 | ``` 62 | $ git clone https://github.com/kriasoft/react-starter-kit.git example 63 | $ cd ./example 64 | $ corepack enable 65 | $ yarn install 66 | $ yarn workspace app start 67 | ``` 68 | 69 | The app will become available at [http://localhost:5173/](http://localhost:5173/) (press `q` + `Enter` to exit). 70 | 71 | **IMPORTANT**: Ensure that VSCode is using the workspace [version of TypeScript](https://code.visualstudio.com/docs/typescript/typescript-compiling#_using-newer-typescript-versions) 72 | and ESLint. 73 | 74 | ![](https://files.tarkus.me/typescript-workspace.png) 75 | 76 | ## Scripts 77 | 78 | - `yarn start` — Launches the app in development mode on [`http://localhost:5173/`](http://localhost:5173/) 79 | - `yarn build` — Compiles and bundles the app for deployment 80 | - `yarn lint` — Validate the code using ESLint 81 | - `yarn tsc` — Validate the code using TypeScript compiler 82 | - `yarn test` — Run unit tests with Vitest, Supertest 83 | - `yarn edge deploy` — Deploys the app to Cloudflare 84 | 85 | ## How to Deploy 86 | 87 | Ensure that all the environment variables for the target deployment environment 88 | (`test`, `prod`) found in [`/env/*.env`](./env/) files are up-to-date. 89 | 90 | If you haven't done it already, push any secret values you may need to CF Workers 91 | environment by running `yarn workspace edge wrangler secret put [--env #0]`. 92 | 93 | Finally build and deploy the app by running: 94 | 95 | ``` 96 | $ yarn build 97 | $ yarn deploy [--env #0] [--version #0] 98 | ``` 99 | 100 | Where `--env` argument is the target deployment area, e.g. `yarn deploy --env=prod`. 101 | 102 | ## How to Update 103 | 104 | - `yarn set version latest` — Bump Yarn to the latest version 105 | - `yarn upgrade-interactive` — Update Node.js modules (dependencies) 106 | - `yarn dlx @yarnpkg/sdks vscode` — Update TypeScript, ESLint, and Prettier settings in VSCode 107 | 108 | ## Contributors 👨‍💻 109 | 110 |                111 | 112 | ## Backers 💰 113 | 114 |                115 | 116 | ## Related Projects 117 | 118 | - [GraphQL API and Relay Starter Kit](https://github.com/kriasoft/graphql-starter) — monorepo template, pre-configured with GraphQL API, React, and Relay 119 | - [Cloudflare Workers Starter Kit](https://github.com/kriasoft/cloudflare-starter-kit) — TypeScript project template for Cloudflare Workers 120 | - [Node.js API Starter Kit](https://github.com/kriasoft/node-starter-kit) — project template, pre-configured with Node.js, GraphQL, and PostgreSQL 121 | 122 | ## How to Contribute 123 | 124 | Anyone and everyone is welcome to [contribute](.github/CONTRIBUTING.md). Start 125 | by checking out the list of [open issues](https://github.com/kriasoft/react-starter-kit/issues) 126 | marked [help wanted](https://github.com/kriasoft/react-starter-kit/issues?q=label:"help+wanted"). 127 | However, if you decide to get involved, please take a moment to review the 128 | [guidelines](.github/CONTRIBUTING.md). 129 | 130 | ## License 131 | 132 | Copyright © 2014-present Kriasoft. This source code is licensed under the MIT license found in the 133 | [LICENSE](https://github.com/kriasoft/react-starter-kit/blob/main/LICENSE) file. 134 | 135 | --- 136 | 137 | Made with ♥ by Konstantin Tarkus ([@koistya](https://twitter.com/koistya), [blog](https://medium.com/@koistya)) 138 | and [contributors](https://github.com/kriasoft/react-starter-kit/graphs/contributors). 139 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # Web Application (front-end) 2 | 3 | ## Directory Structure 4 | 5 | `├──`[`components`](./components) — UI elements
6 | `├──`[`core`](./core) — Core modules, React hooks, customized theme, etc.
7 | `├──`[`icons`](./icons) — Custom icon React components
8 | `├──`[`public`](./public) — Static assets such as robots.txt, index.html etc.
9 | `├──`[`routes`](./routes) — Application routes and page (screen) components
10 | `├──`[`global.d.ts`](./global.d.ts) — Global TypeScript declarations
11 | `├──`[`index.html`](./index.html) — HTML page containing application entry point
12 | `├──`[`index.tsx`](./index.tsx) — Single-page application (SPA) entry point
13 | `├──`[`package.json`](./package.json) — Workspace settings and NPM dependencies
14 | `├──`[`tsconfig.ts`](./tsconfig.json) — TypeScript configuration
15 | `└──`[`vite.config.ts`](./vite.config.ts) — JavaScript bundler configuration ([docs](https://vitejs.dev/config/))
16 | 17 | ## Getting Started 18 | 19 | ``` 20 | $ yarn workspace app start 21 | ``` 22 | 23 | ## Scripts 24 | 25 | - `start [--force]` — Launch the app in development mode 26 | - `build` — Build the app for production 27 | - `preview` — Preview the production build 28 | - `test` — Run unit tests 29 | - `coverage` — Run unit tests with enabled coverage report 30 | - `deploy [--env #0]` — Deploy the app to Cloudflare (CDN) 31 | 32 | ## References 33 | 34 | - https://react.dev/ — React.js documentation 35 | - https://mui.com/joy-ui/getting-started/ — Joy UI documentation 36 | - https://www.typescriptlang.org/ — TypeScript reference 37 | - https://vitejs.dev/ — Front-end tooling (bundler) 38 | - https://vitest.dev/ — Unit test framework 39 | -------------------------------------------------------------------------------- /app/components/button-color-scheme.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { DarkModeRounded, LightModeRounded } from "@mui/icons-material"; 5 | import { 6 | Dropdown, 7 | IconButton, 8 | IconButtonProps, 9 | ListItemContent, 10 | ListItemDecorator, 11 | Menu, 12 | MenuButton, 13 | MenuItem, 14 | useColorScheme, 15 | } from "@mui/joy"; 16 | import { memo } from "react"; 17 | 18 | export function ColorSchemeButton(props: ColorSchemeButtonProps): JSX.Element { 19 | const { mode, systemMode } = useColorScheme(); 20 | 21 | return ( 22 | 23 | 24 | {mode === "light" || (mode === "system" && systemMode === "light") ? ( 25 | 26 | ) : ( 27 | 28 | )} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | const ModeMenuItem = memo(function ModeMenuItem({ 41 | mode, 42 | }: ModeMenuItemProps): JSX.Element { 43 | const scheme = useColorScheme(); 44 | 45 | return ( 46 | { 48 | scheme.setMode(mode); 49 | }} 50 | selected={scheme.mode === mode} 51 | > 52 | 53 | {mode === "light" || 54 | (mode !== "dark" && scheme.systemMode === "light") ? ( 55 | 56 | ) : ( 57 | 58 | )} 59 | 60 | 61 | {mode === "light" 62 | ? "Light theme" 63 | : mode === "dark" 64 | ? "Dark theme" 65 | : "Device default"} 66 | 67 | 68 | ); 69 | }); 70 | 71 | type ColorSchemeButtonProps = Omit; 72 | type ModeMenuItemProps = { mode: "dark" | "light" | "system" }; 73 | -------------------------------------------------------------------------------- /app/components/button-login.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Button, ButtonProps } from "@mui/joy"; 5 | import { SignInMethod, useSignIn } from "../core/auth"; 6 | import { AnonymousIcon, GoogleIcon } from "../icons"; 7 | 8 | export function LoginButton(props: LoginButtonProps): JSX.Element { 9 | const { signInMethod, ...other } = props; 10 | const [signIn, inFlight] = useSignIn(signInMethod); 11 | 12 | const icon = 13 | signInMethod === "google.com" ? ( 14 | 15 | ) : signInMethod === "anonymous" ? ( 16 | 17 | ) : null; 18 | 19 | return ( 20 | 62 | )} 63 | 64 | ); 65 | } 66 | 67 | type ToolbarProps = Omit, "children">; 68 | -------------------------------------------------------------------------------- /app/core/auth.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { 5 | GoogleAuthProvider, 6 | User, 7 | UserCredential, 8 | getAuth, 9 | signInAnonymously, 10 | signInWithPopup, 11 | } from "firebase/auth"; 12 | import { atom, useAtomValue } from "jotai"; 13 | import { loadable } from "jotai/utils"; 14 | import { useCallback, useState } from "react"; 15 | import { useNavigate } from "react-router-dom"; 16 | import { app, auth } from "./firebase"; 17 | import { store } from "./store"; 18 | 19 | export const currentUser = atom | User | null>( 20 | new Promise(() => {}), 21 | ); 22 | 23 | currentUser.debugLabel = "currentUser"; 24 | 25 | const unsubscribe = auth.onAuthStateChanged((user) => { 26 | store.set(currentUser, user); 27 | }); 28 | 29 | if (import.meta.hot) { 30 | import.meta.hot.dispose(() => unsubscribe()); 31 | } 32 | 33 | export function useCurrentUser() { 34 | return useAtomValue(currentUser); 35 | } 36 | 37 | export const currentUserLoadable = loadable(currentUser); 38 | 39 | export function useCurrentUserLoadable() { 40 | return useAtomValue(currentUserLoadable); 41 | } 42 | 43 | export function useSignIn( 44 | signInMethod: SignInMethod, 45 | ): [signIn: () => void, inFlight: boolean] { 46 | const navigate = useNavigate(); 47 | const [inFlight, setInFlight] = useState(false); 48 | 49 | const signIn = useCallback(() => { 50 | let p: Promise | null = null; 51 | 52 | if (signInMethod === "anonymous") { 53 | const auth = getAuth(app); 54 | p = signInAnonymously(auth); 55 | } 56 | 57 | if (signInMethod === "google.com") { 58 | const auth = getAuth(app); 59 | const provider = new GoogleAuthProvider(); 60 | provider.addScope("profile"); 61 | provider.addScope("email"); 62 | provider.setCustomParameters({ 63 | // login_hint: ... 64 | prompt: "consent", 65 | }); 66 | p = signInWithPopup(auth, provider); 67 | } 68 | 69 | if (!p) throw new Error(`Not supported: ${signInMethod}`); 70 | 71 | setInFlight(true); 72 | p.then(() => navigate("/")).finally(() => setInFlight(false)); 73 | }, [signInMethod, navigate]); 74 | 75 | return [signIn, inFlight] as const; 76 | } 77 | 78 | export type SignInMethod = "google.com" | "anonymous"; 79 | -------------------------------------------------------------------------------- /app/core/example.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { expect, test } from "vitest"; 5 | 6 | test("example", () => { 7 | expect({ pass: true }).toMatchInlineSnapshot(` 8 | { 9 | "pass": true, 10 | } 11 | `); 12 | }); 13 | -------------------------------------------------------------------------------- /app/core/firebase.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { getAnalytics } from "firebase/analytics"; 5 | import { initializeApp } from "firebase/app"; 6 | import { getAuth } from "firebase/auth"; 7 | 8 | export const app = initializeApp({ 9 | projectId: import.meta.env.VITE_GOOGLE_CLOUD_PROJECT, 10 | appId: import.meta.env.VITE_FIREBASE_APP_ID, 11 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 12 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 13 | measurementId: import.meta.env.VITE_GA_MEASUREMENT_ID, 14 | }); 15 | 16 | export const auth = getAuth(app); 17 | export const analytics = getAnalytics(app); 18 | -------------------------------------------------------------------------------- /app/core/page.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { getAnalytics, logEvent } from "firebase/analytics"; 5 | import * as React from "react"; 6 | import { useLocation } from "react-router-dom"; 7 | 8 | const appName = import.meta.env.VITE_APP_NAME; 9 | 10 | export function usePageEffect( 11 | options?: Options, 12 | deps: React.DependencyList = [], 13 | ) { 14 | const location = useLocation(); 15 | 16 | // Once the page component was rendered, update the HTML document's title 17 | React.useEffect(() => { 18 | const previousTitle = document.title; 19 | 20 | document.title = 21 | location.pathname === "/" 22 | ? options?.title ?? appName 23 | : options?.title 24 | ? `${options.title} - ${appName}` 25 | : appName; 26 | 27 | return function () { 28 | document.title = previousTitle; 29 | }; 30 | }, [ 31 | ...deps /* eslint-disable-line react-hooks/exhaustive-deps */, 32 | location, 33 | options?.title, 34 | ]); 35 | 36 | // Send "page view" event to Google Analytics 37 | // https://support.google.com/analytics/answer/11403294?hl=en 38 | React.useEffect(() => { 39 | if (!(options?.trackPageView === false)) { 40 | logEvent(getAnalytics(), "page_view", { 41 | page_title: options?.title ?? appName, 42 | page_path: `${location.pathname}${location.search}`, 43 | }); 44 | } 45 | }, [location, options?.title, options?.trackPageView]); 46 | } 47 | 48 | type Options = { 49 | title?: string; 50 | /** @default true */ 51 | trackPageView?: boolean; 52 | }; 53 | -------------------------------------------------------------------------------- /app/core/store.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { createStore, Provider } from "jotai"; 5 | import { createElement, ReactNode } from "react"; 6 | 7 | /** 8 | * Global state management powered by Jotai. 9 | * @see https://jotai.org/ 10 | */ 11 | export const store = createStore(); 12 | 13 | export function StoreProvider(props: StoreProviderProps): JSX.Element { 14 | return createElement(Provider, { store, ...props }); 15 | } 16 | 17 | export type StoreProviderProps = { 18 | children: ReactNode; 19 | }; 20 | -------------------------------------------------------------------------------- /app/core/theme.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { extendTheme, ThemeProvider as Provider } from "@mui/joy/styles"; 5 | import { createElement, ReactNode } from "react"; 6 | 7 | /** 8 | * Customized Joy UI theme. 9 | * @see https://mui.com/joy-ui/customization/approaches/ 10 | */ 11 | export const theme = extendTheme({ 12 | colorSchemes: { 13 | light: {}, 14 | dark: {}, 15 | }, 16 | shadow: {}, 17 | typography: {}, 18 | components: {}, 19 | }); 20 | 21 | export function ThemeProvider(props: ThemeProviderProps): JSX.Element { 22 | return createElement(Provider, { theme, ...props }); 23 | } 24 | 25 | export type ThemeProviderProps = { 26 | children: ReactNode; 27 | }; 28 | -------------------------------------------------------------------------------- /app/global.d.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import * as React from "react"; 5 | import "vite/client"; 6 | 7 | interface Window { 8 | dataLayer: unknown[]; 9 | } 10 | 11 | interface ImportMetaEnv { 12 | readonly VITE_APP_ENV: string; 13 | readonly VITE_APP_NAME: string; 14 | readonly VITE_APP_ORIGIN: string; 15 | readonly VITE_GOOGLE_CLOUD_PROJECT: string; 16 | readonly VITE_FIREBASE_APP_ID: string; 17 | readonly VITE_FIREBASE_API_KEY: string; 18 | readonly VITE_FIREBASE_AUTH_DOMAIN: string; 19 | readonly VITE_GA_MEASUREMENT_ID: string; 20 | } 21 | 22 | declare module "relay-runtime" { 23 | interface PayloadError { 24 | errors?: Record; 25 | } 26 | } 27 | 28 | declare module "*.css"; 29 | 30 | declare module "*.svg" { 31 | const content: React.FC>; 32 | export default content; 33 | } 34 | -------------------------------------------------------------------------------- /app/icons/anonymous.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { SvgIcon, SvgIconProps } from "@mui/joy"; 5 | 6 | export function AnonymousIcon(props: AnonymousIconProps): JSX.Element { 7 | return ( 8 | 9 | Anonymous 10 | 21 | 32 | 41 | 42 | ); 43 | } 44 | 45 | export type AnonymousIconProps = Omit; 46 | -------------------------------------------------------------------------------- /app/icons/apple.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { SvgIcon, SvgIconProps } from "@mui/joy"; 5 | 6 | export function AppleIcon(props: AppleIconProps): JSX.Element { 7 | return ( 8 | 9 | Apple 10 | 14 | 15 | ); 16 | } 17 | 18 | export type AppleIconProps = Omit; 19 | -------------------------------------------------------------------------------- /app/icons/facebook.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { SvgIcon, SvgIconProps } from "@mui/joy"; 5 | 6 | export function FacebookIcon(props: FacebookIconProps): JSX.Element { 7 | return ( 8 | 9 | Facebook 10 | 11 | 12 | ); 13 | } 14 | 15 | export type FacebookIconProps = Omit; 16 | -------------------------------------------------------------------------------- /app/icons/google.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { SvgIcon, SvgIconProps } from "@mui/joy"; 5 | 6 | export function GoogleIcon(props: GoogleIconProps): JSX.Element { 7 | return ( 8 | 9 | Google 10 | 11 | 15 | 19 | 23 | 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | export type GoogleIconProps = Omit; 34 | -------------------------------------------------------------------------------- /app/icons/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | export * from "./anonymous"; 5 | export * from "./apple"; 6 | export * from "./facebook"; 7 | export * from "./google"; 8 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %VITE_APP_NAME% 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { CssBaseline, CssVarsProvider } from "@mui/joy"; 5 | import { SnackbarProvider } from "notistack"; 6 | import { StrictMode } from "react"; 7 | import { createRoot } from "react-dom/client"; 8 | import { StoreProvider } from "./core/store"; 9 | import { theme } from "./core/theme"; 10 | import { Router } from "./routes/index"; 11 | 12 | const container = document.getElementById("root"); 13 | const root = createRoot(container!); 14 | 15 | root.render( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | , 26 | ); 27 | 28 | if (import.meta.hot) { 29 | import.meta.hot.dispose(() => root.unmount()); 30 | } 31 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite serve", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "test": "vitest", 11 | "coverage": "vitest --coverage", 12 | "deploy": "yarn workspace edge deploy", 13 | "app:start": "yarn workspace app start", 14 | "app:build": "yarn workspace app build", 15 | "app:preview": "yarn workspace app preview", 16 | "app:deploy": "yarn workspace app deploy" 17 | }, 18 | "dependencies": { 19 | "@babel/runtime": "^7.23.9", 20 | "@emotion/react": "^11.11.3", 21 | "@emotion/styled": "^11.11.0", 22 | "@mui/base": "^5.0.0-beta.36", 23 | "@mui/icons-material": "^5.15.10", 24 | "@mui/joy": "^5.0.0-beta.28", 25 | "@mui/lab": "^5.0.0-alpha.165", 26 | "@mui/material": "^5.15.10", 27 | "firebase": "^10.8.0", 28 | "jotai": "^2.6.4", 29 | "jotai-effect": "^0.5.0", 30 | "localforage": "^1.10.0", 31 | "notistack": "^3.0.1", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-router-dom": "^6.22.0" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.23.9", 38 | "@emotion/babel-plugin": "^11.11.0", 39 | "@types/node": "^20.11.18", 40 | "@types/react": "^18.2.55", 41 | "@types/react-dom": "^18.2.19", 42 | "@vitejs/plugin-react": "^4.2.1", 43 | "envars": "^1.0.2", 44 | "happy-dom": "^13.3.8", 45 | "typescript": "~5.3.3", 46 | "vite": "~5.1.2", 47 | "vitest": "~1.2.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/react-starter-kit/95c3e61325cc4b1fc8acd23be81590d346dbb4d2/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/react-starter-kit/95c3e61325cc4b1fc8acd23be81590d346dbb4d2/app/public/logo192.png -------------------------------------------------------------------------------- /app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/react-starter-kit/95c3e61325cc4b1fc8acd23be81590d346dbb4d2/app/public/logo512.png -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # www.robotstxt.org/ 2 | 3 | # Allow crawling of all content 4 | User-agent: * 5 | Disallow: 6 | -------------------------------------------------------------------------------- /app/public/site.manifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": "/?utm_source=homescreen", 22 | "display": "standalone", 23 | "background_color": "#fafafa", 24 | "theme_color": "#fafafa" 25 | } 26 | -------------------------------------------------------------------------------- /app/routes/dashboard.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Box, Card, CardContent, Container, Typography } from "@mui/joy"; 5 | import { usePageEffect } from "../core/page"; 6 | 7 | export const Component = function Dashboard(): JSX.Element { 8 | usePageEffect({ title: "Dashboard" }); 9 | 10 | return ( 11 | 12 | 13 | Dashboard 14 | 15 | 16 | 23 | 24 | 25 | Card title 26 | Card content 27 | 28 | 29 | 30 | 31 | 32 | Card title 33 | Card content 34 | 35 | 36 | 37 | 38 | 39 | Card title 40 | Card content 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { createElement } from "react"; 5 | import { 6 | createBrowserRouter, 7 | Navigate, 8 | RouterProvider, 9 | } from "react-router-dom"; 10 | import { BaseLayout, MainLayout, RootError } from "../components"; 11 | 12 | /** 13 | * Application routes 14 | * https://reactrouter.com/en/main/routers/create-browser-router 15 | */ 16 | export const router = createBrowserRouter([ 17 | { 18 | path: "", 19 | element: , 20 | errorElement: , 21 | children: [ 22 | { path: "login", lazy: () => import("./login") }, 23 | { path: "privacy", lazy: () => import("./privacy") }, 24 | { path: "terms", lazy: () => import("./terms") }, 25 | ], 26 | }, 27 | { 28 | path: "", 29 | element: , 30 | errorElement: , 31 | children: [ 32 | { index: true, element: }, 33 | { path: "dashboard", lazy: () => import("./dashboard") }, 34 | { path: "tasks", lazy: () => import("./tasks") }, 35 | { path: "messages", lazy: () => import("./messages") }, 36 | ], 37 | }, 38 | ]); 39 | 40 | export function Router(): JSX.Element { 41 | return createElement(RouterProvider, { router }); 42 | } 43 | 44 | // Clean up on module reload (HMR) 45 | // https://vitejs.dev/guide/api-hmr 46 | if (import.meta.hot) { 47 | import.meta.hot.dispose(() => router.dispose()); 48 | } 49 | -------------------------------------------------------------------------------- /app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Container, ContainerProps, Typography } from "@mui/joy"; 5 | import { LoginButton } from "../components"; 6 | 7 | export const Component = function Login(): JSX.Element { 8 | return ( 9 | 20 | 21 | Sign In 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export type LoginProps = Omit; 31 | -------------------------------------------------------------------------------- /app/routes/messages.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Container, Typography } from "@mui/joy"; 5 | import { usePageEffect } from "../core/page"; 6 | 7 | export const Component = function Messages(): JSX.Element { 8 | usePageEffect({ title: "Messages" }); 9 | 10 | return ( 11 | 12 | 13 | Messages 14 | 15 | Coming soon... 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/routes/privacy.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Container, Link, Typography } from "@mui/joy"; 5 | import { usePageEffect } from "../core/page"; 6 | 7 | const appName = import.meta.env.VITE_APP_NAME; 8 | const appOrigin = import.meta.env.VITE_APP_ORIGIN; 9 | const email = `hello@${import.meta.env.VITE_APP_HOSTNAME}`; 10 | 11 | /** 12 | * Generated by https://getterms.io 13 | */ 14 | export const Component = function Privacy(): JSX.Element { 15 | usePageEffect({ title: "Privacy Policy" }); 16 | 17 | return ( 18 | 19 | 20 | Privacy Policy 21 | 22 | 23 | Your privacy is important to us. It is {appName}' policy to respect 24 | your privacy and comply with any applicable law and regulation regarding 25 | any personal information we may collect about you, including across our 26 | website, {appOrigin}, and other sites we 27 | own and operate. 28 | 29 | 30 | This policy is effective as of 28 February 2021 and was last updated on 31 | 28 February 2021. 32 | 33 | 34 | Information We Collect 35 | 36 | 37 | Information we collect includes both information you knowingly and 38 | actively provide us when using or participating in any of our services 39 | and promotions, and any information automatically sent by your devices 40 | in the course of accessing our products and services. 41 | 42 | 43 | Log Data 44 | 45 | 46 | When you visit our website, our servers may automatically log the 47 | standard data provided by your web browser. It may include your device’s 48 | Internet Protocol (IP) address, your browser type and version, the pages 49 | you visit, the time and date of your visit, the time spent on each page, 50 | other details about your visit, and technical details that occur in 51 | conjunction with any errors you may encounter. 52 | 53 | 54 | Please be aware that while this information may not be personally 55 | identifying by itself, it may be possible to combine it with other data 56 | to personally identify individual persons. 57 | 58 | 59 | Personal Information 60 | 61 | 62 | We may ask for personal information which may include one or more of the 63 | following: 64 | 65 |
    66 |
  • Name
  • 67 |
  • Email
  • 68 |
  • Social media profiles
  • 69 |
70 | 71 | Legitimate Reasons for Processing Your Personal Information 72 | 73 | 74 | We only collect and use your personal information when we have a 75 | legitimate reason for doing so. In which instance, we only collect 76 | personal information that is reasonably necessary to provide our 77 | services to you. 78 | 79 | 80 | Collection and Use of Information 81 | 82 | 83 | We may collect personal information from you when you do any of the 84 | following on our website: 85 | 86 |
    87 |
  • 88 | Sign up to receive updates from us via email or social media channels 89 |
  • 90 |
  • Use a mobile device or web browser to access our content
  • 91 |
  • 92 | Contact us via email, social media, or on any similar technologies 93 |
  • 94 |
  • When you mention us on social media
  • 95 |
96 | 97 | We may collect, hold, use, and disclose information for the following 98 | purposes, and personal information will not be further processed in a 99 | manner that is incompatible with these purposes: 100 | 101 | 102 | We may collect, hold, use, and disclose information for the following 103 | purposes, and personal information will not be further processed in a 104 | manner that is incompatible with these purposes: 105 | 106 |
    107 |
  • 108 | to enable you to customize or personalize your experience of our 109 | website 110 |
  • 111 |
  • to contact and communicate with you
  • 112 |
  • 113 | for analytics, market research, and business development, including to 114 | operate and improve our website, associated applications, and 115 | associated social media platforms 116 |
  • 117 |
  • 118 | for advertising and marketing, including to send you promotional 119 | information about our products and services and information about 120 | third parties that we consider may be of interest to you 121 |
  • 122 |
  • 123 | to enable you to access and use our website, associated applications, 124 | and associated social media platforms 125 |
  • 126 |
  • for internal record keeping and administrative purposes
  • 127 |
128 | 129 | Please be aware that we may combine information we collect about you 130 | with general information or research data we receive from other trusted 131 | sources. 132 | 133 | 134 | Security of Your Personal Information 135 | 136 | 137 | When we collect and process personal information, and while we retain 138 | this information, we will protect it within commercially acceptable 139 | means to prevent loss and theft, as well as unauthorized access, 140 | disclosure, copying, use, or modification. 141 | 142 | 143 | Although we will do our best to protect the personal information you 144 | provide to us, we advise that no method of electronic transmission or 145 | storage is 100% secure, and no one can guarantee absolute data security. 146 | We will comply with laws applicable to us in respect of any data breach. 147 | 148 | 149 | You are responsible for selecting any password and its overall security 150 | strength, ensuring the security of your own information within the 151 | bounds of our services. 152 | 153 | 154 | How Long We Keep Your Personal Information 155 | 156 | 157 | We keep your personal information only for as long as we need to. This 158 | time period may depend on what we are using your information for, in 159 | accordance with this privacy policy. If your personal information is no 160 | longer required, we will delete it or make it anonymous by removing all 161 | details that identify you. 162 | 163 | 164 | However, if necessary, we may retain your personal information for our 165 | compliance with a legal, accounting, or reporting obligation or for 166 | archiving purposes in the public interest, scientific, or historical 167 | research purposes or statistical purposes. 168 | 169 | 170 | Children’s Privacy 171 | 172 | 173 | We do not aim any of our products or services directly at children under 174 | the age of 13, and we do not knowingly collect personal information 175 | about children under 13. 176 | 177 | 178 | Disclosure of Personal Information to Third Parties 179 | 180 | 181 | We may disclose personal information to:{" "} 182 | 183 |
    184 |
  • a parent, subsidiary, or affiliate of our company
  • 185 |
  • 186 | third party service providers for the purpose of enabling them to 187 | provide their services, for example, IT service providers, data 188 | storage, hosting and server providers, advertisers, or analytics 189 | platforms 190 |
  • 191 |
  • our employees, contractors, and/or related entities
  • 192 |
  • our existing or potential agents or business partners
  • 193 |
  • 194 | sponsors or promoters of any competition, sweepstakes, or promotion we 195 | run 196 |
  • 197 |
  • 198 | courts, tribunals, regulatory authorities, and law enforcement 199 | officers, as required by law, in connection with any actual or 200 | prospective legal proceedings, or in order to establish, exercise, or 201 | defend our legal rights 202 |
  • 203 |
  • 204 | third parties, including agents or sub-contractors, who assist us in 205 | providing information, products, services, or direct marketing to you 206 | third parties to collect and process data 207 |
  • 208 |
209 | 210 | International Transfers of Personal Information 211 | 212 | 213 | The personal information we collect is stored and/or processed where we 214 | or our partners, affiliates, and third-party providers maintain 215 | facilities. Please be aware that the locations to which we store, 216 | process, or transfer your personal information may not have the same 217 | data protection laws as the country in which you initially provided the 218 | information. If we transfer your personal information to third parties 219 | in other countries: (i) we will perform those transfers in accordance 220 | with the requirements of applicable law; and (ii) we will protect the 221 | transferred personal information in accordance with this privacy policy. 222 | 223 | 224 | Your Rights and Controlling Your Personal Information 225 | 226 | 227 | You always retain the right to withhold personal information from us, 228 | with the understanding that your experience of our website may be 229 | affected. We will not discriminate against you for exercising any of 230 | your rights over your personal information. If you do provide us with 231 | personal information you understand that we will collect, hold, use and 232 | disclose it in accordance with this privacy policy. You retain the right 233 | to request details of any personal information we hold about you. 234 | 235 | 236 | If we receive personal information about you from a third party, we will 237 | protect it as set out in this privacy policy. If you are a third party 238 | providing personal information about somebody else, you represent and 239 | warrant that you have such person’s consent to provide the personal 240 | information to us. 241 | 242 | 243 | If you have previously agreed to us using your personal information for 244 | direct marketing purposes, you may change your mind at any time. We will 245 | provide you with the ability to unsubscribe from our email-database or 246 | opt out of communications. Please be aware we may need to request 247 | specific information from you to help us confirm your identity. 248 | 249 | 250 | If you believe that any information we hold about you is inaccurate, out 251 | of date, incomplete, irrelevant, or misleading, please contact us using 252 | the details provided in this privacy policy. We will take reasonable 253 | steps to correct any information found to be inaccurate, incomplete, 254 | misleading, or out of date. 255 | 256 | 257 | If you believe that we have breached a relevant data protection law and 258 | wish to make a complaint, please contact us using the details below and 259 | provide us with full details of the alleged breach. We will promptly 260 | investigate your complaint and respond to you, in writing, setting out 261 | the outcome of our investigation and the steps we will take to deal with 262 | your complaint. You also have the right to contact a regulatory body or 263 | data protection authority in relation to your complaint. 264 | 265 | 266 | Use of Cookies 267 | 268 | 269 | We use “cookies” to collect information about you and your 270 | activity across our site. A cookie is a small piece of data that our 271 | website stores on your computer, and accesses each time you visit, so we 272 | can understand how you use our site. This helps us serve you content 273 | based on preferences you have specified. 274 | 275 | 276 | Limits of Our Policy 277 | 278 | 279 | Our website may link to external sites that are not operated by us. 280 | Please be aware that we have no control over the content and policies of 281 | those sites, and cannot accept responsibility or liability for their 282 | respective privacy practices. 283 | 284 | 285 | Changes to This Policy 286 | 287 | 288 | At our discretion, we may change our privacy policy to reflect updates 289 | to our business processes, current acceptable practices, or legislative 290 | or regulatory changes. If we decide to change this privacy policy, we 291 | will post the changes here at the same link by which you are accessing 292 | this privacy policy. 293 | 294 | 295 | If required by law, we will get your permission or give you the 296 | opportunity to opt in to or opt out of, as applicable, any new uses of 297 | your personal information. 298 | 299 | 300 | Contact Us 301 | 302 | 303 | For any questions or concerns regarding your privacy, you may contact us 304 | using the following details: 305 | 306 | 307 | {appName} Support Team ({email}) 308 | 309 |
310 | ); 311 | }; 312 | -------------------------------------------------------------------------------- /app/routes/tasks.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Container, Typography } from "@mui/joy"; 5 | import { usePageEffect } from "../core/page"; 6 | 7 | export const Component = function Tasks(): JSX.Element { 8 | usePageEffect({ title: "Tasks" }); 9 | 10 | return ( 11 | 12 | 13 | Tasks 14 | 15 | Coming soon... 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/routes/terms.tsx: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Container, Link, Typography } from "@mui/joy"; 5 | import { usePageEffect } from "../core/page"; 6 | 7 | const appName = import.meta.env.VITE_APP_NAME; 8 | const appOrigin = import.meta.env.VITE_APP_ORIGIN; 9 | 10 | /** 11 | * Generated by https://getterms.io 12 | */ 13 | export const Component = function Terms(): JSX.Element { 14 | usePageEffect({ title: "Terms of Use" }); 15 | 16 | return ( 17 | x.spacing(3), marginBottom: (x) => x.spacing(3) }} 20 | > 21 | 22 | Terms of Service 23 | 24 | 25 | These Terms of Service govern your use of the website located at{" "} 26 | {appOrigin} and any related services 27 | provided by {appName}. 28 | 29 | 30 | By accessing {appOrigin}, you agree to 31 | abide by these Terms of Service and to comply with all applicable laws 32 | and regulations. If you do not agree with these Terms of Service, you 33 | are prohibited from using or accessing this website or using any other 34 | services provided by {appName}. 35 | 36 | 37 | We, {appName}, reserve the right to review and amend any of these Terms 38 | of Service at our sole discretion. Upon doing so, we will update this 39 | page. Any changes to these Terms of Service will take effect immediately 40 | from the date of publication. 41 | 42 | 43 | These Terms of Service were last updated on 28 February 2021. 44 | 45 | 46 | Limitations of Use 47 | 48 | 49 | By using this website, you warrant on behalf of yourself, your users, 50 | and other parties you represent that you will not: 51 | 52 |
    53 |
  1. 54 | modify, copy, prepare derivative works of, decompile, or reverse 55 | engineer any materials and software contained on this website; 56 |
  2. 57 |
  3. 58 | remove any copyright or other proprietary notations from any materials 59 | and software on this website; 60 |
  4. 61 |
  5. 62 | transfer the materials to another person or “mirror” the materials on 63 | any other server; 64 |
  6. 65 |
  7. 66 | knowingly or negligently use this website or any of its associated 67 | services in a way that abuses or disrupts our networks or any other 68 | service {appName} provides; 69 |
  8. 70 |
  9. 71 | use this website or its associated services to transmit or publish any 72 | harassing, indecent, obscene, fraudulent, or unlawful material; 73 |
  10. 74 |
  11. 75 | use this website or its associated services in violation of any 76 | applicable laws or regulations; 77 |
  12. 78 |
  13. 79 | use this website in conjunction with sending unauthorized advertising 80 | or spam; 81 |
  14. 82 |
  15. 83 | harvest, collect, or gather user data without the user’s consent; or 84 |
  16. 85 |
  17. 86 | use this website or its associated services in such a way that may 87 | infringe the privacy, intellectual property rights, or other rights of 88 | third parties. 89 |
  18. 90 |
91 | 92 | Intellectual Property 93 | 94 | 95 | The intellectual property in the materials contained in this website are 96 | owned by or licensed to {appName} and are protected by applicable 97 | copyright and trademark law. We grant our users permission to download 98 | one copy of the materials for personal, non-commercial transitory use. 99 | 100 | 101 | This constitutes the grant of a license, not a transfer of title. This 102 | license shall automatically terminate if you violate any of these 103 | restrictions or the Terms of Service, and may be terminated by {appName} 104 | at any time. 105 | 106 | 107 | Liability 108 | 109 | 110 | Our website and the materials on our website are provided on an 111 | ‘as is’ basis. To the extent permitted by law, {appName}{" "} 112 | makes no warranties, expressed or implied, and hereby disclaims and 113 | negates all other warranties including, without limitation, implied 114 | warranties or conditions of merchantability, fitness for a particular 115 | purpose, or non-infringement of intellectual property, or other 116 | violation of rights. 117 | 118 | 119 | In no event shall {appName} or its suppliers be liable for any 120 | consequential loss suffered or incurred by you or any third party 121 | arising from the use or inability to use this website or the materials 122 | on this website, even if {appName} or an authorized representative has 123 | been notified, orally or in writing, of the possibility of such damage. 124 | 125 | 126 | In the context of this agreement, “consequential loss” 127 | includes any consequential loss, indirect loss, real or anticipated loss 128 | of profit, loss of benefit, loss of revenue, loss of business, loss of 129 | goodwill, loss of opportunity, loss of savings, loss of reputation, loss 130 | of use and/or loss or corruption of data, whether under statute, 131 | contract, equity, tort (including negligence), indemnity, or otherwise. 132 | 133 | 134 | Because some jurisdictions do not allow limitations on implied 135 | warranties, or limitations of liability for consequential or incidental 136 | damages, these limitations may not apply to you. 137 | 138 | 139 | Accuracy of Materials 140 | 141 | 142 | The materials appearing on our website are not comprehensive and are for 143 | general information purposes only. {appName} does not warrant or make 144 | any representations concerning the accuracy, likely results, or 145 | reliability of the use of the materials on this website, or otherwise 146 | relating to such materials or on any resources linked to this website. 147 | 148 | 149 | Links 150 | 151 | 152 | {appName} has not reviewed all of the sites linked to its website and is 153 | not responsible for the contents of any such linked site. The inclusion 154 | of any link does not imply endorsement, approval, or control by 155 | {appName} of the site. Use of any such linked site is at your own risk 156 | and we strongly advise you make your own investigations with respect to 157 | the suitability of those sites. 158 | 159 | 160 | Right to Terminate 161 | 162 | 163 | We may suspend or terminate your right to use our website and terminate 164 | these Terms of Service immediately upon written notice to you for any 165 | breach of these Terms of Service. 166 | 167 | 168 | Severance 169 | 170 | 171 | Any term of these Terms of Service which is wholly or partially void or 172 | unenforceable is severed to the extent that it is void or unenforceable. 173 | The validity of the remainder of these Terms of Service is not affected. 174 | 175 | 176 | Governing Law 177 | 178 | 179 | These Terms of Service are governed by and construed in accordance with 180 | the laws of United States. You irrevocably submit to the exclusive 181 | jurisdiction of the courts in that State or location. 182 | 183 |
184 | ); 185 | }; 186 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "jsx": "react-jsx", 6 | "jsxImportSource": "@emotion/react", 7 | "types": ["vite/client"], 8 | "outDir": "../.cache/typescript-app", 9 | "module": "ESNext", 10 | "moduleResolution": "Bundler", 11 | "noEmit": true 12 | }, 13 | "include": ["**/*.ts", "**/*.d.ts", "**/*.tsx", "**/*.json"], 14 | "exclude": ["dist/**/*", "vite.config.ts"], 15 | "references": [{ "path": "./tsconfig.node.json" }] 16 | } 17 | -------------------------------------------------------------------------------- /app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "moduleResolution": "Node", 6 | "types": ["vite/client"], 7 | "allowSyntheticDefaultImports": true, 8 | "outDir": "../.cache/typescript-app", 9 | "emitDeclarationOnly": true 10 | }, 11 | "include": ["vite.config.ts", "core/config.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /app/vite.config.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import react from "@vitejs/plugin-react"; 5 | import { URL, fileURLToPath } from "node:url"; 6 | import { loadEnv } from "vite"; 7 | import { defineProject } from "vitest/config"; 8 | 9 | const publicEnvVars = [ 10 | "APP_ENV", 11 | "APP_NAME", 12 | "APP_ORIGIN", 13 | "GOOGLE_CLOUD_PROJECT", 14 | "FIREBASE_APP_ID", 15 | "FIREBASE_API_KEY", 16 | "FIREBASE_AUTH_DOMAIN", 17 | "GA_MEASUREMENT_ID", 18 | ]; 19 | 20 | /** 21 | * Vite configuration. 22 | * https://vitejs.dev/config/ 23 | */ 24 | export default defineProject(async ({ mode }) => { 25 | const envDir = fileURLToPath(new URL("..", import.meta.url)); 26 | const env = loadEnv(mode, envDir, ""); 27 | 28 | publicEnvVars.forEach((key) => { 29 | if (!env[key]) throw new Error(`Missing environment variable: ${key}`); 30 | process.env[`VITE_${key}`] = env[key]; 31 | }); 32 | 33 | return { 34 | cacheDir: fileURLToPath(new URL("../.cache/vite-app", import.meta.url)), 35 | 36 | build: { 37 | rollupOptions: { 38 | output: { 39 | manualChunks: { 40 | firebase: ["firebase/analytics", "firebase/app", "firebase/auth"], 41 | react: ["react", "react-dom", "react-router-dom"], 42 | }, 43 | }, 44 | }, 45 | }, 46 | 47 | plugins: [ 48 | // The default Vite plugin for React projects 49 | // https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md 50 | react({ 51 | jsxImportSource: "@emotion/react", 52 | babel: { 53 | plugins: ["@emotion/babel-plugin"], 54 | }, 55 | }), 56 | ], 57 | 58 | server: { 59 | proxy: { 60 | "/api": { 61 | target: process.env.LOCAL_API_ORIGIN ?? process.env.API_ORIGIN, 62 | changeOrigin: true, 63 | }, 64 | }, 65 | }, 66 | 67 | test: { 68 | ...{ cache: { dir: "../.cache/vitest" } }, 69 | environment: "happy-dom", 70 | }, 71 | }; 72 | }); 73 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # Firestore Database 2 | 3 | Database schema, security rules, indexes, and seed data for the [Firestore](https://cloud.google.com/firestore) database. 4 | 5 | ## Directory Structure 6 | 7 | - [`/models`](./models/) — Database schema definitions using [Zod](https://zod.dev/). 8 | - [`/seeds`](./seeds/) — Sample / reference data for the database. 9 | - [`/scripts`](./scripts/) — Scripts for managing the database. 10 | - [`/firestore.indexes.json`](./firestore.indexes.json) — Firestore indexes. 11 | - [`/firestore.rules`](./firestore.rules) — Firestore security rules. 12 | 13 | ## Scripts 14 | 15 | - `yarn workspace db seed` - Seed the database with data from [`/seeds`](./seeds/). 16 | 17 | ## References 18 | 19 | - https://zod.dev/ 20 | - https://cloud.google.com/firestore 21 | -------------------------------------------------------------------------------- /db/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "workspace", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { "fieldPath": "ownerId", "order": "ASCENDING" }, 8 | { "fieldPath": "archived", "order": "DESCENDING" } 9 | ] 10 | } 11 | ], 12 | "fieldOverrides": [] 13 | } 14 | -------------------------------------------------------------------------------- /db/firestore.rules: -------------------------------------------------------------------------------- 1 | // Firestore security rules. 2 | // https://cloud.google.com/firestore/docs/security/get-started 3 | 4 | rules_version = '2'; 5 | 6 | service cloud.firestore { 7 | match /databases/{database}/documents { 8 | match /workspace/{id} { 9 | allow read: if request.auth != null && ( 10 | resource.data.ownerId = request.auth.uid || 11 | request.auth.token.admin == true 12 | ); 13 | } 14 | 15 | match /{document=**} { 16 | allow read, write: if false; 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /db/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | export * from "./models"; 5 | export { testUsers } from "./seeds/01-users"; 6 | export { testWorkspaces } from "./seeds/02-workspaces"; 7 | -------------------------------------------------------------------------------- /db/models/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | export * from "./workspace"; 5 | -------------------------------------------------------------------------------- /db/models/workspace.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Timestamp } from "@google-cloud/firestore"; 5 | import { z } from "zod"; 6 | 7 | export const WorkspaceSchema = z.object({ 8 | name: z.string().max(100), 9 | ownerId: z.string().max(50), 10 | created: z.instanceof(Timestamp), 11 | updated: z.instanceof(Timestamp), 12 | archived: z.instanceof(Timestamp).nullable(), 13 | }); 14 | 15 | export type Workspace = z.output; 16 | export type WorkspaceInput = z.input; 17 | -------------------------------------------------------------------------------- /db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "default": "./index.ts" 9 | }, 10 | "./package.json": "./package.json" 11 | }, 12 | "scripts": { 13 | "seed": "vite-node ./scripts/seed.ts", 14 | "test": "vitest" 15 | }, 16 | "dependencies": { 17 | "@google-cloud/firestore": "^7.3.0", 18 | "@googleapis/identitytoolkit": "^8.0.1", 19 | "zod": "^3.22.4" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20.11.18", 23 | "dotenv": "^16.4.4", 24 | "ora": "^8.0.1", 25 | "typescript": "~5.3.3", 26 | "vite": "~5.1.2", 27 | "vite-node": "~1.2.2", 28 | "vitest": "~1.2.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /db/scripts/seed.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Firestore } from "@google-cloud/firestore"; 5 | import { configDotenv } from "dotenv"; 6 | import { relative, resolve } from "node:path"; 7 | import { oraPromise } from "ora"; 8 | 9 | const rootDir = resolve(__dirname, "../.."); 10 | 11 | // Load environment variables from .env files. 12 | configDotenv({ path: resolve(rootDir, ".env.local") }); 13 | configDotenv({ path: resolve(rootDir, ".env") }); 14 | 15 | let db: Firestore | null = null; 16 | 17 | // Seed the database with test / sample data. 18 | try { 19 | db = new Firestore({ 20 | projectId: process.env.GOOGLE_CLOUD_PROJECT, 21 | databaseId: process.env.GOOGLE_CLOUD_DATABASE, 22 | }); 23 | 24 | // Import all seed modules from the `/seeds` folder. 25 | const files = import.meta.glob("../seeds/*.ts"); 26 | 27 | // Sequentially seed the database with data from each module. 28 | for (const [path, load] of Object.entries(files)) { 29 | const message = `Seeding ${relative("../seeds", path)}`; 30 | const action = (async () => { 31 | const { seed } = await load(); 32 | await seed(db); 33 | })(); 34 | 35 | await oraPromise(action, message); 36 | } 37 | } finally { 38 | await db?.terminate(); 39 | } 40 | 41 | type SeedModule = { 42 | seed: (db: Firestore) => Promise; 43 | }; 44 | -------------------------------------------------------------------------------- /db/seeds/01-users.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { 5 | AuthPlus, 6 | identitytoolkit, 7 | identitytoolkit_v3, 8 | } from "@googleapis/identitytoolkit"; 9 | 10 | /** 11 | * Test user accounts generated by https://randomuser.me/. 12 | */ 13 | export const testUsers: identitytoolkit_v3.Schema$UserInfo[] = [ 14 | { 15 | localId: "test-erika", 16 | screenName: "erika", 17 | email: "erika.pearson@example.com", 18 | emailVerified: true, 19 | phoneNumber: "+14788078434", 20 | displayName: "Erika Pearson", 21 | photoUrl: "https://randomuser.me/api/portraits/women/29.jpg", 22 | rawPassword: "paloma", 23 | createdAt: new Date("2024-01-01T12:00:00Z").getTime().toString(), 24 | lastLoginAt: new Date("2024-01-01T12:00:00Z").getTime().toString(), 25 | }, 26 | { 27 | localId: "test-ryan", 28 | screenName: "ryan", 29 | email: "ryan.hunt@example.com", 30 | emailVerified: true, 31 | phoneNumber: "+16814758216", 32 | displayName: "Ryan Hunt", 33 | photoUrl: "https://randomuser.me/api/portraits/men/20.jpg", 34 | rawPassword: "baggins", 35 | createdAt: new Date("2024-01-02T12:00:00Z").getTime().toString(), 36 | lastLoginAt: new Date("2024-01-02T12:00:00Z").getTime().toString(), 37 | }, 38 | { 39 | localId: "test-marian", 40 | screenName: "marian", 41 | email: "marian.stone@example.com", 42 | emailVerified: true, 43 | phoneNumber: "+19243007975", 44 | displayName: "Marian Stone", 45 | photoUrl: "https://randomuser.me/api/portraits/women/2.jpg", 46 | rawPassword: "winter1", 47 | createdAt: new Date("2024-01-03T12:00:00Z").getTime().toString(), 48 | lastLoginAt: new Date("2024-01-03T12:00:00Z").getTime().toString(), 49 | }, 50 | { 51 | localId: "test-kurt", 52 | screenName: "kurt", 53 | email: "kurt.howward@example.com", 54 | emailVerified: true, 55 | phoneNumber: "+19243007975", 56 | displayName: "Kurt Howard", 57 | photoUrl: "https://randomuser.me/api/portraits/men/23.jpg", 58 | rawPassword: "mayday", 59 | createdAt: new Date("2024-01-04T12:00:00Z").getTime().toString(), 60 | lastLoginAt: new Date("2024-01-04T12:00:00Z").getTime().toString(), 61 | }, 62 | { 63 | localId: "test-dan", 64 | screenName: "dan", 65 | email: "dan.day@example.com", 66 | emailVerified: true, 67 | phoneNumber: "+12046748092", 68 | displayName: "Dan Day", 69 | photoUrl: "https://randomuser.me/api/portraits/men/65.jpg", 70 | rawPassword: "teresa", 71 | createdAt: new Date("2024-01-05T12:00:00Z").getTime().toString(), 72 | lastLoginAt: new Date("2024-01-05T12:00:00Z").getTime().toString(), 73 | customAttributes: JSON.stringify({ admin: true }), 74 | }, 75 | ]; 76 | 77 | /** 78 | * Seeds the Google Identity Platform (Firebase Auth) with test user accounts. 79 | * 80 | * @see https://randomuser.me/ 81 | * @see https://cloud.google.com/identity-platform 82 | */ 83 | export async function seed() { 84 | const auth = new AuthPlus(); 85 | const { relyingparty } = identitytoolkit({ version: "v3", auth }); 86 | await relyingparty.uploadAccount({ requestBody: { users: testUsers } }); 87 | } 88 | -------------------------------------------------------------------------------- /db/seeds/02-workspaces.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Firestore, Timestamp } from "@google-cloud/firestore"; 5 | import { WorkspaceInput } from "../models"; 6 | import { testUsers as users } from "./01-users"; 7 | 8 | /** 9 | * Test workspaces. 10 | */ 11 | export const testWorkspaces: (WorkspaceInput & { id: string })[] = [ 12 | { 13 | id: "DwYchGFGpk", 14 | ownerId: users[0].localId!, 15 | name: "Personal workspace", 16 | created: Timestamp.fromDate(new Date(+users[0].createdAt!)), 17 | updated: Timestamp.fromDate(new Date(+users[0].createdAt!)), 18 | archived: null, 19 | }, 20 | { 21 | id: "YfYKTcO9q9", 22 | ownerId: users[1].localId!, 23 | name: "Personal workspace", 24 | created: Timestamp.fromDate(new Date(+users[1].createdAt!)), 25 | updated: Timestamp.fromDate(new Date(+users[1].createdAt!)), 26 | archived: null, 27 | }, 28 | { 29 | id: "c2OsmUvFMY", 30 | ownerId: users[2].localId!, 31 | name: "Personal workspace", 32 | created: Timestamp.fromDate(new Date(+users[2].createdAt!)), 33 | updated: Timestamp.fromDate(new Date(+users[2].createdAt!)), 34 | archived: null, 35 | }, 36 | { 37 | id: "uTqcGw4qn7", 38 | ownerId: users[3].localId!, 39 | name: "Personal workspace", 40 | created: Timestamp.fromDate(new Date(+users[3].createdAt!)), 41 | updated: Timestamp.fromDate(new Date(+users[3].createdAt!)), 42 | archived: null, 43 | }, 44 | { 45 | id: "vBHHgg5ydn", 46 | ownerId: users[4].localId!, 47 | name: "Personal workspace", 48 | created: Timestamp.fromDate(new Date(+users[4].createdAt!)), 49 | updated: Timestamp.fromDate(new Date(+users[4].createdAt!)), 50 | archived: null, 51 | }, 52 | ]; 53 | 54 | export async function seed(db: Firestore) { 55 | const batch = db.batch(); 56 | 57 | for (const { id, ...workspace } of testWorkspaces) { 58 | const ref = db.doc(`workspace/${id}`); 59 | batch.set(ref, workspace, { merge: true }); 60 | } 61 | 62 | await batch.commit(); 63 | } 64 | -------------------------------------------------------------------------------- /db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "emitDeclarationOnly": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "noEmit": false, 9 | "outDir": "../.cache/ts-db", 10 | "types": ["node", "vite/client"] 11 | }, 12 | "include": ["**/*.ts", "**/*.json"], 13 | "exclude": ["**/dist/**/*", "**/node_modules/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /edge/README.md: -------------------------------------------------------------------------------- 1 | # CDN edge endpoint 2 | 3 | CDN edge endpoint powered by [Cloudflare Workers](https://workers.cloudflare.com/) that serves the front-end app. 4 | 5 | ## Directory Structure 6 | 7 | `├──`[`core`](./core) — Core application modules
8 | `├──`[`routes`](./routes) — API routes (endpoints)
9 | `├──`[`global.d.ts`](./global.d.ts) — Global TypeScript declarations
10 | `├──`[`index.ts`](./index.tsx) — Cloudflare Worker entry point
11 | `├──`[`package.json`](./package.json) — The list of dependencies
12 | `├──`[`tsconfig.ts`](./tsconfig.json) — TypeScript configuration ([docs](https://www.typescriptlang.org/tsconfig))
13 | `├──`[`vite.config.ts`](./vite.config.ts) — JavaScript bundler configuration ([docs](https://vitejs.dev/config/))
14 | `└──`[`wrangler.toml`](./wrangler.toml) — Wrangler CLI configuration ([docs](https://developers.cloudflare.com/workers/wrangler/configuration/))
15 | 16 | ## Getting Started 17 | 18 | Test the app locally using [Vitest](https://vitejs.dev/): 19 | 20 | ``` 21 | $ yarn workspace edge test 22 | ``` 23 | 24 | Build and deploy the app by running: 25 | 26 | ``` 27 | $ yarn workspace app build 28 | $ yarn workspace edge build 29 | $ yarn workspace edge deploy [--env #0] 30 | ``` 31 | 32 | Start a session to livestream logs from a deployed Worker: 33 | 34 | ``` 35 | $ yarn workspace edge wrangler tail [--env #0] 36 | ``` 37 | 38 | Where `--env` is one of the supported environments, such as `--env=prod`, `--env=test` (default). 39 | 40 | ## Scripts 41 | 42 | - `build` — Build the app for production 43 | - `test` — Run unit tests 44 | - `coverage` — Run unit tests with enabled coverage report 45 | - `deploy [--env #0]` — Deploy the app to Cloudflare (CDN) 46 | - `wrangler [--env #0]` — Wrangler CLI (wrapper) 47 | 48 | ## References 49 | 50 | - https://hono.dev/ — JavaScript framework for CDN edge endpoints 51 | - https://developers.cloudflare.com/workers/ — Cloudflare Workers docs 52 | - https://www.typescriptlang.org/ — TypeScript reference 53 | - https://vitejs.dev/ — Front-end tooling (bundler) 54 | - https://vitest.dev/ — Unit test framework 55 | -------------------------------------------------------------------------------- /edge/core/app.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Hono } from "hono"; 5 | 6 | /** 7 | * Application router for Cloudflare Workers 8 | * @see https://honojs.dev/ 9 | */ 10 | export const app = new Hono(); 11 | 12 | app.onError((err, ctx) => { 13 | console.error(err.stack); 14 | return ctx.text(err.stack ?? "Application error", 500, { 15 | "Content-Type": "text/plain", 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /edge/core/email.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | /** 5 | * Sends an email message using SendGrid API. 6 | * 7 | * @example 8 | * sendEmail({ 9 | * to: [{ email: "user@example.com" }], 10 | * // https://mc.sendgrid.com/dynamic-templates 11 | * templateId: "d-12345678501234537890143456789511", 12 | * templateData: { name: "John" }, 13 | * waitUntil: ctx.executionCtx.waitUntil, 14 | * env: ctx.env, 15 | * }); 16 | * 17 | * @see https://docs.sendgrid.com/api-reference/mail-send/mail-send 18 | */ 19 | export function sendEmail(options: Options) { 20 | const { env, to, subject, templateData, templateId, waitUntil } = options; 21 | 22 | const inFlight = (async function send() { 23 | const data = { 24 | personalizations: [{ to, dynamic_template_data: templateData }], 25 | from: { email: env.FROM_EMAIL, name: env.APP_NAME }, 26 | template_id: templateId, 27 | subject, 28 | }; 29 | 30 | const req = new Request("https://api.sendgrid.com/v3/mail/send", { 31 | method: "POST", 32 | headers: { 33 | ["Authorization"]: `Bearer ${env.SENDGRID_API_KEY}`, 34 | ["Content-Type"]: "application/json", 35 | }, 36 | body: JSON.stringify(data), 37 | }); 38 | 39 | const res = await fetch(req, options.req); 40 | 41 | if (!res.ok) { 42 | const body = await res.json().catch(() => undefined); 43 | console.error({ 44 | req: { url: req.url, method: req.method }, 45 | res: { 46 | status: res.status, 47 | statusText: res.statusText, 48 | errors: (body as ErrorResponse)?.errors, 49 | }, 50 | }); 51 | 52 | throw new Error("Failed to send an email message."); 53 | } 54 | })(); 55 | 56 | waitUntil?.(inFlight); 57 | 58 | return inFlight; 59 | } 60 | 61 | // #region TypeScript types 62 | 63 | type Options = { 64 | to: [{ email: string; name?: string }]; 65 | subject?: string; 66 | templateId: string; 67 | templateData: Record; 68 | env: { 69 | SENDGRID_API_KEY: string; 70 | FROM_EMAIL: string; 71 | APP_NAME: string; 72 | }; 73 | waitUntil: (promise: Promise) => void; 74 | req?: RequestInit; 75 | }; 76 | 77 | type ErrorResponse = { 78 | errors: [{ message: string }]; 79 | }; 80 | 81 | // #endregion 82 | -------------------------------------------------------------------------------- /edge/core/manifest.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | /** 5 | * Cloudflare Workers content manifest fallback (for unit testing). 6 | */ 7 | export default "{}"; 8 | -------------------------------------------------------------------------------- /edge/global.d.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | declare module "__STATIC_CONTENT_MANIFEST" { 5 | const JSON: string; 6 | export default JSON; 7 | } 8 | 9 | declare type Bindings = { 10 | APP_ENV: "local" | "test" | "prod"; 11 | APP_NAME: string; 12 | APP_HOSTNAME: string; 13 | FIREBASE_APP_ID: string; 14 | FIREBASE_API_KEY: string; 15 | FIREBASE_AUTH_DOMAIN: string; 16 | GOOGLE_CLOUD_PROJECT: string; 17 | GOOGLE_CLOUD_CREDENTIALS: string; 18 | SENDGRID_API_KEY: string; 19 | FROM_EMAIL: string; 20 | __STATIC_CONTENT: KVNamespace; 21 | }; 22 | 23 | declare type Env = { 24 | Bindings: Bindings; 25 | }; 26 | 27 | declare function getMiniflareBindings(): T; 28 | -------------------------------------------------------------------------------- /edge/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { app } from "./core/app.js"; 5 | // Register `/__/*` middleware 6 | import "./routes/firebase.js"; 7 | // Register `/api/login` route handler 8 | import "./routes/api-login.js"; 9 | // Register `/api/*` middleware 10 | import "./routes/api-swapi.js"; 11 | // Register `/echo` route handler 12 | import "./routes/echo.js"; 13 | // Register `*` static assets handler 14 | import "./routes/assets.js"; 15 | 16 | export default app; 17 | -------------------------------------------------------------------------------- /edge/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edge", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "vite build", 8 | "test": "vitest", 9 | "coverage": "vitest run --coverage", 10 | "deploy": "node ../scripts/wrangler.js deploy", 11 | "logs": "node ../scripts/wrangler.js tail", 12 | "wrangler": "node ../scripts/wrangler.js", 13 | "edge:cf": "node ../scripts/wrangler.js", 14 | "edge:tsc": "tsc", 15 | "edge:test": "vitest", 16 | "edge:build": "vite build", 17 | "edge:deploy": "node ../scripts/wrangler.js deploy", 18 | "edge:logs": "node ../scripts/wrangler.js tail" 19 | }, 20 | "dependencies": { 21 | "@hono/zod-validator": "^0.1.11", 22 | "hono": "^4.0.2", 23 | "jose": "^5.2.2", 24 | "web-auth-library": "^1.0.3", 25 | "zod": "^3.22.4" 26 | }, 27 | "devDependencies": { 28 | "@cloudflare/workers-types": "^4.20240208.0", 29 | "@types/node": "^20.11.18", 30 | "happy-dom": "^13.3.8", 31 | "toml": "^3.0.0", 32 | "typescript": "~5.3.3", 33 | "vite": "~5.1.2", 34 | "vitest": "~1.2.2", 35 | "vitest-environment-miniflare": "^2.14.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /edge/routes/api-login.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { zValidator } from "@hono/zod-validator"; 5 | import { z } from "zod"; 6 | import { app } from "../core/app.js"; 7 | 8 | /** 9 | * Authenticates the user with an email address and a one-time code (OTP). 10 | */ 11 | export const handler = app.post( 12 | "/api/login", 13 | 14 | // Validate the request body using Zod 15 | zValidator( 16 | "json", 17 | z.object({ 18 | email: z.string({ 19 | required_error: "Email is required", 20 | }), 21 | code: z.string().optional(), 22 | }), 23 | ), 24 | 25 | // Handle the request 26 | ({ req, json }) => { 27 | const input = req.valid("json"); 28 | // TODO: Implement the login logic 29 | return json({ email: input.email }); 30 | }, 31 | ); 32 | 33 | export type LoginHandler = typeof handler; 34 | export type LoginResponse = { 35 | email: string; 36 | }; 37 | -------------------------------------------------------------------------------- /edge/routes/api-swapi.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { app } from "../core/app.js"; 5 | 6 | // Rewrite HTTP requests starting with "/api/" 7 | // to the Star Wars API as an example 8 | export const handler = app.use("/api/*", async ({ req }) => { 9 | const { pathname, search } = new URL(req.url); 10 | const res = await fetch( 11 | `https://swapi.dev${pathname}${search}`, 12 | req.raw as RequestInit, 13 | ); 14 | return res as unknown as Response; 15 | }); 16 | 17 | export type SwapiHandler = typeof handler; 18 | -------------------------------------------------------------------------------- /edge/routes/assets.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { serveStatic } from "hono/cloudflare-workers"; 5 | import { getMimeType } from "hono/utils/mime"; 6 | import assetManifest from "__STATIC_CONTENT_MANIFEST"; 7 | import { app } from "../core/app.js"; 8 | 9 | const manifest = JSON.parse(assetManifest); 10 | 11 | // Static assets handler 12 | // https://hono.dev/getting-started/cloudflare-workers#serve-static-files 13 | const asset = serveStatic({ manifest }); 14 | const fallback = serveStatic({ path: "/index.html", manifest }); 15 | 16 | // Serve web application assets bundled into 17 | // the worker script from the `../app/dist` folder 18 | export const handler = app.use("*", async (ctx, next) => { 19 | const url = new URL(ctx.req.url); 20 | 21 | // Alternatively, import the list of routes from the `app` package 22 | const isKnownRoute = [ 23 | "", 24 | "/", 25 | "/dashboard", 26 | "/settings", 27 | "/settings/account", 28 | "/login", 29 | "/signup", 30 | "/privacy", 31 | "/terms", 32 | ].includes(url.pathname); 33 | 34 | // Serve index.html for known URL routes 35 | if (isKnownRoute) { 36 | return await fallback(ctx, next); 37 | } 38 | 39 | // Otherwise attempt to serve the static asset (file) 40 | const res = await asset(ctx, next); 41 | 42 | // Serve index.html for unknown URL routes with 404 status code 43 | if (!res && !getMimeType(url.pathname)) { 44 | const res = await fallback(ctx, next); 45 | 46 | if (res) { 47 | return new Response(res.body, { ...res, status: 404 }); 48 | } 49 | } 50 | 51 | return res; 52 | }); 53 | 54 | export type AssetsHandler = typeof handler; 55 | -------------------------------------------------------------------------------- /edge/routes/echo.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { expect, test } from "vitest"; 5 | import { handler } from "./echo.js"; 6 | 7 | test("GET /echo", async () => { 8 | // Initialize an HTTP GET request object 9 | const env = getMiniflareBindings(); 10 | const req = new Request(`https://${env.APP_HOSTNAME}/echo`); 11 | req.headers.set("Content-Type", "application/json"); 12 | 13 | // Fetch the target URL and parse the response 14 | const res = await handler.fetch(req, env); 15 | const body = await res 16 | .json() 17 | .catch(() => res.text()) 18 | .catch(() => undefined); 19 | 20 | // Compare the response with the expected result 21 | expect({ status: res.status, body }).toEqual({ 22 | status: 200, 23 | body: { 24 | headers: { 25 | "content-type": "application/json", 26 | }, 27 | }, 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /edge/routes/echo.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { app } from "../core/app.js"; 5 | 6 | export const handler = app.get("/echo", ({ json, req }) => { 7 | return json({ 8 | headers: Object.fromEntries(req.raw.headers.entries()), 9 | cf: req.raw.cf, 10 | }); 11 | }); 12 | 13 | export type EchoHandler = typeof handler; 14 | -------------------------------------------------------------------------------- /edge/routes/firebase.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { app } from "../core/app"; 5 | 6 | export const handler = app.use("/__/*", async ({ req, env }) => { 7 | const url = new URL(req.url); 8 | const origin = `https://${env.GOOGLE_CLOUD_PROJECT}.web.app`; 9 | const res = await fetch(`${origin}${url.pathname}${url.search}`, req.raw); 10 | return res as unknown as Response; 11 | }); 12 | 13 | export type FirebaseHandler = typeof handler; 14 | -------------------------------------------------------------------------------- /edge/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext"], 5 | "types": ["@cloudflare/workers-types", "vite/client"], 6 | "outDir": "../.cache/typescript-edge" 7 | }, 8 | "include": ["**/*.ts", "**/*.d.ts", "**/*.json"], 9 | "exclude": ["dist/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /edge/vite.config.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2020-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { resolve } from "node:path"; 5 | import { defineProject } from "vitest/config"; 6 | import { getCloudflareBindings } from "../scripts/utils.js"; 7 | 8 | export default defineProject({ 9 | cacheDir: "../.cache/vite-edge", 10 | 11 | // Production build configuration 12 | // https://vitejs.dev/guide/build 13 | build: { 14 | lib: { 15 | entry: "index.ts", 16 | fileName: "index", 17 | formats: ["es"], 18 | }, 19 | rollupOptions: { 20 | external: ["__STATIC_CONTENT_MANIFEST"], 21 | }, 22 | }, 23 | 24 | resolve: { 25 | alias: { 26 | ["__STATIC_CONTENT_MANIFEST"]: resolve("./core/manifest.ts"), 27 | }, 28 | }, 29 | 30 | // Unit testing configuration 31 | // https://vitest.dev/config/ 32 | test: { 33 | ...{ cache: { dir: resolve(__dirname, "../.cache/vitest") } }, 34 | deps: { 35 | // ...{ registerNodeLoader: true }, 36 | external: ["__STATIC_CONTENT_MANIFEST"], 37 | }, 38 | environment: "miniflare", 39 | environmentOptions: { 40 | bindings: getCloudflareBindings(resolve(__dirname, "wrangler.toml")), 41 | }, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /edge/wrangler.toml: -------------------------------------------------------------------------------- 1 | # Cloudflare Workers configuration 2 | # https://developers.cloudflare.com/workers/wrangler/configuration/ 3 | 4 | name = "example" 5 | main = "index.js" 6 | 7 | # https://developers.cloudflare.com/workers/platform/compatibility-dates/ 8 | compatibility_date = "2023-03-14" 9 | send_metrics = false 10 | 11 | account_id = "${CLOUDFLARE_ACCOUNT_ID}" 12 | 13 | routes = [ 14 | { pattern = "${APP_HOSTNAME}/*", zone_id = "${CLOUDFLARE_ZONE_ID}" } 15 | ] 16 | 17 | rules = [ 18 | { type = "ESModule", globs = ["dist/*.js"] }, 19 | { type = "Text", globs = ["dist/*.md"], fallthrough = true } 20 | ] 21 | 22 | [vars] 23 | APP_ENV = "${APP_ENV}" 24 | APP_NAME = "${APP_NAME}" 25 | APP_HOSTNAME = "${APP_HOSTNAME}" 26 | GOOGLE_CLOUD_PROJECT = "${GOOGLE_CLOUD_PROJECT}" 27 | FIREBASE_API_KEY = "${FIREBASE_API_KEY}" 28 | FROM_EMAIL = "${FROM_EMAIL}" 29 | 30 | # [secrets] 31 | # GOOGLE_CLOUD_CREDENTIALS 32 | 33 | [site] 34 | bucket = "../../app/dist" 35 | 36 | [env.test] 37 | name = "example-test" 38 | 39 | [env.test.vars] 40 | APP_ENV = "${APP_ENV}" 41 | APP_NAME = "${APP_NAME}" 42 | APP_HOSTNAME = "${APP_HOSTNAME}" 43 | GOOGLE_CLOUD_PROJECT = "${GOOGLE_CLOUD_PROJECT}" 44 | FIREBASE_API_KEY = "${FIREBASE_API_KEY}" 45 | FROM_EMAIL = "${FROM_EMAIL}" 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "version": "0.0.0", 4 | "packageManager": "yarn@4.1.0", 5 | "private": true, 6 | "type": "module", 7 | "workspaces": [ 8 | "app", 9 | "db", 10 | "edge", 11 | "scripts", 12 | "server" 13 | ], 14 | "scripts": { 15 | "postinstall": "husky install && node ./scripts/post-install.js", 16 | "start": "yarn workspace app start", 17 | "lint": "eslint --cache --report-unused-disable-directives .", 18 | "test": "vitest", 19 | "build": "yarn workspaces foreach -tiA run build", 20 | "deploy": "yarn workspace edge deploy" 21 | }, 22 | "devDependencies": { 23 | "@emotion/babel-plugin": "^11.11.0", 24 | "@emotion/eslint-plugin": "^11.11.0", 25 | "@types/eslint": "^8.56.2", 26 | "@typescript-eslint/eslint-plugin": "^7.0.1", 27 | "@typescript-eslint/parser": "^7.0.1", 28 | "eslint": "^8.56.0", 29 | "eslint-config-prettier": "^9.1.0", 30 | "eslint-import-resolver-typescript": "^3.6.1", 31 | "eslint-plugin-import": "^2.29.1", 32 | "eslint-plugin-jsx-a11y": "^6.8.0", 33 | "eslint-plugin-react": "^7.33.2", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "graphql": "^16.8.1", 36 | "happy-dom": "^13.3.8", 37 | "husky": "^9.0.11", 38 | "prettier": "^3.2.5", 39 | "react": "^18.2.0", 40 | "relay-config": "^12.0.1", 41 | "typescript": "~5.3.3", 42 | "vite": "~5.1.2", 43 | "vitest": "~1.2.2" 44 | }, 45 | "prettier": { 46 | "printWidth": 80, 47 | "tabWidth": 2, 48 | "useTabs": false, 49 | "semi": true, 50 | "singleQuote": false, 51 | "quoteProps": "as-needed", 52 | "jsxSingleQuote": false, 53 | "trailingComma": "all", 54 | "bracketSpacing": true, 55 | "bracketSameLine": false, 56 | "arrowParens": "always", 57 | "endOfLine": "lf" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /scripts/bundle-yarn.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { execa } from "execa"; 5 | import { copyFile, readFile, writeFile } from "node:fs/promises"; 6 | import { resolve } from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | 9 | const rootDir = resolve(fileURLToPath(import.meta.url), "../.."); 10 | const rootPkg = JSON.parse(await readFile(`${rootDir}/package.json`, "utf-8")); 11 | const pkg = JSON.parse(await readFile("./package.json", "utf-8")); 12 | 13 | pkg.packageManager = rootPkg.packageManager; 14 | delete pkg.scripts; 15 | delete pkg.devDependencies; 16 | delete pkg.dependencies.db; 17 | 18 | // Create ./dist/package.json 19 | await writeFile("./dist/package.json", JSON.stringify(pkg, null, 2), "utf-8"); 20 | 21 | // Create ./dist/yarn.lock 22 | await copyFile(`${rootDir}/yarn.lock`, "./dist/yarn.lock"); 23 | 24 | // Install production dependencies 25 | await execa("yarn", ["install", "--mode=update-lockfile"], { 26 | env: { 27 | ...process.env, 28 | NODE_OPTIONS: undefined, 29 | YARN_ENABLE_IMMUTABLE_INSTALLS: "false", 30 | }, 31 | cwd: "./dist", 32 | stdio: "inherit", 33 | }); 34 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scripts", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "dotenv": "^16.4.4", 8 | "execa": "^8.0.1", 9 | "get-port": "^7.0.0", 10 | "got": "^13.0.0", 11 | "graphql": "^16.8.1", 12 | "lodash-es": "^4.17.21", 13 | "miniflare": "^3.20240129.2", 14 | "prettier": "^3.2.5", 15 | "toml": "^3.0.0", 16 | "vite": "^5.1.2", 17 | "wrangler": "^3.28.2", 18 | "zx": "^7.2.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/post-install.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { execa } from "execa"; 5 | import fs from "node:fs"; 6 | import { EOL } from "node:os"; 7 | 8 | // Create Git-ignored files for environment variable overrides 9 | if (!fs.existsSync("./.env.local")) { 10 | await fs.writeFile( 11 | "./.env.local", 12 | [ 13 | `# Overrides for the \`.env\` file in the root folder.`, 14 | "#", 15 | "# CLOUDFLARE_API_TOKEN=xxxxx", 16 | "# GOOGLE_CLOUD_CREDENTIALS=xxxxx", 17 | "# SENDGRID_API_KEY=xxxxx", 18 | "#", 19 | "", 20 | "API_URL=http://localhost:8080", 21 | "", 22 | ].join(EOL), 23 | "utf-8", 24 | ); 25 | } 26 | 27 | try { 28 | await execa("yarn", ["tsc", "--build"], { stdin: "inherit" }); 29 | } catch (err) { 30 | console.error(err); 31 | } 32 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { execa } from "execa"; 5 | import getPort, { portNumbers } from "get-port"; 6 | import { debounce } from "lodash-es"; 7 | import { Log, LogLevel, Miniflare } from "miniflare"; 8 | import { getArgs, getCloudflareBindings, readWranglerConfig } from "./utils.js"; 9 | 10 | const [args, envName = "local"] = getArgs(); 11 | const edgeConfig = await readWranglerConfig("edge/wrangler.toml", envName); 12 | 13 | // Build the "edge" (CDN edge endpoint) package in "watch" mode using Vite 14 | const edge = execa( 15 | "yarn", 16 | ["workspace", "edge", "build", "--mode=development", "--watch", ...args], 17 | { stdio: ["inherit", "pipe", "inherit"], env: { FORCE_COLOR: 1 } }, 18 | ); 19 | 20 | // Start listening for the (re)build events 21 | /** @type {Miniflare} */ let mf; 22 | await new Promise((resolve, reject) => { 23 | edge.then(resolve, reject); 24 | const reload = debounce(() => { 25 | mf?.reload(); 26 | resolve(); 27 | }, 300); 28 | edge.stdout.on("data", (data) => { 29 | if (!mf) process.stdout.write(data); 30 | if (data.toString().includes("built in")) reload(); 31 | }); 32 | }); 33 | 34 | // Configure Cloudflare dev server 35 | // https://miniflare.dev/get-started/api 36 | const port = await getPort({ port: portNumbers(8080, 8090) }); 37 | mf = new Miniflare({ 38 | name: edgeConfig.name, 39 | log: new Log(LogLevel.INFO), 40 | scriptPath: "edge/dist/index.js", 41 | sitePath: "app/dist", 42 | wranglerConfigPath: false, 43 | modules: true, 44 | modulesRules: [ 45 | { type: "ESModule", include: ["**/*.js"], fallthrough: true }, 46 | { type: "Text", include: ["**/*.md"] }, 47 | ], 48 | upstream: process.env.APP_ORIGIN, 49 | routes: ["*/*"], 50 | logUnhandledRejections: true, 51 | bindings: getCloudflareBindings("edge/wrangler.toml", envName), 52 | port, 53 | }); 54 | 55 | const server = await mf.createServer(); 56 | await new Promise((resolve) => server.listen(port, resolve)); 57 | process.env.LOCAL_API_ORIGIN = `http://localhost:${port}`; 58 | mf.log.info(`API listening on ${process.env.LOCAL_API_ORIGIN}/`); 59 | 60 | // Launch the front-end app using Vite dev server 61 | execa("yarn", ["workspace", "app", "run", "start", ...args], { 62 | stdio: "inherit", 63 | }).on("close", () => cleanUp()); 64 | 65 | async function cleanUp() { 66 | await mf?.dispose(); 67 | edge.kill(); 68 | setTimeout(() => process.exit(), 500); 69 | } 70 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../typescript-scripts", 5 | "moduleResolution": "node", 6 | "noEmit": true 7 | }, 8 | "include": ["**/*.ts", "**/*.js"] 9 | } 10 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { configDotenv } from "dotenv"; 5 | import { template } from "lodash-es"; 6 | import { readFileSync } from "node:fs"; 7 | import fs from "node:fs/promises"; 8 | import { dirname, resolve } from "node:path"; 9 | import { URL, fileURLToPath } from "node:url"; 10 | import { parse as parseToml } from "toml"; 11 | import { $ } from "zx"; 12 | 13 | export const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); 14 | export const envDir = resolve(rootDir, "env"); 15 | 16 | /** 17 | * Get the arguments passed to the script. 18 | * 19 | * @returns {[args: string[], envName: string | undefined]} 20 | */ 21 | export function getArgs() { 22 | const args = process.argv.slice(2); 23 | /** @type {String} */ 24 | let envName; 25 | 26 | for (let i = 0; i < args.length; i++) { 27 | if (args[i] === "--env") { 28 | envName = args[i + 1]; 29 | args.splice(i, 2); 30 | break; 31 | } 32 | 33 | if (args[i]?.startsWith("--env=")) { 34 | envName = args[i].slice(6); 35 | args.splice(i, 1); 36 | break; 37 | } 38 | } 39 | 40 | return [args, envName]; 41 | } 42 | 43 | /** 44 | * Load environment variables used in the Cloudflare Worker. 45 | */ 46 | export function getCloudflareBindings(file = "wrangler.toml", envName) { 47 | const envDir = fileURLToPath(new URL("..", import.meta.url)); 48 | 49 | configDotenv({ path: resolve(envDir, `.env.${envName}.local`) }); 50 | configDotenv({ path: resolve(envDir, `.env.local`) }); 51 | configDotenv({ path: resolve(envDir, `.env`) }); 52 | 53 | let config = parseToml(readFileSync(file, "utf-8")); 54 | 55 | return { 56 | SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, 57 | GOOGLE_CLOUD_CREDENTIALS: process.env.GOOGLE_CLOUD_CREDENTIALS, 58 | ...JSON.parse(JSON.stringify(config.vars), (key, value) => { 59 | return typeof value === "string" 60 | ? value.replace(/\$\{?([\w]+)\}?/g, (_, key) => process.env[key]) 61 | : value; 62 | }), 63 | }; 64 | } 65 | 66 | export async function readWranglerConfig(file, envName = "test") { 67 | const envDir = fileURLToPath(new URL("..", import.meta.url)); 68 | 69 | configDotenv({ path: resolve(envDir, `.env.${envName}.local`) }); 70 | configDotenv({ path: resolve(envDir, `.env.local`) }); 71 | configDotenv({ path: resolve(envDir, `.env`) }); 72 | 73 | // Load Wrangler CLI configuration file 74 | let config = parseToml(await fs.readFile(file, "utf-8")); 75 | 76 | // Interpolate environment variables 77 | return JSON.parse(JSON.stringify(config), (key, value) => { 78 | return typeof value === "string" 79 | ? template(value, { 80 | interpolate: /\$\{?([\w]+)\}?/, 81 | })($.env) 82 | : value; 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /scripts/wrangler.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { execa } from "execa"; 5 | import path from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | import { $, fs } from "zx"; 8 | import { getArgs, readWranglerConfig } from "./utils.js"; 9 | 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 11 | const [args, envName = "test"] = getArgs(); 12 | 13 | // Interpolate and save Wrangler configuration to ./dist/wrangler.json 14 | let config = await readWranglerConfig("./wrangler.toml", envName); 15 | config = JSON.stringify(config, null, " "); 16 | await fs.writeFile("./dist/wrangler.json", config, "utf-8"); 17 | 18 | const wranglerBin = await execa("yarn", ["bin", "wrangler"], { 19 | cwd: __dirname, 20 | }).then((p) => p.stdout); 21 | 22 | // Check if there is a secret name, for example: 23 | // > yarn workspace edge wrangler secret put AUTH_KEY 24 | const secret = args.find( 25 | (_, i) => args[i - 2] === "secret" && args[i - 1] === "put", 26 | ); 27 | 28 | // Launch Wrangler CLI 29 | const p = execa( 30 | "yarn", 31 | [ 32 | "node", 33 | wranglerBin, 34 | "--experimental-json-config", 35 | "-c", 36 | "./dist/wrangler.json", 37 | envName === "prod" ? undefined : `--env=${envName}`, 38 | ...args, 39 | ].filter(Boolean), 40 | { 41 | stdio: secret && $.env[secret] ? ["pipe", "inherit", "inherit"] : "inherit", 42 | }, 43 | ); 44 | 45 | // Write secret values to stdin (in order to avoid typing them) 46 | if (secret && $.env[secret] && p.stdin) { 47 | p.stdin.write($.env[args[2]]); 48 | p.stdin.end(); 49 | } 50 | 51 | // Suppress the error message from the spawned process 52 | await p.catch(() => { 53 | process.exitCode = process.exitCode ?? 1; 54 | return Promise.resolve(); 55 | }); 56 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2014-present Kriasoft 2 | # SPDX-License-Identifier: MIT 3 | 4 | # Docker image for a Cloud Run service. 5 | # 6 | # https://cloud.google.com/run/docs/container-contract 7 | # https://cloud.google.com/run/docs/quickstarts/build-and-deploy/nodejs 8 | # https://github.com/GoogleCloudPlatform/cloud-run-microservice-template-nodejs/blob/main/Dockerfile 9 | 10 | # Use the official lightweight Node.js image. 11 | # https://hub.docker.com/_/node 12 | FROM node:20.11.0-slim 13 | 14 | # Upgrade OS packages. 15 | RUN apt-get update && apt-get upgrade -y 16 | 17 | # Set environment variables. 18 | ENV NODE_ENV=production 19 | 20 | # Create and change to the app directory. 21 | WORKDIR /usr/src/app 22 | 23 | # Copy application dependency manifests to the container image. 24 | # Copying this separately prevents re-running npm install on every code change. 25 | COPY dist/package.json dist/yarn.lock ./ 26 | 27 | # Install dependencies. 28 | RUN corepack enable && yarn config set nodeLinker node-modules && yarn install --immutable 29 | 30 | # Copy compiled code to the container image. 31 | COPY ./dist . 32 | 33 | # Run the web service on container startup. 34 | CMD [ "node", "index.js" ] 35 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Application Server 2 | 3 | Node.js application server for web and mobile clients using [tRPC](https://trpc.io/) with HTTP and WebSocket transports. 4 | 5 | ## Scripts 6 | 7 | - `yarn workspace server start` — Start the server in development mode. 8 | - `yarn workspace server build` — Build the server for production. 9 | - `yarn workspace server test` — Run tests. 10 | -------------------------------------------------------------------------------- /server/app.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import supertest from "supertest"; 5 | import { describe, expect, it } from "vitest"; 6 | import { app } from "./app"; 7 | 8 | describe("GET /trpc", () => { 9 | it("returns NOT_FOUND response for unknown requests", async () => { 10 | const res = await supertest(app).get("/trpc"); 11 | 12 | expect({ 13 | statusCode: res.statusCode, 14 | body: res.body, 15 | }).toEqual({ 16 | statusCode: 404, 17 | body: { 18 | error: { 19 | code: -32004, 20 | data: { 21 | code: "NOT_FOUND", 22 | httpStatus: 404, 23 | path: "", 24 | }, 25 | message: 'No "query"-procedure on path ""', 26 | }, 27 | }, 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /server/app.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { createExpressMiddleware } from "@trpc/server/adapters/express"; 5 | import express from "express"; 6 | import { createProxyMiddleware } from "http-proxy-middleware"; 7 | import { sessionMiddleware } from "./core/auth"; 8 | import { env } from "./core/env"; 9 | import { loggerMiddleware } from "./core/logging"; 10 | import { createContext } from "./core/trpc"; 11 | import { router } from "./routes/index"; 12 | 13 | export const app = express(); 14 | 15 | app.disable("x-powered-by"); 16 | app.set("trust proxy", 1); // Trust first proxy 17 | 18 | app.use(loggerMiddleware); 19 | app.use(sessionMiddleware); 20 | 21 | /** 22 | * tRPC HTTP handler for Express.js. 23 | * 24 | * @see https://trpc.io/docs/getting-started 25 | * @see https://trpc.io/docs/server/adapters/express 26 | */ 27 | app.use("/trpc", createExpressMiddleware({ router, createContext })); 28 | 29 | /** 30 | * Proxy auth requests to Google Identity Platform. 31 | * 32 | * @see https://firebase.google.com/docs/auth/web/redirect-best-practices 33 | */ 34 | app.use( 35 | createProxyMiddleware("/__", { 36 | target: `https://${env.GOOGLE_CLOUD_PROJECT}.firebaseapp.com`, 37 | changeOrigin: true, 38 | logLevel: "warn", 39 | }), 40 | ); 41 | 42 | /** 43 | * Proxy static assets to Google Cloud Storage. 44 | */ 45 | app.use( 46 | createProxyMiddleware("/", { 47 | target: "https://c.storage.googleapis.com", 48 | changeOrigin: true, 49 | logLevel: "warn", 50 | onProxyReq(proxyReq) { 51 | proxyReq.setHeader("host", env.APP_STORAGE_BUCKET); 52 | }, 53 | }), 54 | ); 55 | -------------------------------------------------------------------------------- /server/core/auth.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import express from "express"; 5 | import supertest from "supertest"; 6 | import { describe, expect, it } from "vitest"; 7 | import { fetchCertificates, sessionMiddleware } from "./auth"; 8 | import { env } from "./env"; 9 | 10 | describe("fetchCertificates()", () => { 11 | it("should fetch certificates from Google", async () => { 12 | const certs = await fetchCertificates(); 13 | expect(certs).toEqual(expect.any(Object)); 14 | expect(Object.values(certs)).toEqual( 15 | expect.arrayContaining([ 16 | expect.stringMatching(/^-----BEGIN CERTIFICATE-----/), 17 | ]), 18 | ); 19 | }); 20 | }); 21 | 22 | describe("sessionMiddleware()", () => { 23 | it.skip("should verify a valid ID token", async () => { 24 | const app = express(); 25 | app.get("/", sessionMiddleware, (req, res) => { 26 | res.type("json"); 27 | res.send(JSON.stringify(req.token)); 28 | }); 29 | 30 | const idToken = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImQxNjg5NDE1ZWMyM2EzMzdlMmJiYWE1ZTNlNjhiNjZkYzk5MzY4ODQiLCJ0eXAiOiJKV1QifQ.eyJuYW1lIjoiS3JpYXNvZnQiLCJwaWN0dXJlIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jS0hBRVhvRXpJMnpoMWNpN3lna0FVZXFiRWppQ1ZxUE96VkZQYnZmUGtXcGc9czk2LWMiLCJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20va3JpYXNvZnQiLCJhdWQiOiJrcmlhc29mdCIsImF1dGhfdGltZSI6MTcwNDU0MjYyOCwidXNlcl9pZCI6IkpRdWxWbjhaMUhZelRIR0J5YnhlcndGRUhsNzIiLCJzdWIiOiJKUXVsVm44WjFIWXpUSEdCeWJ4ZXJ3RkVIbDcyIiwiaWF0IjoxNzA0NTQyNjI4LCJleHAiOjE3MDQ1NDYyMjgsImVtYWlsIjoia3JpYXNvZnRAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImZpcmViYXNlIjp7ImlkZW50aXRpZXMiOnsiZ29vZ2xlLmNvbSI6WyIxMDI2NTQwODA3NzUxNTYyOTI2ODYiXSwiZW1haWwiOlsia3JpYXNvZnRAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoiZ29vZ2xlLmNvbSJ9fQ.E1sOoIeLH0269m4K4DXfXOJk97cxc8h3D62u3q9Kqyk0AsmwQfKURRl34IiOtNEzizjesLex6EHSetFr8kS1GSgVrW6yxHowmJZCY8tsgSWfunZ7Vj8l1kG4y0iM7hdFw3t0dhilK8-vDlKpLeRfLVHG8qgt46qI7Rxmdb928llJoa7H6NuuS5heavNJadLfiYItJyUq7i5kjys6-WfndQQcRb7kTt07arHb_1w2jtZnyjZE_S3ErZcIgwnE9M_gqXZ4y1MucpGPR2_nHzicRBBYOMwZDVG7Y0tI9IWRaTyTF3Psd7XKisE6GorZ_X1cDwkaT5ffoXZ1tkBOjeMjfw"; // prettier-ignore 31 | const res = await supertest(app).get("/").auth(idToken, { type: "bearer" }); 32 | 33 | expect({ status: res.status, body: res.body }).toEqual({ 34 | status: 200, 35 | body: expect.objectContaining({ 36 | name: expect.any(String), 37 | picture: expect.any(String), 38 | iss: `https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`, 39 | aud: env.GOOGLE_CLOUD_PROJECT, 40 | auth_time: expect.any(Number), 41 | sub: expect.any(String), 42 | iat: expect.any(Number), 43 | exp: expect.any(Number), 44 | email: expect.any(String), 45 | email_verified: true, 46 | firebase: expect.objectContaining({ 47 | sign_in_provider: "google.com", 48 | }), 49 | }), 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /server/core/auth.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { NextFunction, Request, Response } from "express"; 5 | import { 6 | Certificates, 7 | GoogleAuth, 8 | IdTokenClient, 9 | TokenPayload, 10 | } from "google-auth-library"; 11 | import { got } from "got"; 12 | import { env } from "./env"; 13 | 14 | export const auth = new GoogleAuth({ 15 | scopes: ["https://www.googleapis.com/auth/cloud-platform"], 16 | }); 17 | 18 | const certificatesURL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com"; // prettier-ignore 19 | const certificatesCache = new Map(); 20 | 21 | /** 22 | * Fetches the latest Google Cloud Identity Platform certificates. 23 | */ 24 | export function fetchCertificates(options?: { signal: AbortSignal }) { 25 | return got.get(certificatesURL, { 26 | cache: certificatesCache, 27 | resolveBodyOnly: true, 28 | responseType: "json", 29 | signal: options?.signal, 30 | }); 31 | } 32 | 33 | // Refresh certificates every 6 hours. 34 | const cleanup = (() => { 35 | const ac = new AbortController(); 36 | const int = setInterval(() => fetchCertificates(), 2.16e7); 37 | fetchCertificates({ signal: ac.signal }); 38 | return () => { 39 | clearInterval(int); 40 | ac.abort(); 41 | }; 42 | })(); 43 | 44 | process.on("SIGTERM", cleanup); 45 | process.on("SIGINT", cleanup); 46 | 47 | const idTokenClients = new Map(); 48 | 49 | /** 50 | * Express middleware that verifies that the request has a valid Firebase ID 51 | * token attached, and adds the decoded token to `req.token`. 52 | */ 53 | export async function sessionMiddleware( 54 | req: Request, 55 | res: Response, 56 | next: NextFunction, 57 | ) { 58 | try { 59 | req.token = null; 60 | const idToken = req.headers.authorization?.replace(/^Bearer /i, ""); 61 | 62 | if (idToken) { 63 | const certificatesPromise = fetchCertificates(); 64 | const audience = env.GOOGLE_CLOUD_PROJECT; 65 | let idTokenClient = idTokenClients.get(audience); 66 | 67 | if (!idTokenClient) { 68 | idTokenClient = await auth.getIdTokenClient(audience); 69 | idTokenClients.set(audience, idTokenClient); 70 | } 71 | 72 | const ticket = await idTokenClient.verifySignedJwtWithCertsAsync( 73 | idToken, 74 | await certificatesPromise, 75 | audience, 76 | [`https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`], 77 | ); 78 | 79 | const token = ticket.getPayload(); 80 | 81 | if (token) { 82 | if ("user_id" in token) delete token.user_id; 83 | Object.assign(token, { uid: token.sub }); 84 | req.token = token as DecodedIdToken; 85 | } 86 | } 87 | 88 | next(); 89 | } catch (err) { 90 | req.log?.warn(err); 91 | next(); 92 | } 93 | } 94 | 95 | // #region Types 96 | 97 | /** 98 | * Interface representing a decoded Firebase ID token, returned from the 99 | * {@link verifyIdToken} method. 100 | * 101 | * Firebase ID tokens are OpenID Connect spec-compliant JSON Web Tokens (JWTs). 102 | * See the 103 | * [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) 104 | * for more information about the specific properties below. 105 | */ 106 | export interface DecodedIdToken extends TokenPayload { 107 | /** 108 | * Time, in seconds since the Unix epoch, when the end-user authentication 109 | * occurred. 110 | * 111 | * This value is not set when this particular ID token was created, but when the 112 | * user initially logged in to this session. In a single session, the Firebase 113 | * SDKs will refresh a user's ID tokens every hour. Each ID token will have a 114 | * different [`iat`](#iat) value, but the same `auth_time` value. 115 | */ 116 | auth_time: number; 117 | 118 | /** 119 | * Information about the sign in event, including which sign in provider was 120 | * used and provider-specific identity details. 121 | * 122 | * This data is provided by the Firebase Authentication service and is a 123 | * reserved claim in the ID token. 124 | */ 125 | firebase: { 126 | /** 127 | * Provider-specific identity details corresponding 128 | * to the provider used to sign in the user. 129 | */ 130 | identities: { 131 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 132 | [key: string]: any; 133 | }; 134 | 135 | /** 136 | * The ID of the provider used to sign in the user. 137 | * One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`, 138 | * `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`, 139 | * `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`, 140 | * or `"custom"`. 141 | * 142 | * Additional Identity Platform provider IDs include `"linkedin.com"`, 143 | * OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."` 144 | * respectively. 145 | */ 146 | sign_in_provider: string; 147 | 148 | /** 149 | * The type identifier or `factorId` of the second factor, provided the 150 | * ID token was obtained from a multi-factor authenticated user. 151 | * For phone, this is `"phone"`. 152 | */ 153 | sign_in_second_factor?: string; 154 | 155 | /** 156 | * The `uid` of the second factor used to sign in, provided the 157 | * ID token was obtained from a multi-factor authenticated user. 158 | */ 159 | second_factor_identifier?: string; 160 | 161 | /** 162 | * The ID of the tenant the user belongs to, if available. 163 | */ 164 | tenant?: string; 165 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 166 | [key: string]: any; 167 | }; 168 | 169 | /** 170 | * The phone number of the user to whom the ID token belongs, if available. 171 | */ 172 | phone_number?: string; 173 | 174 | /** 175 | * The `uid` corresponding to the user who the ID token belonged to. 176 | * 177 | * This value is not actually in the JWT token claims itself. It is added as a 178 | * convenience, and is set as the value of the [`sub`](#sub) property. 179 | */ 180 | uid: string; 181 | 182 | /** 183 | * Indicates whether or not the user is an admin. 184 | */ 185 | admin?: boolean; 186 | } 187 | 188 | // #endregion 189 | -------------------------------------------------------------------------------- /server/core/env.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { cleanEnv, str } from "envalid"; 5 | 6 | /** 7 | * Environment variables that has been validated and sanitized. 8 | * 9 | * @see https://github.com/ilyakaznacheev/cleanenv#readme 10 | */ 11 | export const env = cleanEnv(process.env, { 12 | VERSION: str({ default: "latest" }), 13 | 14 | APP_STORAGE_BUCKET: str(), 15 | 16 | GOOGLE_CLOUD_PROJECT: str(), 17 | GOOGLE_CLOUD_DATABASE: str(), 18 | 19 | OPENAI_ORGANIZATION: str(), 20 | OPENAI_API_KEY: str(), 21 | }); 22 | -------------------------------------------------------------------------------- /server/core/firestore.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Firestore } from "@google-cloud/firestore"; 5 | import { env } from "./env"; 6 | 7 | let db: Firestore | undefined; 8 | 9 | export function getFirestore() { 10 | if (!db) { 11 | db = new Firestore({ 12 | projectId: env.GOOGLE_CLOUD_PROJECT, 13 | databaseId: env.GOOGLE_CLOUD_DATABASE, 14 | }); 15 | } 16 | 17 | return db; 18 | } 19 | -------------------------------------------------------------------------------- /server/core/logging.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Request, Response } from "express"; 5 | import { pino } from "pino"; 6 | import { pinoHttp } from "pino-http"; 7 | import { env } from "./env"; 8 | 9 | /** 10 | * Low overhead Node.js logger. 11 | * 12 | * @see https://github.com/pinojs/pino 13 | */ 14 | export const logger = pino({ 15 | // Custom formatter to set the "severity" property in the JSON payload 16 | // to the log level to be automatically parsed. 17 | // https://cloud.google.com/run/docs/logging#special-fields 18 | formatters: { 19 | level(label) { 20 | return { severity: label }; 21 | }, 22 | }, 23 | transport: { 24 | // Enable pretty printing in development. 25 | // https://github.com/pinojs/pino-pretty#readme 26 | target: env.isProduction ? "pino/file" : "pino-pretty", 27 | options: { 28 | ...(!env.isProduction && { colorize: true }), 29 | ignore: env.isProduction 30 | ? "pid,hostname" 31 | : "pid,hostname,req.headers,req.remoteAddress,req.remotePort,res.headers", 32 | }, 33 | }, 34 | }); 35 | 36 | /** 37 | * Creates a request-based logger with trace ID field for logging correlation. 38 | * 39 | * @see https://cloud.google.com/run/docs/logging#correlate-logs 40 | */ 41 | export const loggerMiddleware = pinoHttp({ 42 | logger, 43 | customProps(req) { 44 | const traceHeader = req.header("X-Cloud-Trace-Context"); 45 | 46 | let trace; 47 | 48 | if (traceHeader) { 49 | const [traceId] = traceHeader.split("/"); 50 | trace = `projects/${env.GOOGLE_CLOUD_PROJECT}/traces/${traceId}`; 51 | } 52 | 53 | return { 54 | "logging.googleapis.com/trace": trace, 55 | }; 56 | }, 57 | redact: { 58 | paths: ["req.headers.authorization", "req.headers.cookie"], 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /server/core/openai.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { OpenAI } from "openai"; 5 | import { env } from "./env"; 6 | 7 | /** 8 | * OpenAI API client. 9 | * 10 | * @see https://github.com/openai/openai-node#readme 11 | */ 12 | export const openai = new OpenAI({ 13 | organization: env.OPENAI_ORGANIZATION, 14 | apiKey: env.OPENAI_API_KEY, 15 | }); 16 | -------------------------------------------------------------------------------- /server/core/trpc.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Firestore } from "@google-cloud/firestore"; 5 | import { TRPCError, initTRPC } from "@trpc/server"; 6 | import { CreateExpressContextOptions } from "@trpc/server/adapters/express"; 7 | import { Logger } from "pino"; 8 | import { SetNonNullable } from "type-fest"; 9 | import { ZodError } from "zod"; 10 | import { DecodedIdToken } from "./auth"; 11 | import { env } from "./env"; 12 | import { getFirestore } from "./firestore"; 13 | import { logger } from "./logging"; 14 | 15 | /** 16 | * tRPC instance. 17 | * 18 | * @see https://trpc.io/docs/quickstart 19 | */ 20 | export const t = initTRPC.context().create({ 21 | isDev: env.isDev, 22 | 23 | // https://trpc.io/docs/server/error-formatting 24 | errorFormatter(opts) { 25 | const { shape, error } = opts; 26 | return { 27 | ...shape, 28 | data: { 29 | ...shape.data, 30 | ...(error.code === "BAD_REQUEST" && 31 | error.cause instanceof ZodError && { 32 | zodError: error.cause.flatten(), 33 | }), 34 | }, 35 | }; 36 | }, 37 | }); 38 | 39 | /** 40 | * Creates a tRPC context for an incoming HTTP request. 41 | */ 42 | export async function createContext( 43 | ctx: CreateExpressContextOptions, 44 | ): Promise { 45 | return new HttpContext(getFirestore(), ctx.req.log, ctx.req.token); 46 | } 47 | 48 | /** 49 | * Creates a tRPC context for an incoming WebSocket request. 50 | */ 51 | export async function createWsContext(): Promise { 52 | return new WsContext(getFirestore(), logger); 53 | } 54 | 55 | class HttpContext { 56 | constructor( 57 | readonly db: Firestore, 58 | readonly log: Logger, 59 | readonly token: DecodedIdToken | null, 60 | ) {} 61 | } 62 | 63 | class WsContext { 64 | constructor( 65 | readonly db: Firestore, 66 | readonly log: Logger, 67 | ) {} 68 | 69 | get token(): DecodedIdToken | null { 70 | throw new Error("ID token is not available in WebSocket context."); 71 | } 72 | } 73 | 74 | /** 75 | * Ensures that the user is authenticated. 76 | */ 77 | export const authorize = t.middleware((opts) => { 78 | if (!opts.ctx.token) { 79 | throw new TRPCError({ code: "UNAUTHORIZED" }); 80 | } 81 | 82 | return opts.next({ 83 | ...opts, 84 | ctx: opts.ctx as SetNonNullable, 85 | }); 86 | }); 87 | 88 | /** 89 | * Ensures that the user is an admin. 90 | */ 91 | export const authorizeAdmin = authorize.unstable_pipe((opts) => { 92 | if (!opts.ctx.token.admin) { 93 | throw new TRPCError({ code: "FORBIDDEN" }); 94 | } 95 | 96 | return opts.next(opts); 97 | }); 98 | 99 | /** 100 | * tRPC context. 101 | */ 102 | export type Context = HttpContext; 103 | -------------------------------------------------------------------------------- /server/core/utils.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { customAlphabet, urlAlphabet } from "nanoid"; 5 | 6 | const shortIdAlphabet = urlAlphabet.replace(/[-_]/g, ""); 7 | 8 | /** 9 | * Generates secure unique ID using URL friendly characters. 10 | * 11 | * @see https://github.com/ai/nanoid#readme 12 | */ 13 | export const newId = customAlphabet(shortIdAlphabet, 10); 14 | -------------------------------------------------------------------------------- /server/global.d.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import "express"; 5 | import { DecodedIdToken } from "./core/auth"; 6 | 7 | declare global { 8 | namespace Express { 9 | interface Request { 10 | token: DecodedIdToken | null; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { applyWSSHandler } from "@trpc/server/adapters/ws"; 5 | import { WebSocketServer } from "ws"; 6 | import { app } from "./app"; 7 | import { logger } from "./core/logging"; 8 | import { createWsContext as createContext } from "./core/trpc"; 9 | import { router } from "./routes"; 10 | 11 | // Detect if running in Google Cloud environment 12 | const isCloudRun = !!process.env.K_SERVICE; 13 | 14 | /** 15 | * Starts the HTTP and WebSocket servers. 16 | */ 17 | export function listen(port: number) { 18 | const server = app.listen(port, () => { 19 | logger.info(`API listening on ${port}`); 20 | }); 21 | 22 | const wss = new WebSocketServer({ server, path: "/trpc" }); 23 | const handler = applyWSSHandler({ wss, router, createContext }); 24 | 25 | wss.on("connection", (ws) => { 26 | logger.info({ clients: wss.clients.size }, "wss:connection"); 27 | ws.once("close", () => { 28 | logger.info({ clients: wss.clients.size }, "wss:close"); 29 | }); 30 | }); 31 | 32 | return function dispose(cb?: () => void) { 33 | handler.broadcastReconnectNotification(); 34 | wss.close((err) => { 35 | if (err) logger.error(err); 36 | if (isCloudRun) logger.info("WebSocket server closed"); 37 | server.close((err) => { 38 | if (err) logger.error(err); 39 | if (isCloudRun) logger.info("HTTP server closed"); 40 | logger.flush((err) => { 41 | if (err) console.error(err); 42 | if (isCloudRun) { 43 | process.exit(0); 44 | } else { 45 | cb?.(); 46 | } 47 | }); 48 | }); 49 | }); 50 | }; 51 | } 52 | 53 | if (process.env.PORT && process.env.K_SERVICE?.startsWith("server")) { 54 | const port = parseInt(process.env.PORT); 55 | const dispose = listen(port); 56 | 57 | /* eslint-disable-next-line no-inner-declarations */ 58 | function handleClose(code: NodeJS.Signals) { 59 | logger.info(`${code} signal received`); 60 | dispose(); 61 | } 62 | 63 | process.on("SIGINT", handleClose); 64 | process.on("SIGTERM", handleClose); 65 | } 66 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "types": "./types.ts" 9 | }, 10 | "./package.json": "./package.json" 11 | }, 12 | "scripts": { 13 | "start": "vite-node --watch ./start.ts", 14 | "build": "vite build && yarn node ../scripts/bundle-yarn.js", 15 | "test": "vitest" 16 | }, 17 | "dependencies": { 18 | "@google-cloud/firestore": "^7.3.0", 19 | "@googleapis/identitytoolkit": "^8.0.1", 20 | "@trpc/server": "^10.45.1", 21 | "db": "workspace:*", 22 | "envalid": "^8.0.0", 23 | "express": "^4.18.2", 24 | "google-auth-library": "^9.6.3", 25 | "got": "^13.0.0", 26 | "http-errors": "^2.0.0", 27 | "http-proxy-middleware": "^2.0.6", 28 | "nanoid": "^5.0.5", 29 | "openai": "^4.28.0", 30 | "pino": "^8.18.0", 31 | "pino-http": "^9.0.0", 32 | "pino-pretty": "^10.3.1", 33 | "type-fest": "^4.10.2", 34 | "ws": "^8.16.0", 35 | "zod": "^3.22.4" 36 | }, 37 | "devDependencies": { 38 | "@types/express": "^4.17.21", 39 | "@types/http-errors": "^2.0.4", 40 | "@types/node": "^20.11.18", 41 | "@types/supertest": "^6.0.2", 42 | "@types/ws": "^8.5.10", 43 | "envars": "^1.0.2", 44 | "get-port": "^7.0.0", 45 | "supertest": "^6.3.4", 46 | "type-fest": "^4.10.2", 47 | "typescript": "~5.3.3", 48 | "vite": "~5.1.2", 49 | "vite-node": "~1.2.2", 50 | "vitest": "~1.2.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/routes/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { t } from "../core/trpc"; 5 | import { workspace } from "./workspace"; 6 | 7 | /** 8 | * The root tRPC router. 9 | * @see https://trpc.io/docs/quickstart 10 | */ 11 | export const router = t.router({ 12 | workspace, 13 | }); 14 | 15 | export type AppRouter = typeof router; 16 | -------------------------------------------------------------------------------- /server/routes/workspace.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { TRPCError } from "@trpc/server"; 5 | import { testWorkspaces } from "db"; 6 | import { describe, expect, it } from "vitest"; 7 | import { createClient } from "../test/context"; 8 | import * as router from "./workspace"; 9 | 10 | describe("workspace.update()", () => { 11 | it("requires authentication", async () => { 12 | const [client] = createClient(router.workspace); 13 | const action = client.update({ 14 | id: "xxxxxxxxxx", 15 | name: "My Workspace", 16 | }); 17 | 18 | await expect(action).rejects.toThrowError( 19 | new TRPCError({ code: "UNAUTHORIZED" }), 20 | ); 21 | }); 22 | 23 | it("workspace must exist", async () => { 24 | const [client] = createClient(router.workspace, { user: "erika" }); 25 | const action = client.update({ 26 | id: "xxxxxxxxxx", // Non-existent workspace ID 27 | name: "My Workspace", 28 | }); 29 | 30 | await expect(action).rejects.toThrowError( 31 | new TRPCError({ code: "NOT_FOUND" }), 32 | ); 33 | }); 34 | 35 | it("workspace must belong to the user", async () => { 36 | const [client, ctx] = createClient(router.workspace, { user: "erika" }); 37 | const id = testWorkspaces.find((x) => x.ownerId !== ctx.token?.uid)?.id; 38 | expect(id).toEqual(expect.any(String)); 39 | 40 | const action = client.update({ 41 | id: id!, // Workspace ID that belongs to another user 42 | name: "My Workspace", 43 | }); 44 | 45 | await expect(action).rejects.toThrowError( 46 | new TRPCError({ code: "NOT_FOUND" }), 47 | ); 48 | }); 49 | 50 | it("updates the workspace", async () => { 51 | const [client, ctx] = createClient(router.workspace, { user: "erika" }); 52 | const id = testWorkspaces.find((x) => x.ownerId === ctx.token?.uid)?.id; 53 | expect(id).toEqual(expect.any(String)); 54 | 55 | await client.update({ id: id!, name: "My Workspace" }); 56 | const doc1 = await ctx.db.doc(`workspace/${id}`).get(); 57 | expect(doc1.data()?.name).toBe("My Workspace"); 58 | 59 | await client.update({ id: id!, name: "Personal workspace" }); 60 | const doc2 = await ctx.db.doc(`workspace/${id}`).get(); 61 | expect(doc2.data()?.name).toBe("Personal workspace"); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /server/routes/workspace.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Timestamp } from "@google-cloud/firestore"; 5 | import { TRPCError } from "@trpc/server"; 6 | import { z } from "zod"; 7 | import { authorize, t } from "../core/trpc"; 8 | 9 | /** 10 | * Workspace API. 11 | */ 12 | export const workspace = t.router({ 13 | /** 14 | * Updates the workspace. 15 | */ 16 | update: t.procedure 17 | .use(authorize) 18 | .input( 19 | z.object({ 20 | id: z.string(), 21 | name: z.string().max(100), 22 | }), 23 | ) 24 | .query(async ({ input, ctx }) => { 25 | const { db } = ctx; 26 | const doc = await db.doc(`workspace/${input.id}`).get(); 27 | 28 | if (!doc.exists || doc.data()?.ownerId !== ctx.token.uid) { 29 | throw new TRPCError({ code: "NOT_FOUND" }); 30 | } 31 | 32 | await doc.ref.update({ 33 | name: input.name, 34 | updated: Timestamp.now(), 35 | }); 36 | }), 37 | }); 38 | 39 | export type WorkspaceRouter = typeof workspace; 40 | -------------------------------------------------------------------------------- /server/start.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import getPort, { portNumbers } from "get-port"; 5 | import { listen } from "./index"; 6 | 7 | const port = await getPort({ port: portNumbers(8080, 9000) }); 8 | 9 | let dispose = listen(port); 10 | 11 | if (import.meta.hot) { 12 | import.meta.hot.accept("/index.ts", () => { 13 | dispose(() => { 14 | import("./index").then(({ listen }) => { 15 | dispose = listen(port); 16 | }); 17 | }); 18 | }); 19 | } 20 | 21 | function cleanUp() { 22 | dispose(() => process.exit()); 23 | } 24 | 25 | process.on("SIGINT", cleanUp); 26 | process.on("SIGTERM", cleanUp); 27 | -------------------------------------------------------------------------------- /server/test/context.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Firestore } from "@google-cloud/firestore"; 5 | import { 6 | AnyRootConfig, 7 | AnyRouterDef, 8 | Router, 9 | createCallerFactory, 10 | } from "@trpc/server"; 11 | import { testUsers } from "db"; 12 | import { IdTokenClient } from "google-auth-library"; 13 | import { auth } from "../core/auth"; 14 | import { env } from "../core/env"; 15 | import { getFirestore } from "../core/firestore"; 16 | import { logger } from "../core/logging"; 17 | import { Context } from "../core/trpc"; 18 | 19 | let idTokenClient: IdTokenClient | undefined; 20 | 21 | export async function getIdToken(audience: string) { 22 | idTokenClient = idTokenClient ?? (await auth.getIdTokenClient(audience)); 23 | return await idTokenClient.idTokenProvider.fetchIdToken(audience); 24 | } 25 | 26 | /** 27 | * Create a tRPC context for unit testing. 28 | */ 29 | export function createContext(options?: ContextOptions): Context { 30 | const user = testUsers.find((u) => u.screenName === options?.user); 31 | const now = Math.floor(Date.now() / 1000); 32 | 33 | if (options?.user && !user) { 34 | throw new Error(`User not found: ${options.user}`); 35 | } 36 | 37 | return { 38 | db: options?.db ?? getFirestore(), 39 | log: logger, 40 | token: user 41 | ? { 42 | uid: user.localId, 43 | sub: user.localId, 44 | email: user.email, 45 | email_verified: user.emailVerified, 46 | aud: env.GOOGLE_CLOUD_PROJECT, 47 | iss: `https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`, 48 | iat: now, 49 | exp: now + 3600, 50 | auth_time: parseInt(user.lastLoginAt!, 10), 51 | firebase: { 52 | identities: {}, 53 | sign_in_provider: "google.com", 54 | }, 55 | ...(user.customAttributes && JSON.parse(user.customAttributes)), 56 | } 57 | : null, 58 | }; 59 | } 60 | 61 | export function createClient< 62 | TRouter extends Router>, 63 | >(router: TRouter, options?: ContextOptions) { 64 | const createCaller = createCallerFactory(); 65 | const ctx = createContext(options); 66 | const client = createCaller(router)(ctx); 67 | return [client, ctx] as const; 68 | } 69 | 70 | // #region Types 71 | 72 | type ContextOptions = { 73 | /** 74 | * The user to impersonate. Use `dan` if you need admin permissions. 75 | */ 76 | user?: "erika" | "ryan" | "marian" | "kurt" | "dan"; 77 | db?: Firestore; 78 | }; 79 | 80 | // #endregion Types 81 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "emitDeclarationOnly": true, 6 | "module": "ESNext", 7 | "moduleResolution": "Bundler", 8 | "noEmit": false, 9 | "outDir": "../.cache/ts-server", 10 | "types": ["node", "vite/client"] 11 | }, 12 | "include": ["**/*.ts", "**/*.json"], 13 | "exclude": ["**/dist/**/*", "**/node_modules/**/*"], 14 | "references": [{ "path": "../db" }] 15 | } 16 | -------------------------------------------------------------------------------- /server/types.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | export type { AppRouter } from "./routes/index"; 5 | -------------------------------------------------------------------------------- /server/vite.config.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { loadEnv } from "envars"; 5 | import { defineConfig } from "vitest/config"; 6 | 7 | /** 8 | * Vite configuration for the server-side bundle. 9 | * 10 | * @see https://vitejs.dev/config/ 11 | */ 12 | export default defineConfig(async ({ mode }) => { 13 | await loadEnv(mode, { 14 | root: "..", 15 | schema: "./core/env.ts", 16 | mergeTo: process.env, 17 | }); 18 | 19 | return { 20 | cacheDir: "../.cache/vite-server", 21 | 22 | build: { 23 | ssr: "./index.ts", 24 | sourcemap: true, 25 | }, 26 | 27 | ssr: { 28 | ...(mode === "production" && { 29 | noExternal: ["http-errors"], 30 | }), 31 | }, 32 | 33 | test: { 34 | environment: "node", 35 | testTimeout: 20000, 36 | }, 37 | }; 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ESNext", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "Bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./app" }, 5 | { "path": "./db" }, 6 | { "path": "./edge" }, 7 | { "path": "./scripts" }, 8 | { "path": "./server" } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { defineConfig } from "vitest/config"; 5 | 6 | /** 7 | * Vitest configuration. 8 | * 9 | * @see https://vitest.dev/config/ 10 | */ 11 | export default defineConfig({ 12 | test: { 13 | cache: { 14 | dir: "./.cache/vitest", 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { defineWorkspace } from "vitest/config"; 5 | import { workspaces } from "./package.json"; 6 | 7 | /** 8 | * Inline Vitest configuration for all workspaces. 9 | * 10 | * @see https://vitest.dev/guide/workspace 11 | */ 12 | export default defineWorkspace( 13 | workspaces 14 | .filter((name) => !["scripts"].includes(name)) 15 | .map((name) => ({ 16 | extends: `./${name}/vite.config.ts`, 17 | test: { 18 | name, 19 | root: `./${name}`, 20 | }, 21 | })), 22 | ); 23 | --------------------------------------------------------------------------------