├── .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 | " $1>",
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 | 
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 |
36 |
37 | );
38 | }
39 |
40 | const ModeMenuItem = memo(function ModeMenuItem({
41 | mode,
42 | }: ModeMenuItemProps): JSX.Element {
43 | const scheme = useColorScheme();
44 |
45 | return (
46 |
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 |
34 | );
35 | }
36 |
37 | export type LoginButtonProps = Omit<
38 | ButtonProps<
39 | "button",
40 | {
41 | signInMethod: SignInMethod;
42 | }
43 | >,
44 | "children"
45 | >;
46 |
--------------------------------------------------------------------------------
/app/components/button-user-avatar.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { LogoutRounded, SettingsRounded } from "@mui/icons-material";
5 | import {
6 | Avatar,
7 | Dropdown,
8 | IconButton,
9 | IconButtonProps,
10 | ListItemContent,
11 | ListItemDecorator,
12 | Menu,
13 | MenuButton,
14 | MenuItem,
15 | } from "@mui/joy";
16 | import { getAuth, signOut } from "firebase/auth";
17 | import { useCurrentUser } from "../core/auth";
18 |
19 | export function UserAvatarButton(props: UserAvatarButtonProps): JSX.Element {
20 | const { sx, ...other } = props;
21 | const user = useCurrentUser()!;
22 |
23 | return (
24 |
25 |
34 |
35 | {user.displayName}
36 |
37 |
38 |
39 |
53 |
54 | );
55 | }
56 |
57 | export type UserAvatarButtonProps = Omit;
58 |
--------------------------------------------------------------------------------
/app/components/error.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Container, Typography } from "@mui/joy";
5 | import { useRouteError } from "react-router-dom";
6 |
7 | export function RootError(): JSX.Element {
8 | const err = useRouteError() as RouteError;
9 |
10 | return (
11 |
12 |
21 | Error {err.status || 500}:{" "}
22 | {err.statusText ?? err.message}
23 |
24 |
25 | );
26 | }
27 |
28 | type RouteError = Error & { status?: number; statusText?: string };
29 |
--------------------------------------------------------------------------------
/app/components/index.ts:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | export * from "./button-login";
5 | export * from "./error";
6 | export * from "./layout";
7 | export * from "./logo";
8 |
--------------------------------------------------------------------------------
/app/components/layout.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Box, GlobalStyles } from "@mui/joy";
5 | import { Fragment, Suspense } from "react";
6 | import { Outlet } from "react-router-dom";
7 | import { Logo } from "./logo";
8 | import { Sidebar } from "./sidebar";
9 | import { Toolbar } from "./toolbar";
10 |
11 | /**
12 | * The main application layout.
13 | */
14 | export function MainLayout(): JSX.Element {
15 | return (
16 |
17 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | /**
42 | * The minimal app layout to be used on pages such Login/Signup,
43 | * Privacy Policy, Terms of Use, etc.
44 | */
45 | export function BaseLayout(): JSX.Element {
46 | return (
47 |
48 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/app/components/logo.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { BrightnessAutoRounded } from "@mui/icons-material";
5 | import { Box, BoxProps, IconButton, Typography } from "@mui/joy";
6 | import { Link } from "react-router-dom";
7 |
8 | export function Logo(props: LogoProps): JSX.Element {
9 | const { sx, ...other } = props;
10 |
11 | return (
12 |
23 |
24 |
25 |
26 |
27 | {import.meta.env.VITE_APP_NAME}
28 |
29 |
30 | );
31 | }
32 |
33 | export type LogoProps = Omit;
34 |
--------------------------------------------------------------------------------
/app/components/navigation.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import {
5 | AssignmentTurnedInRounded,
6 | ChatRounded,
7 | Dashboard,
8 | } from "@mui/icons-material";
9 | import {
10 | List,
11 | ListItem,
12 | ListItemButton,
13 | ListItemContent,
14 | ListItemDecorator,
15 | ListProps,
16 | } from "@mui/joy";
17 | import { ReactNode, memo } from "react";
18 | import { Link, useMatch } from "react-router-dom";
19 |
20 | export const Navigation = memo(function Navigation(
21 | props: NavigationProps,
22 | ): JSX.Element {
23 | const { sx, ...other } = props;
24 |
25 | return (
26 |
32 | } />
33 | }
37 | />
38 | } />
39 |
40 | );
41 | });
42 |
43 | function NavItem(props: NavItemProps): JSX.Element {
44 | return (
45 |
46 |
52 |
53 | {props.label}
54 |
55 |
56 | );
57 | }
58 |
59 | type NavigationProps = Omit;
60 | type NavItemProps = {
61 | path: string;
62 | label: string;
63 | icon: ReactNode;
64 | };
65 |
--------------------------------------------------------------------------------
/app/components/sidebar.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { Sheet, SheetProps } from "@mui/joy";
5 | import { Navigation } from "./navigation";
6 |
7 | const width = 260;
8 |
9 | export function Sidebar(props: SidebarProps): JSX.Element {
10 | const { sx, ...other } = props;
11 |
12 | return (
13 | `1px solid ${palette.divider}`,
18 | overflow: "auto",
19 | width,
20 | ...sx,
21 | }}
22 | aria-label="Sidebar"
23 | {...other}
24 | >
25 |
26 |
27 | );
28 | }
29 |
30 | export type SidebarProps = Omit;
31 |
--------------------------------------------------------------------------------
/app/components/toolbar.tsx:
--------------------------------------------------------------------------------
1 | /* SPDX-FileCopyrightText: 2014-present Kriasoft */
2 | /* SPDX-License-Identifier: MIT */
3 |
4 | import { ExpandMoreRounded, NotificationsRounded } from "@mui/icons-material";
5 | import { Box, BoxProps, Button, IconButton } from "@mui/joy";
6 | import { Fragment, Suspense } from "react";
7 | import { Link } from "react-router-dom";
8 | import { useCurrentUser } from "../core/auth";
9 | import { ColorSchemeButton } from "./button-color-scheme";
10 | import { UserAvatarButton } from "./button-user-avatar";
11 |
12 | export function Toolbar(props: ToolbarProps): JSX.Element {
13 | const { sx, ...other } = props;
14 |
15 | return (
16 |
29 | }
33 | children="Project Name"
34 | />
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | function ActionButtons(): JSX.Element {
46 | const user = useCurrentUser();
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | {user ? (
57 |
58 | ) : (
59 |
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 |
--------------------------------------------------------------------------------