├── .editorconfig ├── .env ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml └── workflows │ ├── conventional-commits.yml │ ├── nightly.yml │ ├── pull_request.yml │ ├── pull_request_lint.yml │ └── push_main.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .vscode ├── api.code-snippets ├── db.code-snippets ├── extensions.json ├── launch.json ├── settings.json └── web.code-snippets ├── .yarn ├── releases │ └── yarn-4.5.1.cjs └── sdks │ ├── eslint │ ├── bin │ │ └── eslint.js │ ├── lib │ │ ├── api.js │ │ ├── types │ │ │ ├── index.d.ts │ │ │ ├── rules │ │ │ │ └── index.d.ts │ │ │ ├── universal.d.ts │ │ │ └── use-at-your-own-risk.d.ts │ │ ├── universal.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 ├── backups │ └── .gitignore ├── cli.ts ├── migrations │ └── 001_initial.ts ├── models │ ├── User.ts │ ├── UserRole.ts │ ├── Workspace.ts │ ├── WorkspaceMember.ts │ └── index.ts ├── package.json ├── seeds │ └── 01-users.ts ├── ssl │ └── .gitignore └── tsconfig.json ├── docs └── authentication.md ├── eslint.config.js ├── infra ├── README.md ├── core │ ├── .terraform.lock.hcl │ ├── artifacts.tf │ ├── database.tf │ ├── iam.tf │ ├── outputs.tf │ ├── providers.tf │ ├── terraform.tf │ └── variables.tf ├── server-preview │ ├── .terraform.lock.hcl │ ├── database.tf │ ├── providers.tf │ ├── terraform.tf │ └── variables.tf ├── server-test │ ├── .terraform.lock.hcl │ ├── database.tf │ ├── providers.tf │ ├── terraform.tf │ └── variables.tf └── server │ ├── .terraform.lock.hcl │ ├── database.tf │ ├── providers.tf │ ├── terraform.tf │ └── variables.tf ├── package.json ├── scripts ├── bundle-yarn.js ├── clean.js ├── env.js ├── gcf-deploy.js ├── gcp-setup.js ├── github.js ├── package.json ├── post-install.js ├── setup.js └── utils.js ├── server ├── Dockerfile ├── README.md ├── core │ ├── auth.test.ts │ ├── auth.ts │ ├── db.ts │ ├── env.ts │ ├── index.ts │ ├── source-map-support.ts │ ├── storage.ts │ └── utils.ts ├── global.d.ts ├── index.ts ├── loaders │ ├── map.test.ts │ ├── map.ts │ └── user.ts ├── package.json ├── schema │ ├── builder.ts │ ├── index.ts │ ├── user.ts │ └── workspace.ts ├── start.ts ├── tsconfig.json └── 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 for the "local" development environment. 2 | # https://vitejs.dev/guide/env-and-mode.html#env-files 3 | # 4 | # NOTE: You can override any of these settings by placing them 5 | # into `.env.local` file in the root of the project. 6 | 7 | APP_NAME=example 8 | APP_ENV=local 9 | APP_HOSTNAME=localhost 10 | APP_ORIGIN=http://localhost:5173 11 | API_ORIGIN=http://localhost:8080 12 | API_SERVICE_ACCOUNT= 13 | VERSION=latest 14 | 15 | # Google Cloud 16 | # https://console.cloud.google.com/ 17 | GOOGLE_CLOUD_PROJECT=example-test 18 | GOOGLE_CLOUD_REGION=us-central1 19 | GOOGLE_CLOUD_ZONE=us-central1-f 20 | GOOGLE_CLOUD_SQL_INSTANCE=example-test:us-central1:pg14 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 / Firestore 24 | # https://console.firebase.google.com/ 25 | FIREBASE_APP_ID=xxxxx 26 | FIREBASE_API_KEY=xxxxx 27 | FIREBASE_AUTH_DOMAIN=example.com 28 | 29 | # Cloudflare 30 | # https://dash.cloudflare.com/ 31 | # https://developers.cloudflare.com/api/tokens/create 32 | CLOUDFLARE_ACCOUNT_ID=xxxxx 33 | CLOUDFLARE_ZONE_ID=xxxxx 34 | CLOUDFLARE_API_TOKEN=xxxxx 35 | 36 | # PostgreSQL 37 | # https://www.postgresql.org/docs/current/static/libpq-envars.html 38 | PGHOST=localhost 39 | PGPORT=5432 40 | PGUSER=postgres 41 | PGPASSWORD= 42 | PGDATABASE=app_local 43 | PGSSLMODE=disable 44 | # PGDEBUG=true 45 | 46 | # Cloud storage bucket for user uploaded content and static assets 47 | # https://console.cloud.google.com/storage/browser 48 | UPLOAD_BUCKET=test-upload.example.com 49 | STORAGE_BUCKET=test-s.example.com 50 | CACHE_BUCKET=test-c.example.com 51 | PKG_BUCKET=pkg.example.com 52 | 53 | # SendGrid 54 | # https://app.sendgrid.com/settings/api_keys 55 | SENDGRID_API_KEY=SG.xxxxx 56 | EMAIL_FROM=hello@example.com 57 | 58 | # Google Analytics (v4) 59 | # https://developers.google.com/analytics/devguides/collection/ga4 60 | GA_MEASUREMENT_ID=G-XXXXXXXX 61 | -------------------------------------------------------------------------------- /.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 | *.ejs text eol=lf 15 | *.hbs text eol=lf 16 | *.js text eol=lf 17 | *.json text eol=lf 18 | *.md text eol=lf 19 | *.sh text eol=lf 20 | *.ts text eol=lf 21 | *.txt text eol=lf 22 | *.xml text eol=lf 23 | 24 | /.yarn/** linguist-vendored 25 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to GraphQL API Starter Kit 2 | 3 | ♥ [GraphQL API Starter Kit](https://github.com/kriasoft/graphql-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/graphql-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//graphql-starter-kit.git 105 | # Navigate to the newly cloned directory 106 | cd graphql-starter-kit 107 | # Assign the original repo to a remote called "upstream" 108 | git remote add upstream https://github.com/kriasoft/graphql-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 | patreon: koistya 5 | -------------------------------------------------------------------------------- /.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/nightly.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow 2 | # https://help.github.com/actions 3 | 4 | name: "Nightly" 5 | 6 | on: 7 | schedule: 8 | - cron: "0 3 * * *" 9 | 10 | env: 11 | GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json 12 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | 15 | jobs: 16 | deploy: 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/cache@v3 22 | with: 23 | path: | 24 | ${{ github.workspace }}/.yarn/cache 25 | ${{ github.workspace }}/.yarn/unplugged 26 | ${{ github.workspace }}/.yarn/install-state.gz 27 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 28 | restore-keys: ${{ runner.os }}-yarn- 29 | 30 | # Install dependencies 31 | - name: yarn install 32 | run: | 33 | yarn config set enableGlobalCache false 34 | yarn install 35 | 36 | # Clean up review deployments for the merged PRs 37 | # - name: Run yarn gh:clean 38 | # env: { GCP_SA_KEY: "${{ secrets.GCP_SA_KEY }}" } 39 | # run: | 40 | # echo "$GCP_SA_KEY" | base64 --decode > "$GOOGLE_APPLICATION_CREDENTIALS" 41 | # gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS" 42 | # yarn gh:clean 43 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow 2 | # https://help.github.com/actions 3 | 4 | name: PR 5 | 6 | on: [pull_request] 7 | 8 | env: 9 | VERSION: ${{ github.event.pull_request.number }} 10 | HUSKY: 0 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | env: 17 | PGHOST: localhost 18 | PGPORT: 5432 19 | PGUSER: postgres 20 | PGPASSWORD: postgres 21 | PGDATABASE: postgres 22 | 23 | services: 24 | postgres: 25 | image: postgres:14-alpine 26 | env: 27 | POSTGRES_USER: ${{ env.PGUSER }} 28 | POSTGRES_PASSWORD: ${{ env.PGPASSWORD }} 29 | POSTGRES_DB: ${{ env.PGDATABASE }} 30 | ports: 31 | - 5432:5432 32 | 33 | steps: 34 | - uses: actions/checkout@v3 35 | - uses: actions/setup-node@v3 36 | with: 37 | node-version: 19 38 | cache: "yarn" 39 | 40 | # Install dependencies 41 | - run: yarn install 42 | 43 | # Analyze code for potential problems 44 | - run: yarn prettier --check . 45 | - run: yarn lint 46 | - run: yarn tsc --build 47 | 48 | # Setup test database 49 | - run: yarn db migrate --seed 50 | 51 | - run: echo "$GCP_SA_KEY" | base64 --decode > "$GOOGLE_APPLICATION_CREDENTIALS" 52 | env: 53 | GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json 54 | GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }} 55 | 56 | # Test 57 | # - run: yarn test 58 | # env: 59 | # GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json 60 | # FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} 61 | 62 | # Compile 63 | - run: yarn workspace api build 64 | - run: yarn workspace app relay 65 | - run: yarn workspace app build 66 | 67 | # Upload to a cloud storage bucket 68 | # - run: yarn workspaces foreach -p run push 69 | 70 | deploy: 71 | runs-on: ubuntu-latest 72 | timeout-minutes: 10 73 | needs: [build] 74 | steps: 75 | - uses: actions/checkout@v3 76 | - uses: actions/setup-node@v3 77 | with: 78 | node-version: 19 79 | cache: "yarn" 80 | 81 | # Install dependencies 82 | - name: yarn install 83 | run: | 84 | yarn config set enableGlobalCache false 85 | yarn install 86 | 87 | # TODO: Deploy from the previously built artifacts 88 | # - run: yarn workspaces foreach -p run deploy 89 | # env: 90 | # GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json 91 | # CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 92 | # SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} 93 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 94 | -------------------------------------------------------------------------------- /.github/workflows/pull_request_lint.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow 2 | # https://help.github.com/actions 3 | 4 | name: "conventionalcommits.org" 5 | 6 | on: 7 | pull_request: 8 | types: 9 | - opened 10 | - edited 11 | - synchronize 12 | 13 | jobs: 14 | main: 15 | name: lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: amannn/action-semantic-pull-request@v4 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | -------------------------------------------------------------------------------- /.github/workflows/push_main.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions workflow 2 | # https://help.github.com/actions 3 | 4 | name: Push (main) 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | 10 | env: 11 | VERSION: ${{ github.event.pull_request.number }} 12 | HUSKY: 0 13 | 14 | jobs: 15 | deploy: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 19 23 | cache: "yarn" 24 | 25 | # Install dependencies 26 | - run: yarn install 27 | 28 | # Authenticate Google Cloud SDK 29 | - name: gcloud auth activate-service-account 30 | env: 31 | GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json 32 | GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }} 33 | run: | 34 | echo "$GCP_SA_KEY" | base64 --decode > "$GOOGLE_APPLICATION_CREDENTIALS" 35 | gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS" 36 | 37 | # Build 38 | - run: yarn workspace app build 39 | - run: yarn workspace api build 40 | 41 | # Deploy 42 | # - run: yarn db migrate --seed --env=test 43 | # - run: yarn workspace api deploy --env=test 44 | # env: 45 | # GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json 46 | # SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} 47 | # - run: yarn workspace edge deploy --env=test 48 | # env: 49 | # CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 50 | -------------------------------------------------------------------------------- /.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 | 17 | # Node.js 18 | node_modules 19 | 20 | # Logs 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # Cache 25 | /.cache 26 | .eslintcache 27 | 28 | # Testing 29 | /coverage 30 | *.lcov 31 | 32 | # Environment variables 33 | .env.local 34 | .env.*.local 35 | .*.override.env 36 | /env/gcp-key.*.json 37 | !/env/gcp-key.test.json 38 | !/env/gcp-key.local.json 39 | 40 | # Visual Studio Code 41 | # https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore 42 | .vscode/* 43 | !.vscode/api.code-snippets 44 | !.vscode/db.code-snippets 45 | !.vscode/extensions.json 46 | !.vscode/launch.json 47 | !.vscode/settings.json 48 | !.vscode/web.code-snippets 49 | 50 | # Terraform 51 | # https://github.com/github/gitignore/blob/main/Terraform.gitignore 52 | **/.terraform/* 53 | *.tfstate 54 | *.tfstate.* 55 | *.tfvars 56 | *.tfvars.json 57 | override.tf 58 | override.tf.json 59 | *_override.tf 60 | *_override.tf.json 61 | crash.log 62 | crash.*.log 63 | .terraformrc 64 | 65 | # WebStorm 66 | .idea 67 | 68 | # macOS 69 | # https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 70 | .DS_Store 71 | -------------------------------------------------------------------------------- /.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 | /api/schema.graphql 5 | /db/types.ts 6 | /db/models/* 7 | 8 | # Cache 9 | /.cache 10 | 11 | # Yarn 12 | /.yarn 13 | /.pnp.* 14 | 15 | # TypeScript 16 | /tsconfig.base.json 17 | 18 | # Terraform 19 | .terraform 20 | *.tfvars.json 21 | 22 | # Misc 23 | /.husky 24 | *.hbs 25 | *.ejs 26 | 27 | -------------------------------------------------------------------------------- /.vscode/api.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Query": { 3 | "scope": "javascript,typescript", 4 | "prefix": "query-list", 5 | "body": [ 6 | "import { GraphQLFieldConfig, GraphQLList, GraphQLNonNull } from \"graphql\";", 7 | "import { Context, db, ${1/^(.)(.*?)s?$/${1:/upcase}$2/} } from \"../core\";", 8 | "import { ${1/^(.)(.*?)s?$/${1:/upcase}$2/}Type } from \"../types\";", 9 | "", 10 | "export const ${1:field}: GraphQLFieldConfig = {", 11 | " type: new GraphQLNonNull(new GraphQLList(${1/^(.)(.*?)s?$/${1:/upcase}$2/}Type)),", 12 | " description: \"${2:description}\",", 13 | "", 14 | " args: {},", 15 | "", 16 | " resolve(self, args, ctx) {", 17 | " return db.table<${1/^(.)(.*?)s?$/${1:/upcase}$2/}>(\"${1/^(.*?)s?$/$1/}\").select();", 18 | " },", 19 | "};", 20 | "", 21 | ], 22 | "description": "Query List Field", 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/db.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Database migration": { 3 | "scope": "javascript,typescript", 4 | "prefix": "migration", 5 | "body": [ 6 | "import type { Knex } from \"knex\";", 7 | "", 8 | "/**", 9 | " * Migrates database schema to the next version.", 10 | " * @see https://knexjs.org/#Schema", 11 | " */", 12 | "export async function up(db: Knex) {", 13 | " await db.schema.createTable(\"${1:table}\", (table) => {", 14 | " table$0", 15 | " table.timestamps(false, true);", 16 | " });", 17 | "}", 18 | "", 19 | "export async function down(db: Knex) {", 20 | " await db.schema.dropTableIfExists(\"${1:table}\");", 21 | "}", 22 | "", 23 | "export const configuration = { transaction: true };", 24 | "", 25 | ], 26 | }, 27 | "Database seed": { 28 | "scope": "javascript,typescript", 29 | "prefix": "seed", 30 | "body": [ 31 | "/**", 32 | " * @see https://knexjs.org/#Builder", 33 | " * @typedef {import(\"knex\")} Knex", 34 | " */", 35 | "", 36 | "module.exports.seed = async (/** @type {Knex} */ db) => {", 37 | " const ${1:table} = [", 38 | " $0", 39 | " ];", 40 | "", 41 | " await db.table(\"${1:table}\").delete();", 42 | " await db.table(\"${1:table}\").insert(${1:table});", 43 | "};", 44 | "", 45 | ], 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /.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 | "hashicorp.terraform", 12 | "mikestead.dotenv", 13 | "streetsidesoftware.code-spell-checker", 14 | "vscode-icons-team.vscode-icons", 15 | "zixuanchen.vitest-explorer" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "test:file", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "yarn", 12 | "runtimeArgs": ["test", "${file}", "--no-watch"], 13 | "skipFiles": ["/**", "node_modules/**"] 14 | }, 15 | { 16 | "name": "api:start", 17 | "request": "launch", 18 | "runtimeExecutable": "yarn", 19 | "runtimeArgs": ["workspace", "api", "run", "vite"], 20 | "skipFiles": ["/**"], 21 | "type": "node" 22 | }, 23 | { 24 | "name": "app:start", 25 | "request": "launch", 26 | "runtimeExecutable": "yarn", 27 | "runtimeArgs": ["workspace", "app", "run", "vite"], 28 | "skipFiles": ["/**"], 29 | "type": "node" 30 | }, 31 | { 32 | "type": "node", 33 | "request": "attach", 34 | "name": "Attach to Node.js", 35 | "processId": "${command:PickProcess}", 36 | "restart": true 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.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 | ".terraformrc": "terraform" 16 | }, 17 | "files.exclude": { 18 | "**/.cache": true, 19 | "**/.DS_Store": true, 20 | "**/.editorconfig": true, 21 | "**/.eslintcache": true, 22 | "**/.git": true, 23 | "**/.gitattributes": true, 24 | "**/.husky": true, 25 | "**/.pnp.*": true, 26 | "**/.prettierignore": true, 27 | "**/node_modules": true, 28 | "**/yarn.lock": true 29 | }, 30 | "search.exclude": { 31 | "**/dist/": true, 32 | "**/.pnp.*": true, 33 | "**/.yarn": true, 34 | "**/yarn-error.log": true, 35 | "**/yarn.lock": true 36 | }, 37 | "terminal.integrated.env.linux": { 38 | "TF_CLI_CONFIG_FILE": "${workspaceFolder}/.terraformrc", 39 | "CLOUDSDK_ACTIVE_CONFIG_NAME": "default", 40 | "CACHE_DIR": "${workspaceFolder}/.cache" 41 | }, 42 | "terminal.integrated.env.osx": { 43 | "TF_CLI_CONFIG_FILE": "${workspaceFolder}/.terraformrc", 44 | "CLOUDSDK_ACTIVE_CONFIG_NAME": "default", 45 | "CACHE_DIR": "${workspaceFolder}/.cache" 46 | }, 47 | "terminal.integrated.env.windows": { 48 | "TF_CLI_CONFIG_FILE": "${workspaceFolder}/.terraformrc", 49 | "CLOUDSDK_ACTIVE_CONFIG_NAME": "default", 50 | "CACHE_DIR": "${workspaceFolder}\\.cache" 51 | }, 52 | "cSpell.ignoreWords": [ 53 | "abcdefghijklmnopqrstuvwxyz", 54 | "browserslist", 55 | "citext", 56 | "cloudfunctions", 57 | "cloudsql", 58 | "corejs", 59 | "dataloader", 60 | "datname", 61 | "devtool", 62 | "downlevel", 63 | "endregion", 64 | "entrypoint", 65 | "envalid", 66 | "envars", 67 | "eslintcache", 68 | "esmodules", 69 | "esnext", 70 | "execa", 71 | "firebaseapp", 72 | "firestore", 73 | "gcloud", 74 | "gcloudsdk", 75 | "globby", 76 | "GraphQLID", 77 | "gsutil", 78 | "gtag", 79 | "hono", 80 | "identitytoolkit", 81 | "jsonb", 82 | "kanel", 83 | "knexfile", 84 | "kriasoft", 85 | "linkedin", 86 | "miniflare", 87 | "nanospinner", 88 | "nothrow", 89 | "notistack", 90 | "octokit", 91 | "pathinfo", 92 | "pgappname", 93 | "pgdatabase", 94 | "pgdebug", 95 | "pghost", 96 | "pgpassword", 97 | "pgport", 98 | "pgservername", 99 | "pgsslcert", 100 | "pgsslkey", 101 | "pgsslmode", 102 | "pgsslrootcert", 103 | "pguser", 104 | "pnpify", 105 | "postgres", 106 | "pothos", 107 | "psql", 108 | "reactstarter", 109 | "recase", 110 | "refetchable", 111 | "relyingparty", 112 | "sendgrid", 113 | "signup", 114 | "sourcemap", 115 | "spdx", 116 | "terraformrc", 117 | "tfvars", 118 | "timestamptz", 119 | "tslib", 120 | "typechecking", 121 | "unprocessable", 122 | "upsert", 123 | "vite", 124 | "vitest", 125 | "webflow", 126 | "websql", 127 | "yarnpkg", 128 | "yarnrc" 129 | ], 130 | "[terraform]": { 131 | "editor.defaultFormatter": "hashicorp.terraform", 132 | "editor.formatOnSave": true, 133 | "editor.formatOnSaveMode": "file" 134 | }, 135 | "[terraform-vars]": { 136 | "editor.defaultFormatter": "hashicorp.terraform", 137 | "editor.formatOnSave": true, 138 | "editor.formatOnSaveMode": "file" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /.vscode/web.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "React": { 3 | "scope": "javascriptreact,typescriptreact", 4 | "prefix": "react", 5 | "body": [ 6 | "import { ${1:Box}, ${1}Props } from \"@mui/material\";", 7 | "import * as React from \"react\";", 8 | "", 9 | "type ${TM_FILENAME_BASE}Props = Omit<${1}Props, \"children\">;", 10 | "", 11 | "function ${TM_FILENAME_BASE}(props: ${TM_FILENAME_BASE}Props): JSX.Element {", 12 | " const { sx, ...other } = props;", 13 | "", 14 | " return (", 15 | " <$1 sx={{ ...sx }} {...other}>", 16 | " $0", 17 | " ", 18 | " );", 19 | "}", 20 | "", 21 | "export { ${TM_FILENAME_BASE}, type ${TM_FILENAME_BASE}Props };", 22 | "", 23 | ], 24 | "description": "React Component", 25 | }, 26 | "Mutation": { 27 | "scope": "typescript", 28 | "prefix": "mutation", 29 | "body": [ 30 | "import * as React from \"react\";", 31 | "import { graphql, useMutation } from \"react-relay\";", 32 | "import { ${1/(.)(.*)/${1:/upcase}$2/}Input } from \"./__generated__/${TM_FILENAME_BASE/\\.hooks//}Mutation.graphql\";", 33 | "", 34 | "const mutation = graphql`", 35 | " mutation ${TM_FILENAME_BASE/\\.hooks//}Mutation(\\$input: ${1/(.)(.*)/${1:/upcase}$2/}Input!) {", 36 | " ${1:action}(input: \\$input) {", 37 | " ${2:payload}", 38 | " }", 39 | " }", 40 | "`;", 41 | "", 42 | "type Input = ${1/(.)(.*)/${1:/upcase}$2/}Input;", 43 | "type InputErrors = { [key in keyof Input | \"_\"]?: string[] };", 44 | "", 45 | "function use${1/(.)(.*)/${1:/upcase}$2/}(): {", 46 | " input: Input;", 47 | " errors: InputErrors;", 48 | " loading: boolean;", 49 | " handleChange: React.ChangeEventHandler;", 50 | " handleSubmit: React.FormEventHandler;", 51 | "} {", 52 | " const [commit, loading] = useMutation(mutation);", 53 | " const [errors, setErrors] = React.useState({});", 54 | " const [input, setInput] = React.useState({", 55 | " $0", 56 | " });", 57 | "", 58 | " const handleChange = React.useCallback(", 59 | " (event: React.ChangeEvent) => {", 60 | " const { name, value } = event.target;", 61 | " setInput((prev) => ({ ...prev, [name]: value }));", 62 | " },", 63 | " [],", 64 | " );", 65 | "", 66 | " const handleSubmit = React.useCallback(", 67 | " (event: React.FormEvent) => {", 68 | " event.preventDefault();", 69 | " commit({", 70 | " variables: { input },", 71 | " onCompleted(res, errors) {", 72 | " const err = errors?.[0];", 73 | " if (err) {", 74 | " setErrors(err.errors || { _: [err.message] });", 75 | " } else {", 76 | " setErrors({});", 77 | " }", 78 | " },", 79 | " });", 80 | " },", 81 | " [input],", 82 | " );", 83 | "", 84 | " return React.useMemo(", 85 | " () => ({ input, errors, loading, handleChange, handleSubmit }),", 86 | " [input, errors, loading, handleChange, handleSubmit],", 87 | " );", 88 | "}", 89 | "", 90 | "export { use${1}, type Input, type InputErrors };", 91 | "", 92 | ], 93 | "description": "Mutation React Hook", 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/bin/eslint.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint/bin/eslint.js 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint/bin/eslint.js your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint/bin/eslint.js`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/types/index.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/types/rules/index.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint/rules 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint/rules your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint/rules`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/types/universal.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint/universal 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint/universal your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint/universal`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/types/use-at-your-own-risk.d.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint/use-at-your-own-risk 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint/use-at-your-own-risk your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint/use-at-your-own-risk`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/universal.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint/universal 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint/universal your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint/universal`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/unsupported-api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require eslint/use-at-your-own-risk 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real eslint/use-at-your-own-risk your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`eslint/use-at-your-own-risk`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "9.14.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs", 6 | "bin": { 7 | "eslint": "./bin/eslint.js" 8 | }, 9 | "exports": { 10 | ".": { 11 | "types": "./lib/types/index.d.ts", 12 | "default": "./lib/api.js" 13 | }, 14 | "./package.json": "./package.json", 15 | "./use-at-your-own-risk": { 16 | "types": "./lib/types/use-at-your-own-risk.d.ts", 17 | "default": "./lib/unsupported-api.js" 18 | }, 19 | "./rules": { 20 | "types": "./lib/types/rules/index.d.ts" 21 | }, 22 | "./universal": { 23 | "types": "./lib/types/universal.d.ts", 24 | "default": "./lib/universal.js" 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.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, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require prettier/bin/prettier.cjs 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real prettier/bin/prettier.cjs your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`prettier/bin/prettier.cjs`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require prettier 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real prettier your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`prettier`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "3.2.5-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, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/bin/tsc 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript/bin/tsc your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsc`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/bin/tsserver 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript/bin/tsserver your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript/bin/tsserver`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript/lib/tsc.js 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript/lib/tsc.js your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript/lib/tsc.js`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, register} = require(`module`); 5 | const {resolve} = require(`path`); 6 | const {pathToFileURL} = require(`url`); 7 | 8 | const relPnpApiPath = "../../../../.pnp.cjs"; 9 | 10 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 11 | const absUserWrapperPath = resolve(__dirname, `./sdk.user.cjs`); 12 | const absRequire = createRequire(absPnpApiPath); 13 | 14 | const absPnpLoaderPath = resolve(absPnpApiPath, `../.pnp.loader.mjs`); 15 | const isPnpLoaderEnabled = existsSync(absPnpLoaderPath); 16 | 17 | if (existsSync(absPnpApiPath)) { 18 | if (!process.versions.pnp) { 19 | // Setup the environment to be able to require typescript 20 | require(absPnpApiPath).setup(); 21 | if (isPnpLoaderEnabled && register) { 22 | register(pathToFileURL(absPnpLoaderPath)); 23 | } 24 | } 25 | } 26 | 27 | const wrapWithUserWrapper = existsSync(absUserWrapperPath) 28 | ? exports => absRequire(absUserWrapperPath)(exports) 29 | : exports => exports; 30 | 31 | // Defer to the real typescript your application uses 32 | module.exports = wrapWithUserWrapper(absRequire(`typescript`)); 33 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "5.6.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 | babel-plugin-relay@*: 9 | dependencies: 10 | "@babel/runtime": "*" 11 | graphql: ^16.8.1 12 | kanel-zod@*: 13 | dependencies: 14 | kanel: "*" 15 | ramda: "*" 16 | local-pkg@*: 17 | dependencies: 18 | happy-dom: "*" 19 | 20 | pnpEnableEsmLoader: true 21 | 22 | yarnPath: .yarn/releases/yarn-4.5.1.cjs 23 | -------------------------------------------------------------------------------- /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 |

2 | GraphQL
3 | GraphQL Starter Kit 4 |
5 | 6 | 7 | 8 | 9 | 10 |

11 | 12 | High-performance GraphQL API server, database dev tools, and React front-end. 13 | 14 | ## Features 15 | 16 | - [Monorepo](https://yarnpkg.com/features/workspaces) project structure powered by [Yarn](https://yarnpkg.com/) with [PnP](https://yarnpkg.com/features/pnp). 17 | - [GraphQL API](https://graphql.org/) powered by [GraphQL Yoga](https://the-guild.dev/graphql/yoga-server), [Pothos GraphQL](https://pothos-graphql.dev/), and [μWebSockets](https://github.com/uNetworking/uWebSockets.js). 18 | - Authentication and authorization powered by [Google Identity Platform](https://cloud.google.com/identity-platform). 19 | - Database tooling — seed files, migrations, [Knex.js](https://knexjs.org/) REPL shell, etc. 20 | - Front-end boilerplate pre-configured with [TypeScript](https://www.typescriptlang.org/), [Vite](https://vitejs.dev/), [React](https://beta.reactjs.org/), and [Joy UI](https://mui.com/joy-ui/getting-started/). 21 | - Pre-configured dev, test / QA, production, and preview environments. 22 | - Pre-configured VSCode code snippets and other [VSCode](https://code.visualstudio.com/) settings. 23 | - The ongoing design and development is supported by these wonderful companies: 24 | 25 |      26 | 27 | --- 28 | 29 | This project was bootstrapped with [GraphQL Starter Kit](https://github.com/kriasoft/graphql-starter-kit). 30 | Be sure to join our [Discord channel](https://discord.com/invite/bSsv7XM) for assistance. 31 | 32 | ## Directory Structure 33 | 34 | `├──`[`.github`](.github) — GitHub configuration including CI/CD workflows.
35 | `├──`[`.vscode`](.vscode) — VSCode settings including code snippets, recommended extensions etc.
36 | `├──`[`app`](./app) — front-end application ([Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), [React](https://reactjs.org/), [Joy UI](https://mui.com/joy-ui/getting-started/templates/)).
37 | `├──`[`db`](./db) — database schema, seeds, and migrations ([PostgreSQL](https://www.postgresql.org/)).
38 | `├──`[`infra`](./infra) — cloud infrastructure configuration ([Terraform](https://www.terraform.io/)).
39 | `├──`[`scripts`](./scripts) — automation scripts shared across the project.
40 | `├──`[`server`](./server) — backend server ([GraphQL Yoga](https://the-guild.dev/graphql/yoga-server), [Pothos GraphQL](https://pothos-graphql.dev/)).
41 | `└── ...` — add more packages such as `worker`, `admin`, `mobile`, etc. 42 | 43 | ## Requirements 44 | 45 | - [Node.js](https://nodejs.org/) v20 or newer with [Corepack](https://nodejs.org/api/corepack.html) enabled. 46 | - Local or remote instance of [PostgreSQL](https://www.postgresql.org/). 47 | - [VS Code](https://code.visualstudio.com/) editor with [recommended extensions](.vscode/extensions.json). 48 | 49 | ## Getting Started 50 | 51 | Just [clone](https://github.com/kriasoft/graphql-starter-kit/generate) the repo 52 | and, install project dependencies and bootstrap the PostgreSQL database: 53 | 54 | ```bash 55 | $ git clone https://github.com/kriasoft/graphql-starter-kit.git example 56 | $ cd ./example # Change current directory to the newly created one 57 | $ corepack enable # Ensure Yarn is installed 58 | $ yarn install # Install project dependencies 59 | $ yarn db create # Create a new database if doesn't exist 60 | $ yarn db migrate --seed # Migrate and seed the database 61 | ``` 62 | 63 | From there on, you can launch the app by running: 64 | 65 | ```bash 66 | $ yarn workspace server start # Or, `yarn server:start` 67 | $ yarn workspace app start # Or, `yarn app:start` 68 | ``` 69 | 70 | The GraphQL API server should become available at [http://localhost:8080/](http://localhost:8080/).
71 | While the front-end server should be running at [http://localhost:5173/](http://localhost:5173/). 72 | 73 | **IMPORTANT**: Tap `Shift`+`Cmd`+`P` in VSCode, run the **TypeScript: Select TypeScript Version** command and select the workspace version. 74 | 75 | ## How to Update 76 | 77 | In the case when you kept the original GraphQL Starter Kit git history, you can 78 | always pull and merge updates from the "seed" repository back into your 79 | project by running: 80 | 81 | ```bash 82 | $ git fetch seed # Fetch GraphQL Starter Kit (seed) repository 83 | $ git checkout main # Switch to the main branch (or, master branch) 84 | $ git merge seed/main # Merge upstream/master into the local branch 85 | ``` 86 | 87 | In order to update Yarn and other dependencies to the latest versions, run: 88 | 89 | ```bash 90 | $ yarn set version latest # Upgrade Yarn CLI to the latest version 91 | $ yarn upgrade-interactive # Bump Node.js dependencies using an interactive mode 92 | $ yarn install # Install the updated Node.js dependencies 93 | $ yarn dlx @yarnpkg/sdks vscode # Update VSCode settings 94 | ``` 95 | 96 | ## Backers 97 | 98 |                99 | 100 | ## How to Contribute 101 | 102 | We welcome contributions through pull requests and issues on our GitHub repository. Feel free to also start a conversation on our [Discord server](https://discord.com/invite/PkRad23) to discuss potential contributions or seek guidance. 103 | 104 | ## License 105 | 106 | Copyright © 2014-present Kriasoft. This source code is licensed under the MIT license found in the 107 | [LICENSE](https://github.com/kriasoft/graphql-starter-kit/blob/main/LICENSE) file. 108 | -------------------------------------------------------------------------------- /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 | }, [...deps, location, options?.title]); 31 | 32 | // Send "page view" event to Google Analytics 33 | // https://support.google.com/analytics/answer/11403294?hl=en 34 | React.useEffect(() => { 35 | if (!(options?.trackPageView === false)) { 36 | logEvent(getAnalytics(), "page_view", { 37 | page_title: options?.title ?? appName, 38 | page_path: `${location.pathname}${location.search}`, 39 | }); 40 | } 41 | }, [location, options?.title, options?.trackPageView]); 42 | } 43 | 44 | type Options = { 45 | title?: string; 46 | /** @default true */ 47 | trackPageView?: boolean; 48 | }; 49 | -------------------------------------------------------------------------------- /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 | 29 | if (import.meta.hot) { 30 | import.meta.hot.dispose(() => root.unmount()); 31 | } 32 | -------------------------------------------------------------------------------- /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.35", 23 | "@mui/icons-material": "^5.15.8", 24 | "@mui/joy": "^5.0.0-beta.26", 25 | "@mui/lab": "^5.0.0-alpha.164", 26 | "@mui/material": "^5.15.8", 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 | "@rollup/plugin-graphql": "^2.0.4", 40 | "@types/node": "^22.9.0", 41 | "@types/react": "^18.2.55", 42 | "@types/react-dom": "^18.2.19", 43 | "@vitejs/plugin-react": "^4.3.3", 44 | "envars": "^1.0.2", 45 | "happy-dom": "^13.3.8", 46 | "typescript": "~5.6.3", 47 | "vite": "~5.4.10", 48 | "vitest": "~2.1.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/graphql-starter-kit/d35106349570002a771d70f7187c5030dd2bd7fe/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/graphql-starter-kit/d35106349570002a771d70f7187c5030dd2bd7fe/app/public/logo192.png -------------------------------------------------------------------------------- /app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/graphql-starter-kit/d35106349570002a771d70f7187c5030dd2bd7fe/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/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/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 | # Database Schema and Administration Tools 2 | 3 | This folder contains database schema migration files, seed files, and administration tools for the [PostgreSQL](https://www.postgresql.org/) database. 4 | 5 | ## Directory Layout 6 | 7 | ```bash 8 | . 9 | ├── backups # Database backup files 10 | │ └── ... # - for example "20240101T120000Z.sql" 11 | ├── migrations # Database schema migration files 12 | │ ├── 001_initial.ts # - initial schema 13 | │ └── ... # - the reset of the migration files 14 | ├── models # Data models and Zod validators (generated) 15 | │ ├── User.ts # - User model 16 | │ ├── Workspace.ts # - Workspace model 17 | │ └── ... # - the reset of the data models 18 | ├── seeds # Database seed files 19 | │ ├── 01-users.ts # - user account 20 | │ ├── 02-workspaces.ts # - user workspaces 21 | │ └── ... # - the reset of the seed files 22 | ├── ssl # TLS/SSL certificates for database access 23 | ├── cli.ts # Database administration CLI 24 | ├── package.json # Node.js dependencies 25 | └── README.md # This file 26 | ``` 27 | 28 | ## Tech Stack 29 | 30 | - **[PostgreSQL](https://www.postgresql.org/)**: db server with vector database capabilities. 31 | - **[CloudSQL Node.js Connector](https://github.com/GoogleCloudPlatform/cloud-sql-nodejs-connector#readme)**: secure tunnel connection to [Cloud SQL](https://cloud.google.com/sql/postgresql). 32 | - **[Knex.js](https://knexjs.org/)**: database client and schema migration tools. 33 | - **[Kanel](https://github.com/kristiandupont/kanel#readme)**: generates TypeScript types from a database. 34 | - **[Commander](https://github.com/tj/commander.js#readme)**: Command-line interface builder. 35 | - **[Node.js](https://nodejs.org/)** with [TypeScript](https://www.typescriptlang.org/) and [Yarn](https://yarnpkg.com/) package manager. 36 | 37 | ## Getting Started 38 | 39 | Ensure that you have the recent version of PostgreSQL installed on your machine as well as `psql` and `pg_dump` client utilities. On macOS, you can install them using [Homebrew](https://brew.sh/): 40 | 41 | ```bash 42 | $ brew update 43 | $ brew install postgresql libpq 44 | $ brew services start postgresql 45 | ``` 46 | 47 | You may need a GUI tool such as [Postico](https://eggerapps.at/postico/) to access the database. 48 | 49 | Once the PostgreSQL server is up and running, you can update the database connection settings inside of the [`.env`](../.env) (or, `.env.local`) file and bootstrap the database (schema and data) by running: 50 | 51 | ```bash 52 | $ yarn db --version # Check current database version 53 | $ yarn db create # Create a new database 54 | $ yarn db migrate --seed # Run all migrations and seeds 55 | $ yarn db types # Generate TypeScript types 56 | ``` 57 | 58 | To see all available commands, run `yarn db --help`: 59 | 60 | ``` 61 | Usage: db [options] [command] 62 | 63 | Database management CLI for PostgreSQL 64 | 65 | Options: 66 | -i, --interactive launch interactive terminal with Knex.js 67 | --env target environment (e.g. prod, staging, test) 68 | -v, --version current database and migration versions 69 | --schema database schema (default: "public") 70 | -h, --help display help for command 71 | 72 | Commands: 73 | create [options] create a new database if doesn't exist 74 | migrate [options] run all migrations that have not yet been run 75 | rollback [options] rollback the last batch of migrations performed 76 | backup [options] create a backup of the database data 77 | restore [options] restore database data from a backup file 78 | types [options] generate TypeScript types from a live db 79 | psql [options] launch PostgreSQL interactive terminal 80 | ``` 81 | 82 | In order to create a new migration, create a new `_.ts` file inside of the [`migrations`](./migrations) folder, give it a descriptive name prefixed with the migration version number, for example `002_products.ts`. Open it in the editor, start typing `migration` and hit `Tab` key which should insert the following VS Code snippet: 83 | 84 |

85 | 86 | To apply the migration, run: 87 | 88 | ``` 89 | $ yarn db migrate [--env ] [--seed] 90 | ``` 91 | 92 | If you need to rollback the migration, run: 93 | 94 | ``` 95 | $ yarn db rollback [--env ] [--all] 96 | ``` 97 | 98 | ## License 99 | 100 | Copyright © 2014-present Kriasoft. This source code is licensed under the MIT license found in the 101 | [LICENSE](https://github.com/kriasoft/relay-starter-kit/blob/main/LICENSE) file. 102 | -------------------------------------------------------------------------------- /db/backups/.gitignore: -------------------------------------------------------------------------------- 1 | *.sql 2 | -------------------------------------------------------------------------------- /db/migrations/001_initial.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import type { Knex } from "knex"; 5 | 6 | /** 7 | * The initial database schema (migration). 8 | * @see https://knexjs.org/#Schema 9 | */ 10 | export async function up(db: Knex) { 11 | // PostgreSQL extensions. 12 | // https://cloud.google.com/sql/docs/postgres/extensions 13 | await db.raw(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); 14 | await db.raw(`CREATE EXTENSION IF NOT EXISTS "hstore"`); 15 | await db.raw(`CREATE EXTENSION IF NOT EXISTS "citext"`); 16 | // await db.raw(`CREATE EXTENSION IF NOT EXISTS "pgvector"`); 17 | 18 | // User roles. 19 | await db.raw(`CREATE TYPE user_role AS ENUM ('owner', 'member')`); 20 | 21 | // User accounts (excluding fields from the auth provider). 22 | await db.schema.createTable("user", (table) => { 23 | table.string("id", 40).notNullable().primary(); 24 | table.string("time_zone", 50); // E.g. "America/New_York" 25 | table.string("locale", 10); // E.g. "en-US" 26 | }); 27 | 28 | // User workspaces/organizations. 29 | await db.schema.createTable("workspace", (table) => { 30 | table.string("id", 40).notNullable().primary(); 31 | table.string("owner_id", 40).notNullable(); 32 | table.string("name", 50).notNullable(); 33 | table.timestamp("created_at").notNullable().defaultTo(db.fn.now()); 34 | table.timestamp("updated_at").notNullable().defaultTo(db.fn.now()); 35 | 36 | table.foreign("owner_id").references("user.id").onUpdate("CASCADE"); 37 | }); 38 | 39 | // User memberships in workspaces/organizations. 40 | await db.schema.createTable("workspace_member", (table) => { 41 | table.string("workspace_id", 40).notNullable(); 42 | table.string("user_id", 40).notNullable(); 43 | table.specificType("role", "user_role").notNullable().defaultTo("member"); 44 | 45 | table.primary(["workspace_id", "user_id"]); 46 | table 47 | .foreign("workspace_id") 48 | .references("workspace.id") 49 | .onUpdate("CASCADE") 50 | .onDelete("CASCADE"); 51 | table 52 | .foreign("user_id") 53 | .references("user.id") 54 | .onUpdate("CASCADE") 55 | .onDelete("CASCADE"); 56 | }); 57 | } 58 | 59 | /** 60 | * Rollback function for the migration. 61 | */ 62 | export async function down(db: Knex) { 63 | await db.schema.dropTableIfExists("workspace_member"); 64 | await db.schema.dropTableIfExists("workspace"); 65 | await db.schema.dropTableIfExists("user"); 66 | await db.raw(`DROP TYPE IF EXISTS user_role`); 67 | } 68 | 69 | export const config = { transaction: true }; 70 | -------------------------------------------------------------------------------- /db/models/User.ts: -------------------------------------------------------------------------------- 1 | // @generated 2 | // This file is automatically generated by Kanel. Do not modify manually. 3 | 4 | import { z } from 'zod'; 5 | 6 | /** Identifier type for public.user */ 7 | export type UserId = string & { __brand: 'UserId' }; 8 | 9 | /** Represents the table public.user */ 10 | export default interface User { 11 | id: UserId; 12 | 13 | time_zone: string | null; 14 | 15 | locale: string | null; 16 | } 17 | 18 | /** Represents the initializer for the table public.user */ 19 | export interface UserInitializer { 20 | id: UserId; 21 | 22 | time_zone?: string | null; 23 | 24 | locale?: string | null; 25 | } 26 | 27 | /** Represents the mutator for the table public.user */ 28 | export interface UserMutator { 29 | id?: UserId; 30 | 31 | time_zone?: string | null; 32 | 33 | locale?: string | null; 34 | } 35 | 36 | export const userId = z.string() as unknown as z.Schema; 37 | 38 | export const user = z.object({ 39 | id: userId, 40 | time_zone: z.string().nullable(), 41 | locale: z.string().nullable(), 42 | }) as unknown as z.Schema; 43 | 44 | export const userInitializer = z.object({ 45 | id: userId, 46 | time_zone: z.string().optional().nullable(), 47 | locale: z.string().optional().nullable(), 48 | }) as unknown as z.Schema; 49 | 50 | export const userMutator = z.object({ 51 | id: userId.optional(), 52 | time_zone: z.string().optional().nullable(), 53 | locale: z.string().optional().nullable(), 54 | }) as unknown as z.Schema; 55 | -------------------------------------------------------------------------------- /db/models/UserRole.ts: -------------------------------------------------------------------------------- 1 | // @generated 2 | // This file is automatically generated by Kanel. Do not modify manually. 3 | 4 | import { z } from 'zod'; 5 | 6 | /** Represents the enum public.user_role */ 7 | enum UserRole { 8 | owner = 'owner', 9 | member = 'member', 10 | }; 11 | 12 | export default UserRole; 13 | 14 | /** Zod schema for user_role */ 15 | export const userRole = z.enum([ 16 | 'owner', 17 | 'member', 18 | ]);; 19 | -------------------------------------------------------------------------------- /db/models/Workspace.ts: -------------------------------------------------------------------------------- 1 | // @generated 2 | // This file is automatically generated by Kanel. Do not modify manually. 3 | 4 | import { userId, type UserId } from './User'; 5 | import { z } from 'zod'; 6 | 7 | /** Identifier type for public.workspace */ 8 | export type WorkspaceId = string & { __brand: 'WorkspaceId' }; 9 | 10 | /** Represents the table public.workspace */ 11 | export default interface Workspace { 12 | id: WorkspaceId; 13 | 14 | owner_id: UserId; 15 | 16 | name: string; 17 | 18 | created_at: Date; 19 | 20 | updated_at: Date; 21 | } 22 | 23 | /** Represents the initializer for the table public.workspace */ 24 | export interface WorkspaceInitializer { 25 | id: WorkspaceId; 26 | 27 | owner_id: UserId; 28 | 29 | name: string; 30 | 31 | /** Default value: CURRENT_TIMESTAMP */ 32 | created_at?: Date; 33 | 34 | /** Default value: CURRENT_TIMESTAMP */ 35 | updated_at?: Date; 36 | } 37 | 38 | /** Represents the mutator for the table public.workspace */ 39 | export interface WorkspaceMutator { 40 | id?: WorkspaceId; 41 | 42 | owner_id?: UserId; 43 | 44 | name?: string; 45 | 46 | created_at?: Date; 47 | 48 | updated_at?: Date; 49 | } 50 | 51 | export const workspaceId = z.string() as unknown as z.Schema; 52 | 53 | export const workspace = z.object({ 54 | id: workspaceId, 55 | owner_id: userId, 56 | name: z.string(), 57 | created_at: z.date(), 58 | updated_at: z.date(), 59 | }) as unknown as z.Schema; 60 | 61 | export const workspaceInitializer = z.object({ 62 | id: workspaceId, 63 | owner_id: userId, 64 | name: z.string(), 65 | created_at: z.date().optional(), 66 | updated_at: z.date().optional(), 67 | }) as unknown as z.Schema; 68 | 69 | export const workspaceMutator = z.object({ 70 | id: workspaceId.optional(), 71 | owner_id: userId.optional(), 72 | name: z.string().optional(), 73 | created_at: z.date().optional(), 74 | updated_at: z.date().optional(), 75 | }) as unknown as z.Schema; 76 | -------------------------------------------------------------------------------- /db/models/WorkspaceMember.ts: -------------------------------------------------------------------------------- 1 | // @generated 2 | // This file is automatically generated by Kanel. Do not modify manually. 3 | 4 | import { workspaceId, type WorkspaceId } from './Workspace'; 5 | import { userId, type UserId } from './User'; 6 | import { userRole, type default as UserRole } from './UserRole'; 7 | import { z } from 'zod'; 8 | 9 | /** Represents the table public.workspace_member */ 10 | export default interface WorkspaceMember { 11 | workspace_id: WorkspaceId; 12 | 13 | user_id: UserId; 14 | 15 | role: UserRole; 16 | } 17 | 18 | /** Represents the initializer for the table public.workspace_member */ 19 | export interface WorkspaceMemberInitializer { 20 | workspace_id: WorkspaceId; 21 | 22 | user_id: UserId; 23 | 24 | /** Default value: 'member'::user_role */ 25 | role?: UserRole; 26 | } 27 | 28 | /** Represents the mutator for the table public.workspace_member */ 29 | export interface WorkspaceMemberMutator { 30 | workspace_id?: WorkspaceId; 31 | 32 | user_id?: UserId; 33 | 34 | role?: UserRole; 35 | } 36 | 37 | export const workspaceMember = z.object({ 38 | workspace_id: workspaceId, 39 | user_id: userId, 40 | role: userRole, 41 | }) as unknown as z.Schema; 42 | 43 | export const workspaceMemberInitializer = z.object({ 44 | workspace_id: workspaceId, 45 | user_id: userId, 46 | role: userRole.optional(), 47 | }) as unknown as z.Schema; 48 | 49 | export const workspaceMemberMutator = z.object({ 50 | workspace_id: workspaceId.optional(), 51 | user_id: userId.optional(), 52 | role: userRole.optional(), 53 | }) as unknown as z.Schema; 54 | -------------------------------------------------------------------------------- /db/models/index.ts: -------------------------------------------------------------------------------- 1 | // @generated 2 | // This file is automatically generated by Kanel. Do not modify manually. 3 | 4 | export { type UserId, type default as User, type UserInitializer, type UserMutator, userId, user, userInitializer, userMutator } from './User'; 5 | export { type WorkspaceId, type default as Workspace, type WorkspaceInitializer, type WorkspaceMutator, workspaceId, workspace, workspaceInitializer, workspaceMutator } from './Workspace'; 6 | export { type default as WorkspaceMember, type WorkspaceMemberInitializer, type WorkspaceMemberMutator, workspaceMember, workspaceMemberInitializer, workspaceMemberMutator } from './WorkspaceMember'; 7 | export { default as UserRole, userRole } from './UserRole'; 8 | -------------------------------------------------------------------------------- /db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | "./models": "./models/index.ts", 8 | "./package.json": "./package.json" 9 | }, 10 | "scripts": { 11 | "db": "tsx ./cli.ts" 12 | }, 13 | "dependencies": { 14 | "zod": "^3.22.4" 15 | }, 16 | "devDependencies": { 17 | "@google-cloud/cloud-sql-connector": "^1.2.2", 18 | "@googleapis/identitytoolkit": "^8.0.1", 19 | "@types/node": "^22.9.0", 20 | "chalk": "^5.3.0", 21 | "commander": "^12.0.0", 22 | "dotenv": "^16.4.1", 23 | "execa": "^8.0.1", 24 | "kanel": "^3.8.7", 25 | "kanel-zod": "^1.3.3", 26 | "knex": "^3.1.0", 27 | "ora": "^8.0.1", 28 | "pg": "^8.11.3", 29 | "tsx": "~4.19.2", 30 | "typescript": "~5.6.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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/ssl/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriasoft/graphql-starter-kit/d35106349570002a771d70f7187c5030dd2bd7fe/db/ssl/.gitignore -------------------------------------------------------------------------------- /db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext"], 5 | "noEmit": true, 6 | "outDir": "../.cache/ts-db", 7 | "types": ["node"] 8 | }, 9 | "include": ["**/*.ts", "**/*.cjs", "**/*.js", "**/*.json"], 10 | "exclude": ["dist/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | This project is using [Google Identity Platform](https://cloud.google.com/identity-platform) — a cloud-based customer identity and access management platform. 4 | 5 | ### Cloud Resources 6 | 7 | - [Users](https://console.cloud.google.com/customer-identity/users?project=example) ([test](https://console.cloud.google.com/customer-identity/users?project=example-test)) 8 | - [Providers](https://console.cloud.google.com/customer-identity/providers?project=example) ([test](https://console.cloud.google.com/customer-identity/providers?project=example-test)) 9 | - [Settings](https://console.cloud.google.com/customer-identity/settings?project=example) ([test](https://console.cloud.google.com/customer-identity/settings?project=example-test)) 10 | - [Firestore: `users`](https://console.cloud.google.com/firestore/data/panel/users?project=example) ([test](https://console.cloud.google.com/firestore/data/panel/users?project=example-test)) 11 | 12 | ### How to check the user's authentication state? 13 | 14 | ```ts 15 | import { getAuth } from "firebase/auth"; 16 | 17 | const me = getAuth().currentUser; 18 | ``` 19 | 20 | ... or, using React hook: 21 | 22 | ```tsx 23 | import { useCurrentUser } from "../core/auth.js"; 24 | 25 | function Example(): JSX.Element { 26 | const me = useCurrentUser(); 27 | } 28 | ``` 29 | 30 | ### How to authenticate HTTP requests to the GraphQL API? 31 | 32 | By passing `Authorization: Bearer ` HTTP header, for example: 33 | 34 | ```ts 35 | const idToken = await auth.currentUser?.getIdToken(); 36 | const res = await fetch("/api", { 37 | method: "POST", 38 | headers: { 39 | ["Authorization"]: idToken ? `Bearer ${idToken}` : undefined, 40 | }, 41 | ... 42 | }); 43 | ``` 44 | 45 | See [`app/core/relay.ts`](../app/core/relay.ts). 46 | 47 | ### How to authenticate HTTP requests on the server-side? 48 | 49 | ```ts 50 | import { getAuth } from "firebase-admin/auth"; 51 | 52 | const app = express(); 53 | 54 | app.use(function (req, res, next) { 55 | const idToken = req.headers.authorization?.match(/^[Bb]earer (\S+)/)?.[1]; 56 | 57 | if (idToken) { 58 | const auth = getAuth(); 59 | const user = await auth.verifyIdToken(idToken, true); 60 | ... 61 | } 62 | 63 | next(); 64 | }); 65 | ``` 66 | 67 | See [`api/core/session.ts`](../api/core/session.ts). 68 | 69 | ### How to access the user's OAuth credentials upon signing in? 70 | 71 | We use a [Cloud Function](https://cloud.google.com/identity-platform/docs/blocking-functions) that triggers each time the user signs in or registers a new account, saving user credentials (`access` and `refresh` tokens) to the `users/{uid}/credentials` [Firestore](https://firebase.google.com/docs/firestore) collection. See [`/auth`](../auth/) package. 72 | 73 | ### References 74 | 75 | - [Signing in with Google](https://cloud.google.com/identity-platform/docs/web/google) 76 | - [Signing in with Microsoft](https://cloud.google.com/identity-platform/docs/web/microsoft) 77 | - [Signing in with Salesforce](https://cloud.google.com/identity-platform/docs/web/oidc) 78 | - [Signing in with SAML](https://cloud.google.com/identity-platform/docs/web/saml) 79 | - [Signing in users from a Chrome extension](https://cloud.google.com/identity-platform/docs/web/chrome-extension) 80 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import react from "@eslint-react/eslint-plugin"; 5 | import js from "@eslint/js"; 6 | import * as tsParser from "@typescript-eslint/parser"; 7 | import prettierConfig from "eslint-config-prettier"; 8 | import globals from "globals"; 9 | import ts from "typescript-eslint"; 10 | 11 | /** 12 | * ESLint configuration 13 | * https://eslint.org/docs/latest/use/configure/ 14 | */ 15 | export default ts.config( 16 | { 17 | ignores: [".cache", ".yarn", "**/dist", "**/node_modules", ".pnp.*"], 18 | }, 19 | { 20 | ignores: ["app/**/*"], 21 | languageOptions: { 22 | globals: { ...globals.node }, 23 | }, 24 | }, 25 | js.configs.recommended, 26 | ...ts.configs.recommended, 27 | prettierConfig, 28 | { 29 | files: ["app/**/*.ts", "**/*.tsx"], 30 | ...react.configs["recommended-typescript"], 31 | languageOptions: { 32 | parser: tsParser, 33 | }, 34 | }, 35 | ); 36 | -------------------------------------------------------------------------------- /infra/README.md: -------------------------------------------------------------------------------- 1 | # Terraform Cloud Project 2 | 3 | This folder contains the Terraform configurations for our project's infrastructure, managed through [Terraform Cloud](https://developer.hashicorp.com/terraform/cloud-docs). The infrastructure is divided into multiple workspaces to handle different environments and shared resources. 4 | 5 | ## Directory Structure 6 | 7 | The repository is organized into the following directories, each corresponding to a specific Terraform Cloud workspace: 8 | 9 | - **[`/core`](./core/)** - This directory contains the Terraform configurations for the global/shared resources used across all environments. These may include VPCs, shared databases, IAM roles, etc. 10 | 11 | - **[`/server`](./server/)** - Contains the Terraform configurations for the production web server. This workspace should be configured with production-grade settings, ensuring high availability and security. 12 | 13 | - **[`/server-preview`](./server-preview/)** - This directory is for the preview web server, typically used for pull request reviews. It might contain configurations that are under testing or not yet approved for the testing environment. 14 | 15 | - **[`/server-test`](./server-test/)** - Holds the configurations for the testing/QA web server. This environment mirrors production closely and is used for final testing before deploying to production. 16 | 17 | ## Usage 18 | 19 | ### Prerequisites 20 | 21 | - [Terraform CLI](https://developer.hashicorp.com/terraform/install) 22 | - Access to the [Terraform Cloud](https://app.terraform.io/) workspace 23 | 24 | ### Setting Up Workspaces in Terraform Cloud 25 | 26 | 1. Log in to [Terraform Cloud](https://app.terraform.io/). 27 | 2. Create a workspace for each directory/environment. 28 | 3. Link each workspace to the corresponding directory in this repository. 29 | 4. Save Terraform API token to the `../.terraformrc` file. 30 | 31 | ### Working with Terraform 32 | 33 | To work with Terraform configurations: 34 | 35 | 1. Navigate to the appropriate directory (e.g., `cd server`). 36 | 2. Initialize Terraform: `terraform init`. 37 | 3. Apply configurations: `terraform apply`. 38 | 39 | Ensure that you are working in the correct workspace to avoid misconfigurations. 40 | 41 | ### Contributions 42 | 43 | Please follow our contribution guidelines for making changes or adding new configurations. Ensure you test configurations in the test and preview environments before applying them to production. 44 | 45 | ### References 46 | 47 | - https://learn.hashicorp.com/terraform 48 | - https://cloud.google.com/docs/terraform/best-practices-for-terraform 49 | - https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines 50 | - https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference.html 51 | 52 | ## Support 53 | 54 | For any issues or questions related to this Terraform setup, please contact [@koistya](https://github.com/koistya) on our [Discord server](https://discord.com/invite/bSsv7XM). 55 | -------------------------------------------------------------------------------- /infra/core/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/cloudflare/cloudflare" { 5 | version = "4.23.0" 6 | constraints = "~> 4.23" 7 | hashes = [ 8 | "h1:Kn7JXfcIA+vYHZhXtWUxNJ7wJ62ICet3a93gyYez9/U=", 9 | "zh:034aae9f29e51b008eb5ff62bcfea4078d92d74fd8eb6e0f1833395002bf483d", 10 | "zh:0e4f72b52647791e34894c231c7d17b55c701fb4ff9d8aeb8355031378b20910", 11 | "zh:248ecf3820a65870a8a811a90488a77a8fcc49ee6e3099734328912250c4145a", 12 | "zh:750114d16fefb3ce6cfc81fc4d86ab3746062dccd3fc5556a6dff39d600d55f3", 13 | "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", 14 | "zh:8fe4b545d8c90eb55b75ede1bc5a6bb1483a00466364cd08b1096abddc52e34b", 15 | "zh:ba203d96d07a313dd77933ff29d09110c1dc5100a44aa540c2c73ea280215c85", 16 | "zh:be22358de9729068edc462985c2c99c4d49eec87c6662e75e7216962b0b47a12", 17 | "zh:c55add4c66855191020b5ed61fe8561403eac9d3f55f343876f1f0a5e2ccf1bc", 18 | "zh:c57034c34a10317715264b9455a74b53b2604a3cb206f2c5089ae61b5e8e18fa", 19 | "zh:c95b026d652cb2f90b526cdc79dc22faa0789a049e55b5f2a41412ac45bca2ec", 20 | "zh:ca49437e5462c060b64d0ebf7a7d1370f55139afdb6a23f032694d363b44243b", 21 | "zh:d52788bd6ca087fa72ae9d22c09693c3f5ce5502a00e2c195bea5f420735006c", 22 | "zh:e43da4d400951310020969bd5952483c05de824d67fdcdddc76ec9d97de0d18e", 23 | "zh:ff150dddcbb0d623ff1948d1359fa956519f0672f832faedb121fc809e9c4c22", 24 | ] 25 | } 26 | 27 | provider "registry.terraform.io/hashicorp/google" { 28 | version = "5.14.0" 29 | constraints = "~> 5.14.0" 30 | hashes = [ 31 | "h1:T6EW5HOI1IrE4zHzQ/5kLyul+U2ByEaIgqMu4Ja7JFI=", 32 | "zh:3927ef7417d9d8a56077e6655d76c99f4175f9746e39226a00ee0555f8c63f8f", 33 | "zh:4b4f521f0779a1797047a8c531afda093aade934b4a49c080fe8d38680b3a52f", 34 | "zh:7e880c5b72684fc8342e03180a1fbbec65c6afeb70511b9c16181d5e168269e6", 35 | "zh:81a7f2efc30e698f476d3e240ee2d82f14eda374852059429fe808ad77b6addd", 36 | "zh:826d4ea55b4afceefb332646f21c6b6dc590b39b16e8d9b5d4a4211beb91dc5e", 37 | "zh:865600ef669fcdd4ae77515c3fd12565fab0f2a263fa2a6dae562f6fe68ed093", 38 | "zh:8e933d1d10fd316e62340175667264f093e4d24457b63d5adf3c424cce22b495", 39 | "zh:bf261924f7350074a355e5b9337f3a8054efb20d316e9085f2b5766dfb5126c4", 40 | "zh:e28e67dcbd4bbd82798561baf86d3dd04f97e08bbf523dfb9f355564ef27d3d6", 41 | "zh:f33cdd3117af8a15f33d375dbe398a5e558730cf6a7a145a479ab68e77572c12", 42 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 43 | "zh:f913a0e0708391ccd26fc3458158cc1e10d68dc621bef3a1583328c61a77225d", 44 | ] 45 | } 46 | 47 | provider "registry.terraform.io/hashicorp/random" { 48 | version = "3.6.0" 49 | hashes = [ 50 | "h1:I8MBeauYA8J8yheLJ8oSMWqB0kovn16dF/wKZ1QTdkk=", 51 | "zh:03360ed3ecd31e8c5dac9c95fe0858be50f3e9a0d0c654b5e504109c2159287d", 52 | "zh:1c67ac51254ba2a2bb53a25e8ae7e4d076103483f55f39b426ec55e47d1fe211", 53 | "zh:24a17bba7f6d679538ff51b3a2f378cedadede97af8a1db7dad4fd8d6d50f829", 54 | "zh:30ffb297ffd1633175d6545d37c2217e2cef9545a6e03946e514c59c0859b77d", 55 | "zh:454ce4b3dbc73e6775f2f6605d45cee6e16c3872a2e66a2c97993d6e5cbd7055", 56 | "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", 57 | "zh:91df0a9fab329aff2ff4cf26797592eb7a3a90b4a0c04d64ce186654e0cc6e17", 58 | "zh:aa57384b85622a9f7bfb5d4512ca88e61f22a9cea9f30febaa4c98c68ff0dc21", 59 | "zh:c4a3e329ba786ffb6f2b694e1fd41d413a7010f3a53c20b432325a94fa71e839", 60 | "zh:e2699bc9116447f96c53d55f2a00570f982e6f9935038c3810603572693712d0", 61 | "zh:e747c0fd5d7684e5bfad8aa0ca441903f15ae7a98a737ff6aca24ba223207e2c", 62 | "zh:f1ca75f417ce490368f047b63ec09fd003711ae48487fba90b4aba2ccf71920e", 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /infra/core/artifacts.tf: -------------------------------------------------------------------------------- 1 | # Cloud Storage for Build Artifacts 2 | 3 | # Docker Registry 4 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/artifact_registry_repository 5 | resource "google_artifact_registry_repository" "cloud_run" { 6 | location = var.gcp_region 7 | repository_id = "cloud-run" 8 | description = "Docker repository for Cloud Run services." 9 | format = "DOCKER" 10 | 11 | cleanup_policies { 12 | id = "cloud-run-cleanup" 13 | action = "KEEP" 14 | 15 | most_recent_versions { 16 | keep_count = 2 17 | } 18 | } 19 | } 20 | 21 | # Cloud Storage Bucket 22 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket 23 | # 24 | # NOTE: The Google account under which the Terraform Cloud agents run must have 25 | # the owner role on the target domain name in order to create Google Cloud 26 | # Storage buckets using that domain name. You can update the list of owner 27 | # members in the Google Search Console at the following URL: 28 | # https://search.google.com/search-console/welcome?new_domain_name=example.com 29 | # https://cloud.google.com/storage/docs/domain-name-verification 30 | resource "google_storage_bucket" "pkg" { 31 | name = "pkg.${var.root_level_domain}" 32 | location = var.gcp_region 33 | force_destroy = false 34 | 35 | uniform_bucket_level_access = true 36 | public_access_prevention = "enforced" 37 | } 38 | -------------------------------------------------------------------------------- /infra/core/database.tf: -------------------------------------------------------------------------------- 1 | # Google Cloud SQL 2 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database 3 | 4 | resource "random_id" "db_name_suffix" { 5 | byte_length = 4 6 | } 7 | 8 | resource "google_project_service" "compute" { 9 | service = "compute.googleapis.com" 10 | disable_on_destroy = false 11 | } 12 | 13 | resource "google_project_service" "sqladmin" { 14 | service = "sqladmin.googleapis.com" 15 | disable_on_destroy = false 16 | } 17 | 18 | resource "google_sql_database_instance" "db" { 19 | name = "db-${random_id.db_name_suffix.hex}" 20 | database_version = "POSTGRES_15" 21 | deletion_protection = false 22 | 23 | settings { 24 | tier = "db-f1-micro" 25 | 26 | database_flags { 27 | name = "cloudsql.iam_authentication" 28 | value = "on" 29 | } 30 | } 31 | 32 | depends_on = [google_project_service.sqladmin] 33 | } 34 | 35 | resource "google_sql_user" "developer" { 36 | name = trimsuffix(google_service_account.developer.email, ".gserviceaccount.com") 37 | instance = google_sql_database_instance.db.name 38 | type = "CLOUD_IAM_SERVICE_ACCOUNT" 39 | depends_on = [google_service_account.developer] 40 | } 41 | -------------------------------------------------------------------------------- /infra/core/iam.tf: -------------------------------------------------------------------------------- 1 | # Google Cloud Service Account 2 | # https://cloud.google.com/compute/docs/access/service-accounts 3 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_service_account 4 | 5 | resource "google_service_account" "developer" { 6 | account_id = "developer" 7 | display_name = "Developer" 8 | } 9 | -------------------------------------------------------------------------------- /infra/core/outputs.tf: -------------------------------------------------------------------------------- 1 | # Output Values 2 | # https://developer.hashicorp.com/terraform/language/values/outputs 3 | 4 | output "db_instance_name" { 5 | value = google_sql_database_instance.db.name 6 | sensitive = false 7 | } 8 | 9 | output "developer_service_account_email" { 10 | value = google_service_account.developer.email 11 | sensitive = false 12 | } 13 | -------------------------------------------------------------------------------- /infra/core/providers.tf: -------------------------------------------------------------------------------- 1 | # Provider Configuration 2 | # https://developer.hashicorp.com/terraform/language/providers/configuration 3 | 4 | # Google Cloud Platform Provider 5 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs 6 | provider "google" { 7 | project = var.gcp_project 8 | region = var.gcp_region 9 | zone = var.gcp_zone 10 | } 11 | -------------------------------------------------------------------------------- /infra/core/terraform.tf: -------------------------------------------------------------------------------- 1 | # Terraform Cloud configuration for the core (shared) workspace 2 | # https://developer.hashicorp.com/terraform/cli/cloud/settings 3 | 4 | terraform { 5 | cloud { 6 | organization = "example" 7 | 8 | workspaces { 9 | project = "default" 10 | name = "core" 11 | } 12 | } 13 | 14 | required_providers { 15 | # Google Cloud Platform Provider 16 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs 17 | google = { 18 | source = "hashicorp/google" 19 | version = "~> 5.14.0" 20 | } 21 | 22 | # Cloudflare Provider 23 | # https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs 24 | cloudflare = { 25 | source = "cloudflare/cloudflare" 26 | version = "~> 4.23" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /infra/core/variables.tf: -------------------------------------------------------------------------------- 1 | # Input Variables 2 | # https://developer.hashicorp.com/terraform/language/values/variables 3 | 4 | variable "gcp_project" { 5 | type = string 6 | description = "Google Cloud Project ID" 7 | default = "example" 8 | } 9 | 10 | variable "gcp_region" { 11 | type = string 12 | description = "Google Cloud region" 13 | default = "us-central1" 14 | } 15 | 16 | variable "gcp_zone" { 17 | type = string 18 | description = "Google Cloud zone" 19 | default = "us-central1-c" 20 | } 21 | 22 | variable "root_level_domain" { 23 | type = string 24 | description = "The root-level domain for the app" 25 | default = "example.com" 26 | } 27 | -------------------------------------------------------------------------------- /infra/server-preview/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "5.14.0" 6 | constraints = "~> 5.14.0" 7 | hashes = [ 8 | "h1:T6EW5HOI1IrE4zHzQ/5kLyul+U2ByEaIgqMu4Ja7JFI=", 9 | "zh:3927ef7417d9d8a56077e6655d76c99f4175f9746e39226a00ee0555f8c63f8f", 10 | "zh:4b4f521f0779a1797047a8c531afda093aade934b4a49c080fe8d38680b3a52f", 11 | "zh:7e880c5b72684fc8342e03180a1fbbec65c6afeb70511b9c16181d5e168269e6", 12 | "zh:81a7f2efc30e698f476d3e240ee2d82f14eda374852059429fe808ad77b6addd", 13 | "zh:826d4ea55b4afceefb332646f21c6b6dc590b39b16e8d9b5d4a4211beb91dc5e", 14 | "zh:865600ef669fcdd4ae77515c3fd12565fab0f2a263fa2a6dae562f6fe68ed093", 15 | "zh:8e933d1d10fd316e62340175667264f093e4d24457b63d5adf3c424cce22b495", 16 | "zh:bf261924f7350074a355e5b9337f3a8054efb20d316e9085f2b5766dfb5126c4", 17 | "zh:e28e67dcbd4bbd82798561baf86d3dd04f97e08bbf523dfb9f355564ef27d3d6", 18 | "zh:f33cdd3117af8a15f33d375dbe398a5e558730cf6a7a145a479ab68e77572c12", 19 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 20 | "zh:f913a0e0708391ccd26fc3458158cc1e10d68dc621bef3a1583328c61a77225d", 21 | ] 22 | } 23 | 24 | provider "registry.terraform.io/hashicorp/tfe" { 25 | version = "0.51.1" 26 | hashes = [ 27 | "h1:n7O1yooxtUk1G0T0jyrt5ItstX5fIPoSUqMibBHA6E0=", 28 | "zh:08f5c2296a7eba39fb3b86cc9f294ac7a9ca06eee1dec44fbf14615523fd24d0", 29 | "zh:46ea31b9ca5450d947c0b28b698aca6df97714e671f25a2e2c9ea3ba0e0d45a0", 30 | "zh:6326962e8afda2da9c2724a465eee4ae12c514700ccfaead7be81c73d6d7f8cd", 31 | "zh:649639793e0cbfe8732377052110441ae24b4a3b2e35126aab5d0b9da291ac9a", 32 | "zh:8ca07718347273bbbc8a7c0f488da22efa38a67ec05b6db3bf97d01d9ba600c0", 33 | "zh:a9065ad79c7a3d91ee1f7021edea02f92ace116830fa0857580dcd267643a016", 34 | "zh:bc6014f7597b281ca9aa73fe91c9060f29311096a543c2cf794803920662edc4", 35 | "zh:c48897ce5820983c1a94b6912317fbf4927b4eca9f550c7d5e1af40a4bc9fd5f", 36 | "zh:ca32cc1543d65a1e418eaadccd0f00d6a626ff0b40d495e508b444d95b290ed0", 37 | "zh:d179e1f38f789ebefb4cfce8148181999bd2e24f860b2e18cdaeca2b37fbe7f6", 38 | "zh:d95ec293fa70e946b6cd657912b33155f8be3413e6128ed2bfa5a493f788e439", 39 | "zh:fe9538fc1da16bbd675f33176ac3d2a20c264b5c7a14389c552a889f7a1c46e4", 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /infra/server-preview/database.tf: -------------------------------------------------------------------------------- 1 | # Google CLoud SQL Database. 2 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database 3 | 4 | data "tfe_outputs" "core" { 5 | organization = var.tfe_organization 6 | workspace = "core" 7 | } 8 | 9 | resource "google_sql_database" "db" { 10 | name = var.db_name 11 | instance = data.tfe_outputs.core.values.db_instance_name 12 | } 13 | -------------------------------------------------------------------------------- /infra/server-preview/providers.tf: -------------------------------------------------------------------------------- 1 | # Provider Configuration 2 | # https://developer.hashicorp.com/terraform/language/providers/configuration 3 | 4 | provider "google" { 5 | project = var.gcp_project 6 | region = var.gcp_region 7 | zone = var.gcp_zone 8 | } 9 | -------------------------------------------------------------------------------- /infra/server-preview/terraform.tf: -------------------------------------------------------------------------------- 1 | # Terraform Cloud configuration for the core (shared) workspace 2 | # https://developer.hashicorp.com/terraform/cli/cloud/settings 3 | 4 | terraform { 5 | cloud { 6 | organization = "example" 7 | 8 | workspaces { 9 | project = "default" 10 | name = "server-preview" 11 | } 12 | } 13 | 14 | required_providers { 15 | google = { 16 | source = "hashicorp/google" 17 | version = "~> 5.14.0" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /infra/server-preview/variables.tf: -------------------------------------------------------------------------------- 1 | # Input Variables 2 | # https://developer.hashicorp.com/terraform/language/values/variables 3 | 4 | variable "tfe_organization" { 5 | type = string 6 | description = "Terraform Cloud organization ID" 7 | default = "example" 8 | } 9 | 10 | variable "gcp_project" { 11 | type = string 12 | description = "Google Cloud Project ID" 13 | default = "example" 14 | } 15 | 16 | variable "gcp_region" { 17 | type = string 18 | description = "Google Cloud region" 19 | default = "us-central1" 20 | } 21 | 22 | variable "gcp_zone" { 23 | type = string 24 | description = "Google Cloud zone" 25 | default = "us-central1-c" 26 | } 27 | 28 | variable "db_name" { 29 | type = string 30 | description = "Database name" 31 | default = "example-preview" 32 | } 33 | -------------------------------------------------------------------------------- /infra/server-test/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "5.14.0" 6 | constraints = "~> 5.14.0" 7 | hashes = [ 8 | "h1:T6EW5HOI1IrE4zHzQ/5kLyul+U2ByEaIgqMu4Ja7JFI=", 9 | "zh:3927ef7417d9d8a56077e6655d76c99f4175f9746e39226a00ee0555f8c63f8f", 10 | "zh:4b4f521f0779a1797047a8c531afda093aade934b4a49c080fe8d38680b3a52f", 11 | "zh:7e880c5b72684fc8342e03180a1fbbec65c6afeb70511b9c16181d5e168269e6", 12 | "zh:81a7f2efc30e698f476d3e240ee2d82f14eda374852059429fe808ad77b6addd", 13 | "zh:826d4ea55b4afceefb332646f21c6b6dc590b39b16e8d9b5d4a4211beb91dc5e", 14 | "zh:865600ef669fcdd4ae77515c3fd12565fab0f2a263fa2a6dae562f6fe68ed093", 15 | "zh:8e933d1d10fd316e62340175667264f093e4d24457b63d5adf3c424cce22b495", 16 | "zh:bf261924f7350074a355e5b9337f3a8054efb20d316e9085f2b5766dfb5126c4", 17 | "zh:e28e67dcbd4bbd82798561baf86d3dd04f97e08bbf523dfb9f355564ef27d3d6", 18 | "zh:f33cdd3117af8a15f33d375dbe398a5e558730cf6a7a145a479ab68e77572c12", 19 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 20 | "zh:f913a0e0708391ccd26fc3458158cc1e10d68dc621bef3a1583328c61a77225d", 21 | ] 22 | } 23 | 24 | provider "registry.terraform.io/hashicorp/tfe" { 25 | version = "0.51.1" 26 | hashes = [ 27 | "h1:n7O1yooxtUk1G0T0jyrt5ItstX5fIPoSUqMibBHA6E0=", 28 | "zh:08f5c2296a7eba39fb3b86cc9f294ac7a9ca06eee1dec44fbf14615523fd24d0", 29 | "zh:46ea31b9ca5450d947c0b28b698aca6df97714e671f25a2e2c9ea3ba0e0d45a0", 30 | "zh:6326962e8afda2da9c2724a465eee4ae12c514700ccfaead7be81c73d6d7f8cd", 31 | "zh:649639793e0cbfe8732377052110441ae24b4a3b2e35126aab5d0b9da291ac9a", 32 | "zh:8ca07718347273bbbc8a7c0f488da22efa38a67ec05b6db3bf97d01d9ba600c0", 33 | "zh:a9065ad79c7a3d91ee1f7021edea02f92ace116830fa0857580dcd267643a016", 34 | "zh:bc6014f7597b281ca9aa73fe91c9060f29311096a543c2cf794803920662edc4", 35 | "zh:c48897ce5820983c1a94b6912317fbf4927b4eca9f550c7d5e1af40a4bc9fd5f", 36 | "zh:ca32cc1543d65a1e418eaadccd0f00d6a626ff0b40d495e508b444d95b290ed0", 37 | "zh:d179e1f38f789ebefb4cfce8148181999bd2e24f860b2e18cdaeca2b37fbe7f6", 38 | "zh:d95ec293fa70e946b6cd657912b33155f8be3413e6128ed2bfa5a493f788e439", 39 | "zh:fe9538fc1da16bbd675f33176ac3d2a20c264b5c7a14389c552a889f7a1c46e4", 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /infra/server-test/database.tf: -------------------------------------------------------------------------------- 1 | # Google CLoud SQL Database. 2 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database 3 | 4 | data "tfe_outputs" "core" { 5 | organization = var.tfe_organization 6 | workspace = "core" 7 | } 8 | 9 | resource "google_sql_database" "db" { 10 | name = var.db_name 11 | instance = data.tfe_outputs.core.values.db_instance_name 12 | } 13 | -------------------------------------------------------------------------------- /infra/server-test/providers.tf: -------------------------------------------------------------------------------- 1 | # Provider Configuration 2 | # https://developer.hashicorp.com/terraform/language/providers/configuration 3 | 4 | provider "google" { 5 | project = var.gcp_project 6 | region = var.gcp_region 7 | zone = var.gcp_zone 8 | } 9 | -------------------------------------------------------------------------------- /infra/server-test/terraform.tf: -------------------------------------------------------------------------------- 1 | # Terraform Cloud configuration for the core (shared) workspace 2 | # https://developer.hashicorp.com/terraform/cli/cloud/settings 3 | 4 | terraform { 5 | cloud { 6 | organization = "example" 7 | 8 | workspaces { 9 | project = "default" 10 | name = "server-test" 11 | } 12 | } 13 | 14 | required_providers { 15 | google = { 16 | source = "hashicorp/google" 17 | version = "~> 5.14.0" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /infra/server-test/variables.tf: -------------------------------------------------------------------------------- 1 | # Input Variables 2 | # https://developer.hashicorp.com/terraform/language/values/variables 3 | 4 | variable "tfe_organization" { 5 | type = string 6 | description = "Terraform Cloud organization ID" 7 | default = "example" 8 | } 9 | 10 | variable "gcp_project" { 11 | type = string 12 | description = "Google Cloud Project ID" 13 | default = "example" 14 | } 15 | 16 | variable "gcp_region" { 17 | type = string 18 | description = "Google Cloud region" 19 | default = "us-central1" 20 | } 21 | 22 | variable "gcp_zone" { 23 | type = string 24 | description = "Google Cloud zone" 25 | default = "us-central1-c" 26 | } 27 | 28 | variable "db_name" { 29 | type = string 30 | description = "Database name" 31 | default = "example-test" 32 | } 33 | -------------------------------------------------------------------------------- /infra/server/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/google" { 5 | version = "5.14.0" 6 | constraints = "~> 5.14.0" 7 | hashes = [ 8 | "h1:T6EW5HOI1IrE4zHzQ/5kLyul+U2ByEaIgqMu4Ja7JFI=", 9 | "zh:3927ef7417d9d8a56077e6655d76c99f4175f9746e39226a00ee0555f8c63f8f", 10 | "zh:4b4f521f0779a1797047a8c531afda093aade934b4a49c080fe8d38680b3a52f", 11 | "zh:7e880c5b72684fc8342e03180a1fbbec65c6afeb70511b9c16181d5e168269e6", 12 | "zh:81a7f2efc30e698f476d3e240ee2d82f14eda374852059429fe808ad77b6addd", 13 | "zh:826d4ea55b4afceefb332646f21c6b6dc590b39b16e8d9b5d4a4211beb91dc5e", 14 | "zh:865600ef669fcdd4ae77515c3fd12565fab0f2a263fa2a6dae562f6fe68ed093", 15 | "zh:8e933d1d10fd316e62340175667264f093e4d24457b63d5adf3c424cce22b495", 16 | "zh:bf261924f7350074a355e5b9337f3a8054efb20d316e9085f2b5766dfb5126c4", 17 | "zh:e28e67dcbd4bbd82798561baf86d3dd04f97e08bbf523dfb9f355564ef27d3d6", 18 | "zh:f33cdd3117af8a15f33d375dbe398a5e558730cf6a7a145a479ab68e77572c12", 19 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", 20 | "zh:f913a0e0708391ccd26fc3458158cc1e10d68dc621bef3a1583328c61a77225d", 21 | ] 22 | } 23 | 24 | provider "registry.terraform.io/hashicorp/tfe" { 25 | version = "0.51.1" 26 | hashes = [ 27 | "h1:n7O1yooxtUk1G0T0jyrt5ItstX5fIPoSUqMibBHA6E0=", 28 | "zh:08f5c2296a7eba39fb3b86cc9f294ac7a9ca06eee1dec44fbf14615523fd24d0", 29 | "zh:46ea31b9ca5450d947c0b28b698aca6df97714e671f25a2e2c9ea3ba0e0d45a0", 30 | "zh:6326962e8afda2da9c2724a465eee4ae12c514700ccfaead7be81c73d6d7f8cd", 31 | "zh:649639793e0cbfe8732377052110441ae24b4a3b2e35126aab5d0b9da291ac9a", 32 | "zh:8ca07718347273bbbc8a7c0f488da22efa38a67ec05b6db3bf97d01d9ba600c0", 33 | "zh:a9065ad79c7a3d91ee1f7021edea02f92ace116830fa0857580dcd267643a016", 34 | "zh:bc6014f7597b281ca9aa73fe91c9060f29311096a543c2cf794803920662edc4", 35 | "zh:c48897ce5820983c1a94b6912317fbf4927b4eca9f550c7d5e1af40a4bc9fd5f", 36 | "zh:ca32cc1543d65a1e418eaadccd0f00d6a626ff0b40d495e508b444d95b290ed0", 37 | "zh:d179e1f38f789ebefb4cfce8148181999bd2e24f860b2e18cdaeca2b37fbe7f6", 38 | "zh:d95ec293fa70e946b6cd657912b33155f8be3413e6128ed2bfa5a493f788e439", 39 | "zh:fe9538fc1da16bbd675f33176ac3d2a20c264b5c7a14389c552a889f7a1c46e4", 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /infra/server/database.tf: -------------------------------------------------------------------------------- 1 | # Google CLoud SQL Database. 2 | # https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/sql_database 3 | 4 | data "tfe_outputs" "core" { 5 | organization = var.tfe_organization 6 | workspace = "core" 7 | } 8 | 9 | resource "google_sql_database" "db" { 10 | name = var.db_name 11 | instance = data.tfe_outputs.core.values.db_instance_name 12 | } 13 | -------------------------------------------------------------------------------- /infra/server/providers.tf: -------------------------------------------------------------------------------- 1 | # Provider Configuration 2 | # https://developer.hashicorp.com/terraform/language/providers/configuration 3 | 4 | provider "google" { 5 | project = var.gcp_project 6 | region = var.gcp_region 7 | zone = var.gcp_zone 8 | } 9 | -------------------------------------------------------------------------------- /infra/server/terraform.tf: -------------------------------------------------------------------------------- 1 | # Terraform Cloud configuration for the core (shared) workspace 2 | # https://developer.hashicorp.com/terraform/cli/cloud/settings 3 | 4 | terraform { 5 | cloud { 6 | organization = "example" 7 | 8 | workspaces { 9 | project = "default" 10 | name = "server" 11 | } 12 | } 13 | 14 | required_providers { 15 | google = { 16 | source = "hashicorp/google" 17 | version = "~> 5.14.0" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /infra/server/variables.tf: -------------------------------------------------------------------------------- 1 | # Input Variables 2 | # https://developer.hashicorp.com/terraform/language/values/variables 3 | 4 | variable "tfe_organization" { 5 | type = string 6 | description = "Terraform Cloud organization ID" 7 | default = "example" 8 | } 9 | 10 | variable "gcp_project" { 11 | type = string 12 | description = "Google Cloud Project ID" 13 | default = "example" 14 | } 15 | 16 | variable "gcp_region" { 17 | type = string 18 | description = "Google Cloud region" 19 | default = "us-central1" 20 | } 21 | 22 | variable "gcp_zone" { 23 | type = string 24 | description = "Google Cloud zone" 25 | default = "us-central1-c" 26 | } 27 | 28 | variable "db_name" { 29 | type = string 30 | description = "Database name" 31 | default = "example" 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "packageManager": "yarn@4.5.1", 7 | "workspaces": [ 8 | "app", 9 | "db", 10 | "scripts", 11 | "server" 12 | ], 13 | "scripts": { 14 | "postinstall": "node ./scripts/post-install.js", 15 | "start": "yarn workspaces foreach -ip -j unlimited run start", 16 | "build": "yarn workspaces foreach -p -j unlimited run build", 17 | "setup": "node ./scripts/setup.js", 18 | "lint": "eslint --cache --report-unused-disable-directives .", 19 | "test": "pnpify vitest", 20 | "tf": "node --no-warnings ./scripts/tf.js", 21 | "g:lint": "yarn lint \"$INIT_CWD\"", 22 | "db": "tsx ./db/cli.ts" 23 | }, 24 | "devDependencies": { 25 | "@emotion/babel-plugin": "^11.11.0", 26 | "@emotion/eslint-plugin": "^11.11.0", 27 | "@emotion/react": "^11.11.3", 28 | "@eslint-react/eslint-plugin": "^1.16.1", 29 | "@eslint/js": "^9.14.0", 30 | "@types/node": "^22.9.0", 31 | "@typescript-eslint/parser": "^8.13.0", 32 | "@yarnpkg/pnpify": "^4.0.1", 33 | "envars": "^1.0.2", 34 | "eslint": "^9.14.0", 35 | "eslint-config-prettier": "^9.1.0", 36 | "globals": "^15.12.0", 37 | "graphql": "^16.8.1", 38 | "graphql-config": "^5.0.3", 39 | "happy-dom": "^13.3.8", 40 | "husky": "^9.0.10", 41 | "prettier": "^3.3.3", 42 | "react": "^18.2.0", 43 | "relay-config": "^12.0.1", 44 | "tsx": "~4.19.2", 45 | "typescript": "~5.6.3", 46 | "typescript-eslint": "^8.13.0", 47 | "vitest": "~2.1.4", 48 | "wrangler": "^3.27.0", 49 | "zx": "^8.2.1" 50 | }, 51 | "resolutions": { 52 | "graphql": "^16.8.1", 53 | "vite": "~5.4.10" 54 | }, 55 | "envars": { 56 | "cwd": "./env" 57 | }, 58 | "graphql": { 59 | "projects": { 60 | "api": { 61 | "schema": "api/schema.graphql", 62 | "documents": "api/**/*.ts", 63 | "extensions": { 64 | "endpoints": { 65 | "default": "http://localhost:8080/api" 66 | } 67 | } 68 | }, 69 | "web": { 70 | "schema": [ 71 | "api/schema.graphql", 72 | "api/schema.relay.graphql", 73 | "web/schema.graphql" 74 | ], 75 | "documents": "web/**/*.{ts,tsx}", 76 | "extensions": { 77 | "endpoints": { 78 | "default": "http://localhost:5173/api" 79 | } 80 | } 81 | } 82 | } 83 | }, 84 | "prettier": { 85 | "printWidth": 80, 86 | "tabWidth": 2, 87 | "useTabs": false, 88 | "semi": true, 89 | "singleQuote": false, 90 | "quoteProps": "as-needed", 91 | "jsxSingleQuote": false, 92 | "trailingComma": "all", 93 | "bracketSpacing": true, 94 | "bracketSameLine": false, 95 | "arrowParens": "always", 96 | "endOfLine": "lf" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /scripts/bundle-yarn.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { execa } from "execa"; 5 | import { $, fs, globby, path } from "zx"; 6 | 7 | process.once("uncaughtException", (err) => { 8 | process.exit(err.exitCode ?? 1); 9 | }); 10 | 11 | function toJSON(obj) { 12 | return JSON.stringify(obj, null, " "); 13 | } 14 | 15 | // Copy package.json file 16 | const pkg = JSON.parse(await fs.readFile("./package.json", "utf-8")); 17 | delete pkg.scripts; 18 | delete pkg.devDependencies; 19 | delete pkg.babel; 20 | delete pkg.envars; 21 | pkg.bundledDependencies?.forEach((name) => { 22 | delete pkg.dependencies[name]; 23 | }); 24 | await fs.writeFile("./dist/package.json", toJSON(pkg), "utf-8"); 25 | 26 | // Copy Yarn files 27 | const yarnFiles = await globby( 28 | ["../yarn.lock", "../.yarnrc.yml", "../.yarn/releases", "../.yarn/plugins"], 29 | { dot: true }, 30 | ); 31 | 32 | await Promise.all( 33 | yarnFiles.map((file) => fs.copy(file, path.join("./dist/tmp", file))), 34 | ); 35 | 36 | // Disable global cache in Yarn settings 37 | await execa("yarn", ["config", "set", "enableGlobalCache", "false"], { 38 | env: { ...$.env, NODE_OPTIONS: undefined }, 39 | stdio: "inherit", 40 | cwd: "./dist", 41 | }); 42 | 43 | // Install Yarn dependencies 44 | await execa("yarn", ["install"], { 45 | env: { 46 | ...$.env, 47 | NODE_OPTIONS: undefined, 48 | YARN_ENABLE_IMMUTABLE_INSTALLS: "false", 49 | }, 50 | stdio: "inherit", 51 | cwd: "./dist", 52 | }); 53 | 54 | // Clean up the output directory 55 | await fs.remove("./dist/.yarn/install-state.gz"); 56 | await fs.remove("./dist/.yarn/unplugged"); 57 | -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Octokit } from "@octokit/rest"; 5 | import envars from "envars"; 6 | import path from "node:path"; 7 | import { $, nothrow } from "zx"; 8 | import { rootDir } from "./utils.js"; 9 | 10 | // Load environment variables 11 | envars.config({ env: "test", cwd: path.resolve(rootDir, "../env") }); 12 | 13 | // Initialize GitHub client 14 | const env = process.env; 15 | const [owner, repo] = env.GITHUB_REPOSITORY.split("/"); 16 | const gh = new Octokit({ auth: env.GITHUB_TOKEN }); 17 | 18 | // Get the list of merged PRs 19 | const { data: pulls } = await gh.pulls.list({ 20 | owner, 21 | repo, 22 | state: "closed", 23 | sort: "updated", 24 | direction: "desc", 25 | base: "main", 26 | per_page: 30, 27 | }); 28 | 29 | // Cleans up transient deployments for merged PRs 30 | for (const pr of pulls.slice(3)) { 31 | console.log("[", pr.number, "]", pr.title); 32 | const [res] = await Promise.all([ 33 | gh.repos.listDeployments({ 34 | owner, 35 | repo, 36 | environment: `${pr.number}-test`, 37 | }), 38 | nothrow( 39 | $`gcloud functions delete api-${pr.number} --project=${env.GOOGLE_CLOUD_PROJECT} --region=${env.GOOGLE_CLOUD_REGION} --gen2 --verbosity=none --quiet`, 40 | ), 41 | ]); 42 | 43 | for (const deployment of res.data) { 44 | await gh.repos.createDeploymentStatus({ 45 | mediaType: { previews: ["ant-man", "flash"] }, 46 | owner, 47 | repo, 48 | deployment_id: deployment.id, 49 | state: "inactive", 50 | }); 51 | 52 | await gh.repos.deleteDeployment({ 53 | owner, 54 | repo, 55 | deployment_id: deployment.id, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /scripts/env.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { SecretManagerServiceClient } from "@google-cloud/secret-manager"; 5 | import { resolve } from "node:path"; 6 | import { fileURLToPath, URL } from "node:url"; 7 | import { load } from "ts-import"; 8 | import { loadEnv as loadViteEnv } from "vite"; 9 | 10 | // The root workspace folder 11 | const rootDir = fileURLToPath(new URL("../", import.meta.url)); 12 | 13 | // Matches Google Secret Manager secret names 14 | const secretRegExp = /^projects\/[\w-]+\/secrets\/[\w-]+\/versions\/[\w-]+/; 15 | 16 | /** 17 | * Loads environment variables from the `.env` file(s). 18 | * 19 | * @param {string | undefined} mode Environment name such as "development" or "production" 20 | * @param {string} envFile Path to the `env.ts` file (using `envalid`) 21 | * @returns {Promise} 22 | */ 23 | export async function loadEnv(mode, envFile) { 24 | const originalEnv = process.env; 25 | 26 | // Load environment variables from `.env` file(s) using Vite's `loadEnv()` 27 | // https://vitejs.dev/guide/api-javascript.html#loadenv 28 | const env = loadViteEnv(mode, rootDir, ""); 29 | 30 | // Load the list of environment variables required by the application 31 | process.env = { ...process.env, ...env }; 32 | const envModule = await load(envFile, { 33 | useCache: false, 34 | transpileOptions: { 35 | cache: { dir: resolve("./node_modules/.cache/ts-import") }, 36 | }, 37 | }); 38 | 39 | // Restore the original environment variables 40 | process.env = originalEnv; 41 | 42 | // Initialize Google Secret Manager client 43 | // https://cloud.google.com/secret-manager/docs 44 | const sm = new SecretManagerServiceClient(); 45 | 46 | // Add environment variables required by the application to the `process.env` 47 | await Promise.all( 48 | Object.keys(envModule.env ?? envModule.default ?? {}).map(async (key) => { 49 | if (env[key]) { 50 | if (secretRegExp.test(env[key])) { 51 | // Load the secret value from Google Secret Manager 52 | // https://cloud.google.com/secret-manager/docs/access-secret-version 53 | const [res] = await sm.accessSecretVersion({ name: env[key] }); 54 | const secret = res.payload?.data?.toString(); 55 | if (secret) process.env[key] = secret; 56 | } else { 57 | process.env[key] = env[key]; 58 | } 59 | } 60 | }), 61 | ); 62 | } 63 | 64 | loadEnv("development", "./env.ts"); 65 | -------------------------------------------------------------------------------- /scripts/gcf-deploy.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import envars from "envars"; 5 | import { execa } from "execa"; 6 | import { $, argv, chalk, fs, path } from "zx"; 7 | import { getArgs, saveEnvVars } from "./utils.js"; 8 | 9 | process.once("uncaughtException", (err) => { 10 | console.error(err); 11 | process.exitCode = err.exitCode ?? 1; 12 | }); 13 | 14 | process.once("unhandledRejection", (err) => { 15 | console.error(err); 16 | process.exitCode = err.exitCode ?? 1; 17 | }); 18 | 19 | // Parse the command line arguments and load environment variables 20 | const [[name], envName = "test"] = getArgs(); 21 | envars.config({ env: envName }); 22 | 23 | // The name of the Cloud Function, e.g. "api" or "api-123" (preview) 24 | const functionName = argv.pr ? `${name}-${argv.pr}` : name; 25 | const serviceAccount = $.env[`${name}_SERVICE_ACCOUNT`]; 26 | 27 | // Load the list of environment variables required by the app 28 | const app = await import(path.join(process.cwd(), "./dist/index.js")); 29 | const envFile = `../.cache/${name}-${envName}.yml`; 30 | 31 | // Save the required environment variables to .yml file before deployment 32 | if (app.env) { 33 | const envEntries = Object.keys(app.env).map((key) => [key, $.env[key]]); 34 | await fs.ensureDir(path.dirname(envFile)); 35 | await saveEnvVars(Object.fromEntries(envEntries), envFile, functionName); 36 | process.once("exit", () => fs.unlinkSync(envFile)); 37 | } 38 | 39 | // Deploy to Google Cloud Functions (GCF) 40 | await execa( 41 | "gcloud", 42 | [ 43 | "functions", 44 | "deploy", 45 | functionName, 46 | `--project=${$.env.GOOGLE_CLOUD_PROJECT}`, 47 | `--region=${$.env.GOOGLE_CLOUD_REGION}`, 48 | "--allow-unauthenticated", 49 | argv.gen2 !== false && "--gen2", 50 | `--entry-point=${argv.entry}`, 51 | "--memory=1Gi", 52 | "--runtime=nodejs18", 53 | serviceAccount && `--service-account=${serviceAccount}`, 54 | "--source=./dist", 55 | "--timeout=30", 56 | app.env && `--env-vars-file=${envFile}`, 57 | "--min-instances=0", // TODO: Set to 1 for production 58 | "--max-instances=2", 59 | "--trigger-http", 60 | ].filter(Boolean), 61 | { stdio: "inherit" }, 62 | ); 63 | 64 | // Fetch the URL of the deployed Cloud Function 65 | let cmd = await execa( 66 | "gcloud", 67 | [ 68 | "functions", 69 | "describe", 70 | functionName, 71 | `--project=${$.env.GOOGLE_CLOUD_PROJECT}`, 72 | `--region=${$.env.GOOGLE_CLOUD_REGION}`, 73 | "--format=value(serviceConfig.uri)", 74 | argv.gen2 !== false && "--gen2", 75 | ].filter(Boolean), 76 | ); 77 | 78 | if (cmd.exitCode !== 0) { 79 | console.error(cmd.stderr || cmd.stdout); 80 | process.exit(cmd.exitCode); 81 | } 82 | 83 | const deployedURI = cmd.stdout.trim(); 84 | 85 | console.log(`Deployed to ${chalk.blueBright(deployedURI)}`); 86 | 87 | // if (name === "api" && argv.pr) { 88 | // const previewBucket = $.env.APP_BUCKET?.replace(/^test\./, "preview."); 89 | // const file = `gs://${previewBucket}/${argv.pr}/api.txt`; 90 | // cmd = execa("gsutil", ["cp", "-", file]); 91 | // cmd.stdout.pipe(process.stdout); 92 | // cmd.stderr.pipe(process.stderr); 93 | // cmd.stdin.write(deployedURI); 94 | // cmd.stdin.end(); 95 | // await cmd; 96 | // } 97 | -------------------------------------------------------------------------------- /scripts/gcp-setup.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import envars from "envars"; 5 | import { execa as spawn } from "execa"; 6 | import { URL } from "node:url"; 7 | import { $, argv, chalk, fs, path, question } from "zx"; 8 | import { rootDir } from "./utils.js"; 9 | 10 | /** 11 | * This script can be used as a lightweight alternative to Terraform 12 | * for bootstrapping a new Google Cloud (GCP) project. Usage example: 13 | * 14 | * $ yarn gcp:setup --env=test 15 | * $ yarn gcp:setup --env=prod 16 | */ 17 | 18 | // Load the environment variables 19 | const envName = argv.env ?? "test"; 20 | const env = envars.config({ env: envName }); 21 | const project = env.GOOGLE_CLOUD_PROJECT; 22 | const region = env.GOOGLE_CLOUD_REGION; 23 | const cwd = process.cwd(); 24 | 25 | await question( 26 | [ 27 | chalk.grey(`Setting up the Google Cloud environment`), 28 | ``, 29 | ` ${chalk.bold(`Project`)}: ${chalk.green(project)}`, 30 | ` ${chalk.bold(`Region`)}: ${chalk.green(region)}`, 31 | ``, 32 | chalk.grey(`Click ${chalk.bold(`[Enter]`)} to continue...\n`), 33 | ].join("\n"), 34 | ); 35 | 36 | // Get the GCP project number 37 | const projectNum = await spawn("gcloud", [ 38 | ...["projects", "list", `--filter`, project], 39 | ...["--format", "value(project_number)"], 40 | ]).then((cmd) => cmd.stdout.toString()); 41 | 42 | // The list of Google Cloud services that needs to be enabled 43 | const services = [ 44 | "iamcredentials.googleapis.com", 45 | "compute.googleapis.com", 46 | "cloudfunctions.googleapis.com", 47 | "logging.googleapis.com", 48 | "run.googleapis.com", 49 | "sqladmin.googleapis.com", 50 | "pubsub.googleapis.com", 51 | "cloudbuild.googleapis.com", 52 | "artifactregistry.googleapis.com", 53 | "sourcerepo.googleapis.com", 54 | "identitytoolkit.googleapis.com", 55 | ]; 56 | 57 | for (const service of services) { 58 | await $`gcloud services enable ${service} --project=${project}`; 59 | } 60 | 61 | let cmd = await spawn(`gsutil`, [`kms`, `serviceaccount`, `-p`, projectNum]); 62 | 63 | // The list of IAM service accounts 64 | const appAccount = `service@${project}.iam.gserviceaccount.com`; // GCS, URL signing 65 | const computeAccount = `${projectNum}-compute@developer.gserviceaccount.com`; // GCF 66 | const pubSubAccount = `service-${projectNum}@gcp-sa-pubsub.iam.gserviceaccount.com`; 67 | const storageAccount = cmd.stdout.toString(); 68 | 69 | // Fetch the list of IAM service accounts 70 | const serviceAccounts = await spawn("gcloud", [ 71 | ...["iam", "service-accounts", "list"], 72 | ...["--project", project, "--format", "value(email)"], 73 | ]).then((cmd) => cmd.stdout.toString().split("\n").filter(Boolean)); 74 | 75 | // Create a custom service account for the app if not exists 76 | if (!serviceAccounts.includes(appAccount)) { 77 | await $`gcloud iam service-accounts create ${appAccount.split("@")[0]} ${[ 78 | ...["--project", project, "--display-name", "App Service"], 79 | ]}`; 80 | } 81 | 82 | async function addRole(iamAccount, role) { 83 | await $`gcloud projects add-iam-policy-binding ${project} ${[ 84 | `--member=serviceAccount:${iamAccount}`, 85 | `--role=${role}`, 86 | `--format=none`, 87 | ]}`; 88 | } 89 | 90 | await addRole(pubSubAccount, "roles/iam.serviceAccountTokenCreator"); 91 | await addRole(storageAccount, "roles/pubsub.publisher"); 92 | await addRole(computeAccount, "roles/eventarc.eventReceiver"); 93 | await addRole(computeAccount, "roles/iam.serviceAccountTokenCreator"); 94 | await addRole(appAccount, "roles/iam.serviceAccountTokenCreator"); 95 | await addRole(appAccount, "roles/storage.objectAdmin"); 96 | 97 | // Fetch the list of service account keys 98 | cmd = await spawn("gcloud", [ 99 | ...["iam", "service-accounts", "keys", "list"], 100 | ...["--iam-account", appAccount, "--managed-by", "user"], 101 | ]); 102 | 103 | // Create a new service account (JSON) key if not exists 104 | if (!cmd.stdout.toString()) { 105 | await $`gcloud iam service-accounts keys create ${[ 106 | path.resolve(rootDir, `env/gcp-key.${envName}.json`), 107 | `--iam-account=${appAccount}`, 108 | ]}`; 109 | } 110 | 111 | // Get the primary domain name (from the production environment) 112 | const prodEnv = envars.config({ env: "prod" }); 113 | const domain = new URL(prodEnv.APP_ORIGIN).hostname; 114 | 115 | // Ensure that the domain name is verified 116 | while (true) { 117 | cmd = await spawn("gcloud", ["domains", "list-user-verified"]); 118 | const verifiedDomains = cmd.stdout.toString().split("\n").slice(1); 119 | if (verifiedDomains.includes(domain)) break; 120 | await $`gcloud domains verify ${domain} --project ${project}`; 121 | await question(chalk.grey(`Click ${chalk.bold(`[Enter]`)} to continue...\n`)); 122 | } 123 | 124 | // Fetch the list of existing GCS buckets 125 | cmd = await spawn("gcloud", ["alpha", "storage", "ls", "--project", project]); 126 | const corsFile = path.relative(cwd, path.join(rootDir, ".cache/cors.json")); 127 | const existingBuckets = cmd.stdout.toString().split("\n"); 128 | const buckets = Object.keys(env) 129 | .filter((key) => key.endsWith("_BUCKET")) 130 | .filter((key) => envName === "prod" || env[key] !== prodEnv[key]) 131 | .map((key) => env[key]); 132 | 133 | // Create missing GCS buckets if any 134 | for (const bucket of buckets) { 135 | if (!existingBuckets.includes(`gs://${bucket}/`)) { 136 | await $`gsutil mb ${[ 137 | ...["-p", project, "-l", region.split("-")[0], "-b", "on"], 138 | ...["-c", "standard", `gs://${bucket}/`], 139 | ]}`; 140 | } 141 | 142 | // Write CORS settings to a temporary file 143 | await fs.writeFile( 144 | corsFile, 145 | JSON.stringify([ 146 | { 147 | origin: [ 148 | env.APP_ORIGIN, 149 | envName !== "prod" && "http://localhost:5173", 150 | ].filter(Boolean), 151 | responseHeader: ["Content-Type"], 152 | method: ["GET"], 153 | maxAgeSeconds: 3600, 154 | }, 155 | ]), 156 | { encoding: "utf-8" }, 157 | ); 158 | 159 | // Apply CORS settings to the target bucket 160 | try { 161 | await $`gsutil cors set ${corsFile} ${`gs://${bucket}`}`; 162 | } finally { 163 | await fs.unlink(corsFile); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /scripts/github.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Octokit } from "@octokit/rest"; 5 | import envars from "envars"; 6 | import minimist from "minimist"; 7 | import path from "node:path"; 8 | import { rootDir } from "./utils.js"; 9 | 10 | const env = process.env; 11 | 12 | /** 13 | * https://docs.github.com/en/rest/reference/repos#list-deployments 14 | */ 15 | async function listDeployments(options = {}) { 16 | const [owner, repo] = env.GITHUB_REPOSITORY?.split("/") ?? []; 17 | const gh = new Octokit({ auth: env.GITHUB_TOKEN }); 18 | const res = await gh.repos.listDeployments({ 19 | owner, 20 | repo, 21 | sha: options.sha, 22 | ref: options.ref, 23 | task: options.task, 24 | environment: options.env, 25 | }); 26 | console.log(res.data); 27 | } 28 | 29 | /** 30 | * https://docs.github.com/en/rest/reference/repos#create-a-deployment 31 | */ 32 | async function createDeployment(options = {}) { 33 | const [owner, repo] = env.GITHUB_REPOSITORY?.split("/") ?? []; 34 | const gh = new Octokit({ auth: env.GITHUB_TOKEN }); 35 | 36 | console.log("Creating a new deployment..."); 37 | const res = await gh.repos.createDeployment({ 38 | mediaType: { previews: ["ant-man"] }, 39 | owner, 40 | repo, 41 | ref: options.ref, 42 | task: options.task, 43 | auto_merge: options.auto_merge, 44 | required_contexts: [], 45 | payload: options.payload, 46 | environment: options.env, 47 | description: options.description, 48 | transient_environment: 49 | options.transient === undefined ? true : options.transient, 50 | production_environment: options.env === "production", 51 | }); 52 | const id = res.data.id; 53 | 54 | if (id) { 55 | console.log(`::set-output name=id::${id}`); 56 | await createDeploymentStatus({ 57 | state: "in_progress", 58 | ...options, 59 | id, 60 | }); 61 | } 62 | } 63 | 64 | /** 65 | * https://docs.github.com/en/rest/reference/repos#delete-a-deployment 66 | */ 67 | async function deleteDeployment(options = {}) { 68 | const [owner, repo] = env.GITHUB_REPOSITORY?.split("/") ?? []; 69 | const gh = new Octokit({ auth: env.GITHUB_TOKEN }); 70 | 71 | let res = await gh.repos.getDeployment({ 72 | owner, 73 | repo, 74 | deployment_id: options.id, 75 | }); 76 | 77 | if (res.status === 200) { 78 | console.log(`Deleting deployment # ${options.id} ...`); 79 | res = await gh.repos.deleteDeployment({ 80 | owner, 81 | repo, 82 | deployment_id: options.id, 83 | }); 84 | } 85 | } 86 | 87 | /** 88 | * https://docs.github.com/en/rest/reference/repos#create-a-deployment-status 89 | * https://octokit.github.io/rest.js/v18#repos-create-deployment-status 90 | */ 91 | async function createDeploymentStatus(options = {}) { 92 | /* eslint-disable @typescript-eslint/no-unused-expressions */ 93 | options.state === "cancelled" ? "inactive" : options.state; 94 | const [owner, repo] = env.GITHUB_REPOSITORY?.split("/") ?? []; 95 | const gh = new Octokit({ auth: env.GITHUB_TOKEN }); 96 | 97 | let id = options.id; 98 | 99 | if (!options.id && options.ref) { 100 | const { data: deployments } = await gh.repos.listDeployments({ 101 | owner, 102 | repo, 103 | sha: options.sha, 104 | ref: options.ref, 105 | task: options.task, 106 | environment: options.env, 107 | }); 108 | if (deployments.length === 0) { 109 | throw new Error( 110 | `Cannot find a deployment by sha: ${options.sha}, ref: ${options.ref}, task: ${options.task}, environment: ${options.env}`, 111 | ); 112 | } 113 | if (deployments.length > 1) { 114 | throw new Error( 115 | `More than one deployment found by sha: ${options.sha}, ref: ${options.ref}, task: ${options.task}, environment: ${options.env}`, 116 | ); 117 | } 118 | id = deployments[0].id; 119 | } 120 | 121 | const res = await gh.repos.createDeploymentStatus({ 122 | mediaType: { previews: ["ant-man", "flash"] }, 123 | owner, 124 | repo, 125 | deployment_id: id, 126 | state: options.state, 127 | target_url: options.target_url || options.env_url, 128 | log_url: options.log_url, 129 | description: options.description, 130 | environment: options.env, 131 | environment_url: options.env_url || options.target_url, 132 | auto_inactive: options.auto_inactive, 133 | }); 134 | 135 | console.log(res.data); 136 | } 137 | 138 | // Load environment variables (GITHUB_TOKEN, etc.) 139 | const options = { 140 | default: { env: "test" }, 141 | boolean: ["transient", "auto_inactive", "auto_merge"], 142 | }; 143 | const args = minimist(process.argv.slice(2), options); 144 | envars.config({ env: args.env, cwd: path.resolve(rootDir, "env") }); 145 | args.env = args.env === "prod" ? "production" : args.env; 146 | args.payload = args.payload && JSON.parse(args.payload); 147 | 148 | let cmd; 149 | 150 | switch (args._[0]) { 151 | case "deployments": 152 | cmd = listDeployments(args); 153 | break; 154 | case "deployment-status": 155 | cmd = createDeploymentStatus(args); 156 | break; 157 | case "create-deployment": 158 | cmd = createDeployment(args); 159 | break; 160 | case "delete-deployment": 161 | cmd = deleteDeployment(args); 162 | break; 163 | default: 164 | cmd = Promise.reject(`Unknown command: ${args._[0]}`); 165 | } 166 | 167 | cmd.catch((err) => { 168 | console.error(err); 169 | process.exit(1); 170 | }); 171 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scripts", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | "./env": { 8 | "types": "./types.d.ts", 9 | "default": "./env.js" 10 | } 11 | }, 12 | "scripts": { 13 | "gcp:setup": "node ./gcp-setup.js", 14 | "gh:clean": "node --no-warnings ./clean.js", 15 | "gh:deployments": "node --no-warnings ./github.js deployments", 16 | "gh:deployment-status": "node --no-warnings ./github.js deployment-status", 17 | "gh:create-deployment": "node --no-warnings ./github.js create-deployment", 18 | "gh:delete-deployment": "node --no-warnings ./github.js delete-deployment" 19 | }, 20 | "dependencies": { 21 | "@babel/core": "^7.23.9", 22 | "@babel/register": "^7.23.7", 23 | "@google-cloud/secret-manager": "~5.0.1", 24 | "@google-cloud/storage": "^7.7.0", 25 | "@octokit/rest": "^20.0.2", 26 | "@types/cross-spawn": "^6.0.6", 27 | "@types/node": "^22.9.0", 28 | "cross-spawn": "^7.0.3", 29 | "dotenv": "^16.4.1", 30 | "envars": "^1.0.2", 31 | "execa": "^8.0.1", 32 | "globby": "^14.0.0", 33 | "got": "^14.2.0", 34 | "inquirer": "^9.2.14", 35 | "lodash-es": "^4.17.21", 36 | "minimist": "^1.2.8", 37 | "ora": "^8.0.1", 38 | "server": "workspace:*", 39 | "toml": "^3.0.0", 40 | "ts-import": "^5.0.0-beta.0", 41 | "ts-node": "^10.9.2", 42 | "typescript": "~5.6.3", 43 | "vite": "~5.4.10", 44 | "wrangler": "^3.27.0", 45 | "zx": "^8.2.1" 46 | }, 47 | "envars": { 48 | "cwd": "../env" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/post-install.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { execa } from "execa"; 5 | import { EOL } from "node:os"; 6 | import { dirname } from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | import { fs } from "zx"; 9 | 10 | if (process.env.CI === "true") process.exit(); 11 | 12 | export const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); 13 | process.cwd(rootDir); 14 | 15 | // Enable Git hooks 16 | // https://typicode.github.io/husky/ 17 | await execa("yarn", ["husky", "install"], { stdio: "inherit" }); 18 | 19 | // Create environment variable override files 20 | // such as `env/.prod.override.env`. 21 | const envFile = `./.env.local`; 22 | 23 | if (!fs.existsSync(envFile)) { 24 | await fs.writeFile( 25 | envFile, 26 | [ 27 | `# Environment variables overrides for local development`, 28 | `#`, 29 | `# GOOGLE_CLOUD_CREDENTIALS=xxxxx`, 30 | "# CLOUDFLARE_API_TOKEN=xxxxx", 31 | `# SENDGRID_API_KEY=SG.xxxxx`, 32 | `# PGPASSWORD=xxxxx`, 33 | ``, 34 | ].join(EOL), 35 | "utf-8", 36 | ); 37 | } 38 | 39 | try { 40 | await execa("yarn", ["tsc", "--build"], { stdin: "inherit" }); 41 | } catch (err) { 42 | console.error(err); 43 | } 44 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import envars from "envars"; 5 | import { template } from "lodash-es"; 6 | import { readFileSync } from "node:fs"; 7 | import { dirname, resolve } from "node:path"; 8 | import { fileURLToPath } from "node:url"; 9 | import { parse as parseToml } from "toml"; 10 | import { $, YAML, fs } from "zx"; 11 | 12 | export const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); 13 | export const envDir = resolve(rootDir, "env"); 14 | 15 | /** 16 | * Normalizes and saves the environment variables to a YAML file (for deployment). 17 | * 18 | * @param {Record { 38 | if (typeof env[key] === "number") env[key] = String(env[key]); 39 | if (typeof env[key] !== "string") env[key] = JSON.stringify(env[key]); 40 | }); 41 | 42 | await fs.writeFile(dest, YAML.stringify(env)); 43 | } 44 | 45 | /** 46 | * Fetches the URL of the Google Cloud Function (GCF). 47 | * 48 | * @param {string} name - The name of the Cloud Function 49 | */ 50 | export function getApiOrigin(name) { 51 | return $`gcloud beta functions describe ${name} --gen2 ${[ 52 | ...["--project", process.env.GOOGLE_CLOUD_PROJECT], 53 | ...["--region", process.env.GOOGLE_CLOUD_REGION], 54 | ...["--format", "value(serviceConfig.uri)"], 55 | ]}` 56 | .then((cmd) => cmd.stdout.toString().trim()) 57 | .catch(() => Promise.resolve(process.env.API_ORIGIN /* fallback */)); 58 | } 59 | 60 | /** 61 | * Get the arguments passed to the script. 62 | * 63 | * @returns {[args: string[], envName: string | undefined]} 64 | */ 65 | export function getArgs() { 66 | const args = process.argv.slice(2); 67 | /** @type {String} */ 68 | let envName; 69 | 70 | for (let i = 0; i < args.length; i++) { 71 | if (args[i] === "--env") { 72 | envName = args[i + 1]; 73 | args.splice(i, 2); 74 | break; 75 | } 76 | 77 | if (args[i]?.startsWith("--env=")) { 78 | envName = args[i].slice(6); 79 | args.splice(i, 1); 80 | break; 81 | } 82 | } 83 | 84 | return [args, envName]; 85 | } 86 | 87 | /** 88 | * Load environment variables used in the Cloudflare Worker. 89 | */ 90 | export function getCloudflareBindings(file = "wrangler.toml", envName) { 91 | const env = envars.config({ cwd: envDir, env: envName }); 92 | let config = parseToml(readFileSync(file, "utf-8")); 93 | 94 | return { 95 | SENDGRID_API_KEY: process.env.SENDGRID_API_KEY, 96 | GOOGLE_CLOUD_CREDENTIALS: process.env.GOOGLE_CLOUD_CREDENTIALS, 97 | ...JSON.parse(JSON.stringify(config.vars), (key, value) => { 98 | return typeof value === "string" 99 | ? value.replace(/\$\{?([\w]+)\}?/g, (_, key) => env[key]) 100 | : value; 101 | }), 102 | }; 103 | } 104 | 105 | export async function readWranglerConfig(file, envName = "test") { 106 | // Load environment variables from `env/*.env` file(s) 107 | envars.config({ cwd: resolve(rootDir, "env"), env: envName }); 108 | 109 | // Load Wrangler CLI configuration file 110 | let config = parseToml(await fs.readFile(file, "utf-8")); 111 | 112 | // Interpolate environment variables 113 | return JSON.parse(JSON.stringify(config), (key, value) => { 114 | return typeof value === "string" 115 | ? template(value, { 116 | interpolate: /\$\{?([\w]+)\}?/, 117 | })($.env) 118 | : value; 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /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 | # GraphQL API Server 2 | 3 | Node.js backend server with GraphQL API. 4 | 5 | ## Tech Stack 6 | 7 | - **[GraphQL Yoga](https://the-guild.dev/graphql/yoga-server)**: GraphQL server library for Node.js. 8 | - **[Pothos GraphQL](https://github.com/hayes/pothos#readme)**: Code-first GraphQL schema builder. 9 | - **[µWebSockets](https://github.com/uNetworking/uWebSockets#readme)**: High-performance HTTP and WebSocket server. 10 | - **[PostgreSQL](https://www.postgresql.org/)**: Database server with [vector database](https://cloud.google.com/blog/products/databases/using-pgvector-llms-and-langchain-with-google-cloud-databases) capabilities. 11 | - **[Cloud Firestore](https://firebase.google.com/docs/firestore)**: Real-time document database. 12 | - **[Identity Platform](https://cloud.google.com/security/products/identity-platform)**: authentication provider by Google Cloud. 13 | - **[Knex.js](https://knexjs.org/)**: Database client for PostgreSQL and query builder. 14 | - **[Node.js](https://nodejs.org/)** `v20` or newer with [Yarn](https://yarnpkg.com/) package manager. 15 | - [Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), [TypeScript](https://www.typescriptlang.org/), [Prettier](https://prettier.io/), [ESLint](https://eslint.org/): development tools. 16 | 17 | ## Directory Layout 18 | 19 | ```bash 20 | . 21 | ├── core/ # Common application modules 22 | ├── schema/ # GraphQL schema definitions 23 | ├── Dockerfile # Docker configuration for Cloud Run 24 | ├── global.d.ts # TypeScript definition overrides 25 | ├── graphql.ts # GraphQL API schema 26 | ├── index.ts # uWebSockets web server 27 | ├── package.json # Node.js dependencies and scripts 28 | ├── start.ts # Launch script for development 29 | ├── tsconfig.json # TypeScript configuration 30 | └── vite.config.ts # Bundler configuration 31 | ``` 32 | 33 | ## Getting Started 34 | 35 | Launch the app by running: 36 | 37 | ```bash 38 | $ yarn workspace server start # Or, `yarn server:start` 39 | ``` 40 | 41 | It should become available at [http://localhost:8080/](http://localhost:8080/) 42 | 43 | ## License 44 | 45 | Copyright © 2014-present Kriasoft. This source code is licensed under the MIT license found in the 46 | [LICENSE](https://github.com/kriasoft/relay-starter-kit/blob/main/LICENSE) file. 47 | -------------------------------------------------------------------------------- /server/core/auth.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { describe, expect, it } from "vitest"; 5 | import { fetchCertificates } from "./auth"; 6 | 7 | describe("fetchCertificates()", () => { 8 | it("should fetch certificates from Google", async () => { 9 | const certs = await fetchCertificates(); 10 | expect(certs).toEqual(expect.any(Object)); 11 | expect(Object.values(certs)).toEqual( 12 | expect.arrayContaining([ 13 | expect.stringMatching(/^-----BEGIN CERTIFICATE-----/), 14 | ]), 15 | ); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /server/core/auth.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { identitytoolkit_v3 } from "@googleapis/identitytoolkit"; 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 | projectId: env.GOOGLE_CLOUD_PROJECT, 16 | scopes: ["https://www.googleapis.com/auth/cloud-platform"], 17 | }); 18 | 19 | const { Identitytoolkit } = identitytoolkit_v3; 20 | export const { relyingparty } = new Identitytoolkit({ auth }); 21 | 22 | const certificatesURL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com"; // prettier-ignore 23 | const certificatesCache = new Map(); 24 | 25 | /** 26 | * Fetches the latest Google Cloud Identity Platform certificates. 27 | */ 28 | export function fetchCertificates(options?: { signal: AbortSignal }) { 29 | return got.get(certificatesURL, { 30 | cache: certificatesCache, 31 | resolveBodyOnly: true, 32 | responseType: "json", 33 | signal: options?.signal, 34 | }); 35 | } 36 | 37 | // Refresh certificates every 6 hours. 38 | const cleanup = (() => { 39 | const ac = new AbortController(); 40 | const int = setInterval(() => fetchCertificates(), 2.16e7); 41 | fetchCertificates({ signal: ac.signal }); 42 | return () => { 43 | clearInterval(int); 44 | ac.abort(); 45 | }; 46 | })(); 47 | 48 | process.on("SIGTERM", cleanup); 49 | process.on("SIGINT", cleanup); 50 | 51 | const idTokenClients = new Map(); 52 | 53 | export async function getIdToken(req: Request) { 54 | try { 55 | const idToken = req.headers.get("authorization")?.replace(/^Bearer /i, ""); 56 | let result: DecodedIdToken | null = null; 57 | 58 | if (idToken) { 59 | const certificatesPromise = fetchCertificates(); 60 | const audience = env.GOOGLE_CLOUD_PROJECT; 61 | let idTokenClient = idTokenClients.get(audience); 62 | 63 | if (!idTokenClient) { 64 | idTokenClient = await auth.getIdTokenClient(audience); 65 | idTokenClients.set(audience, idTokenClient); 66 | } 67 | 68 | const ticket = await idTokenClient.verifySignedJwtWithCertsAsync( 69 | idToken, 70 | await certificatesPromise, 71 | audience, 72 | [`https://securetoken.google.com/${env.GOOGLE_CLOUD_PROJECT}`], 73 | ); 74 | 75 | const token = ticket.getPayload(); 76 | 77 | if (token) { 78 | if ("user_id" in token) delete token.user_id; 79 | Object.assign(token, { uid: token.sub }); 80 | result = token as DecodedIdToken; 81 | } 82 | } 83 | 84 | return result; 85 | } catch (err) { 86 | console.log(err); 87 | return null; 88 | } 89 | } 90 | 91 | // #region Types 92 | 93 | /** 94 | * Interface representing a decoded Firebase ID token, returned from the 95 | * {@link verifyIdToken} method. 96 | * 97 | * Firebase ID tokens are OpenID Connect spec-compliant JSON Web Tokens (JWTs). 98 | * See the 99 | * [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) 100 | * for more information about the specific properties below. 101 | */ 102 | export interface DecodedIdToken extends TokenPayload { 103 | /** 104 | * Time, in seconds since the Unix epoch, when the end-user authentication 105 | * occurred. 106 | * 107 | * This value is not set when this particular ID token was created, but when the 108 | * user initially logged in to this session. In a single session, the Firebase 109 | * SDKs will refresh a user's ID tokens every hour. Each ID token will have a 110 | * different [`iat`](#iat) value, but the same `auth_time` value. 111 | */ 112 | auth_time: number; 113 | 114 | /** 115 | * Information about the sign in event, including which sign in provider was 116 | * used and provider-specific identity details. 117 | * 118 | * This data is provided by the Firebase Authentication service and is a 119 | * reserved claim in the ID token. 120 | */ 121 | firebase: { 122 | /** 123 | * Provider-specific identity details corresponding 124 | * to the provider used to sign in the user. 125 | */ 126 | identities: { 127 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 128 | [key: string]: any; 129 | }; 130 | 131 | /** 132 | * The ID of the provider used to sign in the user. 133 | * One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`, 134 | * `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`, 135 | * `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`, 136 | * or `"custom"`. 137 | * 138 | * Additional Identity Platform provider IDs include `"linkedin.com"`, 139 | * OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."` 140 | * respectively. 141 | */ 142 | sign_in_provider: string; 143 | 144 | /** 145 | * The type identifier or `factorId` of the second factor, provided the 146 | * ID token was obtained from a multi-factor authenticated user. 147 | * For phone, this is `"phone"`. 148 | */ 149 | sign_in_second_factor?: string; 150 | 151 | /** 152 | * The `uid` of the second factor used to sign in, provided the 153 | * ID token was obtained from a multi-factor authenticated user. 154 | */ 155 | second_factor_identifier?: string; 156 | 157 | /** 158 | * The ID of the tenant the user belongs to, if available. 159 | */ 160 | tenant?: string; 161 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 162 | [key: string]: any; 163 | }; 164 | 165 | /** 166 | * The phone number of the user to whom the ID token belongs, if available. 167 | */ 168 | phone_number?: string; 169 | 170 | /** 171 | * The `uid` corresponding to the user who the ID token belonged to. 172 | * 173 | * This value is not actually in the JWT token claims itself. It is added as a 174 | * convenience, and is set as the value of the [`sub`](#sub) property. 175 | */ 176 | uid: string; 177 | 178 | /** 179 | * Indicates whether or not the user is an admin. 180 | */ 181 | admin?: boolean; 182 | } 183 | 184 | // #endregion 185 | -------------------------------------------------------------------------------- /server/core/db.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import knex, { Knex } from "knex"; 5 | import { env } from "./env"; 6 | 7 | /** 8 | * Knex.js database client and query builder for PostgreSQL. 9 | * 10 | * @see https://knexjs.org/ 11 | */ 12 | export const db = knex({ 13 | client: "pg", 14 | connection: { 15 | // Uses Cloud SQL Connector in development mode. 16 | ...("dbOptions" in globalThis 17 | ? (globalThis as unknown as { dbOptions: DbOptions }).dbOptions 18 | : { host: env.PGHOST, port: env.PGPORT }), 19 | user: env.PGUSER, 20 | password: env.PGPASSWORD, 21 | database: env.PGDATABASE, 22 | }, 23 | }); 24 | 25 | type DbOptions = Knex.ConnectionConfigProvider; 26 | -------------------------------------------------------------------------------- /server/core/env.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { cleanEnv, num, str, url } from "envalid"; 5 | 6 | /** 7 | * Validated and sanitized environment variables. 8 | * 9 | * @see https://github.com/af/envalid#readme 10 | */ 11 | export const env = cleanEnv(process.env, { 12 | /** 13 | * The port your HTTP server should listen on. 14 | * @default 8080 15 | */ 16 | PORT: num({ default: 8080 }), 17 | /** 18 | * The name of the Cloud Run service being run. 19 | * @example server 20 | */ 21 | K_SERVICE: str({ default: "" }), 22 | /** 23 | * The name of the Cloud Run revision being run. 24 | * @example server.1 25 | */ 26 | K_REVISION: str({ default: "" }), 27 | /** 28 | * The name of the Cloud Run configuration that created the revision. 29 | * @example server 30 | */ 31 | K_CONFIGURATION: str({ default: "" }), 32 | 33 | GOOGLE_CLOUD_PROJECT: str(), 34 | GOOGLE_CLOUD_REGION: str(), 35 | GOOGLE_CLOUD_CREDENTIALS: str({ default: "" }), 36 | FIREBASE_APP_ID: str(), 37 | FIREBASE_API_KEY: str(), 38 | FIREBASE_AUTH_DOMAIN: str(), 39 | 40 | APP_NAME: str(), 41 | APP_ORIGIN: url(), 42 | APP_ENV: str({ choices: ["prod", "test", "local"] }), 43 | 44 | VERSION: str({ default: "latest" }), 45 | 46 | PGHOST: str(), 47 | PGPORT: num({ default: 5432 }), 48 | PGUSER: str(), 49 | PGPASSWORD: str(), 50 | PGDATABASE: str(), 51 | 52 | SENDGRID_API_KEY: str(), 53 | EMAIL_FROM: str(), 54 | 55 | UPLOAD_BUCKET: str(), 56 | STORAGE_BUCKET: str(), 57 | }); 58 | -------------------------------------------------------------------------------- /server/core/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | export * from "./auth"; 5 | export * from "./db"; 6 | export * from "./env"; 7 | -------------------------------------------------------------------------------- /server/core/source-map-support.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { readFileSync } from "node:fs"; 5 | import { pathToFileURL } from "node:url"; 6 | import * as SourceMapSupport from "source-map-support"; 7 | 8 | const prefixURL = `${pathToFileURL(process.cwd())}/`; 9 | 10 | /** 11 | * Enables source map support 12 | * https://github.com/evanw/node-source-map-support#readme 13 | */ 14 | SourceMapSupport.install({ 15 | retrieveSourceMap(file) { 16 | if (file.startsWith(prefixURL) && file.endsWith(".js")) { 17 | return { 18 | url: file.substring(prefixURL.length), 19 | map: readFileSync(`${file.substring(prefixURL.length)}.map`, "utf-8"), 20 | }; 21 | } 22 | 23 | return null; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /server/core/storage.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { Storage } from "@google-cloud/storage"; 5 | import { env } from "./env"; 6 | 7 | export const storage = new Storage(); 8 | export const uploadBucket = storage.bucket(env.UPLOAD_BUCKET); 9 | export const storageBucket = storage.bucket(env.STORAGE_BUCKET); 10 | -------------------------------------------------------------------------------- /server/core/utils.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { fromGlobalId as parse } from "graphql-relay"; 5 | import { Knex } from "knex"; 6 | import { customAlphabet } from "nanoid"; 7 | 8 | // An alphabet for generating short IDs. 9 | const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; 10 | 11 | // Creates a function that generates a short ID of specified length. 12 | export function createNewId( 13 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 14 | db: Knex, 15 | table: string, 16 | size: number, 17 | ): (unique: boolean) => Promise { 18 | const generateId = customAlphabet(alphabet, size); 19 | 20 | return async function newId(unique = true) { 21 | let id = generateId(); 22 | 23 | // Ensures that the generated ID is unique 24 | while (unique) { 25 | const record = await db.table(table).where({ id }).first(db.raw(1)); 26 | 27 | if (record) { 28 | console.warn(`Re-generating new ${table} ID.`); 29 | id = generateId(); 30 | } else { 31 | break; 32 | } 33 | } 34 | 35 | return id; 36 | }; 37 | } 38 | 39 | /** 40 | * Converts (Relay) global ID into a raw database ID. 41 | */ 42 | export function fromGlobalId(globalId: string, expectedType: string): string { 43 | const { id, type } = parse(globalId); 44 | 45 | if (expectedType && type !== expectedType) { 46 | throw new Error( 47 | `Expected an ID of type '${expectedType}' but got '${type}'.`, 48 | ); 49 | } 50 | 51 | return id; 52 | } 53 | -------------------------------------------------------------------------------- /server/global.d.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | declare module "graphql" { 5 | interface GraphQLFormattedError { 6 | fieldErrors?: Record; 7 | formErrors?: Record; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { createYoga } from "graphql-yoga"; 5 | import uWS from "uWebSockets.js"; 6 | import { db, env } from "./core"; 7 | import { getIdToken } from "./core/auth"; 8 | import { schema } from "./schema"; 9 | 10 | /** 11 | * GraphQL API server middleware. 12 | * @see https://the-guild.dev/graphql/yoga-server/docs 13 | */ 14 | const yoga = createYoga({ 15 | schema, 16 | async context({ request }) { 17 | const token = await getIdToken(request); 18 | return { token }; 19 | }, 20 | }); 21 | 22 | /** 23 | * High performance HTTP and WebSocket server based on uWebSockets.js. 24 | * @see https://github.com/uNetworking/uWebSockets 25 | */ 26 | const app = uWS 27 | .App() 28 | // GraphQL API endpoint. 29 | .any("/*", yoga); 30 | 31 | /** 32 | * Starts the HTTP server. 33 | */ 34 | export function listen() { 35 | app.listen(env.PORT, () => { 36 | console.log(`Server listening on http://localhost:${env.PORT}/`); 37 | }); 38 | 39 | return async () => { 40 | app.close(); 41 | await db.destroy(); 42 | }; 43 | } 44 | 45 | // Start the server if running in a Cloud Run environment. 46 | if (env.K_SERVICE) { 47 | const close = listen(); 48 | process.on("SIGINT", () => close()); 49 | process.on("SIGTERM", () => close()); 50 | } 51 | -------------------------------------------------------------------------------- /server/loaders/map.test.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { expect, test } from "vitest"; 5 | import { mapTo, mapToMany, mapToManyValues, mapToValues } from "./map"; 6 | 7 | test("mapTo()", () => { 8 | const result = mapTo( 9 | [ 10 | { id: 2, name: "b" }, 11 | { id: 1, name: "a" }, 12 | ], 13 | [1, 2], 14 | (x) => x.id, 15 | ); 16 | expect(result).toMatchInlineSnapshot(` 17 | [ 18 | { 19 | "id": 1, 20 | "name": "a", 21 | }, 22 | { 23 | "id": 2, 24 | "name": "b", 25 | }, 26 | ] 27 | `); 28 | }); 29 | 30 | test("mapToMany()", () => { 31 | const result = mapToMany( 32 | [ 33 | { id: 2, name: "b" }, 34 | { id: 1, name: "a" }, 35 | { id: 1, name: "c" }, 36 | ], 37 | [1, 2], 38 | (x) => x.id, 39 | ); 40 | expect(result).toMatchInlineSnapshot(` 41 | [ 42 | [ 43 | { 44 | "id": 1, 45 | "name": "a", 46 | }, 47 | { 48 | "id": 1, 49 | "name": "c", 50 | }, 51 | ], 52 | [ 53 | { 54 | "id": 2, 55 | "name": "b", 56 | }, 57 | ], 58 | ] 59 | `); 60 | }); 61 | 62 | test("mapToValues()", () => { 63 | const result = mapToValues( 64 | [ 65 | { id: 2, name: "b" }, 66 | { id: 1, name: "a" }, 67 | { id: 3, name: "c" }, 68 | ], 69 | [1, 2, 3, 4], 70 | (x) => x.id, 71 | (x) => x?.name || null, 72 | ); 73 | expect(result).toMatchInlineSnapshot(` 74 | [ 75 | "a", 76 | "b", 77 | "c", 78 | null, 79 | ] 80 | `); 81 | }); 82 | 83 | test("mapToManyValues()", () => { 84 | const result = mapToManyValues( 85 | [ 86 | { id: 2, name: "b" }, 87 | { id: 2, name: "c" }, 88 | { id: 1, name: "a" }, 89 | ], 90 | [1, 2], 91 | (x) => x.id, 92 | (x) => x?.name || null, 93 | ); 94 | expect(result).toMatchInlineSnapshot(` 95 | [ 96 | [ 97 | "a", 98 | ], 99 | [ 100 | "b", 101 | "c", 102 | ], 103 | ] 104 | `); 105 | }); 106 | -------------------------------------------------------------------------------- /server/loaders/map.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | // These helper functions are intended to be used in data loaders for mapping 5 | // entity keys to entity values (db records). See `../context.ts`. 6 | // https://github.com/graphql/dataloader 7 | 8 | export function mapTo( 9 | records: ReadonlyArray, 10 | keys: ReadonlyArray, 11 | keyFn: (record: R) => K, 12 | ): Array { 13 | const map = new Map(records.map((x) => [keyFn(x), x])); 14 | return keys.map((key) => map.get(key) || null); 15 | } 16 | 17 | export function mapToMany( 18 | records: ReadonlyArray, 19 | keys: ReadonlyArray, 20 | keyFn: (record: R) => K, 21 | ): Array { 22 | const group = new Map(keys.map((key) => [key, []])); 23 | records.forEach((record) => (group.get(keyFn(record)) || []).push(record)); 24 | return Array.from(group.values()); 25 | } 26 | 27 | export function mapToValues( 28 | records: ReadonlyArray, 29 | keys: ReadonlyArray, 30 | keyFn: (record: R) => K, 31 | valueFn: (record?: R) => V, 32 | ): Array { 33 | const map = new Map(records.map((x) => [keyFn(x), x])); 34 | return keys.map((key) => valueFn(map.get(key))); 35 | } 36 | 37 | export function mapToManyValues( 38 | records: ReadonlyArray, 39 | keys: ReadonlyArray, 40 | keyFn: (record: R) => K, 41 | valueFn: (record?: R) => V, 42 | ): Array { 43 | const group = new Map(keys.map((key) => [key, []])); 44 | records.forEach((record) => 45 | (group.get(keyFn(record)) || []).push(valueFn(record)), 46 | ); 47 | return Array.from(group.values()); 48 | } 49 | -------------------------------------------------------------------------------- /server/loaders/user.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import DataLoader from "dataloader"; 5 | import { User } from "../schema/user"; 6 | 7 | export const userById = new DataLoader(async (keys) => { 8 | // TODO: Load users by ID. 9 | return keys.map(() => null); 10 | }); 11 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": "./index.js", 7 | "scripts": { 8 | "test": "vitest", 9 | "start": "vite-node --watch ./start.ts", 10 | "build": "vite build --ssr ./index.ts && node ../scripts/bundle-yarn.js", 11 | "server:test": "yarn workspace server test", 12 | "server:start": "yarn workspace server start", 13 | "server:build": "yarn workspace server run build" 14 | }, 15 | "dependencies": { 16 | "@google-cloud/storage": "^7.7.0", 17 | "@googleapis/identitytoolkit": "^8.0.1", 18 | "@pothos/core": "^3.41.0", 19 | "@sendgrid/mail": "^8.1.0", 20 | "dataloader": "^2.2.2", 21 | "date-fns": "^3.3.1", 22 | "date-fns-tz": "^2.0.0", 23 | "db": "workspace:*", 24 | "envalid": "^8.0.0", 25 | "google-auth-library": "^9.6.3", 26 | "got": "^14.2.0", 27 | "graphql": "^16.8.1", 28 | "graphql-relay": "^0.10.0", 29 | "graphql-yoga": "^5.1.1", 30 | "knex": "^3.1.0", 31 | "lodash-es": "^4.17.21", 32 | "nanoid": "^5.0.5", 33 | "pg": "^8.11.3", 34 | "quick-lru": "^7.0.0", 35 | "slugify": "^1.6.6", 36 | "source-map-support": "^0.5.21", 37 | "uWebSockets.js": "https://github.com/uNetworking/uWebSockets.js#v20.49.0", 38 | "zod": "^3.22.4" 39 | }, 40 | "bundledDependencies": [ 41 | "db" 42 | ], 43 | "devDependencies": { 44 | "@google-cloud/cloud-sql-connector": "^1.2.2", 45 | "@graphql-codegen/cli": "5.0.2", 46 | "@originjs/vite-plugin-commonjs": "^1.0.3", 47 | "@types/lodash-es": "^4.17.12", 48 | "@types/node": "^22.9.0", 49 | "@types/pg": "^8.11.0", 50 | "@types/source-map-support": "^0.5.10", 51 | "@types/supertest": "^6.0.2", 52 | "chalk": "^5.3.0", 53 | "envars": "^1.0.2", 54 | "execa": "^8.0.1", 55 | "get-port": "^7.0.0", 56 | "happy-dom": "^13.3.8", 57 | "scripts": "workspace:*", 58 | "supertest": "^6.3.4", 59 | "type-fest": "^4.26.1", 60 | "typescript": "~5.6.3", 61 | "vite": "~5.4.10", 62 | "vite-node": "~2.1.4", 63 | "vite-plugin-node": "^4.0.0", 64 | "vite-plugin-static-copy": "^2.1.0", 65 | "vitest": "~2.1.4" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/schema/builder.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import SchemaBuilder from "@pothos/core"; 5 | 6 | const builder = new SchemaBuilder({}); 7 | 8 | builder.queryType({}); 9 | 10 | export { builder }; 11 | -------------------------------------------------------------------------------- /server/schema/index.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { builder } from "./builder"; 5 | import "./user"; 6 | import "./workspace"; 7 | 8 | export const schema = builder.toSchema({}); 9 | -------------------------------------------------------------------------------- /server/schema/user.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import * as Db from "db/models"; 5 | import { db } from "../core"; 6 | import { relyingparty } from "../core/auth"; 7 | import { builder } from "./builder"; 8 | 9 | export const User = builder.objectRef("User"); 10 | 11 | User.implement({ 12 | fields: (t) => ({ 13 | id: t.exposeID("localId"), 14 | email: t.exposeString("email", { nullable: true }), 15 | emailVerified: t.exposeBoolean("emailVerified", { nullable: true }), 16 | displayName: t.exposeString("displayName", { nullable: true }), 17 | photoUrl: t.exposeString("photoUrl", { nullable: true }), 18 | locale: t.exposeString("locale", { nullable: true }), 19 | timeZone: t.exposeString("time_zone", { nullable: true }), 20 | disabled: t.exposeBoolean("disabled", { nullable: true }), 21 | createdAt: t.exposeString("createdAt", { nullable: true }), 22 | lastLoginAt: t.exposeString("lastLoginAt", { nullable: true }), 23 | }), 24 | }); 25 | 26 | builder.queryField("user", (t) => 27 | t.field({ 28 | type: User, 29 | nullable: true, 30 | args: { id: t.arg.id({ required: true }) }, 31 | async resolve(_, args): Promise { 32 | const id = String(args.id); 33 | const result = await Promise.all([ 34 | relyingparty 35 | .getAccountInfo({ 36 | // quotaUser: ctx.token.uid, 37 | requestBody: { localId: [id] }, 38 | }) 39 | .then((res) => res.data.users?.[0]), 40 | db.from("user").where("id", "=", id).first(), 41 | ]); 42 | 43 | const account = result[0]; 44 | let user = result[1]; 45 | 46 | // User account not found. 47 | if (!account) return null as unknown as User; 48 | 49 | // Create user record if it doesn't exist. 50 | if (!user) { 51 | user = await db 52 | .table("user") 53 | .insert({ id: id as Db.UserId }) 54 | .returning("*") 55 | .first(); 56 | } 57 | 58 | return { 59 | ...account, 60 | ...user, 61 | localId: id, 62 | id: id as Db.UserId, 63 | }; 64 | }, 65 | }), 66 | ); 67 | 68 | export interface User extends Db.UserInitializer { 69 | localId: string; 70 | email?: string | null; 71 | emailVerified?: boolean | null; 72 | displayName?: string | null; 73 | photoUrl?: string | null; 74 | disabled?: boolean | null; 75 | createdAt?: string | null; 76 | lastLoginAt?: string | null; 77 | } 78 | -------------------------------------------------------------------------------- /server/schema/workspace.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import * as Db from "db/models"; 5 | import { db } from "../core/db"; 6 | import { builder } from "./builder"; 7 | 8 | export const Workspace = builder.objectRef("Workspace"); 9 | 10 | Workspace.implement({ 11 | fields: (t) => ({ 12 | id: t.exposeID("id"), 13 | name: t.exposeString("name"), 14 | }), 15 | }); 16 | 17 | builder.queryField("workspace", (t) => 18 | t.field({ 19 | type: Workspace, 20 | nullable: true, 21 | args: { 22 | id: t.arg.id({ required: true }), 23 | }, 24 | resolve(_, args) { 25 | return db 26 | .from("workspace") 27 | .where("id", "=", args.id) 28 | .first(); 29 | }, 30 | }), 31 | ); 32 | -------------------------------------------------------------------------------- /server/start.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | // This file is used by Vite to start the HTTP server 5 | // in development mode. It is not used in production. 6 | // 7 | // Usage example: 8 | // $ yarn vite-node --watch ./start.ts 9 | // 10 | import { Connector, IpAddressTypes } from "@google-cloud/cloud-sql-connector"; 11 | import getPort, { portNumbers } from "get-port"; 12 | 13 | // Get the first available port number in the 8080..9000 range. 14 | process.env.PORT = `${await getPort({ port: portNumbers(8080, 9000) })}`; 15 | process.env.PGHOST = process.env.PGHOST ?? ""; 16 | 17 | let connector: Connector | undefined = undefined; 18 | const dbHost = process.env.PGHOST ?? ""; 19 | 20 | // Setup Cloud SQL Connector when database hostname 21 | // was set to a value such as `project:us-central1:db`. 22 | if (/^[\w-]+:[\w-]+:[\w-]+$/i.test(dbHost)) { 23 | connector = new Connector(); 24 | const dbOptions = await connector.getOptions({ 25 | instanceConnectionName: dbHost, 26 | ipType: IpAddressTypes.PUBLIC, 27 | }); 28 | Object.defineProperty(globalThis, "dbOptions", { 29 | value: dbOptions, 30 | writable: false, 31 | }); 32 | } 33 | 34 | async function listen() { 35 | const module = await import("./index"); 36 | return module.listen(); 37 | } 38 | 39 | // Start the HTTP server. 40 | let dispose = await listen(); 41 | 42 | // Automatically restart the server when the source code changes. 43 | // https://vitejs.dev/guide/features.html#hot-module-replacement 44 | if (import.meta.hot) { 45 | import.meta.hot.accept("/index.ts", async () => { 46 | await dispose?.(); 47 | dispose = await listen(); 48 | }); 49 | } 50 | 51 | async function cleanUp() { 52 | await dispose?.(); 53 | connector?.close(); 54 | process.exit(); 55 | } 56 | 57 | process.on("SIGINT", cleanUp); 58 | process.on("SIGTERM", cleanUp); 59 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ESNext"], 5 | "outDir": "../.cache/ts-server", 6 | "types": ["vite/client"], 7 | "noEmit": true 8 | }, 9 | "include": ["**/*.ts", "**/*.cjs", "**/*.js", "**/*.json", "../db/types"], 10 | "exclude": ["dist/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /server/vite.config.ts: -------------------------------------------------------------------------------- 1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */ 2 | /* SPDX-License-Identifier: MIT */ 3 | 4 | import { viteCommonjs } from "@originjs/vite-plugin-commonjs"; 5 | import { loadEnv } from "envars"; 6 | import { resolve } from "node:path"; 7 | import { defineProject } from "vitest/config"; 8 | 9 | /** 10 | * Vite configuration. 11 | * @see https://vitejs.dev/config/ 12 | */ 13 | export default defineProject(async ({ mode }) => { 14 | // Load environment variables from `.env` files 15 | // https://vitejs.dev/config/#using-environment-variables-in-config 16 | await loadEnv(mode, { 17 | root: "..", 18 | schema: "./core/env.ts", 19 | mergeTo: process.env, 20 | }); 21 | 22 | return { 23 | cacheDir: "../.cache/vite-api", 24 | 25 | build: { 26 | ssr: "./index.ts", 27 | emptyOutDir: true, 28 | sourcemap: "inline", 29 | }, 30 | 31 | ssr: {}, 32 | 33 | plugins: [ 34 | viteCommonjs({ 35 | include: ["graphql-relay"], 36 | }), 37 | ], 38 | 39 | server: { 40 | port: 8080, 41 | deps: { 42 | inline: ["graphql", "graphql-relay"], 43 | }, 44 | }, 45 | 46 | test: { 47 | ...{ cache: { dir: resolve(__dirname, "../.cache/vitest") } }, 48 | environment: "node", 49 | testTimeout: 10000, 50 | teardownTimeout: 10000, 51 | server: { 52 | deps: { 53 | inline: ["graphql", "graphql-relay"], 54 | }, 55 | }, 56 | }, 57 | }; 58 | }); 59 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./app" }, 5 | { "path": "./db" }, 6 | { "path": "./server" } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /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 { existsSync } from "node:fs"; 5 | import { defineWorkspace } from "vitest/config"; 6 | import { workspaces } from "./package.json"; 7 | 8 | /** 9 | * Inline Vitest configuration for all workspaces. 10 | * 11 | * @see https://vitest.dev/guide/workspace 12 | */ 13 | export default defineWorkspace( 14 | workspaces 15 | .filter((name) => existsSync(`./${name}/vite.config.ts`)) 16 | .map((name) => ({ 17 | extends: `./${name}/vite.config.ts`, 18 | test: { 19 | name, 20 | root: `./${name}`, 21 | }, 22 | })), 23 | ); 24 | --------------------------------------------------------------------------------