├── .dockerignore ├── .env.template ├── .env.test ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ ├── docker.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .husky ├── post-checkout ├── post-commit ├── post-merge ├── pre-commit └── pre-push ├── .npmrc ├── .prettierrc.mjs ├── .vscode └── settings.json ├── LICENSE ├── apps ├── core │ ├── .env │ ├── .npmrc │ ├── .vscode │ │ └── settings.json │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── app.config.testing.ts │ │ ├── app.config.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── bootstrap.ts │ │ ├── common │ │ │ ├── adapter │ │ │ │ ├── fastify.adapter.ts │ │ │ │ └── io.adapter.ts │ │ │ ├── contexts │ │ │ │ └── request.context.ts │ │ │ ├── decorators │ │ │ │ ├── api-controller.decorator.ts │ │ │ │ ├── auth.decorator.ts │ │ │ │ ├── cache.decorator.ts │ │ │ │ ├── get-owner.decorator.ts │ │ │ │ ├── http.decorator.ts │ │ │ │ └── ip.decorator.ts │ │ │ ├── exceptions │ │ │ │ └── biz.exception.ts │ │ │ ├── filters │ │ │ │ └── all-exception.filter.ts │ │ │ ├── guards │ │ │ │ ├── auth.guard.ts │ │ │ │ └── spider.guard.ts │ │ │ ├── interceptors │ │ │ │ ├── allow-all-cors.interceptor.ts │ │ │ │ ├── cache.interceptor.ts │ │ │ │ ├── idempotence.interceptor.ts │ │ │ │ ├── json-transformer.interceptor.ts │ │ │ │ ├── logging.interceptor.ts │ │ │ │ └── response.interceptor.ts │ │ │ ├── middlewares │ │ │ │ └── request-context.middleware.ts │ │ │ └── pipes │ │ │ │ └── zod-validation.pipe.ts │ │ ├── constants │ │ │ ├── article.constant.ts │ │ │ ├── business-event.constant.ts │ │ │ ├── cache.constant.ts │ │ │ ├── error-code.constant.ts │ │ │ ├── event-bus.constant.ts │ │ │ ├── event-scope.constant.ts │ │ │ ├── meta.constant.ts │ │ │ ├── parser.utilt.ts │ │ │ ├── path.constant.ts │ │ │ └── system.constant.ts │ │ ├── global │ │ │ ├── consola.global.ts │ │ │ ├── dayjs.global.ts │ │ │ ├── env.global.ts │ │ │ └── index.global.ts │ │ ├── main.ts │ │ ├── modules │ │ │ ├── auth │ │ │ │ ├── auth.config.ts │ │ │ │ ├── auth.constant.ts │ │ │ │ ├── auth.implement.ts │ │ │ │ ├── auth.middleware.ts │ │ │ │ ├── auth.module.ts │ │ │ │ ├── auth.service.ts │ │ │ │ └── req.transformer.ts │ │ │ └── user │ │ │ │ ├── user.controller.ts │ │ │ │ ├── user.dto.ts │ │ │ │ └── user.module.ts │ │ ├── processors │ │ │ ├── cache │ │ │ │ ├── cache.config.service.ts │ │ │ │ ├── cache.module.ts │ │ │ │ └── cache.service.ts │ │ │ ├── database │ │ │ │ ├── database.module.ts │ │ │ │ └── database.service.ts │ │ │ ├── gateway │ │ │ │ ├── base.gateway.ts │ │ │ │ ├── gateway.module.ts │ │ │ │ ├── shared │ │ │ │ │ └── events.gateway.ts │ │ │ │ └── web │ │ │ │ │ └── events.gateway.ts │ │ │ └── helper │ │ │ │ ├── helper.http.service.ts │ │ │ │ └── helper.module.ts │ │ ├── shared │ │ │ ├── dto │ │ │ │ ├── id.dto.ts │ │ │ │ └── pager.dto.ts │ │ │ ├── interface │ │ │ │ └── paginator.interface.ts │ │ │ └── utils │ │ │ │ ├── ip.util.ts │ │ │ │ ├── machine.util.ts │ │ │ │ ├── redis.util.ts │ │ │ │ ├── schedule.util.ts │ │ │ │ ├── schema.util.ts │ │ │ │ ├── time.util.ts │ │ │ │ ├── tool.utils.ts │ │ │ │ ├── validator.util.ts │ │ │ │ └── zod.util.ts │ │ ├── transformers │ │ │ └── get-req.transformer.ts │ │ └── utils │ │ │ └── redis-sub-pub.util.ts │ ├── tsconfig.build.json │ └── tsconfig.json └── web │ ├── .env.example │ ├── .prettierrc.mjs │ ├── cssAsPlugin.js │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.cjs │ ├── readme.md │ ├── renovate.json │ ├── src │ ├── App.tsx │ ├── api │ │ └── session.ts │ ├── atoms │ │ ├── app.ts │ │ └── route.ts │ ├── components │ │ ├── common │ │ │ ├── ErrorElement.tsx │ │ │ ├── LoadRemixAsyncComponent.tsx │ │ │ ├── NotFound.tsx │ │ │ └── ProviderComposer.tsx │ │ └── ui │ │ │ ├── button │ │ │ ├── MotionButton.tsx │ │ │ ├── StyledButton.tsx │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── loading.tsx │ │ │ └── sonner.tsx │ ├── framer-lazy-feature.ts │ ├── global.d.ts │ ├── hooks │ │ ├── biz │ │ │ └── useSession.ts │ │ └── common │ │ │ ├── index.ts │ │ │ ├── useBizQuery.ts │ │ │ ├── useInputComposition.ts │ │ │ ├── useIsOnline.ts │ │ │ ├── usePrevious.ts │ │ │ ├── useRefValue.ts │ │ │ └── useTitle.ts │ ├── initialize.ts │ ├── lib │ │ ├── api-fetch.ts │ │ ├── auth │ │ │ ├── auth.ts │ │ │ ├── client.ts │ │ │ └── index.ts │ │ ├── cn.ts │ │ ├── defineQuery.ts │ │ ├── dev.tsx │ │ ├── jotai.ts │ │ ├── query-client.ts │ │ └── route-builder.ts │ ├── main.tsx │ ├── modules │ │ └── main-layout │ │ │ └── MainLayoutHeader.tsx │ ├── pages │ │ ├── (auth) │ │ │ └── login │ │ │ │ └── index.tsx │ │ └── (main) │ │ │ ├── index.tsx │ │ │ ├── layout.tsx │ │ │ └── posts │ │ │ └── index.tsx │ ├── providers │ │ ├── root-providers.tsx │ │ └── stable-router-provider.tsx │ ├── router.tsx │ ├── store │ │ └── .gitkeep │ ├── styles │ │ ├── index.css │ │ ├── layer.css │ │ ├── tailwind.css │ │ └── theme.css │ └── vite-env.d.ts │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── vite.config.ts ├── docker-clean.sh ├── docker-compose.yml ├── dockerfile ├── drizzle.config.ts ├── drizzle ├── 0000_ancient_masque.sql ├── index.ts ├── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── package.json ├── schema.ts └── tsconfig.json ├── ecosystem.config.js ├── eslint.config.mjs ├── external ├── pino │ ├── index.js │ └── package.json └── readme.md ├── package.json ├── packages ├── compiled │ ├── index.ts │ ├── package.json │ ├── tsconfig.json │ └── tsup.config.ts └── utils │ ├── _.ts │ ├── id.ts │ ├── index.ts │ ├── package.json │ ├── snowflake.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── readme.md ├── renovate.json ├── scripts ├── pre-build.sh └── workflow │ ├── test-docker.sh │ └── test-server.sh ├── test ├── app.e2e-spec.ts ├── helper │ ├── create-e2e-app.ts │ ├── create-service-unit.ts │ ├── defineProvider.ts │ ├── redis-mock.helper.ts │ ├── serialize-data.ts │ └── setup-e2e.ts ├── lib │ ├── drizzle.ts │ └── reset-db.ts ├── mock │ ├── helper │ │ ├── helper.event.ts │ │ └── helper.module.ts │ ├── modules │ │ └── auth.mock.ts │ └── processors │ │ └── database │ │ ├── database.module.ts │ │ └── database.service.ts ├── package.json ├── setup-file.ts ├── setup.ts ├── setupFiles │ └── lifecycle.ts ├── tsconfig.json └── vitest.config.mts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | 6 | dist 7 | apps/apps/core/dist 8 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | POSTGRES_PASSWORD=password 2 | POSTGRES_USER=postgres 3 | 4 | DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/nest-drizzle 5 | 6 | GITHUB_CLIENT_ID= 7 | AUTH_SECRET= 8 | GITHUB_CLIENT_SECRET= 9 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgresql://postgres:password@127.0.0.1:5432/nest-drizzle-test 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.paw filter=lfs diff=lfs merge=lfs -text 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js Build CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | services: 17 | postgres: 18 | image: postgres 19 | 20 | env: 21 | POSTGRES_PASSWORD: postgres 22 | POSTGRES_USER: postgres 23 | POSTGRES_DB: postgres 24 | 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | ports: 31 | - 5432:5432 32 | 33 | strategy: 34 | matrix: 35 | node-version: [20.x] 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: pnpm/action-setup@v4.0.0 40 | 41 | - name: Use Node.js ${{ matrix.node-version }} 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | cache: pnpm 46 | 47 | - name: Install Dependencies 48 | run: | 49 | pnpm i 50 | - name: Build project 51 | run: | 52 | npm run build 53 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | - name: Docker meta 17 | id: meta 18 | uses: docker/metadata-action@v5 19 | with: 20 | # list of Docker images to use as base name for tags 21 | images: | 22 | nest/nest-http 23 | # generate Docker tags based on the following events/attributes 24 | tags: | 25 | type=ref,event=branch 26 | type=semver,pattern={{version}} 27 | type=semver,pattern={{major}}.{{minor}} 28 | type=semver,pattern={{major}} 29 | type=sha 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | - name: Login to DockerHub 35 | uses: docker/login-action@v3 36 | with: 37 | username: ${{ secrets.DOCKERHUB_USERNAME }} 38 | password: ${{ secrets.DOCKERHUB_TOKEN }} 39 | - name: Build and export to Docker 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: . 43 | load: true 44 | tags: ${{ steps.meta.outputs.tags }},nest/nest-http:latest 45 | labels: ${{ steps.meta.outputs.labels }} 46 | - name: Test 47 | run: | 48 | bash ./scripts/workflow/test-docker.sh 49 | - name: Build and push 50 | id: docker_build 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | # push: ${{ startsWith(github.ref, 'refs/tags/v') }} 55 | push: false 56 | tags: ${{ steps.meta.outputs.tags }},nest/nest-http:latest 57 | labels: ${{ steps.meta.outputs.labels }} 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - 'v*' 5 | 6 | name: Release 7 | 8 | jobs: 9 | build: 10 | name: Upload Release Asset 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | runs-on: ${{ matrix.os }} 15 | outputs: 16 | release_url: ${{ steps.create_release.outputs.upload_url }} 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20.x 24 | - name: Start MongoDB 25 | uses: supercharge/mongodb-github-action@v1.10.0 26 | with: 27 | mongodb-version: 4.4 28 | - name: Start Redis 29 | uses: supercharge/redis-github-action@1.7.0 30 | with: 31 | redis-version: 6 32 | - uses: pnpm/action-setup@v4.0.0 33 | with: 34 | run_install: true 35 | - name: Test 36 | run: | 37 | npm run lint 38 | npm run test 39 | npm run test:e2e 40 | 41 | - name: Build project 42 | run: | 43 | pnpm run bundle 44 | - name: Test Bundle Server 45 | run: | 46 | bash scripts/workflow/test-server.sh 47 | # - name: Zip Assets 48 | # run: | 49 | # sh scripts/zip-asset.sh 50 | - name: Create Release 51 | id: create_release 52 | uses: actions/create-release@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | tag_name: ${{ github.ref }} 57 | release_name: Release ${{ github.ref }} 58 | draft: false 59 | prerelease: false 60 | - name: Upload Release Asset 61 | id: upload-release-asset 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ steps.create_release.outputs.upload_url }} 67 | asset_path: ./release.zip 68 | asset_name: release-${{ matrix.os }}.zip 69 | asset_content_type: application/zip 70 | # deploy: 71 | # name: Deploy To Remote Server 72 | # runs-on: ubuntu-latest 73 | # needs: [build] 74 | # steps: 75 | # - name: Exec deploy script with SSH 76 | # uses: appleboy/ssh-action@master 77 | # env: 78 | # JWTSECRET: ${{ secrets.JWTSECRET }} 79 | # with: 80 | # command_timeout: 10m 81 | # host: ${{ secrets.HOST }} 82 | # username: ${{ secrets.USER }} 83 | # password: ${{ secrets.PASSWORD }} 84 | # envs: JWTSECRET 85 | # script_stop: true 86 | # script: | 87 | # whoami 88 | # cd 89 | # source ~/.zshrc 90 | # cd mx 91 | # ls -a 92 | # node server-deploy.js --jwtSecret=$JWTSECRET 93 | 94 | build_other_platform: 95 | name: Build Other Platform 96 | strategy: 97 | matrix: 98 | os: [macos-latest] 99 | runs-on: ${{ matrix.os }} 100 | needs: [build] 101 | steps: 102 | - name: Checkout code 103 | uses: actions/checkout@v4 104 | - name: Cache pnpm modules 105 | uses: actions/cache@v3 106 | env: 107 | cache-name: cache-pnpm-modules 108 | with: 109 | path: ~/.pnpm-store 110 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 111 | restore-keys: | 112 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 113 | - uses: pnpm/action-setup@v4.0.0 114 | with: 115 | run_install: true 116 | - name: Build project 117 | run: | 118 | pnpm run bundle 119 | - name: Zip Assets 120 | run: | 121 | sh scripts/zip-asset.sh 122 | - name: Upload Release Asset 123 | id: upload-release-asset 124 | uses: actions/upload-release-asset@v1 125 | env: 126 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 127 | with: 128 | upload_url: ${{ needs.build.outputs.release_url }} 129 | asset_path: ./release.zip 130 | asset_name: release-${{ matrix.os }}.zip 131 | asset_content_type: application/zip 132 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js Test CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | services: 17 | postgres: 18 | image: postgres 19 | 20 | env: 21 | POSTGRES_PASSWORD: postgres 22 | POSTGRES_USER: postgres 23 | POSTGRES_DB: postgres 24 | 25 | options: >- 26 | --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | ports: 31 | - 5432:5432 32 | 33 | strategy: 34 | matrix: 35 | node-version: [20.x] 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Start Redis 40 | uses: supercharge/redis-github-action@1.7.0 41 | with: 42 | redis-version: 6 43 | 44 | - uses: pnpm/action-setup@v4.0.0 45 | 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | cache: pnpm 51 | 52 | - name: Install dependencies 53 | run: pnpm i 54 | - name: Run Test 55 | run: | 56 | pnpm run lint 57 | pnpm run test 58 | env: 59 | CI: true 60 | DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/postgres?schema=public' 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | patch/dist 37 | tmp 38 | out 39 | release.zip 40 | 41 | run 42 | 43 | apps/core/dist 44 | 45 | drizzle/*.js 46 | packages/*/dist 47 | .vite -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } 3 | git lfs post-checkout "$@" 4 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } 3 | git lfs post-commit "$@" 4 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } 3 | git lfs post-merge "$@" 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks').\n"; exit 2; } 3 | git lfs pre-push "$@" 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=*fastify* 2 | public-hoist-pattern[]=*prettier* 3 | public-hoist-pattern[]=*socket.io* 4 | public-hoist-pattern[]=*jsonwebtoken* 5 | public-hoist-pattern[]=*dotenv* 6 | public-hoist-pattern[]=*@nestjs* 7 | 8 | registry=https://registry.npmjs.org 9 | 10 | enable-pre-post-scripts=true 11 | 12 | strict-peer-dependencies=false 13 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | import { factory } from '@innei/prettier' 2 | 3 | export default { 4 | ...factory({ 5 | tailwindcss: false, 6 | importSort: false, 7 | }), 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[javascriptreact]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "material-icon-theme.activeIconPack": "nest", 16 | "typescript.tsdk": "node_modules/typescript/lib", 17 | "typescript.experimental.updateImportsOnPaste": true, 18 | "editor.codeActionsOnSave": { 19 | "quickfix.biome": "explicit" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 寻 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/core/.env: -------------------------------------------------------------------------------- 1 | ../../.env -------------------------------------------------------------------------------- /apps/core/.npmrc: -------------------------------------------------------------------------------- 1 | ../../.npmrc -------------------------------------------------------------------------------- /apps/core/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[javascript]": { 4 | "editor.defaultFormatter": "esbenp.prettier-vscode" 5 | }, 6 | "[javascriptreact]": { 7 | "editor.defaultFormatter": "esbenp.prettier-vscode" 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "esbenp.prettier-vscode" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode" 14 | }, 15 | "editor.codeActionsOnSave": {}, 16 | "material-icon-theme.activeIconPack": "nest", 17 | "typescript.tsdk": "node_modules/typescript/lib", 18 | "typescript.preferences.preferTypeOnlyAutoImports": false 19 | } 20 | -------------------------------------------------------------------------------- /apps/core/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": [] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App (Core)", 3 | "version": "0.0.1", 4 | "private": true, 5 | "packageManager": "pnpm@9.14.4", 6 | "description": "", 7 | "license": "MIT", 8 | "author": "Innei ", 9 | "scripts": { 10 | "build": "nest build --webpack", 11 | "dev": "npm run start", 12 | "start": "cross-env NODE_ENV=development nest start -w", 13 | "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", 14 | "start:prod": "npm run prism:migrate:deploy && cross-env NODE_ENV=production node dist/main.js", 15 | "prod": "cross-env NODE_ENV=production pm2-runtime start ecosystem.config.js", 16 | "prod:pm2": "cross-env NODE_ENV=production pm2 restart ecosystem.config.js", 17 | "prod:stop": "pm2 stop ecosystem.config.js", 18 | "prod:debug": "cross-env NODE_ENV=production nest start --debug --watch" 19 | }, 20 | "dependencies": { 21 | "@auth/drizzle-adapter": "1.7.4", 22 | "@fastify/static": "7.0.4", 23 | "@nestjs/cache-manager": "2.3.0", 24 | "@nestjs/common": "10.4.13", 25 | "@nestjs/config": "3.3.0", 26 | "@nestjs/core": "10.4.13", 27 | "@nestjs/event-emitter": "2.1.1", 28 | "@nestjs/jwt": "10.2.0", 29 | "@nestjs/passport": "10.0.3", 30 | "@nestjs/platform-fastify": "10.4.13", 31 | "@nestjs/platform-socket.io": "10.4.13", 32 | "@nestjs/schedule": "4.1.1", 33 | "@nestjs/swagger": "8.1.0", 34 | "@nestjs/throttler": "6.2.1", 35 | "@nestjs/websockets": "10.4.13", 36 | "@packages/compiled": "workspace:*", 37 | "@packages/drizzle": "workspace:*", 38 | "@packages/utils": "workspace:*", 39 | "@scalar/fastify-api-reference": "1.25.76", 40 | "@scalar/nestjs-api-reference": "0.3.172", 41 | "@socket.io/redis-adapter": "8.3.0", 42 | "@socket.io/redis-emitter": "5.1.0", 43 | "@wahyubucil/nestjs-zod-openapi": "0.1.2", 44 | "axios": "1.7.9", 45 | "cache-manager": "5.7.6", 46 | "cache-manager-ioredis": "2.1.0", 47 | "chalk": "^4.1.2", 48 | "cls-hooked": "4.2.2", 49 | "commander": "12.1.0", 50 | "consola": "^3.2.3", 51 | "cron": "^3.2.1", 52 | "cross-env": "7.0.3", 53 | "dayjs": "1.11.13", 54 | "dotenv": "16.4.7", 55 | "dotenv-expand": "12.0.1", 56 | "drizzle-zod": "0.5.1", 57 | "lodash": "4.17.21", 58 | "nestjs-pretty-logger": "0.3.1", 59 | "redis": "4.7.0", 60 | "reflect-metadata": "0.2.2", 61 | "rxjs": "7.8.1", 62 | "slugify": "1.6.6", 63 | "snakecase-keys": "8.0.1", 64 | "zod": "3.23.8" 65 | }, 66 | "devDependencies": { 67 | "@nestjs/cli": "10.4.8", 68 | "@nestjs/schematics": "10.2.3", 69 | "@types/cache-manager": "4.0.6", 70 | "@types/lodash": "4.17.13", 71 | "@types/supertest": "6.0.2", 72 | "@types/ua-parser-js": "0.7.39", 73 | "fastify": "^4.29.0", 74 | "ioredis": "^5.4.1" 75 | }, 76 | "bump": { 77 | "before": [ 78 | "git pull --rebase" 79 | ] 80 | }, 81 | "redisMemoryServer": { 82 | "downloadDir": "./tmp/redis/binaries", 83 | "version": "6.0.10", 84 | "disablePostinstall": "1", 85 | "systemBinary": "/opt/homebrew/bin/redis-server" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /apps/core/src/app.config.testing.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander' 2 | 3 | import type { AxiosRequestConfig } from 'axios' 4 | import { isDev } from './global/env.global' 5 | 6 | program 7 | .option('-p, --port [port]', 'port to listen') 8 | .option('--disable_cache', 'disable cache') 9 | .option('--redis_host [host]', 'redis host') 10 | .option('--redis_port [port]', 'redis port') 11 | .option('--redis_password [password]', 'redis password') 12 | .option('--jwtSecret [secret]', 'jwt secret') 13 | 14 | program.parse(process.argv) 15 | 16 | const argv = program.opts() 17 | export const PORT = argv.port || 3333 18 | export const CROSS_DOMAIN = { 19 | allowedOrigins: [ 20 | 'innei.in', 21 | 'shizuri.net', 22 | 'localhost:9528', 23 | 'localhost:2323', 24 | '127.0.0.1', 25 | 'mbp.cc', 26 | 'local.innei.test', 27 | '22333322.xyz', 28 | ], 29 | allowedReferer: 'innei.in', 30 | } 31 | 32 | export const DATABASE = { 33 | url: mergeArgv('database_url'), 34 | } 35 | 36 | export const REDIS = { 37 | host: argv.redis_host || 'localhost', 38 | port: argv.redis_port || 6379, 39 | password: argv.redis_password || null, 40 | ttl: null, 41 | httpCacheTTL: 5, 42 | max: 5, 43 | disableApiCache: 44 | (isDev || argv.disable_cache) && !process.env.ENABLE_CACHE_DEBUG, 45 | } 46 | export const SECURITY = { 47 | jwtSecret: argv.jwtSecret || 'asjhczxiucipoiopiqm2376', 48 | jwtExpire: '7d', 49 | } 50 | 51 | export const AXIOS_CONFIG: AxiosRequestConfig = { 52 | timeout: 10000, 53 | } 54 | 55 | function mergeArgv(key: string) { 56 | const env = process.env 57 | const toUpperCase = (key: string) => key.toUpperCase() 58 | return argv[key] ?? env[toUpperCase(key)] 59 | } 60 | -------------------------------------------------------------------------------- /apps/core/src/app.config.ts: -------------------------------------------------------------------------------- 1 | import { program } from 'commander' 2 | import { parseBooleanishValue } from './constants/parser.utilt' 3 | import { machineIdSync } from './shared/utils/machine.util' 4 | import 'dotenv-expand/config' 5 | 6 | import type { AxiosRequestConfig } from 'axios' 7 | import { isDev } from './global/env.global' 8 | 9 | export const API_VERSION = 1 10 | 11 | program 12 | .option('-p, --port ', 'port to listen') 13 | .option('--disable_cache', 'disable cache') 14 | .option('--redis_host ', 'redis host') 15 | .option('--redis_port ', 'redis port') 16 | .option('--redis_password ', 'redis password') 17 | .option('--jwtSecret ', 'jwt secret') 18 | 19 | program.parse(process.argv) 20 | const argv = program.opts() 21 | 22 | export const PORT = mergeArgv('port') || 3333 23 | 24 | export const CROSS_DOMAIN = { 25 | allowedOrigins: [ 26 | 'innei.in', 27 | 'shizuri.net', 28 | 'localhost', 29 | '127.0.0.1', 30 | 'mbp.cc', 31 | 'local.innei.test', 32 | '22333322.xyz', 33 | ], 34 | allowedReferer: 'innei.in', 35 | } 36 | 37 | export const REDIS = { 38 | host: mergeArgv('redis_host') || 'localhost', 39 | port: mergeArgv('redis_port') || 6379, 40 | password: mergeArgv('redis_password') || null, 41 | ttl: null, 42 | max: 5, 43 | disableApiCache: 44 | (isDev || argv.disable_cache) && !process.env.ENABLE_CACHE_DEBUG, 45 | } 46 | 47 | export const HTTP_CACHE = { 48 | ttl: 15, // s 49 | enableCDNHeader: 50 | parseBooleanishValue(argv.http_cache_enable_cdn_header) ?? true, // s-maxage 51 | enableForceCacheHeader: 52 | parseBooleanishValue(argv.http_cache_enable_force_cache_header) ?? false, // cache-control: max-age 53 | } 54 | 55 | export const DATABASE = { 56 | url: mergeArgv('database_url'), 57 | } 58 | 59 | export const SECURITY = { 60 | jwtSecret: mergeArgv('jwtSecret') || 'asjhczxiucipoiopiqm2376', 61 | jwtExpire: '7d', 62 | } 63 | 64 | export const AXIOS_CONFIG: AxiosRequestConfig = { 65 | timeout: 10000, 66 | } 67 | 68 | export const CLUSTER = { 69 | enable: mergeArgv('cluster') ?? false, 70 | workers: mergeArgv('cluster_workers'), 71 | } 72 | 73 | const ENCRYPT_KEY = mergeArgv('encrypt_key') || mergeArgv('mx_encrypt_key') 74 | 75 | export const ENCRYPT = { 76 | key: ENCRYPT_KEY || machineIdSync(), 77 | enable: parseBooleanishValue(mergeArgv('encrypt_enable')) 78 | ? !!ENCRYPT_KEY 79 | : false, 80 | algorithm: mergeArgv('encrypt_algorithm') || 'aes-256-ecb', 81 | } 82 | 83 | export const AUTH = { 84 | github: { 85 | clientId: mergeArgv('github_client_id'), 86 | clientSecret: mergeArgv('github_client_secret'), 87 | }, 88 | secret: mergeArgv('auth_secret'), 89 | } 90 | 91 | function mergeArgv(key: string) { 92 | const env = process.env 93 | const toUpperCase = (key: string) => key.toUpperCase() 94 | return argv[key] ?? env[toUpperCase(key)] 95 | } 96 | -------------------------------------------------------------------------------- /apps/core/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common' 2 | 3 | @Controller() 4 | export class AppController { 5 | @Get(['/ping', '/']) 6 | ping(): 'pong' { 7 | return 'pong' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/core/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { LoggerModule } from 'nestjs-pretty-logger' 2 | 3 | import { 4 | type MiddlewareConsumer, 5 | Module, 6 | type NestModule, 7 | Type, 8 | } from '@nestjs/common' 9 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core' 10 | import { ThrottlerGuard } from '@nestjs/throttler' 11 | 12 | import { AppController } from './app.controller' 13 | import { AllExceptionsFilter } from './common/filters/all-exception.filter' 14 | 15 | import { HttpCacheInterceptor } from './common/interceptors/cache.interceptor' 16 | import { IdempotenceInterceptor } from './common/interceptors/idempotence.interceptor' 17 | import { JSONTransformerInterceptor } from './common/interceptors/json-transformer.interceptor' 18 | import { ResponseInterceptor } from './common/interceptors/response.interceptor' 19 | import { ZodValidationPipe } from './common/pipes/zod-validation.pipe' 20 | import { AuthModule } from './modules/auth/auth.module' 21 | import { CacheModule } from './processors/cache/cache.module' 22 | import { DatabaseModule } from './processors/database/database.module' 23 | import { GatewayModule } from './processors/gateway/gateway.module' 24 | import { HelperModule } from './processors/helper/helper.module' 25 | import { RequestContextMiddleware } from './common/middlewares/request-context.middleware' 26 | import { UserModule } from './modules/user/user.module' 27 | import { authConfig } from './modules/auth/auth.config' 28 | 29 | // Request -----> 30 | // Response <----- 31 | const appInterceptors: Type[] = [ 32 | IdempotenceInterceptor, 33 | HttpCacheInterceptor, 34 | JSONTransformerInterceptor, 35 | 36 | ResponseInterceptor, 37 | ] 38 | @Module({ 39 | imports: [ 40 | // processors 41 | CacheModule, 42 | DatabaseModule, 43 | HelperModule, 44 | LoggerModule, 45 | GatewayModule, 46 | 47 | // BIZ 48 | AuthModule.forRoot(authConfig), 49 | UserModule, 50 | ], 51 | controllers: [AppController], 52 | providers: [ 53 | ...appInterceptors.map((interceptor) => ({ 54 | provide: APP_INTERCEPTOR, 55 | useClass: interceptor, 56 | })), 57 | 58 | { 59 | provide: APP_PIPE, 60 | useClass: ZodValidationPipe, 61 | }, 62 | 63 | { 64 | provide: APP_GUARD, 65 | useClass: ThrottlerGuard, 66 | }, 67 | 68 | { 69 | provide: APP_FILTER, 70 | useClass: AllExceptionsFilter, 71 | }, 72 | ], 73 | }) 74 | export class AppModule implements NestModule { 75 | configure(consumer: MiddlewareConsumer) { 76 | consumer.apply(RequestContextMiddleware).forRoutes('(.*)') 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /apps/core/src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import { Logger } from 'nestjs-pretty-logger' 3 | 4 | import { NestFactory } from '@nestjs/core' 5 | import { patchNestjsSwagger } from '@wahyubucil/nestjs-zod-openapi' // <-- add this. Import the patch for NestJS Swagger 6 | 7 | import { CROSS_DOMAIN, PORT } from './app.config' 8 | import { AppModule } from './app.module' 9 | import { fastifyApp } from './common/adapter/fastify.adapter' 10 | import { SpiderGuard } from './common/guards/spider.guard' 11 | import { LoggingInterceptor } from './common/interceptors/logging.interceptor' 12 | import { consola, logger } from './global/consola.global' 13 | import type { NestFastifyApplication } from '@nestjs/platform-fastify' 14 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' 15 | import { isDev } from './global/env.global' 16 | 17 | // const APIVersion = 1 18 | const Origin = CROSS_DOMAIN.allowedOrigins 19 | 20 | declare const module: any 21 | 22 | export async function bootstrap() { 23 | const app = await NestFactory.create( 24 | AppModule, 25 | fastifyApp, 26 | { logger: ['error', 'debug'] }, 27 | ) 28 | 29 | const hosts = Origin.map((host) => new RegExp(host, 'i')) 30 | 31 | app.enableCors({ 32 | origin: (origin, callback) => { 33 | const allow = hosts.some((host) => host.test(origin)) 34 | 35 | callback(null, allow) 36 | }, 37 | credentials: true, 38 | }) 39 | 40 | if (isDev) { 41 | app.useGlobalInterceptors(new LoggingInterceptor()) 42 | } 43 | app.useGlobalGuards(new SpiderGuard()) 44 | 45 | const config = new DocumentBuilder() 46 | .setTitle('App API document') 47 | .setVersion('1.0') 48 | .build() 49 | patchNestjsSwagger({ schemasSort: 'alpha' }) 50 | const document = SwaggerModule.createDocument(app, config) 51 | SwaggerModule.setup('docs', app, document) 52 | 53 | await app.listen(+PORT, '0.0.0.0', async () => { 54 | app.useLogger(app.get(Logger)) 55 | consola.info('ENV:', process.env.NODE_ENV) 56 | const url = await app.getUrl() 57 | const pid = process.pid 58 | 59 | const prefix = 'P' 60 | consola.success(`[${prefix + pid}] Server listen on: ${url}`) 61 | 62 | logger.info(`Server is up. ${chalk.yellow(`+${performance.now() | 0}ms`)}`) 63 | }) 64 | if (module.hot) { 65 | module.hot.accept() 66 | module.hot.dispose(() => app.close()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /apps/core/src/common/adapter/fastify.adapter.ts: -------------------------------------------------------------------------------- 1 | import { FastifyAdapter } from '@nestjs/platform-fastify' 2 | 3 | const app: FastifyAdapter = new FastifyAdapter({ 4 | trustProxy: true, 5 | }) 6 | export { app as fastifyApp } 7 | 8 | app.register(require('@scalar/fastify-api-reference'), { 9 | routePrefix: '/reference', 10 | configuration: { 11 | title: 'Our API Reference', 12 | spec: { 13 | url: '/docs-json', 14 | }, 15 | }, 16 | }) 17 | 18 | app.getInstance().addHook('onRequest', (request, reply, done) => { 19 | // set undefined origin 20 | const origin = request.headers.origin 21 | if (!origin) { 22 | request.headers.origin = request.headers.host 23 | } 24 | 25 | // forbidden php 26 | 27 | const url = request.url 28 | 29 | if (url.endsWith('.php')) { 30 | reply.raw.statusMessage = 31 | 'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.' 32 | 33 | return reply.code(418).send() 34 | } else if (/\/(adminer|admin|wp-login)$/g.test(url)) { 35 | reply.raw.statusMessage = 'Hey, What the fuck are you doing!' 36 | return reply.code(200).send() 37 | } 38 | 39 | // skip favicon request 40 | if (/favicon.ico$/.test(url) || /manifest.json$/.test(url)) { 41 | return reply.code(204).send() 42 | } 43 | 44 | done() 45 | }) 46 | -------------------------------------------------------------------------------- /apps/core/src/common/adapter/io.adapter.ts: -------------------------------------------------------------------------------- 1 | import { redisSubPub } from '@core/utils/redis-sub-pub.util' 2 | import { IoAdapter } from '@nestjs/platform-socket.io' 3 | import { createAdapter } from '@socket.io/redis-adapter' 4 | 5 | export const RedisIoAdapterKey = 'meta-socket' 6 | 7 | export class RedisIoAdapter extends IoAdapter { 8 | createIOServer(port: number, options?: any) { 9 | const server = super.createIOServer(port, options) 10 | 11 | const { pubClient, subClient } = redisSubPub 12 | 13 | const redisAdapter = createAdapter(pubClient, subClient, { 14 | key: RedisIoAdapterKey, 15 | requestsTimeout: 10000, 16 | }) 17 | server.adapter(redisAdapter) 18 | return server 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/core/src/common/contexts/request.context.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable dot-notation */ 2 | // @reference https://github.com/ever-co/ever-gauzy/blob/d36b4f40b1446f3c33d02e0ba00b53a83109d950/packages/core/src/core/context/request-context.ts 3 | import * as cls from 'cls-hooked' 4 | import { UnauthorizedException } from '@nestjs/common' 5 | import type { IncomingMessage, ServerResponse } from 'node:http' 6 | 7 | type UserDocument = {} 8 | 9 | type Nullable = T | null 10 | export class RequestContext { 11 | readonly id: number 12 | request: IncomingMessage 13 | response: ServerResponse 14 | 15 | constructor(request: IncomingMessage, response: ServerResponse) { 16 | this.id = Math.random() 17 | this.request = request 18 | this.response = response 19 | } 20 | 21 | static currentRequestContext(): Nullable { 22 | const session = cls.getNamespace(RequestContext.name) 23 | if (session && session.active) { 24 | return session.get(RequestContext.name) 25 | } 26 | 27 | return null 28 | } 29 | 30 | static currentRequest(): Nullable { 31 | const requestContext = RequestContext.currentRequestContext() 32 | 33 | if (requestContext) { 34 | return requestContext.request 35 | } 36 | 37 | return null 38 | } 39 | 40 | static currentUser(throwError?: boolean): Nullable { 41 | const requestContext = RequestContext.currentRequestContext() 42 | 43 | if (requestContext) { 44 | const user: UserDocument = requestContext.request['user'] 45 | 46 | if (user) { 47 | return user 48 | } 49 | } 50 | 51 | if (throwError) { 52 | throw new UnauthorizedException() 53 | } 54 | 55 | return null 56 | } 57 | 58 | static currentSession() { 59 | const requestContext = RequestContext.currentRequestContext() 60 | const session = requestContext?.request['session'] 61 | if (!session) { 62 | throw new UnauthorizedException() 63 | } 64 | return session as { 65 | expires: string 66 | user: { 67 | name: string 68 | email: string 69 | image: string 70 | } 71 | } 72 | } 73 | 74 | static currentIsAuthenticated() { 75 | const requestContext = RequestContext.currentRequestContext() 76 | 77 | if (requestContext) { 78 | const isAuthenticated = 79 | requestContext.request['isAuthenticated'] || 80 | requestContext.request['isAuthenticated'] 81 | 82 | return !!isAuthenticated 83 | } 84 | 85 | return false 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /apps/core/src/common/decorators/api-controller.decorator.ts: -------------------------------------------------------------------------------- 1 | import { API_VERSION } from '@core/app.config' 2 | import { isDev, isTest } from '@core/global/env.global' 3 | import { 4 | Controller, 5 | type ControllerOptions, 6 | applyDecorators, 7 | } from '@nestjs/common' 8 | 9 | import { Auth } from './auth.decorator' 10 | 11 | export const apiRoutePrefix = isDev || isTest ? '' : `/api/v${API_VERSION}` 12 | export const ApiController: ( 13 | optionOrString?: string | string[] | undefined | ControllerOptions, 14 | ) => ReturnType = (...rest) => { 15 | const [controller, ...args] = rest 16 | if (!controller) { 17 | return Controller(apiRoutePrefix) 18 | } 19 | 20 | const transformPath = (path: string) => 21 | `${apiRoutePrefix}/${path.replace(/^\/*/, '')}` 22 | 23 | if (typeof controller === 'string') { 24 | return Controller(transformPath(controller), ...args) 25 | } else if (Array.isArray(controller)) { 26 | return Controller( 27 | controller.map((path) => transformPath(path)), 28 | ...args, 29 | ) 30 | } else { 31 | const path = controller.path || '' 32 | 33 | return Controller( 34 | Array.isArray(path) 35 | ? path.map((i) => transformPath(i)) 36 | : transformPath(path), 37 | ...args, 38 | ) 39 | } 40 | } 41 | 42 | export const AdminApiController = (path: string) => { 43 | return applyDecorators( 44 | ApiController(`/admin/${path.replace(/^\/*/, '')}`), 45 | Auth(), 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /apps/core/src/common/decorators/auth.decorator.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards, applyDecorators } from '@nestjs/common' 2 | 3 | import { AuthGuard } from '../guards/auth.guard' 4 | 5 | export function Auth() { 6 | const decorators: (ClassDecorator | PropertyDecorator | MethodDecorator)[] = [ 7 | UseGuards(AuthGuard), 8 | ] 9 | 10 | return applyDecorators(...decorators) 11 | } 12 | -------------------------------------------------------------------------------- /apps/core/src/common/decorators/cache.decorator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache decorator. 3 | * @file 缓存装饰器 4 | * @module decorator/cache 5 | * @author Surmon 6 | */ 7 | import * as META from '@core/constants/meta.constant' 8 | import { CacheKey, CacheTTL } from '@nestjs/cache-manager' 9 | import { SetMetadata } from '@nestjs/common' 10 | 11 | // 缓存器配置 12 | interface ICacheOption { 13 | ttl?: number 14 | key?: string 15 | disable?: boolean 16 | } 17 | 18 | /** 19 | * 统配构造器 20 | * @function HttpCache 21 | * @description 两种用法 22 | * @example @HttpCache({ key: CACHE_KEY, ttl: 60 * 60 }) 23 | * @example @HttpCache({ disable: true }) 24 | */ 25 | 26 | export function HttpCache(option: ICacheOption): MethodDecorator { 27 | const { disable, key, ttl = 60 } = option 28 | return (_, __, descriptor: PropertyDescriptor) => { 29 | if (disable) { 30 | SetMetadata(META.HTTP_CACHE_DISABLE, true)(descriptor.value) 31 | return descriptor 32 | } 33 | if (key) { 34 | CacheKey(key)(descriptor.value) 35 | } 36 | if (typeof ttl === 'number' && !Number.isNaN(ttl)) { 37 | CacheTTL(ttl)(descriptor.value) 38 | } 39 | return descriptor 40 | } 41 | } 42 | 43 | HttpCache.disable = (_, __, descriptor) => { 44 | SetMetadata(META.HTTP_CACHE_DISABLE, true)(descriptor.value) 45 | } 46 | -------------------------------------------------------------------------------- /apps/core/src/common/decorators/get-owner.decorator.ts: -------------------------------------------------------------------------------- 1 | import { getNestExecutionContextRequest } from '@core/transformers/get-req.transformer' 2 | import { type ExecutionContext, createParamDecorator } from '@nestjs/common' 3 | 4 | export const Owner = createParamDecorator( 5 | (data: unknown, ctx: ExecutionContext) => { 6 | return getNestExecutionContextRequest(ctx).owner 7 | }, 8 | ) 9 | -------------------------------------------------------------------------------- /apps/core/src/common/decorators/http.decorator.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_IDEMPOTENCE_OPTIONS } from '@core/constants/meta.constant' 2 | import * as SYSTEM from '@core/constants/system.constant' 3 | import { SetMetadata } from '@nestjs/common' 4 | 5 | import type { IdempotenceOption } from '../interceptors/idempotence.interceptor' 6 | 7 | /** 8 | * @description 跳过响应体处理 9 | */ 10 | const Bypass: MethodDecorator = ( 11 | target, 12 | key, 13 | descriptor: PropertyDescriptor, 14 | ) => { 15 | SetMetadata(SYSTEM.RESPONSE_PASSTHROUGH_METADATA, true)(descriptor.value) 16 | } 17 | 18 | /** 19 | * 幂等 20 | */ 21 | const Idempotence: (options?: IdempotenceOption) => MethodDecorator = 22 | (options) => (target, key, descriptor: PropertyDescriptor) => { 23 | SetMetadata(HTTP_IDEMPOTENCE_OPTIONS, options || {})(descriptor.value) 24 | } 25 | 26 | /** 27 | * @description 过滤响应体中的字段 28 | */ 29 | const ProtectKeys: (keys: string[]) => MethodDecorator = 30 | (keys) => (target, key, descriptor: PropertyDescriptor) => { 31 | SetMetadata(SYSTEM.OMIT_RESPONSE_PROTECT_KEYS, keys)(descriptor.value) 32 | } 33 | 34 | export const HTTPDecorators = { 35 | Bypass, 36 | Idempotence, 37 | ProtectKeys, 38 | } 39 | -------------------------------------------------------------------------------- /apps/core/src/common/decorators/ip.decorator.ts: -------------------------------------------------------------------------------- 1 | import { getIp } from '@core/shared/utils/ip.util' 2 | import { type ExecutionContext, createParamDecorator } from '@nestjs/common' 3 | import type { FastifyRequest } from 'fastify' 4 | 5 | export type IpRecord = { 6 | ip: string 7 | agent: string 8 | } 9 | export const IpLocation = createParamDecorator( 10 | (data: unknown, ctx: ExecutionContext) => { 11 | const request = ctx.switchToHttp().getRequest() 12 | const ip = getIp(request) 13 | const agent = request.headers['user-agent'] 14 | return { 15 | ip, 16 | agent, 17 | } 18 | }, 19 | ) 20 | -------------------------------------------------------------------------------- /apps/core/src/common/exceptions/biz.exception.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorCode, 3 | type ErrorCodeEnum, 4 | } from '@core/constants/error-code.constant' 5 | import { HttpException } from '@nestjs/common' 6 | 7 | export class BizException extends HttpException { 8 | constructor(public code: ErrorCodeEnum) { 9 | const [message, chMessage, status] = ErrorCode[code] 10 | super(HttpException.createBody({ code, message, chMessage }), status) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/core/src/common/filters/all-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import fs, { WriteStream } from 'node:fs' 2 | import { resolve } from 'node:path' 3 | import chalk from 'chalk' 4 | import { FastifyReply, FastifyRequest } from 'fastify' 5 | 6 | import { HTTP_REQUEST_TIME } from '@core/constants/meta.constant' 7 | import { LOG_DIR } from '@core/constants/path.constant' 8 | import { REFLECTOR } from '@core/constants/system.constant' 9 | import { isDev, isTest } from '@core/global/env.global' 10 | import { 11 | ArgumentsHost, 12 | Catch, 13 | ExceptionFilter, 14 | HttpException, 15 | HttpStatus, 16 | Inject, 17 | Logger, 18 | } from '@nestjs/common' 19 | import { Reflector } from '@nestjs/core' 20 | 21 | import { getIp } from '../../shared/utils/ip.util' 22 | import { BizException } from '../exceptions/biz.exception' 23 | import { LoggingInterceptor } from '../interceptors/logging.interceptor' 24 | 25 | type myError = { 26 | readonly status: number 27 | readonly statusCode?: number 28 | 29 | readonly message?: string 30 | } 31 | 32 | @Catch() 33 | export class AllExceptionsFilter implements ExceptionFilter { 34 | private readonly logger = new Logger(AllExceptionsFilter.name) 35 | private errorLogPipe: WriteStream 36 | constructor(@Inject(REFLECTOR) private reflector: Reflector) {} 37 | catch(exception: unknown, host: ArgumentsHost) { 38 | const ctx = host.switchToHttp() 39 | const response = ctx.getResponse() 40 | const request = ctx.getRequest() 41 | 42 | if (request.method === 'OPTIONS') { 43 | return response.status(HttpStatus.OK).send() 44 | } 45 | 46 | const status = 47 | exception instanceof HttpException 48 | ? exception.getStatus() 49 | : (exception as myError)?.status || 50 | (exception as myError)?.statusCode || 51 | HttpStatus.INTERNAL_SERVER_ERROR 52 | 53 | const message = 54 | (exception as any)?.response?.message || 55 | (exception as myError)?.message || 56 | '' 57 | 58 | const bizCode = (exception as BizException).code 59 | 60 | const url = request.raw.url! 61 | if (status === HttpStatus.INTERNAL_SERVER_ERROR) { 62 | Logger.error(exception, undefined, 'Catch') 63 | console.error(exception) 64 | 65 | if (!isDev) { 66 | this.errorLogPipe = 67 | this.errorLogPipe ?? 68 | fs.createWriteStream(resolve(LOG_DIR, 'error.log'), { 69 | flags: 'a+', 70 | encoding: 'utf-8', 71 | }) 72 | 73 | this.errorLogPipe.write( 74 | `[${new Date().toISOString()}] ${decodeURI(url)}: ${ 75 | (exception as any)?.response?.message || 76 | (exception as myError)?.message 77 | }\n${(exception as Error).stack}\n`, 78 | ) 79 | } 80 | } else { 81 | const ip = getIp(request) 82 | const logMessage = `IP: ${ip} Error Info: (${status}${ 83 | bizCode ? ` ,B${bizCode}` : '' 84 | }) ${message} Path: ${decodeURI(url)}` 85 | if (isTest) console.log(logMessage) 86 | this.logger.warn(logMessage) 87 | } 88 | // @ts-ignore 89 | const prevRequestTs = this.reflector.get(HTTP_REQUEST_TIME, request as any) 90 | 91 | if (prevRequestTs) { 92 | const content = `${request.method} -> ${request.url}` 93 | Logger.debug( 94 | `--- ResponseError:${content}${chalk.yellow( 95 | ` +${Date.now() - prevRequestTs}ms`, 96 | )}`, 97 | LoggingInterceptor.name, 98 | ) 99 | } 100 | const res = (exception as any).response 101 | response 102 | .status(status) 103 | .type('application/json') 104 | .send({ 105 | ok: 0, 106 | code: res?.code || status, 107 | chMessage: res?.chMessage, 108 | message: 109 | (exception as any)?.response?.message || 110 | (exception as any)?.message || 111 | 'Unknown Error', 112 | }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /apps/core/src/common/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable dot-notation */ 2 | import { isTest } from '@core/global/env.global' 3 | import { AuthService } from '@core/modules/auth/auth.service' 4 | 5 | import { getNestExecutionContextRequest } from '@core/transformers/get-req.transformer' 6 | import { 7 | CanActivate, 8 | ExecutionContext, 9 | Inject, 10 | Injectable, 11 | UnauthorizedException, 12 | } from '@nestjs/common' 13 | 14 | @Injectable() 15 | export class AuthGuard implements CanActivate { 16 | constructor( 17 | @Inject(AuthService) 18 | private readonly authService: AuthService, 19 | ) {} 20 | async canActivate(context: ExecutionContext): Promise { 21 | if (isTest) { 22 | return true 23 | } 24 | 25 | const req = this.getRequest(context) 26 | const session = await this.authService.getSessionUser(req.raw) 27 | 28 | req.raw['session'] = session 29 | req.raw['isAuthenticated'] = !!session 30 | 31 | if (!session) { 32 | throw new UnauthorizedException() 33 | } 34 | 35 | return !!session 36 | } 37 | 38 | getRequest(context: ExecutionContext) { 39 | return getNestExecutionContextRequest(context) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/core/src/common/guards/spider.guard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module common/guard/spider.guard 3 | * @description 禁止爬虫的守卫 4 | * @author Innei 5 | */ 6 | import { Observable } from 'rxjs' 7 | 8 | import { isDev } from '@core/global/env.global' 9 | import { getNestExecutionContextRequest } from '@core/transformers/get-req.transformer' 10 | import { 11 | CanActivate, 12 | ExecutionContext, 13 | ForbiddenException, 14 | Injectable, 15 | } from '@nestjs/common' 16 | 17 | @Injectable() 18 | export class SpiderGuard implements CanActivate { 19 | canActivate( 20 | context: ExecutionContext, 21 | ): boolean | Promise | Observable { 22 | if (isDev) { 23 | return true 24 | } 25 | 26 | const request = this.getRequest(context) 27 | const headers = request.headers 28 | const ua: string = headers['user-agent'] || '' 29 | const isSpiderUA = 30 | !!/(scrapy|httpclient|axios|python|requests)/i.test(ua) && 31 | !/(mx-space|rss|google|baidu|bing)/gi.test(ua) 32 | if (ua && !isSpiderUA) { 33 | return true 34 | } 35 | throw new ForbiddenException(`爬虫是被禁止的哦,UA: ${ua}`) 36 | } 37 | 38 | getRequest(context: ExecutionContext) { 39 | return getNestExecutionContextRequest(context) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/core/src/common/interceptors/allow-all-cors.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CallHandler, 3 | type ExecutionContext, 4 | type NestInterceptor, 5 | RequestMethod, 6 | } from '@nestjs/common' 7 | import type { FastifyReply, FastifyRequest } from 'fastify' 8 | 9 | declare module 'fastify' { 10 | // @ts-ignore 11 | interface FastifyRequest { 12 | cors?: boolean 13 | } 14 | } 15 | 16 | export class AllowAllCorsInterceptor implements NestInterceptor { 17 | intercept(context: ExecutionContext, next: CallHandler) { 18 | const handle = next.handle() 19 | const request = context.switchToHttp().getRequest() as FastifyRequest 20 | const response: FastifyReply = context.switchToHttp().getResponse() 21 | const allowedMethods = [ 22 | RequestMethod.GET, 23 | RequestMethod.HEAD, 24 | RequestMethod.PUT, 25 | RequestMethod.PATCH, 26 | RequestMethod.POST, 27 | RequestMethod.DELETE, 28 | ] 29 | const allowedHeaders = [ 30 | 'Authorization', 31 | 'Origin', 32 | 'No-Cache', 33 | 'X-Requested-With', 34 | 'If-Modified-Since', 35 | 'Last-Modified', 36 | 'Cache-Control', 37 | 'Expires', 38 | 'Content-Type', 39 | ] 40 | response.headers({ 41 | 'Access-Control-Allow-Origin': '*', 42 | 'Access-Control-Allow-Headers': allowedHeaders.join(','), 43 | 'Access-Control-Allow-Methods': allowedMethods.join(','), 44 | 'Access-Control-Max-Age': '86400', 45 | }) 46 | request.cors = true 47 | return handle 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apps/core/src/common/interceptors/idempotence.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from 'fastify' 2 | import { catchError, tap } from 'rxjs' 3 | 4 | import { 5 | HTTP_IDEMPOTENCE_KEY, 6 | HTTP_IDEMPOTENCE_OPTIONS, 7 | } from '@core/constants/meta.constant' 8 | import { REFLECTOR } from '@core/constants/system.constant' 9 | import { CacheService } from '@core/processors/cache/cache.service' 10 | import { getIp } from '@core/shared/utils/ip.util' 11 | import { getRedisKey } from '@core/shared/utils/redis.util' 12 | import { hashString } from '@core/shared/utils/tool.utils' 13 | import { 14 | CallHandler, 15 | ConflictException, 16 | ExecutionContext, 17 | Inject, 18 | Injectable, 19 | NestInterceptor, 20 | SetMetadata, 21 | } from '@nestjs/common' 22 | import { Reflector } from '@nestjs/core' 23 | 24 | const IdempotenceHeaderKey = 'x-idempotence' 25 | 26 | export type IdempotenceOption = { 27 | errorMessage?: string 28 | pendingMessage?: string 29 | 30 | /** 31 | * 如果重复请求的话,手动处理异常 32 | */ 33 | handler?: (req: FastifyRequest) => any 34 | 35 | /** 36 | * 记录重复请求的时间 37 | * @default 60 38 | */ 39 | expired?: number 40 | 41 | /** 42 | * 如果 header 没有幂等 key,根据 request 生成 key,如何生成这个 key 的方法 43 | */ 44 | generateKey?: (req: FastifyRequest) => string 45 | 46 | /** 47 | * 仅读取 header 的 key,不自动生成 48 | * @default false 49 | */ 50 | disableGenerateKey?: boolean 51 | } 52 | 53 | @Injectable() 54 | export class IdempotenceInterceptor implements NestInterceptor { 55 | constructor( 56 | private readonly cacheService: CacheService, 57 | @Inject(REFLECTOR) private readonly reflector: Reflector, 58 | ) {} 59 | 60 | async intercept(context: ExecutionContext, next: CallHandler) { 61 | const request = context.switchToHttp().getRequest() 62 | 63 | // skip Get 请求 64 | if (request.method.toUpperCase() === 'GET') { 65 | return next.handle() 66 | } 67 | 68 | const handler = context.getHandler() 69 | const options: IdempotenceOption | undefined = this.reflector.get( 70 | HTTP_IDEMPOTENCE_OPTIONS, 71 | handler, 72 | ) 73 | 74 | if (!options) { 75 | return next.handle() 76 | } 77 | 78 | const { 79 | errorMessage = '相同请求成功后在 60 秒内只能发送一次', 80 | pendingMessage = '相同请求正在处理中...', 81 | handler: errorHandler, 82 | expired = 60, 83 | disableGenerateKey = false, 84 | } = options 85 | const redis = this.cacheService.getClient() 86 | 87 | const idempotence = request.headers[IdempotenceHeaderKey] as string 88 | const key = disableGenerateKey 89 | ? undefined 90 | : options.generateKey 91 | ? options.generateKey(request) 92 | : this.generateKey(request) 93 | 94 | const idempotenceKey = 95 | !!(idempotence || key) && getRedisKey(`idempotence:${idempotence || key}`) 96 | 97 | SetMetadata(HTTP_IDEMPOTENCE_KEY, idempotenceKey)(handler) 98 | 99 | if (idempotenceKey) { 100 | const resultValue: '0' | '1' | null = (await redis.get( 101 | idempotenceKey, 102 | )) as any 103 | if (resultValue !== null) { 104 | if (errorHandler) { 105 | return await errorHandler(request) 106 | } 107 | 108 | const message = { 109 | 1: errorMessage, 110 | 0: pendingMessage, 111 | }[resultValue] 112 | throw new ConflictException(message) 113 | } else { 114 | await redis.set(idempotenceKey, '0', 'EX', expired) 115 | } 116 | } 117 | return next.handle().pipe( 118 | tap(async () => { 119 | idempotenceKey && (await redis.set(idempotenceKey, '1', 'KEEPTTL')) 120 | }), 121 | catchError(async (err) => { 122 | if (idempotenceKey) { 123 | await redis.del(idempotenceKey) 124 | } 125 | throw err 126 | }), 127 | ) 128 | } 129 | 130 | private generateKey(req: FastifyRequest) { 131 | const { body, params, query = {}, headers, url } = req 132 | 133 | const obj = { body, url, params, query } as any 134 | 135 | const uuid = headers['x-uuid'] 136 | if (uuid) { 137 | obj.uuid = uuid 138 | } else { 139 | const ua = headers['user-agent'] 140 | const ip = getIp(req) 141 | 142 | if (!ua && !ip) { 143 | return undefined 144 | } 145 | Object.assign(obj, { ua, ip }) 146 | } 147 | 148 | return hashString(JSON.stringify(obj)) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /apps/core/src/common/interceptors/json-transformer.interceptor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 对响应体进行 JSON 标准的转换 3 | * @author Innei 4 | */ 5 | import { isObjectLike } from 'lodash' 6 | import { Observable, map } from 'rxjs' 7 | import snakecaseKeys from 'snakecase-keys' 8 | 9 | import { RESPONSE_PASSTHROUGH_METADATA } from '@core/constants/system.constant' 10 | import { 11 | CallHandler, 12 | ExecutionContext, 13 | Injectable, 14 | NestInterceptor, 15 | } from '@nestjs/common' 16 | import { Reflector } from '@nestjs/core' 17 | 18 | @Injectable() 19 | export class JSONTransformerInterceptor implements NestInterceptor { 20 | constructor(private readonly reflector: Reflector) {} 21 | intercept(context: ExecutionContext, next: CallHandler): Observable { 22 | const handler = context.getHandler() 23 | // 跳过 bypass 装饰的请求 24 | const bypass = this.reflector.get( 25 | RESPONSE_PASSTHROUGH_METADATA, 26 | handler, 27 | ) 28 | if (bypass) { 29 | return next.handle() 30 | } 31 | const http = context.switchToHttp() 32 | 33 | if (!http.getRequest()) { 34 | return next.handle() 35 | } 36 | 37 | return next.handle().pipe( 38 | map((data) => { 39 | return this.serialize(data) 40 | }), 41 | ) 42 | } 43 | 44 | private serialize(obj: any) { 45 | if (!isObjectLike(obj)) { 46 | return obj 47 | } 48 | return snakecaseKeys(obj, { deep: true }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/core/src/common/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logging interceptor. 3 | * @file 日志拦截器 4 | * @module interceptor/logging 5 | * @author Surmon 6 | * @author Innei 7 | */ 8 | import { Observable } from 'rxjs' 9 | import { tap } from 'rxjs/operators' 10 | 11 | import { HTTP_REQUEST_TIME } from '@core/constants/meta.constant' 12 | import { 13 | CallHandler, 14 | ExecutionContext, 15 | Injectable, 16 | Logger, 17 | NestInterceptor, 18 | SetMetadata, 19 | } from '@nestjs/common' 20 | import { isDev } from '@core/global/env.global' 21 | 22 | @Injectable() 23 | export class LoggingInterceptor implements NestInterceptor { 24 | private logger: Logger 25 | 26 | constructor() { 27 | this.logger = new Logger(LoggingInterceptor.name) 28 | } 29 | intercept( 30 | context: ExecutionContext, 31 | next: CallHandler, 32 | ): Observable { 33 | const call$ = next.handle() 34 | if (!isDev) { 35 | return call$ 36 | } 37 | const request = this.getRequest(context) 38 | const content = `${request.method} -> ${request.url}` 39 | Logger.debug(`+++ Request:${content}`, LoggingInterceptor.name) 40 | const now = Date.now() 41 | SetMetadata(HTTP_REQUEST_TIME, now)(this.getRequest(context)) 42 | 43 | return call$.pipe( 44 | tap(() => 45 | this.logger.debug(`--- Response:${content} +${Date.now() - now}ms`), 46 | ), 47 | ) 48 | } 49 | 50 | getRequest(context: ExecutionContext) { 51 | const req = context.switchToHttp().getRequest() 52 | if (req) { 53 | return req 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/core/src/common/interceptors/response.interceptor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 对响应体进行转换结构 3 | * @author Innei 4 | */ 5 | import { isArrayLike, omit } from 'lodash' 6 | import { Observable } from 'rxjs' 7 | import { map } from 'rxjs/operators' 8 | 9 | import * as SYSTEM from '@core/constants/system.constant' 10 | import { 11 | CallHandler, 12 | ExecutionContext, 13 | Injectable, 14 | NestInterceptor, 15 | } from '@nestjs/common' 16 | import { Reflector } from '@nestjs/core' 17 | 18 | export interface Response { 19 | data: T 20 | } 21 | 22 | @Injectable() 23 | export class ResponseInterceptor implements NestInterceptor> { 24 | constructor(private readonly reflector: Reflector) {} 25 | intercept( 26 | context: ExecutionContext, 27 | next: CallHandler, 28 | ): Observable> { 29 | if (!context.switchToHttp().getRequest()) { 30 | return next.handle() 31 | } 32 | const handler = context.getHandler() 33 | 34 | // 跳过 bypass 装饰的请求 35 | const bypass = this.reflector.get( 36 | SYSTEM.RESPONSE_PASSTHROUGH_METADATA, 37 | handler, 38 | ) 39 | if (bypass) { 40 | return next.handle() 41 | } 42 | 43 | const omitKeys = this.reflector.getAllAndOverride( 44 | SYSTEM.OMIT_RESPONSE_PROTECT_KEYS, 45 | [handler, context.getClass()], 46 | ) 47 | 48 | return next.handle().pipe( 49 | map((data) => { 50 | if (typeof data === 'undefined') { 51 | context.switchToHttp().getResponse().status(204) 52 | return data 53 | } 54 | 55 | if (Array.isArray(omitKeys)) { 56 | data = omit(data, omitKeys) 57 | } 58 | 59 | return isArrayLike(data) ? { data } : data 60 | }), 61 | ) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /apps/core/src/common/middlewares/request-context.middleware.ts: -------------------------------------------------------------------------------- 1 | // https://github.dev/ever-co/ever-gauzy/packages/core/src/core/context/request-context.middleware.ts 2 | 3 | import * as cls from 'cls-hooked' 4 | import { Injectable } from '@nestjs/common' 5 | import { RequestContext } from '../contexts/request.context' 6 | import type { NestMiddleware } from '@nestjs/common' 7 | import type { IncomingMessage, ServerResponse } from 'node:http' 8 | 9 | @Injectable() 10 | export class RequestContextMiddleware implements NestMiddleware { 11 | use(req: IncomingMessage, res: ServerResponse, next: () => any) { 12 | const requestContext = new RequestContext(req, res) 13 | 14 | const session = 15 | cls.getNamespace(RequestContext.name) || 16 | cls.createNamespace(RequestContext.name) 17 | 18 | session.run(async () => { 19 | session.set(RequestContext.name, requestContext) 20 | next() 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/core/src/common/pipes/zod-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | export { ZodValidationPipe } from '@wahyubucil/nestjs-zod-openapi' 2 | -------------------------------------------------------------------------------- /apps/core/src/constants/article.constant.ts: -------------------------------------------------------------------------------- 1 | export enum ArticleType { 2 | Post = 'Post', 3 | Note = 'Note', 4 | Page = 'Page', 5 | } 6 | -------------------------------------------------------------------------------- /apps/core/src/constants/business-event.constant.ts: -------------------------------------------------------------------------------- 1 | export const enum BusinessEvents { 2 | GATEWAY_CONNECT = 'GATEWAY_CONNECT', 3 | GATEWAY_DISCONNECT = 'GATEWAY_DISCONNECT', 4 | 5 | VISITOR_ONLINE = 'VISITOR_ONLINE', 6 | VISITOR_OFFLINE = 'VISITOR_OFFLINE', 7 | 8 | AUTH_FAILED = 'AUTH_FAILED', 9 | 10 | POST_CREATE = 'POST_CREATE', 11 | } 12 | 13 | /// ============= types ========= 14 | // 15 | // 16 | 17 | interface IGatewayConnectData {} 18 | 19 | interface IGatewayDisconnectData {} 20 | 21 | interface IVisitorOnlineData {} 22 | 23 | interface IVisitorOfflineData {} 24 | 25 | interface IAuthFailedData {} 26 | 27 | export type BizEventDataMap = { 28 | [BusinessEvents.GATEWAY_CONNECT]: IGatewayConnectData 29 | [BusinessEvents.GATEWAY_DISCONNECT]: IGatewayDisconnectData 30 | [BusinessEvents.VISITOR_ONLINE]: IVisitorOnlineData 31 | [BusinessEvents.VISITOR_OFFLINE]: IVisitorOfflineData 32 | [BusinessEvents.AUTH_FAILED]: IAuthFailedData 33 | [BusinessEvents.POST_CREATE]: any 34 | } 35 | -------------------------------------------------------------------------------- /apps/core/src/constants/cache.constant.ts: -------------------------------------------------------------------------------- 1 | import { name } from 'package.json' 2 | 3 | export enum RedisKeys { 4 | JWTStore = 'jwt_store', 5 | /** HTTP 请求缓存 */ 6 | HTTPCache = 'http_cache', 7 | 8 | ConfigCache = 'config_cache', 9 | 10 | /** 最大在线人数 */ 11 | MaxOnlineCount = 'max_online_count', 12 | 13 | // Article count 14 | // 15 | Read = 'read', 16 | Like = 'like', 17 | } 18 | export const API_CACHE_PREFIX = `${name}#api:` 19 | 20 | export enum CacheKeys {} 21 | -------------------------------------------------------------------------------- /apps/core/src/constants/error-code.constant.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCodeEnum { 2 | NoContentCanBeModified = 1000, 3 | 4 | PostNotFound = 10000, 5 | PostExist = 10001, 6 | CategoryNotFound = 10002, 7 | CategoryCannotDeleted = 10003, 8 | CategoryAlreadyExists = 10004, 9 | PostNotPublished = 10005, 10 | 11 | AuthFailUserNotExist = 20000, 12 | AuthFail = 20001, 13 | 14 | UserNotFound = 30000, 15 | UserExist = 30001, 16 | } 17 | 18 | export const ErrorCode = Object.freeze< 19 | Record 20 | >({ 21 | [ErrorCodeEnum.NoContentCanBeModified]: [ 22 | 'no content can be modified', 23 | '没有内容可以被修改', 24 | 400, 25 | ], 26 | [ErrorCodeEnum.PostNotFound]: ['post not found', '文章不存在', 404], 27 | [ErrorCodeEnum.PostNotPublished]: ['post not found', '文章不存在', 404], 28 | [ErrorCodeEnum.PostExist]: ['post already exist', '文章已存在', 400], 29 | [ErrorCodeEnum.CategoryNotFound]: [ 30 | 'category not found', 31 | '该分类未找到 (。•́︿•̀。)', 32 | 404, 33 | ], 34 | [ErrorCodeEnum.CategoryCannotDeleted]: [ 35 | 'there are other posts in this category, cannot be deleted', 36 | '该分类中有其他文章,无法被删除', 37 | 400, 38 | ], 39 | [ErrorCodeEnum.CategoryAlreadyExists]: [ 40 | 'category already exists', 41 | '分类已存在', 42 | 400, 43 | ], 44 | [ErrorCodeEnum.AuthFailUserNotExist]: [ 45 | 'auth failed, user not exist', 46 | '认证失败,用户不存在', 47 | 400, 48 | ], 49 | [ErrorCodeEnum.AuthFail]: [ 50 | 'auth failed, please check your username and password', 51 | '认证失败,请检查用户名和密码', 52 | 400, 53 | ], 54 | [ErrorCodeEnum.UserNotFound]: ['user not found', '用户不存在', 404], 55 | [ErrorCodeEnum.UserExist]: ['user already exist', '用户已存在', 400], 56 | }) 57 | -------------------------------------------------------------------------------- /apps/core/src/constants/event-bus.constant.ts: -------------------------------------------------------------------------------- 1 | export enum EventBusEvents { 2 | SystemException = 'system.exception', 3 | TokenExpired = 'token.expired', 4 | } 5 | -------------------------------------------------------------------------------- /apps/core/src/constants/event-scope.constant.ts: -------------------------------------------------------------------------------- 1 | export enum EventScope { 2 | ALL, 3 | TO_VISITOR, 4 | TO_ADMIN, 5 | TO_SYSTEM, 6 | TO_VISITOR_ADMIN, 7 | TO_SYSTEM_VISITOR, 8 | TO_SYSTEM_ADMIN, 9 | } 10 | -------------------------------------------------------------------------------- /apps/core/src/constants/meta.constant.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CACHE_KEY_METADATA, 3 | CACHE_TTL_METADATA, 4 | } from '@nestjs/common/cache/cache.constants' 5 | 6 | export const HTTP_CACHE_KEY_METADATA = CACHE_KEY_METADATA 7 | export const HTTP_CACHE_TTL_METADATA = CACHE_TTL_METADATA 8 | export const HTTP_CACHE_DISABLE = 'cache_module:cache_disable' 9 | export const HTTP_REQUEST_TIME = 'http:req_time' 10 | 11 | export const HTTP_IDEMPOTENCE_KEY = '__idempotence_key__' 12 | export const HTTP_IDEMPOTENCE_OPTIONS = '__idempotence_options__' 13 | 14 | export const HTTP_RES_UPDATE_DOC_COUNT_TYPE = '__updateDocCount__' 15 | export const HTTP_CACHE_META_OPTIONS = 'cache_module:cache_meta_options' 16 | -------------------------------------------------------------------------------- /apps/core/src/constants/parser.utilt.ts: -------------------------------------------------------------------------------- 1 | export const parseBooleanishValue = (value: string | boolean | undefined) => { 2 | if (typeof value === 'boolean') return value 3 | if (typeof value === 'string') { 4 | if (value === 'true') return true 5 | if (value === 'false') return false 6 | } 7 | return false 8 | } 9 | -------------------------------------------------------------------------------- /apps/core/src/constants/path.constant.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from 'node:os' 2 | import { join } from 'node:path' 3 | 4 | import { isDev } from '../global/env.global' 5 | 6 | const appName = 'nest-app-template' 7 | export const HOME = homedir() 8 | export const DATA_DIR = isDev 9 | ? join(process.cwd(), './tmp') 10 | : join(HOME, `.${appName}`) 11 | 12 | export const LOG_DIR = join(DATA_DIR, 'log') 13 | -------------------------------------------------------------------------------- /apps/core/src/constants/system.constant.ts: -------------------------------------------------------------------------------- 1 | export const HTTP_ADAPTER_HOST = 'HttpAdapterHost' 2 | export const REFLECTOR = 'Reflector' 3 | 4 | export const RESPONSE_PASSTHROUGH_METADATA = '__responsePassthrough__' 5 | 6 | export const OMIT_RESPONSE_PROTECT_KEYS = 'omitResponseProtectKeys' 7 | -------------------------------------------------------------------------------- /apps/core/src/global/consola.global.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from 'nestjs-pretty-logger' 2 | 3 | import { LOG_DIR } from '@core/constants/path.constant' 4 | 5 | const logger = createLogger({ 6 | writeToFile: { 7 | loggerDir: LOG_DIR, 8 | }, 9 | }) 10 | 11 | export { logger as consola, logger } 12 | -------------------------------------------------------------------------------- /apps/core/src/global/dayjs.global.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | import 'dayjs/locale/zh-cn' 4 | 5 | import localizedFormat from 'dayjs/plugin/localizedFormat' 6 | 7 | dayjs.locale('zh-cn') 8 | dayjs.extend(localizedFormat) 9 | -------------------------------------------------------------------------------- /apps/core/src/global/env.global.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV !== 'production' 2 | 3 | export const isTest = !!process.env.TEST || process.env.NODE_ENV == 'test' 4 | -------------------------------------------------------------------------------- /apps/core/src/global/index.global.ts: -------------------------------------------------------------------------------- 1 | import { mkdirSync } from 'node:fs' 2 | 3 | import crypto from 'node:crypto' 4 | import { DATA_DIR, LOG_DIR } from '@core/constants/path.constant' 5 | import { Logger } from '@nestjs/common' 6 | 7 | import './dayjs.global' 8 | 9 | import chalk from 'chalk' 10 | 11 | // 建立目录 12 | function mkdirs() { 13 | mkdirSync(DATA_DIR, { recursive: true }) 14 | Logger.log(chalk.blue(`Data dir is make up: ${DATA_DIR}`)) 15 | 16 | mkdirSync(LOG_DIR, { recursive: true }) 17 | Logger.log(chalk.blue(`Log dir is make up: ${LOG_DIR}`)) 18 | } 19 | 20 | export function register() { 21 | mkdirs() 22 | 23 | if (!globalThis.crypto) 24 | // @ts-expect-error 25 | // Compatibility with node 18 26 | globalThis.crypto = crypto.webcrypto 27 | } 28 | -------------------------------------------------------------------------------- /apps/core/src/main.ts: -------------------------------------------------------------------------------- 1 | #!env node 2 | import { name } from '../package.json' 3 | import { register } from './global/index.global' 4 | import '@wahyubucil/nestjs-zod-openapi/boot' 5 | 6 | process.title = `${name} - ${process.env.NODE_ENV || 'unknown'}` 7 | async function main() { 8 | register() 9 | const { bootstrap } = await import('./bootstrap') 10 | bootstrap() 11 | } 12 | 13 | main() 14 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/auth.config.ts: -------------------------------------------------------------------------------- 1 | import { API_VERSION, AUTH } from '@core/app.config' 2 | import { isDev } from '@core/global/env.global' 3 | import { db } from '@core/processors/database/database.service' 4 | 5 | import { authjs, DrizzleAdapter } from '@packages/compiled' 6 | import { 7 | accounts, 8 | authenticators, 9 | sessions, 10 | users, 11 | verificationTokens, 12 | } from '@packages/drizzle/schema' 13 | import type { ServerAuthConfig } from './auth.implement' 14 | 15 | const { 16 | providers: { github: GitHub }, 17 | } = authjs 18 | 19 | export const authConfig: ServerAuthConfig = { 20 | basePath: isDev ? '/auth' : `/api/v${API_VERSION}/auth`, 21 | secret: AUTH.secret, 22 | callbacks: { 23 | redirect({ url }) { 24 | return url 25 | }, 26 | }, 27 | providers: [ 28 | GitHub({ 29 | clientId: AUTH.github.clientId, 30 | clientSecret: AUTH.github.clientSecret, 31 | profile(profile) { 32 | return { 33 | id: profile.id.toString(), 34 | 35 | email: profile.email, 36 | name: profile.name || profile.login, 37 | handle: profile.login, 38 | image: profile.avatar_url, 39 | } 40 | }, 41 | }), 42 | ], 43 | trustHost: true, 44 | adapter: DrizzleAdapter(db, { 45 | usersTable: users, 46 | accountsTable: accounts, 47 | sessionsTable: sessions, 48 | verificationTokensTable: verificationTokens, 49 | authenticatorsTable: authenticators, 50 | }), 51 | } 52 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/auth.constant.ts: -------------------------------------------------------------------------------- 1 | export const AuthConfigInjectKey = Symbol() 2 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/auth.implement.ts: -------------------------------------------------------------------------------- 1 | import { Auth, setEnvDefaults, type AuthConfig } from '@packages/compiled' 2 | 3 | import { getRequest } from './req.transformer' 4 | import type { IncomingMessage, ServerResponse } from 'node:http' 5 | 6 | export type ServerAuthConfig = Omit & { 7 | basePath: string 8 | } 9 | 10 | export function CreateAuth(config: ServerAuthConfig) { 11 | return async (req: IncomingMessage, res: ServerResponse) => { 12 | try { 13 | setEnvDefaults(process.env, config) 14 | 15 | const auth = await Auth(await toWebRequest(req), config) 16 | 17 | await toServerResponse(req, auth, res) 18 | } catch (error) { 19 | console.error(error) 20 | // throw error 21 | res.end(error.message) 22 | } 23 | } 24 | } 25 | 26 | async function toWebRequest(req: IncomingMessage) { 27 | const host = req.headers.host || 'localhost' 28 | const protocol = req.headers['x-forwarded-proto'] || 'http' 29 | const base = `${protocol}://${host}` 30 | 31 | return getRequest(base, req) 32 | } 33 | 34 | async function toServerResponse( 35 | req: IncomingMessage, 36 | response: Response, 37 | res: ServerResponse, 38 | ) { 39 | response.headers.forEach((value, key) => { 40 | if (!value) { 41 | return 42 | } 43 | if (res.hasHeader(key)) { 44 | res.appendHeader(key, value) 45 | } else { 46 | res.setHeader(key, value) 47 | } 48 | }) 49 | res.setHeader('Content-Type', response.headers.get('content-type') || '') 50 | res.setHeader('access-control-allow-methods', 'GET, POST') 51 | res.setHeader('access-control-allow-headers', 'content-type') 52 | res.setHeader( 53 | 'access-control-allow-origin', 54 | req.headers.origin || req.headers.referer || req.headers.host || '*', 55 | ) 56 | res.setHeader('access-control-allow-credentials', 'true') 57 | 58 | const text = await response.text() 59 | res.writeHead(response.status, response.statusText) 60 | res.end(text) 61 | } 62 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Inject, type NestMiddleware } from '@nestjs/common' 2 | import type { IncomingMessage, ServerResponse } from 'node:http' 3 | import { AuthConfigInjectKey } from './auth.constant' 4 | import { CreateAuth, ServerAuthConfig } from './auth.implement' 5 | 6 | export class AuthMiddleware implements NestMiddleware { 7 | constructor( 8 | @Inject(AuthConfigInjectKey) private readonly config: ServerAuthConfig, 9 | ) {} 10 | authHandler = CreateAuth(this.config) 11 | async use(req: IncomingMessage, res: ServerResponse, next: () => void) { 12 | if (req.method !== 'GET' && req.method !== 'POST') { 13 | next() 14 | return 15 | } 16 | 17 | await this.authHandler(req, res) 18 | 19 | next() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type DynamicModule, 3 | Global, 4 | Inject, 5 | type MiddlewareConsumer, 6 | Module, 7 | type NestModule, 8 | } from '@nestjs/common' 9 | 10 | import { AuthConfigInjectKey } from './auth.constant' 11 | import type { ServerAuthConfig } from './auth.implement' 12 | import { AuthMiddleware } from './auth.middleware' 13 | import { AuthService } from './auth.service' 14 | 15 | @Module({}) 16 | @Global() 17 | export class AuthModule implements NestModule { 18 | constructor( 19 | @Inject(AuthConfigInjectKey) private readonly config: ServerAuthConfig, 20 | ) {} 21 | static forRoot(config: ServerAuthConfig): DynamicModule { 22 | return { 23 | module: AuthModule, 24 | global: true, 25 | exports: [AuthService], 26 | providers: [ 27 | { 28 | provide: AuthService, 29 | useFactory() { 30 | return new AuthService(config) 31 | }, 32 | }, 33 | { 34 | provide: AuthConfigInjectKey, 35 | useValue: config, 36 | }, 37 | ], 38 | } 39 | } 40 | 41 | configure(consumer: MiddlewareConsumer) { 42 | const config = this.config 43 | 44 | consumer 45 | .apply(AuthMiddleware) 46 | .forRoutes(`${config.basePath || '/auth'}/(.*)`) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'node:http' 2 | import { Injectable } from '@nestjs/common' 3 | 4 | import { 5 | Auth, 6 | createActionURL, 7 | setEnvDefaults, 8 | type Session, 9 | } from '@packages/compiled' 10 | import { ServerAuthConfig } from './auth.implement' 11 | import type { users } from '@packages/drizzle/schema' 12 | 13 | export interface SessionUser { 14 | sessionToken: string 15 | userId: string 16 | expires: string 17 | user: typeof users.$inferSelect 18 | } 19 | @Injectable() 20 | export class AuthService { 21 | constructor(private readonly authConfig: ServerAuthConfig) {} 22 | 23 | private async getSessionBase(req: IncomingMessage, config: ServerAuthConfig) { 24 | setEnvDefaults(process.env, config) 25 | 26 | const protocol = (req.headers['x-forwarded-proto'] || 'http') as string 27 | const url = createActionURL( 28 | 'session', 29 | protocol, 30 | // @ts-expect-error 31 | 32 | new Headers(req.headers), 33 | process.env, 34 | config, 35 | ) 36 | 37 | const response = await Auth( 38 | new Request(url, { headers: { cookie: req.headers.cookie ?? '' } }), 39 | config, 40 | ) 41 | 42 | const { status = 200 } = response 43 | 44 | const data = await response.json() 45 | 46 | if (!data || !Object.keys(data).length) return null 47 | if (status === 200) return data 48 | } 49 | 50 | getSessionUser(req: IncomingMessage) { 51 | const { authConfig } = this 52 | return new Promise((resolve) => { 53 | this.getSessionBase(req, { 54 | ...authConfig, 55 | callbacks: { 56 | ...authConfig.callbacks, 57 | async session(...args) { 58 | resolve(args[0].session as any as SessionUser) 59 | 60 | const session = 61 | (await authConfig.callbacks?.session?.(...args)) ?? 62 | args[0].session 63 | const user = args[0].user ?? args[0].token 64 | return { user, ...session } satisfies Session 65 | }, 66 | }, 67 | }).then((session) => { 68 | if (!session) { 69 | resolve(null) 70 | } 71 | }) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /apps/core/src/modules/auth/req.transformer.ts: -------------------------------------------------------------------------------- 1 | import { PayloadTooLargeException } from '@nestjs/common' 2 | import type { IncomingMessage } from 'node:http' 3 | 4 | /** 5 | * @param {import('http').IncomingMessage} req 6 | 7 | */ 8 | function get_raw_body(req) { 9 | const h = req.headers 10 | 11 | if (!h['content-type']) { 12 | return null 13 | } 14 | 15 | const content_length = Number(h['content-length']) 16 | 17 | // check if no request body 18 | if ( 19 | (req.httpVersionMajor === 1 && 20 | Number.isNaN(content_length) && 21 | h['transfer-encoding'] == null) || 22 | content_length === 0 23 | ) { 24 | return null 25 | } 26 | 27 | if (req.destroyed) { 28 | const readable = new ReadableStream() 29 | readable.cancel() 30 | return readable 31 | } 32 | 33 | let size = 0 34 | let cancelled = false 35 | 36 | return new ReadableStream({ 37 | start(controller) { 38 | req.on('error', (error) => { 39 | cancelled = true 40 | controller.error(error) 41 | }) 42 | 43 | req.on('end', () => { 44 | if (cancelled) return 45 | controller.close() 46 | }) 47 | 48 | req.on('data', (chunk) => { 49 | if (cancelled) return 50 | 51 | size += chunk.length 52 | if (size > content_length) { 53 | cancelled = true 54 | 55 | const constraint = content_length 56 | ? 'content-length' 57 | : 'BODY_SIZE_LIMIT' 58 | const message = `request body size exceeded ${constraint} of ${content_length}` 59 | 60 | const error = new PayloadTooLargeException(message) 61 | controller.error(error) 62 | 63 | return 64 | } 65 | 66 | controller.enqueue(chunk) 67 | 68 | if (controller.desiredSize === null || controller.desiredSize <= 0) { 69 | req.pause() 70 | } 71 | }) 72 | }, 73 | 74 | pull() { 75 | req.resume() 76 | }, 77 | 78 | cancel(reason) { 79 | cancelled = true 80 | req.destroy(reason) 81 | }, 82 | }) 83 | } 84 | 85 | export async function getRequest( 86 | base: string, 87 | req: IncomingMessage, 88 | ): Promise { 89 | const headers = req.headers as Record 90 | 91 | // @ts-expect-error 92 | const request = new Request(base + req.originalUrl, { 93 | method: req.method, 94 | headers, 95 | body: get_raw_body(req), 96 | credentials: 'include', 97 | // @ts-expect-error 98 | duplex: 'half', 99 | }) 100 | return request 101 | } 102 | -------------------------------------------------------------------------------- /apps/core/src/modules/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { RequestContext } from '@core/common/contexts/request.context' 2 | import { ApiController } from '@core/common/decorators/api-controller.decorator' 3 | import { Auth } from '@core/common/decorators/auth.decorator' 4 | import { Get } from '@nestjs/common' 5 | import { ApiOkResponse } from '@nestjs/swagger' 6 | import { UserSessionDto } from './user.dto' 7 | 8 | @ApiController('users') 9 | @Auth() 10 | export class UserController { 11 | @Get('/me') 12 | @ApiOkResponse({ 13 | type: UserSessionDto, 14 | }) 15 | async me() { 16 | return RequestContext.currentSession().user 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/core/src/modules/user/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from '@wahyubucil/nestjs-zod-openapi' 2 | import { createSelectSchema } from 'drizzle-zod' 3 | import { users } from '@packages/drizzle/schema' 4 | 5 | const selectUserSchema = createSelectSchema(users, { 6 | name: (schema) => schema.name.openapi({ description: 'User name' }), 7 | }) 8 | export class UserSessionDto extends createZodDto(selectUserSchema) {} 9 | -------------------------------------------------------------------------------- /apps/core/src/modules/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common' 2 | import { UserController } from './user.controller' 3 | 4 | @Module({ 5 | controllers: [UserController], 6 | }) 7 | export class UserModule {} 8 | -------------------------------------------------------------------------------- /apps/core/src/processors/cache/cache.config.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache config service. 3 | * @file Cache 配置器 4 | * @module processor/cache/config.service 5 | * @author Surmon 6 | */ 7 | // import redisStore from 'cache-manager-redis-store' 8 | import redisStore from 'cache-manager-ioredis' 9 | 10 | import { REDIS } from '@core/app.config' 11 | import { CacheModuleOptions, CacheOptionsFactory } from '@nestjs/cache-manager' 12 | import { Injectable } from '@nestjs/common' 13 | 14 | @Injectable() 15 | export class CacheConfigService implements CacheOptionsFactory { 16 | // 缓存配置 17 | public createCacheOptions(): CacheModuleOptions { 18 | const redisOptions: any = { 19 | host: REDIS.host as string, 20 | port: REDIS.port as number, 21 | } 22 | if (REDIS.password) { 23 | redisOptions.password = REDIS.password 24 | } 25 | return { 26 | store: redisStore, 27 | ttl: REDIS.ttl, 28 | // https://github.com/dabroek/node-cache-manager-redis-store/blob/master/CHANGELOG.md#breaking-changes 29 | // Any value (undefined | null) return true (cacheable) after redisStore v2.0.0 30 | is_cacheable_value: () => true, 31 | max: REDIS.max, 32 | ...redisOptions, 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/core/src/processors/cache/cache.module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache module. 3 | * @file Cache 全局模块 4 | * @module processor/cache/module 5 | * @author Surmon 6 | */ 7 | import { CacheModule as NestCacheModule } from '@nestjs/cache-manager' 8 | import { Global, Module } from '@nestjs/common' 9 | 10 | import { CacheConfigService } from './cache.config.service' 11 | import { CacheService } from './cache.service' 12 | 13 | @Global() 14 | @Module({ 15 | imports: [ 16 | NestCacheModule.registerAsync({ 17 | useClass: CacheConfigService, 18 | inject: [CacheConfigService], 19 | }), 20 | ], 21 | providers: [CacheConfigService, CacheService], 22 | exports: [CacheService], 23 | }) 24 | export class CacheModule {} 25 | -------------------------------------------------------------------------------- /apps/core/src/processors/cache/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from 'cache-manager' 2 | import { Redis } from 'ioredis' 3 | 4 | import { RedisIoAdapterKey } from '@core/common/adapter/io.adapter' 5 | import { CACHE_MANAGER } from '@nestjs/cache-manager' 6 | import { Inject, Injectable, Logger } from '@nestjs/common' 7 | import { Emitter } from '@socket.io/redis-emitter' 8 | 9 | // Cache 客户端管理器 10 | 11 | // 获取器 12 | export type TCacheKey = string 13 | export type TCacheResult = Promise 14 | 15 | /** 16 | * @class CacheService 17 | * @classdesc 承载缓存服务 18 | * @example CacheService.get(CacheKey).then() 19 | * @example CacheService.set(CacheKey).then() 20 | */ 21 | @Injectable() 22 | export class CacheService { 23 | private cache!: Cache 24 | private logger = new Logger(CacheService.name) 25 | 26 | constructor(@Inject(CACHE_MANAGER) cache: Cache) { 27 | this.cache = cache 28 | 29 | this.redisClient.on('ready', () => { 30 | this.logger.log('Redis is ready!') 31 | }) 32 | } 33 | 34 | private get redisClient(): Redis { 35 | // @ts-expect-error 36 | return this.cache.store.getClient() 37 | } 38 | 39 | public get(key: TCacheKey): TCacheResult { 40 | return this.cache.get(key) 41 | } 42 | 43 | public set(key: TCacheKey, value: any, ttl?: number | undefined) { 44 | return this.cache.set(key, value, ttl || 0) 45 | } 46 | 47 | public getClient() { 48 | return this.redisClient 49 | } 50 | 51 | private _emitter: Emitter 52 | 53 | public get emitter(): Emitter { 54 | if (this._emitter) { 55 | return this._emitter 56 | } 57 | this._emitter = new Emitter(this.redisClient, { 58 | key: RedisIoAdapterKey, 59 | }) 60 | 61 | return this._emitter 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /apps/core/src/processors/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { DatabaseService } from './database.service' 4 | 5 | @Module({ 6 | providers: [DatabaseService], 7 | exports: [DatabaseService], 8 | }) 9 | @Global() 10 | export class DatabaseModule {} 11 | -------------------------------------------------------------------------------- /apps/core/src/processors/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { DATABASE } from '@core/app.config' 2 | import { createDrizzle, migrateDb } from '@packages/drizzle' 3 | import { Injectable, OnModuleInit } from '@nestjs/common' 4 | // const drizzleLogger = new Logger('') 5 | 6 | export const db = createDrizzle(DATABASE.url, { 7 | // logger: { 8 | // logQuery(query, params) { 9 | // drizzleLogger.debug(query + inspect(params)) 10 | // }, 11 | // }, 12 | }) 13 | 14 | @Injectable() 15 | export class DatabaseService implements OnModuleInit { 16 | public drizzle: ReturnType 17 | 18 | constructor() { 19 | this.drizzle = db 20 | } 21 | 22 | async onModuleInit() { 23 | await migrateDb(DATABASE.url) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/core/src/processors/gateway/base.gateway.ts: -------------------------------------------------------------------------------- 1 | import { BusinessEvents } from '@core/constants/business-event.constant' 2 | import type { Socket } from 'socket.io' 3 | 4 | export abstract class BaseGateway { 5 | public gatewayMessageFormat( 6 | type: BusinessEvents, 7 | message: any, 8 | code?: number, 9 | ) { 10 | return { 11 | type, 12 | data: message, 13 | code, 14 | } 15 | } 16 | 17 | handleDisconnect(client: Socket) { 18 | client.send( 19 | this.gatewayMessageFormat( 20 | BusinessEvents.GATEWAY_CONNECT, 21 | 'WebSocket 断开', 22 | ), 23 | ) 24 | } 25 | handleConnect(client: Socket) { 26 | client.send( 27 | this.gatewayMessageFormat( 28 | BusinessEvents.GATEWAY_CONNECT, 29 | 'WebSocket 已连接', 30 | ), 31 | ) 32 | } 33 | } 34 | 35 | export abstract class BroadcastBaseGateway extends BaseGateway { 36 | broadcast(_event: BusinessEvents, _data: any) {} 37 | } 38 | -------------------------------------------------------------------------------- /apps/core/src/processors/gateway/gateway.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { SharedGateway } from './shared/events.gateway' 4 | import { WebEventsGateway } from './web/events.gateway' 5 | 6 | @Global() 7 | @Module({ 8 | imports: [], 9 | providers: [WebEventsGateway, SharedGateway], 10 | exports: [WebEventsGateway, SharedGateway], 11 | }) 12 | export class GatewayModule {} 13 | -------------------------------------------------------------------------------- /apps/core/src/processors/gateway/shared/events.gateway.ts: -------------------------------------------------------------------------------- 1 | import { BusinessEvents } from '@core/constants/business-event.constant' 2 | import { Injectable } from '@nestjs/common' 3 | 4 | import { WebEventsGateway } from '../web/events.gateway' 5 | 6 | @Injectable() 7 | export class SharedGateway { 8 | constructor(private readonly web: WebEventsGateway) {} 9 | 10 | broadcast(event: BusinessEvents, data: any) { 11 | this.web.broadcast(event, data) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/core/src/processors/gateway/web/events.gateway.ts: -------------------------------------------------------------------------------- 1 | import SocketIO from 'socket.io' 2 | 3 | import { BusinessEvents } from '@core/constants/business-event.constant' 4 | import { RedisKeys } from '@core/constants/cache.constant' 5 | import { CacheService } from '@core/processors/cache/cache.service' 6 | import { getRedisKey } from '@core/shared/utils/redis.util' 7 | import { scheduleManager } from '@core/shared/utils/schedule.util' 8 | import { getShortDate } from '@core/shared/utils/time.util' 9 | import { 10 | GatewayMetadata, 11 | OnGatewayConnection, 12 | OnGatewayDisconnect, 13 | WebSocketGateway, 14 | WebSocketServer, 15 | } from '@nestjs/websockets' 16 | 17 | import { BroadcastBaseGateway } from '../base.gateway' 18 | 19 | const namespace = 'web' 20 | @WebSocketGateway({ 21 | namespace, 22 | }) 23 | export class WebEventsGateway 24 | extends BroadcastBaseGateway 25 | implements OnGatewayConnection, OnGatewayDisconnect 26 | { 27 | constructor(private readonly cacheService: CacheService) { 28 | super() 29 | } 30 | 31 | @WebSocketServer() 32 | private namespace: SocketIO.Namespace 33 | 34 | async sendOnlineNumber() { 35 | return { 36 | online: await this.getCurrentClientCount(), 37 | timestamp: new Date().toISOString(), 38 | } 39 | } 40 | 41 | async getCurrentClientCount() { 42 | const server = this.namespace.server 43 | const sockets = await server.of(`/${namespace}`).adapter.sockets(new Set()) 44 | return sockets.size 45 | } 46 | 47 | async handleConnection(socket: SocketIO.Socket) { 48 | this.broadcast(BusinessEvents.VISITOR_ONLINE, await this.sendOnlineNumber()) 49 | 50 | scheduleManager.schedule(async () => { 51 | const redisClient = this.cacheService.getClient() 52 | const dateFormat = getShortDate(new Date()) 53 | 54 | // get and store max_online_count 55 | const maxOnlineCount = 56 | +(await redisClient.hget( 57 | getRedisKey(RedisKeys.MaxOnlineCount), 58 | dateFormat, 59 | ))! || 0 60 | await redisClient.hset( 61 | getRedisKey(RedisKeys.MaxOnlineCount), 62 | dateFormat, 63 | Math.max(maxOnlineCount, await this.getCurrentClientCount()), 64 | ) 65 | const key = getRedisKey(RedisKeys.MaxOnlineCount, 'total') 66 | 67 | const totalCount = +(await redisClient.hget(key, dateFormat))! || 0 68 | await redisClient.hset(key, dateFormat, totalCount + 1) 69 | }) 70 | 71 | super.handleConnect(socket) 72 | } 73 | 74 | async handleDisconnect(client: SocketIO.Socket) { 75 | super.handleDisconnect(client) 76 | this.broadcast( 77 | BusinessEvents.VISITOR_OFFLINE, 78 | await this.sendOnlineNumber(), 79 | ) 80 | } 81 | 82 | override broadcast(event: BusinessEvents, data: any) { 83 | const emitter = this.cacheService.emitter 84 | 85 | emitter 86 | .of(`/${namespace}`) 87 | .emit('message', this.gatewayMessageFormat(event, data)) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /apps/core/src/processors/helper/helper.module.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from '@core/global/env.global' 2 | import { Global, Module, Provider } from '@nestjs/common' 3 | import { EventEmitterModule } from '@nestjs/event-emitter/dist/event-emitter.module' 4 | import { ThrottlerModule } from '@nestjs/throttler' 5 | 6 | import { HttpService } from './helper.http.service' 7 | 8 | const providers: Provider[] = [HttpService] 9 | 10 | @Module({ 11 | imports: [ 12 | ThrottlerModule.forRoot([ 13 | { 14 | ttl: 60, 15 | limit: 100, 16 | }, 17 | ]), 18 | EventEmitterModule.forRoot({ 19 | wildcard: false, 20 | // the delimiter used to segment namespaces 21 | delimiter: '.', 22 | // set this to `true` if you want to emit the newListener event 23 | newListener: false, 24 | // set this to `true` if you want to emit the removeListener event 25 | removeListener: false, 26 | // the maximum amount of listeners that can be assigned to an event 27 | maxListeners: 10, 28 | // show event name in memory leak message when more than maximum amount of listeners is assigned 29 | verboseMemoryLeak: isDev, 30 | // disable throwing uncaughtException if an error event is emitted and it has no listeners 31 | ignoreErrors: false, 32 | }), 33 | ], 34 | 35 | providers, 36 | exports: providers, 37 | }) 38 | @Global() 39 | export class HelperModule {} 40 | -------------------------------------------------------------------------------- /apps/core/src/shared/dto/id.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from '@wahyubucil/nestjs-zod-openapi' 2 | import { z } from 'zod' 3 | 4 | export class SnowflakeIdDto extends createZodDto( 5 | z.object({ 6 | id: z 7 | .string() 8 | .regex(/^\d{18}$/) 9 | .openapi({ description: 'Snowflake ID' }), 10 | }), 11 | ) {} 12 | -------------------------------------------------------------------------------- /apps/core/src/shared/dto/pager.dto.ts: -------------------------------------------------------------------------------- 1 | import { createZodDto } from '@wahyubucil/nestjs-zod-openapi' 2 | import { z } from 'zod' 3 | 4 | export const basePagerSchema = z.object({ 5 | size: z.coerce.number().int().min(1).max(50).default(10).optional(), 6 | page: z.coerce.number().int().min(1).default(1).optional(), 7 | sortBy: z.string().optional(), 8 | sortOrder: z.coerce.number().or(z.literal(1)).or(z.literal(-1)).optional(), 9 | }) 10 | 11 | export class PagerDto extends createZodDto(basePagerSchema) {} 12 | 13 | const withYearPagerSchema = basePagerSchema.extend({ 14 | year: z.coerce.number().int().min(1970).max(2100), 15 | }) 16 | 17 | export class WithYearPagerDto extends createZodDto(withYearPagerSchema) {} 18 | -------------------------------------------------------------------------------- /apps/core/src/shared/interface/paginator.interface.ts: -------------------------------------------------------------------------------- 1 | export interface PaginationResult { 2 | data: T[] 3 | pagination: Paginator 4 | } 5 | export class Paginator { 6 | /** 7 | * 总条数 8 | */ 9 | readonly total: number 10 | /** 11 | * 一页多少条 12 | */ 13 | readonly size: number 14 | /** 15 | * 当前页 16 | */ 17 | readonly currentPage: number 18 | /** 19 | * 总页数 20 | */ 21 | readonly totalPage: number 22 | readonly hasNextPage: boolean 23 | readonly hasPrevPage: boolean 24 | } 25 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/ip.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @module utils/ip 3 | * @description IP utility functions 4 | */ 5 | import type { IncomingMessage } from 'node:http' 6 | import type { FastifyRequest } from 'fastify' 7 | 8 | export const getIp = (request: FastifyRequest | IncomingMessage) => { 9 | const _ = request as any 10 | 11 | let ip: string = 12 | _.headers['x-forwarded-for'] || 13 | _.ip || 14 | _.raw.connection.remoteAddress || 15 | _.raw.socket.remoteAddress || 16 | undefined 17 | if (ip && ip.split(',').length > 0) { 18 | ip = ip.split(',')[0] 19 | } 20 | return ip 21 | } 22 | 23 | export const parseRelativeUrl = (path: string) => { 24 | if (!path || !path.startsWith('/')) { 25 | return new URL('http://a.com') 26 | } 27 | return new URL(`http://a.com${path}`) 28 | } 29 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/machine.util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-prototype-builtins */ 2 | import { exec, execSync } from 'node:child_process' 3 | import { createHash } from 'node:crypto' 4 | 5 | const { platform }: NodeJS.Process = process 6 | const win32RegBinPath: Record = { 7 | native: String.raw`%windir%\System32`, 8 | mixed: String.raw`%windir%\sysnative\cmd.exe /c %windir%\System32`, 9 | } 10 | 11 | const guid: Record = { 12 | darwin: 'ioreg -rd1 -c IOPlatformExpertDevice', 13 | win32: `${ 14 | win32RegBinPath[isWindowsProcessMixedOrNativeArchitecture()] 15 | }\\REG.exe ${String.raw`QUERY HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography `}/v MachineGuid`, 16 | linux: 17 | '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :', 18 | freebsd: 'kenv -q smbios.system.uuid || sysctl -n kern.hostuuid', 19 | } 20 | 21 | function isWindowsProcessMixedOrNativeArchitecture(): string { 22 | // detect if the node binary is the same arch as the Windows OS. 23 | // or if this is 32 bit node on 64 bit windows. 24 | if (process.platform !== 'win32') { 25 | return '' 26 | } 27 | if ( 28 | process.arch === 'ia32' && 29 | process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432') 30 | ) { 31 | return 'mixed' 32 | } 33 | return 'native' 34 | } 35 | 36 | function hash(guid: string): string { 37 | return createHash('sha256').update(guid).digest('hex') 38 | } 39 | 40 | function expose(result: string): string { 41 | switch (platform) { 42 | case 'darwin': 43 | return result 44 | .split('IOPlatformUUID')[1] 45 | .split('\n')[0] 46 | .replaceAll(/=|\s+|"/gi, '') 47 | .toLowerCase() 48 | case 'win32': 49 | return result 50 | .toString() 51 | .split('REG_SZ')[1] 52 | .replaceAll(/\r+|\n+|\s+/gi, '') 53 | .toLowerCase() 54 | case 'linux': 55 | return result 56 | .toString() 57 | .replaceAll(/\r+|\n+|\s+/gi, '') 58 | .toLowerCase() 59 | case 'freebsd': 60 | return result 61 | .toString() 62 | .replaceAll(/\r+|\n+|\s+/gi, '') 63 | .toLowerCase() 64 | default: 65 | throw new Error(`Unsupported platform: ${process.platform}`) 66 | } 67 | } 68 | 69 | export function machineIdSync(original?: boolean): string { 70 | const id: string = expose(execSync(guid[platform]).toString()) 71 | return original ? id : hash(id) 72 | } 73 | 74 | export function machineId(original?: boolean): Promise { 75 | return new Promise((resolve, reject) => { 76 | return exec(guid[platform], {}, (err, stdout) => { 77 | if (err) { 78 | return reject( 79 | new Error(`Error while obtaining machine id: ${err.stack}`), 80 | ) 81 | } 82 | const id: string = expose(stdout.toString()) 83 | return resolve(original ? id : hash(id)) 84 | }) 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/redis.util.ts: -------------------------------------------------------------------------------- 1 | import type { RedisKeys } from '@core/constants/cache.constant' 2 | 3 | export const getRedisKey = ( 4 | key: T, 5 | ...concatKeys: string[] 6 | ): `${'nest'}:${T}${string | ''}` => { 7 | return `${'nest'}:${key}${ 8 | concatKeys && concatKeys.length ? `:${concatKeys.join('_')}` : '' 9 | }` 10 | } 11 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/schedule.util.ts: -------------------------------------------------------------------------------- 1 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 2 | 3 | export function scheduleMicrotask(callback: () => void) { 4 | sleep(0).then(callback) 5 | } 6 | 7 | // TYPES 8 | 9 | type NotifyCallback = () => void 10 | 11 | type NotifyFunction = (callback: () => void) => void 12 | 13 | type BatchNotifyFunction = (callback: () => void) => void 14 | 15 | export function createNotifyManager() { 16 | let queue: NotifyCallback[] = [] 17 | let transactions = 0 18 | let notifyFn: NotifyFunction = (callback) => { 19 | callback() 20 | } 21 | let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => { 22 | callback() 23 | } 24 | 25 | const batch = (callback: () => T): T => { 26 | let result 27 | transactions++ 28 | try { 29 | result = callback() 30 | } finally { 31 | transactions-- 32 | if (!transactions) { 33 | flush() 34 | } 35 | } 36 | return result 37 | } 38 | 39 | const schedule = (callback: NotifyCallback): void => { 40 | if (transactions) { 41 | queue.push(callback) 42 | } else { 43 | scheduleMicrotask(() => { 44 | notifyFn(callback) 45 | }) 46 | } 47 | } 48 | 49 | /** 50 | * All calls to the wrapped function will be batched. 51 | */ 52 | const batchCalls = (callback: T): T => { 53 | return ((...args: any[]) => { 54 | schedule(() => { 55 | callback(...args) 56 | }) 57 | }) as any 58 | } 59 | 60 | const flush = (): void => { 61 | const originalQueue = queue 62 | queue = [] 63 | if (originalQueue.length) { 64 | scheduleMicrotask(() => { 65 | batchNotifyFn(() => { 66 | originalQueue.forEach((callback) => { 67 | notifyFn(callback) 68 | }) 69 | }) 70 | }) 71 | } 72 | } 73 | 74 | /** 75 | * Use this method to set a custom notify function. 76 | * This can be used to for example wrap notifications with `React.act` while running tests. 77 | */ 78 | const setNotifyFunction = (fn: NotifyFunction) => { 79 | notifyFn = fn 80 | } 81 | 82 | /** 83 | * Use this method to set a custom function to batch notifications together into a single tick. 84 | * By default React Query will use the batch function provided by ReactDOM or React Native. 85 | */ 86 | const setBatchNotifyFunction = (fn: BatchNotifyFunction) => { 87 | batchNotifyFn = fn 88 | } 89 | 90 | return { 91 | batch, 92 | batchCalls, 93 | schedule, 94 | setNotifyFunction, 95 | setBatchNotifyFunction, 96 | } as const 97 | } 98 | 99 | // SINGLETON 100 | export const scheduleManager = createNotifyManager() 101 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/schema.util.ts: -------------------------------------------------------------------------------- 1 | // type DefaultKeys = 'id' | 'created' | 'modified' | 'deleted' 2 | type DefaultKeys = 'id' 3 | const defaultProjectKeys = ['id', 'created', 'modified', 'deleted'] as const 4 | 5 | type Projection = { 6 | [P in K]: true 7 | } 8 | 9 | export function createProjectionOmit( 10 | obj: T, 11 | keys: K[], 12 | withDefaults: true, 13 | ): Projection 14 | export function createProjectionOmit( 15 | obj: T, 16 | keys: K[], 17 | ): Projection 18 | 19 | export function createProjectionOmit( 20 | obj: T, 21 | keys: K[], 22 | withDefaults: boolean = false, 23 | ): any { 24 | const projection: Partial> = {} 25 | 26 | // Add default keys if withDefaults is true 27 | if (withDefaults) { 28 | defaultProjectKeys.forEach((key) => { 29 | projection[key] = true 30 | }) 31 | } 32 | 33 | // Add specified keys 34 | for (const key of keys) { 35 | projection[key] = true 36 | } 37 | 38 | // @ts-ignore 39 | projection.keys = [...keys, ...(withDefaults ? defaultProjectKeys : [])] 40 | return projection as any 41 | } 42 | 43 | export const getProjectionKeys = (projection: Projection): string[] => { 44 | // @ts-expect-error 45 | return projection.keys 46 | } 47 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/time.util.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | /** Get Time, format `12:00:00` */ 4 | export const getShortTime = (date: Date) => { 5 | return Intl.DateTimeFormat('en-US', { 6 | timeStyle: 'medium', 7 | hour12: false, 8 | }).format(date) 9 | } 10 | 11 | export const getShortDate = (date: Date) => { 12 | return Intl.DateTimeFormat('en-US', { 13 | dateStyle: 'short', 14 | }) 15 | .format(date) 16 | .replaceAll('/', '-') 17 | } 18 | /** 2-12-22, 21:31:42 */ 19 | export const getShortDateTime = (date: Date) => { 20 | return Intl.DateTimeFormat('en-US', { 21 | dateStyle: 'short', 22 | timeStyle: 'medium', 23 | hour12: false, 24 | }) 25 | .format(date) 26 | .replaceAll('/', '-') 27 | } 28 | /** YYYY-MM-DD_HH:mm:ss */ 29 | export const getMediumDateTime = (date: Date) => { 30 | return dayjs(date).format('YYYY-MM-DD_HH:mm:ss') 31 | } 32 | export const getTodayEarly = (today: Date) => 33 | dayjs(today).set('hour', 0).set('minute', 0).set('millisecond', 0).toDate() 34 | 35 | export const getWeekStart = (today: Date) => 36 | dayjs(today) 37 | .set('day', 0) 38 | .set('hour', 0) 39 | .set('millisecond', 0) 40 | .set('minute', 0) 41 | .toDate() 42 | 43 | export const getMonthStart = (today: Date) => 44 | dayjs(today) 45 | .set('date', 1) 46 | .set('hour', 0) 47 | .set('minute', 0) 48 | .set('millisecond', 0) 49 | .toDate() 50 | 51 | export function getMonthLength(month: number, year: number) { 52 | return new Date(year, month, 0).getDate() 53 | } 54 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/tool.utils.ts: -------------------------------------------------------------------------------- 1 | export const md5 = (text: string) => 2 | require('node:crypto').createHash('md5').update(text).digest('hex') as string 3 | 4 | export function getAvatar(mail: string | undefined) { 5 | if (!mail) { 6 | return '' 7 | } 8 | return `https://cravatar.cn/avatar/${md5(mail)}?d=retro` 9 | } 10 | 11 | export function sleep(ms: number) { 12 | return new Promise((resolve) => setTimeout(resolve, ms)) 13 | } 14 | 15 | export const safeJSONParse = (p: any) => { 16 | try { 17 | return JSON.parse(p) 18 | } catch { 19 | return null 20 | } 21 | } 22 | 23 | /** 24 | * hash string 25 | */ 26 | export const hashString = function (str, seed = 0) { 27 | let h1 = 0xdeadbeef ^ seed, 28 | h2 = 0x41c6ce57 ^ seed 29 | for (let i = 0, ch; i < str.length; i++) { 30 | ch = str.charCodeAt(i) 31 | h1 = Math.imul(h1 ^ ch, 2654435761) 32 | h2 = Math.imul(h2 ^ ch, 1597334677) 33 | } 34 | h1 = 35 | Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ 36 | Math.imul(h2 ^ (h2 >>> 13), 3266489909) 37 | h2 = 38 | Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ 39 | Math.imul(h1 ^ (h1 >>> 13), 3266489909) 40 | return 4294967296 * (2097151 & h2) + (h1 >>> 0) 41 | } 42 | 43 | export async function* asyncPool( 44 | concurrency: number, 45 | iterable: T[], 46 | iteratorFn: (item: T, arr: T[]) => any, 47 | ) { 48 | const executing = new Set>() 49 | async function consume() { 50 | const [promise, value] = await Promise.race(executing) 51 | executing.delete(promise) 52 | return value 53 | } 54 | for (const item of iterable) { 55 | // Wrap iteratorFn() in an async fn to ensure we get a promise. 56 | // Then expose such promise, so it's possible to later reference and 57 | // remove it from the executing pool. 58 | const promise = (async () => await iteratorFn(item, iterable))().then( 59 | (value) => [promise, value], 60 | ) 61 | executing.add(promise) 62 | if (executing.size >= concurrency) { 63 | yield await consume() 64 | } 65 | } 66 | while (executing.size) { 67 | yield await consume() 68 | } 69 | } 70 | 71 | export const camelcaseKey = (key: string) => 72 | key.replaceAll(/_(\w)/g, (_, c) => (c ? c.toUpperCase() : '')) 73 | 74 | export const camelcaseKeys = (obj: any) => { 75 | if (typeof obj !== 'object' || obj === null) { 76 | return obj 77 | } 78 | if (Array.isArray(obj)) { 79 | return obj.map(camelcaseKeys) 80 | } 81 | const n: any = {} 82 | Object.keys(obj).forEach((k) => { 83 | n[camelcaseKey(k)] = camelcaseKeys(obj[k]) 84 | }) 85 | return n 86 | } 87 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/validator.util.ts: -------------------------------------------------------------------------------- 1 | export function isDefined(value: T | undefined | null): value is T { 2 | return value !== undefined && value !== null 3 | } 4 | -------------------------------------------------------------------------------- /apps/core/src/shared/utils/zod.util.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export function makeOptionalPropsNullable( 4 | schema: Schema, 5 | ) { 6 | const entries = Object.entries(schema.shape) as [ 7 | keyof Schema['shape'], 8 | z.ZodTypeAny, 9 | ][] 10 | const newProps = entries.reduce( 11 | (acc, [key, value]) => { 12 | acc[key] = 13 | value instanceof z.ZodOptional 14 | ? value.unwrap().nullable() 15 | : value.optional() 16 | return acc 17 | }, 18 | {} as { 19 | [key in keyof Schema['shape']]: Schema['shape'][key] extends z.ZodOptional< 20 | infer T 21 | > 22 | ? z.ZodOptional 23 | : z.ZodOptional 24 | }, 25 | ) 26 | return z.object(newProps) 27 | } 28 | -------------------------------------------------------------------------------- /apps/core/src/transformers/get-req.transformer.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest } from 'fastify' 2 | 3 | import type { ExecutionContext } from '@nestjs/common' 4 | 5 | export function getNestExecutionContextRequest( 6 | context: ExecutionContext, 7 | ): FastifyRequest & { owner?: any } & Record { 8 | return context.switchToHttp().getRequest() 9 | } 10 | -------------------------------------------------------------------------------- /apps/core/src/utils/redis-sub-pub.util.ts: -------------------------------------------------------------------------------- 1 | import IORedis, { type Redis, type RedisOptions } from 'ioredis' 2 | 3 | import { REDIS } from '@core/app.config' 4 | import { Logger } from '@nestjs/common' 5 | 6 | import { isTest } from '../global/env.global' 7 | 8 | class RedisSubPub { 9 | public pubClient: Redis 10 | public subClient: Redis 11 | constructor(private channelPrefix: string = 'meta-channel#') { 12 | if (!isTest) { 13 | this.init() 14 | } 15 | } 16 | 17 | public init() { 18 | const redisOptions: RedisOptions = { 19 | host: REDIS.host, 20 | port: REDIS.port, 21 | } 22 | 23 | if (REDIS.password) { 24 | redisOptions.password = REDIS.password 25 | } 26 | 27 | const pubClient = new IORedis(redisOptions) 28 | const subClient = pubClient.duplicate() 29 | this.pubClient = pubClient 30 | this.subClient = subClient 31 | } 32 | 33 | public async publish(event: string, data: any) { 34 | const channel = this.channelPrefix + event 35 | const _data = JSON.stringify(data) 36 | if (event !== 'log') { 37 | Logger.debug(`发布事件:${channel} <- ${_data}`, RedisSubPub.name) 38 | } 39 | await this.pubClient.publish(channel, _data) 40 | } 41 | 42 | private ctc = new WeakMap() 43 | 44 | public async subscribe(event: string, callback: (data: any) => void) { 45 | const myChannel = this.channelPrefix + event 46 | this.subClient.subscribe(myChannel) 47 | 48 | const cb = (channel, message) => { 49 | if (channel === myChannel) { 50 | if (event !== 'log') { 51 | Logger.debug(`接收事件:${channel} -> ${message}`, RedisSubPub.name) 52 | } 53 | callback(JSON.parse(message)) 54 | } 55 | } 56 | 57 | this.ctc.set(callback, cb) 58 | this.subClient.on('message', cb) 59 | } 60 | 61 | public async unsubscribe(event: string, callback: (data: any) => void) { 62 | const channel = this.channelPrefix + event 63 | this.subClient.unsubscribe(channel) 64 | const cb = this.ctc.get(callback) 65 | if (cb) { 66 | this.subClient.off('message', cb) 67 | 68 | this.ctc.delete(callback) 69 | } 70 | } 71 | } 72 | 73 | export const redisSubPub = new RedisSubPub() 74 | 75 | type Callback = (channel: string, message: string) => void 76 | 77 | export type { RedisSubPub } 78 | -------------------------------------------------------------------------------- /apps/core/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "sourceMap": true 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2021", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "baseUrl": ".", 8 | "module": "CommonJS", 9 | "paths": { 10 | "@core": [ 11 | "./src" 12 | ], 13 | "@core/*": [ 14 | "./src/*" 15 | ], 16 | "@packages/utils": [ 17 | "../../packages/utils" 18 | ] 19 | }, 20 | "resolveJsonModule": true, 21 | "strictNullChecks": true, 22 | "noImplicitAny": false, 23 | "declaration": true, 24 | "outDir": "./dist", 25 | "removeComments": true, 26 | "sourceMap": true, 27 | "allowSyntheticDefaultImports": true, 28 | "esModuleInterop": true, 29 | "skipLibCheck": true 30 | }, 31 | "exclude": [ 32 | "dist", 33 | "tmp" 34 | ] 35 | } -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:3333 2 | -------------------------------------------------------------------------------- /apps/web/.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | import config from '@innei/prettier' 2 | 3 | export default { 4 | ...config, 5 | importOrderParserPlugins: ['importAssertions', 'typescript', 'jsx'], 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/cssAsPlugin.js: -------------------------------------------------------------------------------- 1 | // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856 2 | // cssAsPlugin.js 3 | const { readFileSync } = require('node:fs') 4 | const postcss = require('postcss') 5 | const postcssJs = require('postcss-js') 6 | 7 | require.extensions['.css'] = function (module, filename) { 8 | module.exports = ({ addBase, addComponents, addUtilities }) => { 9 | const css = readFileSync(filename, 'utf8') 10 | const root = postcss.parse(css) 11 | const jss = postcssJs.objectify(root) 12 | 13 | if ('@layer base' in jss) { 14 | addBase(jss['@layer base']) 15 | } 16 | if ('@layer components' in jss) { 17 | addComponents(jss['@layer components']) 18 | } 19 | if ('@layer utilities' in jss) { 20 | addUtilities(jss['@layer utilities']) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite App 8 | 9 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vite-react-tailwind-template", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://" 8 | }, 9 | "scripts": { 10 | "prepare": "simple-git-hooks", 11 | "dev": "vite", 12 | "build": "tsc && vite build", 13 | "serve": "vite preview", 14 | "format": "prettier --write \"src/**/*.ts\" ", 15 | "lint": "eslint --fix" 16 | }, 17 | "dependencies": { 18 | "@auth/core": "0.37.4", 19 | "@headlessui/react": "2.2.0", 20 | "@packages/drizzle": "workspace:*", 21 | "@radix-ui/react-avatar": "1.1.1", 22 | "@tanstack/react-query": "5.62.2", 23 | "clsx": "2.1.1", 24 | "framer-motion": "11.13.1", 25 | "immer": "10.1.1", 26 | "jotai": "2.10.3", 27 | "lodash-es": "4.17.21", 28 | "next-themes": "0.4.4", 29 | "ofetch": "1.4.1", 30 | "react": "19.0.0", 31 | "react-dom": "19.0.0", 32 | "react-router-dom": "7.0.2", 33 | "sonner": "1.7.0", 34 | "tailwind-merge": "2.5.5" 35 | }, 36 | "devDependencies": { 37 | "@egoist/tailwindcss-icons": "1.8.1", 38 | "@iconify-json/mingcute": "1.2.1", 39 | "@tailwindcss/container-queries": "0.1.1", 40 | "@tailwindcss/typography": "0.5.15", 41 | "@types/lodash-es": "4.17.12", 42 | "@types/node": "20.17.9", 43 | "@types/react": "18.3.14", 44 | "@types/react-dom": "18.3.2", 45 | "@vitejs/plugin-react": "^4.3.4", 46 | "autoprefixer": "10.4.20", 47 | "click-to-react-component": "1.1.2", 48 | "daisyui": "4.12.14", 49 | "postcss": "8.4.49", 50 | "postcss-import": "16.1.0", 51 | "postcss-js": "4.0.1", 52 | "tailwind-scrollbar": "3.1.0", 53 | "tailwind-variants": "0.3.0", 54 | "tailwindcss": "3.4.16", 55 | "tailwindcss-animated": "1.1.2", 56 | "vite": "6.0.3", 57 | "vite-plugin-checker": "0.8.0", 58 | "vite-tsconfig-paths": "5.1.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /apps/web/readme.md: -------------------------------------------------------------------------------- 1 | # Vite React Tailwind Template 2 | 3 | This template provide toolchain below: 4 | 5 | - Vite 6 | - React, ReactDOM 7 | - Biomejs 8 | - Prettier 9 | - Git Hook (simple-git-hook, Lint Staged) 10 | - TailwindCSS 3 11 | - daisyui 12 | - React Router DOM (auto generated routes) 13 | - Auth.js 14 | 15 | # Usage 16 | 17 | ```sh 18 | pnpm i 19 | pnpm dev 20 | ``` 21 | 22 | # Screenshot 23 | 24 | -------------------------------------------------------------------------------- /apps/web/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"] 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom' 2 | import type { FC } from 'react' 3 | 4 | import { useAppIsReady } from './atoms/app' 5 | import { RootProviders } from './providers/root-providers' 6 | 7 | export const App: FC = () => { 8 | return ( 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | const AppLayer = () => { 16 | const appIsReady = useAppIsReady() 17 | return appIsReady ? : 18 | } 19 | 20 | const AppSkeleton = () => { 21 | return null 22 | } 23 | // eslint-disable-next-line import/no-default-export 24 | export default App 25 | -------------------------------------------------------------------------------- /apps/web/src/api/session.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from '@auth/core/types' 2 | 3 | import { apiFetch } from '~/lib/api-fetch' 4 | 5 | export const getSession = async () => { 6 | return apiFetch('/auth/session') 7 | } 8 | -------------------------------------------------------------------------------- /apps/web/src/atoms/app.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai' 2 | 3 | import { createAtomHooks } from '~/lib/jotai' 4 | 5 | export const [, , useAppIsReady, , , setAppIsReady] = createAtomHooks( 6 | atom(false), 7 | ) 8 | -------------------------------------------------------------------------------- /apps/web/src/atoms/route.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react' 2 | import { atom, useAtomValue } from 'jotai' 3 | import { selectAtom } from 'jotai/utils' 4 | import type { Location, NavigateFunction, Params } from 'react-router-dom' 5 | 6 | import { createAtomHooks } from '~/lib/jotai' 7 | 8 | interface RouteAtom { 9 | params: Readonly> 10 | searchParams: URLSearchParams 11 | location: Location 12 | } 13 | 14 | export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks( 15 | atom({ 16 | params: {}, 17 | searchParams: new URLSearchParams(), 18 | location: { 19 | pathname: '', 20 | search: '', 21 | hash: '', 22 | state: null, 23 | key: '', 24 | }, 25 | }), 26 | ) 27 | 28 | const noop = [] 29 | export const useReadonlyRouteSelector = ( 30 | selector: (route: RouteAtom) => T, 31 | deps: any[] = noop, 32 | ): T => 33 | useAtomValue( 34 | useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps), 35 | ) 36 | 37 | // Vite HMR will create new router instance, but RouterProvider always stable 38 | 39 | const [, , , , navigate, setNavigate] = createAtomHooks( 40 | atom<{ fn: NavigateFunction | null }>({ fn() {} }), 41 | ) 42 | const getStableRouterNavigate = () => navigate().fn 43 | export { getStableRouterNavigate, setNavigate } 44 | -------------------------------------------------------------------------------- /apps/web/src/components/common/ErrorElement.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { isRouteErrorResponse, useRouteError } from 'react-router-dom' 3 | import { repository } from '@pkg' 4 | 5 | import { attachOpenInEditor } from '~/lib/dev' 6 | 7 | import { StyledButton } from '../ui' 8 | 9 | export function ErrorElement() { 10 | const error = useRouteError() 11 | const message = isRouteErrorResponse(error) 12 | ? `${error.status} ${error.statusText}` 13 | : error instanceof Error 14 | ? error.message 15 | : JSON.stringify(error) 16 | const stack = error instanceof Error ? error.stack : null 17 | 18 | useEffect(() => { 19 | console.error('Error handled by React Router default ErrorBoundary:', error) 20 | }, [error]) 21 | 22 | const reloadRef = useRef(false) 23 | if ( 24 | message.startsWith('Failed to fetch dynamically imported module') && 25 | window.sessionStorage.getItem('reload') !== '1' 26 | ) { 27 | if (reloadRef.current) return null 28 | window.sessionStorage.setItem('reload', '1') 29 | window.location.reload() 30 | reloadRef.current = true 31 | return null 32 | } 33 | 34 | return ( 35 |
36 |
37 |
38 | 39 |

40 | Sorry, the app has encountered an error 41 |

42 |
43 |

{message}

44 | {import.meta.env.DEV && stack ? ( 45 |
46 | {attachOpenInEditor(stack)} 47 |
48 | ) : null} 49 | 50 |

51 | The App has a temporary problem, click the button below to try reloading 52 | the app or another solution? 53 |

54 | 55 |
56 | (window.location.href = '/')}> 57 | Reload 58 | 59 |
60 | 61 |

62 | Still having this issue? Please give feedback in Github, thanks! 63 | 73 | Submit Issue 74 | 75 |

76 |
77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /apps/web/src/components/common/LoadRemixAsyncComponent.tsx: -------------------------------------------------------------------------------- 1 | import { createElement, useEffect, useState } from 'react' 2 | import type { FC, ReactNode } from 'react' 3 | 4 | import { LoadingCircle } from '../ui/loading' 5 | 6 | export const LoadRemixAsyncComponent: FC<{ 7 | loader: () => Promise 8 | Header: FC<{ loader: () => any; [key: string]: any }> 9 | }> = ({ loader, Header }) => { 10 | const [loading, setLoading] = useState(true) 11 | 12 | const [Component, setComponent] = useState<{ c: () => ReactNode }>({ 13 | c: () => null, 14 | }) 15 | 16 | useEffect(() => { 17 | let isUnmounted = false 18 | setLoading(true) 19 | loader() 20 | .then((module) => { 21 | if (!module.Component) { 22 | return 23 | } 24 | if (isUnmounted) return 25 | 26 | const { loader } = module 27 | setComponent({ 28 | c: () => ( 29 | <> 30 |
31 | 32 | 33 | ), 34 | }) 35 | }) 36 | .finally(() => { 37 | setLoading(false) 38 | }) 39 | return () => { 40 | isUnmounted = true 41 | } 42 | }, [Header, loader]) 43 | 44 | if (loading) { 45 | return ( 46 |
47 | 48 |
49 | ) 50 | } 51 | 52 | return createElement(Component.c) 53 | } 54 | -------------------------------------------------------------------------------- /apps/web/src/components/common/NotFound.tsx: -------------------------------------------------------------------------------- 1 | export const NotFound = () => ( 2 |
404 Not Found
3 | ) 4 | -------------------------------------------------------------------------------- /apps/web/src/components/common/ProviderComposer.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cloneElement } from 'react' 4 | import type { JSX } from 'react' 5 | 6 | export const ProviderComposer: Component<{ 7 | contexts: JSX.Element[] 8 | }> = ({ contexts, children }) => 9 | contexts.reduceRight( 10 | (kids: any, parent: any) => cloneElement(parent, { children: kids }), 11 | children, 12 | ) 13 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/button/MotionButton.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react' 2 | import { m } from 'framer-motion' 3 | import type { HTMLMotionProps } from 'framer-motion' 4 | 5 | export const MotionButtonBase = forwardRef< 6 | HTMLButtonElement, 7 | HTMLMotionProps<'button'> 8 | >(({ children, ...rest }, ref) => { 9 | return ( 10 | 18 | {children} 19 | 20 | ) 21 | }) 22 | 23 | MotionButtonBase.displayName = 'MotionButtonBase' 24 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/button/StyledButton.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom' 2 | import { clsx } from 'clsx' 3 | import { tv } from 'tailwind-variants' 4 | import type { FC, PropsWithChildren } from 'react' 5 | 6 | import { MotionButtonBase } from './MotionButton' 7 | 8 | const variantStyles = tv({ 9 | base: 'inline-flex select-none cursor-default items-center gap-2 justify-center rounded-lg py-2 px-3 text-sm outline-offset-2 transition active:transition-none', 10 | variants: { 11 | variant: { 12 | primary: clsx( 13 | 'bg-accent text-zinc-100', 14 | 'hover:contrast-[1.10] active:contrast-125', 15 | 'font-semibold', 16 | 'disabled:cursor-not-allowed disabled:bg-accent/40 disabled:opacity-80 disabled:dark:text-zinc-50', 17 | 'dark:text-neutral-800', 18 | ), 19 | secondary: clsx( 20 | 'group rounded-full bg-gradient-to-b from-zinc-50/50 to-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:from-zinc-900/50 dark:to-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20', 21 | 'disabled:cursor-not-allowed disabled:bg-gray-400 disabled:opacity-80 disabled:dark:bg-gray-800 disabled:dark:text-zinc-50', 22 | ), 23 | }, 24 | }, 25 | }) 26 | type NativeButtonProps = React.ButtonHTMLAttributes & { 27 | href?: string 28 | } 29 | type NativeLinkProps = React.AnchorHTMLAttributes 30 | type SharedProps = { 31 | variant?: 'primary' | 'secondary' 32 | className?: string 33 | isLoading?: boolean 34 | } 35 | type ButtonProps = SharedProps & (NativeButtonProps | NativeLinkProps) 36 | 37 | export const StyledButton: FC = ({ 38 | variant = 'primary', 39 | className, 40 | isLoading, 41 | href, 42 | 43 | ...props 44 | }) => { 45 | const Wrapper = isLoading ? LoadingButtonWrapper : 'div' 46 | return ( 47 | 48 | {href ? ( 49 | 57 | ) : ( 58 | 65 | )} 66 | 67 | ) 68 | } 69 | 70 | const LoadingButtonWrapper: FC = ({ children }) => { 71 | return ( 72 |
73 | {children} 74 | 75 |
76 |
77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './MotionButton' 2 | export * from './StyledButton' 3 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './button' 2 | export * from './sonner' 3 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/loading.tsx: -------------------------------------------------------------------------------- 1 | import { clsxm } from '~/lib/cn' 2 | 3 | interface LoadingCircleProps { 4 | size: 'small' | 'medium' | 'large' 5 | } 6 | 7 | const sizeMap = { 8 | small: 'text-md', 9 | medium: 'text-xl', 10 | large: 'text-3xl', 11 | } 12 | export const LoadingCircle: Component = ({ 13 | className, 14 | size, 15 | }) => ( 16 |
17 | 18 |
19 | ) 20 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster as Sonner } from 'sonner' 2 | 3 | type ToasterProps = React.ComponentProps 4 | 5 | const Toaster = ({ ...props }: ToasterProps) => ( 6 | 22 | ) 23 | 24 | export { Toaster } 25 | -------------------------------------------------------------------------------- /apps/web/src/framer-lazy-feature.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-default-export 2 | export { domMax as default } from 'framer-motion' 3 | -------------------------------------------------------------------------------- /apps/web/src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | export type Component

= FC 3 | 4 | export type ComponentType

= { 5 | className?: string 6 | } & PropsWithChildren & 7 | P 8 | export type Nullable = T | null | undefined 9 | 10 | export const APP_DEV_CWD: string 11 | export const APP_NAME: string 12 | 13 | interface ImportMetaEnv { 14 | VITE_API_URL: string 15 | } 16 | } 17 | 18 | export {} 19 | -------------------------------------------------------------------------------- /apps/web/src/hooks/biz/useSession.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query' 2 | import type { users } from '@packages/drizzle/schema' 3 | 4 | import { apiFetch } from '~/lib/api-fetch' 5 | 6 | export const useSession = () => { 7 | const { data } = useQuery({ 8 | queryKey: ['auth', 'session'], 9 | queryFn: async () => { 10 | return apiFetch('/users/me', { 11 | method: 'GET', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | }) 16 | }, 17 | }) 18 | 19 | return data 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useBizQuery' 2 | export * from './usePrevious' 3 | export * from './useRefValue' 4 | export * from './useTitle' 5 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/useBizQuery.ts: -------------------------------------------------------------------------------- 1 | import { useInfiniteQuery, useQuery } from '@tanstack/react-query' 2 | import type { 3 | InfiniteData, 4 | QueryKey, 5 | UseInfiniteQueryOptions, 6 | UseInfiniteQueryResult, 7 | UseQueryOptions, 8 | UseQueryResult, 9 | } from '@tanstack/react-query' 10 | import type { DefinedQuery } from '~/lib/defineQuery' 11 | import type { FetchError } from 'ofetch' 12 | 13 | // TODO split normal define query and infinite define query for better type checking 14 | export type SafeReturnType = T extends (...args: any[]) => infer R 15 | ? R 16 | : never 17 | 18 | export type CombinedObject = T & U 19 | export function useAuthQuery< 20 | TQuery extends DefinedQuery, 21 | TError = FetchError, 22 | TQueryFnData = Awaited>, 23 | TData = TQueryFnData, 24 | >( 25 | query: TQuery, 26 | options: Omit< 27 | UseQueryOptions, 28 | 'queryKey' | 'queryFn' 29 | > = {}, 30 | ): CombinedObject< 31 | UseQueryResult, 32 | { key: TQuery['key']; fn: TQuery['fn'] } 33 | > { 34 | // @ts-expect-error 35 | return Object.assign( 36 | {}, 37 | useQuery({ 38 | queryKey: query.key, 39 | queryFn: query.fn, 40 | enabled: options.enabled !== false, 41 | ...options, 42 | }), 43 | { 44 | key: query.key, 45 | fn: query.fn, 46 | }, 47 | ) 48 | } 49 | 50 | export function useAuthInfiniteQuery< 51 | T extends DefinedQuery, 52 | E = FetchError, 53 | FNR = Awaited>, 54 | R = FNR, 55 | >( 56 | query: T, 57 | options: Omit, 'queryKey' | 'queryFn'>, 58 | ): CombinedObject< 59 | UseInfiniteQueryResult, FetchError>, 60 | { key: T['key']; fn: T['fn'] } 61 | > { 62 | // @ts-expect-error 63 | return Object.assign( 64 | {}, 65 | // @ts-expect-error 66 | useInfiniteQuery({ 67 | queryFn: query.fn, 68 | queryKey: query.key, 69 | enabled: options.enabled !== false, 70 | ...options, 71 | }), 72 | { 73 | key: query.key, 74 | fn: query.fn, 75 | }, 76 | ) 77 | } 78 | 79 | /** 80 | * @deprecated use `useAuthQuery` instead 81 | */ 82 | export const useBizQuery = useAuthQuery 83 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/useInputComposition.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react' 2 | import type { CompositionEventHandler } from 'react' 3 | 4 | export const useInputComposition = ( 5 | props: Pick< 6 | | React.DetailedHTMLProps< 7 | React.InputHTMLAttributes, 8 | HTMLInputElement 9 | > 10 | | React.DetailedHTMLProps< 11 | React.TextareaHTMLAttributes, 12 | HTMLTextAreaElement 13 | >, 14 | 'onKeyDown' | 'onCompositionEnd' | 'onCompositionStart' 15 | >, 16 | ) => { 17 | const { onKeyDown, onCompositionStart, onCompositionEnd } = props 18 | 19 | const isCompositionRef = useRef(false) 20 | 21 | const handleCompositionStart: CompositionEventHandler = useCallback( 22 | (e) => { 23 | isCompositionRef.current = true 24 | onCompositionStart?.(e) 25 | }, 26 | [onCompositionStart], 27 | ) 28 | 29 | const handleCompositionEnd: CompositionEventHandler = useCallback( 30 | (e) => { 31 | isCompositionRef.current = false 32 | onCompositionEnd?.(e) 33 | }, 34 | [onCompositionEnd], 35 | ) 36 | 37 | const handleKeyDown: React.KeyboardEventHandler = useCallback( 38 | (e) => { 39 | onKeyDown?.(e) 40 | 41 | if (isCompositionRef.current) { 42 | e.stopPropagation() 43 | return 44 | } 45 | 46 | if (e.key === 'Escape') { 47 | e.currentTarget.blur() 48 | e.preventDefault() 49 | e.stopPropagation() 50 | } 51 | }, 52 | [onKeyDown], 53 | ) 54 | 55 | return { 56 | onCompositionEnd: handleCompositionEnd, 57 | onCompositionStart: handleCompositionStart, 58 | onKeyDown: handleKeyDown, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/useIsOnline.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | export const useIsOnline = () => { 4 | const [isOnline, setIsOnline] = useState(navigator.onLine) 5 | 6 | useEffect(() => { 7 | const handleOnline = () => setIsOnline(true) 8 | const handleOffline = () => setIsOnline(false) 9 | 10 | window.addEventListener('online', handleOnline) 11 | window.addEventListener('offline', handleOffline) 12 | 13 | return () => { 14 | window.removeEventListener('online', handleOnline) 15 | window.removeEventListener('offline', handleOffline) 16 | } 17 | }, []) 18 | 19 | return isOnline 20 | } 21 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export const usePrevious = (value: T): T | undefined => { 4 | const ref = useRef() 5 | useEffect(() => { 6 | ref.current = value 7 | }) 8 | return ref.current 9 | } 10 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/useRefValue.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from 'react' 2 | 3 | export const useRefValue = ( 4 | value: S, 5 | ): Readonly<{ 6 | current: Readonly 7 | }> => { 8 | const ref = useRef(value) 9 | 10 | useLayoutEffect(() => { 11 | ref.current = value 12 | }) 13 | return ref 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/hooks/common/useTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | const titleTemplate = `%s | ${APP_NAME}` 4 | export const useTitle = (title?: Nullable) => { 5 | const currentTitleRef = useRef(document.title) 6 | useEffect(() => { 7 | if (!title) return 8 | 9 | document.title = titleTemplate.replace('%s', title) 10 | return () => { 11 | document.title = currentTitleRef.current 12 | } 13 | }, [title]) 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/initialize.ts: -------------------------------------------------------------------------------- 1 | import { setAppIsReady } from './atoms/app' 2 | import { authConfigManager } from './lib/auth' 3 | 4 | export const initializeApp = () => { 5 | authConfigManager.setConfig({ 6 | basePath: '/auth', 7 | baseUrl: import.meta.env.VITE_API_URL, 8 | credentials: 'include', 9 | }) 10 | 11 | setAppIsReady(true) 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/src/lib/api-fetch.ts: -------------------------------------------------------------------------------- 1 | import { createFetch } from 'ofetch' 2 | 3 | import { getCsrfToken } from './auth' 4 | 5 | let csrfTokenPromise: Promise | null = null 6 | export const apiFetch = createFetch({ 7 | defaults: { 8 | baseURL: import.meta.env.VITE_API_URL, 9 | credentials: 'include', 10 | async onRequest({ options }) { 11 | if (!csrfTokenPromise) csrfTokenPromise = getCsrfToken() 12 | 13 | const csrfToken = await csrfTokenPromise 14 | if (options.method && options.method.toLowerCase() !== 'get') { 15 | if (typeof options.body === 'string') { 16 | options.body = JSON.parse(options.body) 17 | } 18 | if (!options.body) { 19 | options.body = {} 20 | } 21 | if (options.body instanceof FormData) { 22 | options.body.append('csrfToken', csrfToken) 23 | } else { 24 | ;(options.body as Record).csrfToken = csrfToken 25 | } 26 | } 27 | }, 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /apps/web/src/lib/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth' 2 | export * from './client' 3 | 4 | /** 5 | * Copy from `@hono/middleware/auth` 6 | */ 7 | -------------------------------------------------------------------------------- /apps/web/src/lib/cn.ts: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export const clsxm = (...args: any[]) => { 5 | return twMerge(clsx(args)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/src/lib/dev.tsx: -------------------------------------------------------------------------------- 1 | declare const APP_DEV_CWD: string 2 | export const attachOpenInEditor = (stack: string) => { 3 | const lines = stack.split('\n') 4 | return lines.map((line) => { 5 | // A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9) 6 | // Find the `localhost` part and open the file in the editor 7 | if (!line.includes('at ')) { 8 | return line 9 | } 10 | const match = line.match(/(http:\/\/localhost:\d+\/[^:]+):(\d+):(\d+)/) 11 | 12 | if (match) { 13 | const [o] = match 14 | 15 | // Find `@fs/` 16 | // Like: `http://localhost:5173/@fs/Users/innei/git/work/rss3/follow/node_modules/.vite/deps/chunk-RPCDYKBN.js?v=757920f2:11548:26` 17 | const realFsPath = o.split('@fs')[1] 18 | 19 | if (realFsPath) { 20 | return ( 21 | // Delete `v=` hash, like `v=757920f2` 22 |

30 | {line} 31 |
32 | ) 33 | } else { 34 | // at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9) 35 | const srcFsPath = o.split('/src')[1] 36 | 37 | if (srcFsPath) { 38 | const fs = srcFsPath.replace(/\?t=[\da-f]+/, '') 39 | 40 | return ( 41 |
49 | {line} 50 |
51 | ) 52 | } 53 | } 54 | } 55 | 56 | return line 57 | }) 58 | } 59 | // http://localhost:5173/src/App.tsx?t=1720527056591:41:9 60 | const openInEditor = (file: string) => { 61 | fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`) 62 | } 63 | -------------------------------------------------------------------------------- /apps/web/src/lib/jotai.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { createStore, useAtom, useAtomValue, useSetAtom } from 'jotai' 3 | import { selectAtom } from 'jotai/utils' 4 | import type { Atom, PrimitiveAtom } from 'jotai' 5 | 6 | export const jotaiStore = createStore() 7 | 8 | export const createAtomAccessor = (atom: PrimitiveAtom) => 9 | [ 10 | () => jotaiStore.get(atom), 11 | (value: T) => jotaiStore.set(atom, value), 12 | ] as const 13 | 14 | const options = { store: jotaiStore } 15 | /** 16 | * @param atom - jotai 17 | * @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set] 18 | */ 19 | export const createAtomHooks = (atom: PrimitiveAtom) => 20 | [ 21 | atom, 22 | () => useAtom(atom, options), 23 | () => useAtomValue(atom, options), 24 | () => useSetAtom(atom, options), 25 | ...createAtomAccessor(atom), 26 | ] as const 27 | 28 | export const createAtomSelector = (atom: Atom) => { 29 | const useHook = (selector: (a: T) => R, deps: any[] = []) => 30 | useAtomValue( 31 | selectAtom( 32 | atom, 33 | useCallback((a) => selector(a as T), deps), 34 | ), 35 | ) 36 | 37 | useHook.__atom = atom 38 | return useHook 39 | } 40 | -------------------------------------------------------------------------------- /apps/web/src/lib/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from '@tanstack/react-query' 2 | import { FetchError } from 'ofetch' 3 | 4 | const queryClient = new QueryClient({ 5 | defaultOptions: { 6 | queries: { 7 | gcTime: Infinity, 8 | retryDelay: 1000, 9 | retry(failureCount, error) { 10 | console.error(error) 11 | if (error instanceof FetchError && error.statusCode === undefined) { 12 | return false 13 | } 14 | 15 | return !!(3 - failureCount) 16 | }, 17 | // throwOnError: import.meta.env.DEV, 18 | }, 19 | }, 20 | }) 21 | 22 | export { queryClient } 23 | -------------------------------------------------------------------------------- /apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | 3 | import './styles/index.css' 4 | 5 | import { ClickToComponent } from 'click-to-react-component' 6 | import React from 'react' 7 | import { RouterProvider } from 'react-router-dom' 8 | 9 | import { initializeApp } from './initialize' 10 | import { router } from './router' 11 | 12 | initializeApp() 13 | const $container = document.querySelector('#root') as HTMLElement 14 | 15 | createRoot($container).render( 16 | 17 | 18 | 19 | , 20 | ) 21 | -------------------------------------------------------------------------------- /apps/web/src/modules/main-layout/MainLayoutHeader.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Menu, 3 | MenuButton, 4 | MenuItem, 5 | MenuItems, 6 | MenuSeparator, 7 | } from '@headlessui/react' 8 | import * as Avatar from '@radix-ui/react-avatar' 9 | 10 | import { useSession } from '~/hooks/biz/useSession' 11 | import { signOut } from '~/lib/auth' 12 | 13 | export const MainLayoutHeader = () => { 14 | const session = useSession() 15 | if (!session) return null 16 | return ( 17 |
18 |
19 | 20 |
21 | ) 22 | } 23 | 24 | const MainAvatar = () => { 25 | const session = useSession()! 26 | return ( 27 | 28 | 29 | 30 | 35 | 36 | {session.name?.charAt(0)} 37 | 38 | 39 | 40 | 45 | 46 |
47 | {session.name} 48 | 49 | 50 | 55 | 56 | {session.name?.charAt(0)} 57 | 58 | 59 |
60 |
61 | 62 | 63 | 70 | 71 |
72 |
73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /apps/web/src/pages/(auth)/login/index.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'react-router-dom' 2 | 3 | import { useSession } from '~/hooks/biz/useSession' 4 | import { signIn } from '~/lib/auth' 5 | 6 | export const Component = () => { 7 | const session = useSession() 8 | if (session) { 9 | redirect('/') 10 | return null 11 | } 12 | 13 | return ( 14 |
15 |

Login to App

16 | 17 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/pages/(main)/index.tsx: -------------------------------------------------------------------------------- 1 | export const Component = () => { 2 | return
3 | } 4 | -------------------------------------------------------------------------------- /apps/web/src/pages/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet, redirect } from 'react-router-dom' 2 | 3 | import { getSession } from '~/api/session' 4 | import { MainLayoutHeader } from '~/modules/main-layout/MainLayoutHeader' 5 | 6 | export const loader = async () => { 7 | const session = await getSession() 8 | 9 | if (!session) { 10 | return redirect('/login') 11 | } 12 | 13 | return null 14 | } 15 | export const Component: Component = () => { 16 | return ( 17 |
18 | 19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /apps/web/src/pages/(main)/posts/index.tsx: -------------------------------------------------------------------------------- 1 | export const Component = () => 'Post' 2 | -------------------------------------------------------------------------------- /apps/web/src/providers/root-providers.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from '@tanstack/react-query' 2 | import { LazyMotion, MotionConfig } from 'framer-motion' 3 | import { Provider } from 'jotai' 4 | import { ThemeProvider } from 'next-themes' 5 | import type { FC, PropsWithChildren } from 'react' 6 | 7 | import { Toaster } from '~/components/ui/sonner' 8 | import { jotaiStore } from '~/lib/jotai' 9 | import { queryClient } from '~/lib/query-client' 10 | 11 | import { StableRouterProvider } from './stable-router-provider' 12 | 13 | const loadFeatures = () => 14 | import('../framer-lazy-feature').then((res) => res.default) 15 | export const RootProviders: FC = ({ children }) => ( 16 | 17 | 24 | 25 | 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | -------------------------------------------------------------------------------- /apps/web/src/providers/stable-router-provider.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | import { 3 | useLocation, 4 | useNavigate, 5 | useParams, 6 | useSearchParams, 7 | } from 'react-router-dom' 8 | import type { NavigateFunction } from 'react-router-dom' 9 | 10 | import { setNavigate, setRoute } from '~/atoms/route' 11 | 12 | declare global { 13 | export const router: { 14 | navigate: NavigateFunction 15 | } 16 | interface Window { 17 | router: typeof router 18 | } 19 | } 20 | window.router = { 21 | navigate() {}, 22 | } 23 | 24 | /** 25 | * Why this. 26 | * Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render. 27 | * This provider is hold a empty component, to store the router hooks value. 28 | * And use our router hooks will not re-render the component when the router has any changes. 29 | * Also it can access values outside of the component and provide a value selector 30 | */ 31 | export const StableRouterProvider = () => { 32 | const [searchParams] = useSearchParams() 33 | const params = useParams() 34 | const nav = useNavigate() 35 | const location = useLocation() 36 | 37 | // NOTE: This is a hack to expose the navigate function to the window object, avoid to import `router` circular issue. 38 | useLayoutEffect(() => { 39 | window.router.navigate = nav 40 | 41 | setRoute({ 42 | params, 43 | searchParams, 44 | location, 45 | }) 46 | setNavigate({ fn: nav }) 47 | }, [searchParams, params, location, nav]) 48 | 49 | return null 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router-dom' 2 | 3 | import { App } from './App' 4 | import { ErrorElement } from './components/common/ErrorElement' 5 | import { NotFound } from './components/common/NotFound' 6 | import { buildGlobRoutes } from './lib/route-builder' 7 | 8 | const globTree = import.meta.glob('./pages/**/*.tsx') 9 | const tree = buildGlobRoutes(globTree) 10 | 11 | export const router = createBrowserRouter([ 12 | { 13 | path: '/', 14 | element: , 15 | children: tree, 16 | errorElement: , 17 | }, 18 | { 19 | path: '*', 20 | element: , 21 | }, 22 | ]) 23 | -------------------------------------------------------------------------------- /apps/web/src/store/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/innei-template/nest-drizzle-authjs/8ee77689cfcddbfbbda551873166b4553d6e2d23/apps/web/src/store/.gitkeep -------------------------------------------------------------------------------- /apps/web/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import './tailwind.css'; 2 | -------------------------------------------------------------------------------- /apps/web/src/styles/layer.css: -------------------------------------------------------------------------------- 1 | /* This CSS File do not import anywhere, just write atom class for tailwindcss. The tailwindcss intellisense will be work. */ 2 | 3 | @tailwind components; 4 | 5 | @layer components { 6 | .drag-region { 7 | -webkit-app-region: drag; 8 | } 9 | 10 | .no-drag-region { 11 | -webkit-app-region: no-drag; 12 | } 13 | .mask-squircle { 14 | mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMTAwIDBDMjAgMCAwIDIwIDAgMTAwczIwIDEwMCAxMDAgMTAwIDEwMC0yMCAxMDAtMTAwUzE4MCAwIDEwMCAwWicvPjwvc3ZnPg==); 15 | } 16 | .mask { 17 | mask-size: contain; 18 | mask-repeat: no-repeat; 19 | mask-position: center; 20 | } 21 | 22 | .center { 23 | @apply flex items-center justify-center; 24 | } 25 | 26 | .shadow-perfect { 27 | /* https://codepen.io/jh3y/pen/yLWgjpd */ 28 | --tint: 214; 29 | --alpha: 3; 30 | --base: hsl(var(--tint, 214) 80% 27% / calc(var(--alpha, 4) * 1%)); 31 | /** 32 | * Use relative syntax to get to: hsl(221 25% 22% / 40%) 33 | */ 34 | --shade: hsl(from var(--base) calc(h + 8) 25 calc(l - 5)); 35 | --perfect-shadow: 0 0 0 1px var(--base), 0 1px 1px -0.5px var(--shade), 36 | 0 3px 3px -1.5px var(--shade), 0 6px 6px -3px var(--shade), 37 | 0 12px 12px -6px var(--base), 0 24px 24px -12px var(--base); 38 | box-shadow: var(--perfect-shadow); 39 | } 40 | 41 | .perfect-sm { 42 | --alpha: 1; 43 | } 44 | 45 | .perfect-md { 46 | --alpha: 2; 47 | } 48 | 49 | [data-theme='dark'] .shadow-perfect { 50 | --tint: 221; 51 | } 52 | 53 | .shadow-modal { 54 | @apply shadow-2xl shadow-stone-300 dark:shadow-stone-900; 55 | } 56 | /* Utils */ 57 | .no-animation { 58 | --btn-focus-scale: 1; 59 | --animation-btn: 0; 60 | --animation-input: 0; 61 | } 62 | } 63 | 64 | /* Context menu */ 65 | @layer components { 66 | .shadow-context-menu { 67 | box-shadow: 68 | rgba(0, 0, 0, 0.067) 0px 3px 8px, 69 | rgba(0, 0, 0, 0.067) 0px 2px 5px, 70 | rgba(0, 0, 0, 0.067) 0px 1px 1px; 71 | } 72 | } 73 | 74 | @layer base { 75 | .border-border { 76 | border-width: 1px; 77 | border-color: #e5e7eb; 78 | } 79 | 80 | [data-theme='dark'] .border-border { 81 | border-color: #374151; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /apps/web/src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | html { 9 | font-size: 14px; 10 | line-height: 1.5; 11 | 12 | @apply font-sans; 13 | } 14 | 15 | html body { 16 | @apply max-w-screen overflow-x-hidden; 17 | } 18 | 19 | @media print { 20 | html { 21 | font-size: 12px; 22 | } 23 | } 24 | 25 | /* @media (min-width: 2160px) { 26 | html { 27 | font-size: 15px; 28 | } 29 | } */ 30 | 31 | .prose { 32 | max-width: 100% !important; 33 | font-size: 1.1rem; 34 | 35 | p { 36 | @apply break-words; 37 | } 38 | 39 | figure img { 40 | @apply mb-0 mt-0; 41 | } 42 | } 43 | 44 | *:focus { 45 | outline: none; 46 | } 47 | 48 | *:not(input):not(textarea):not([contenteditable='true']):focus-visible { 49 | outline: 0 !important; 50 | box-shadow: theme(colors.accent) 0px 0px 0px 1px; 51 | } 52 | * { 53 | tab-size: 2; 54 | 55 | &:hover { 56 | scrollbar-color: auto; 57 | } 58 | } 59 | 60 | .animate-ping { 61 | animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; 62 | } 63 | 64 | @keyframes ping { 65 | 75%, 66 | 100% { 67 | transform: scale(1.4); 68 | opacity: 0; 69 | } 70 | } 71 | 72 | /* input, 73 | textarea { 74 | font-size: max(16px, 1rem); 75 | } */ 76 | 77 | a { 78 | @apply break-all; 79 | } 80 | 81 | @screen lg { 82 | input, 83 | textarea { 84 | font-size: 1rem; 85 | } 86 | } 87 | 88 | .prose p:last-child { 89 | margin-bottom: 0; 90 | } 91 | 92 | .prose 93 | :where(blockquote):not(:where([class~='not-prose'], [class~='not-prose'] *)) { 94 | @apply relative border-0; 95 | 96 | &::before { 97 | content: ''; 98 | display: block; 99 | width: 3px; 100 | height: 100%; 101 | position: absolute; 102 | left: 0; 103 | top: 0; 104 | border-radius: 1em; 105 | background-color: theme(colors.accent); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /apps/web/src/styles/theme.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @layer base { 3 | } 4 | -------------------------------------------------------------------------------- /apps/web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import daisyui from 'daisyui' 2 | import { withTV } from 'tailwind-variants/transformer' 3 | import type { Config } from 'tailwindcss' 4 | 5 | import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons' 6 | import typography from '@tailwindcss/typography' 7 | 8 | require('./cssAsPlugin') 9 | 10 | const twConfig: Config = { 11 | content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], 12 | darkMode: ['class', '[data-theme="dark"]'], 13 | safelist: [], 14 | theme: { 15 | extend: { 16 | fontFamily: { 17 | sans: 'system-ui,-apple-system,PingFang SC,"Microsoft YaHei",Segoe UI,Roboto,Helvetica,noto sans sc,hiragino sans gb,"sans-serif",Apple Color Emoji,Segoe UI Emoji,Not Color Emoji', 18 | serif: 19 | '"Noto Serif CJK SC","Noto Serif SC",var(--font-serif),"Source Han Serif SC","Source Han Serif",source-han-serif-sc,SongTi SC,SimSum,"Hiragino Sans GB",system-ui,-apple-system,Segoe UI,Roboto,Helvetica,"Microsoft YaHei","WenQuanYi Micro Hei",sans-serif', 20 | mono: `"OperatorMonoSSmLig Nerd Font","Cascadia Code PL","FantasqueSansMono Nerd Font","operator mono",JetBrainsMono,"Fira code Retina","Fira code","Consolas", Monaco, "Hannotate SC", monospace, -apple-system`, 21 | }, 22 | screens: { 23 | 'light-mode': { raw: '(prefers-color-scheme: light)' }, 24 | 'dark-mode': { raw: '(prefers-color-scheme: dark)' }, 25 | 26 | 'w-screen': '100vw', 27 | 'h-screen': '100vh', 28 | }, 29 | maxWidth: { 30 | screen: '100vw', 31 | }, 32 | width: { 33 | screen: '100vw', 34 | }, 35 | height: { 36 | screen: '100vh', 37 | }, 38 | maxHeight: { 39 | screen: '100vh', 40 | }, 41 | 42 | colors: { 43 | themed: { 44 | bg_opacity: 'var(--bg-opacity)', 45 | }, 46 | }, 47 | }, 48 | }, 49 | 50 | daisyui: { 51 | logs: false, 52 | themes: [ 53 | { 54 | light: { 55 | 'color-scheme': 'light', 56 | primary: '#33A6B8', 57 | secondary: '#A8D8B9', 58 | accent: '#33A6B8', 59 | 'accent-content': '#fafafa', 60 | neutral: '#C7C7CC', 61 | 'base-100': '#fff', 62 | 'base-content': '#000', 63 | info: '#007AFF', 64 | success: '#34C759', 65 | warning: '#FF9500', 66 | error: '#FF3B30', 67 | '--rounded-btn': '1.9rem', 68 | '--tab-border': '2px', 69 | '--tab-radius': '.5rem', 70 | }, 71 | }, 72 | { 73 | dark: { 74 | 'color-scheme': 'dark', 75 | primary: '#F596AA', 76 | secondary: '#FB966E', 77 | accent: '#F596AA', 78 | neutral: '#48484A', 79 | 'base-100': '#1C1C1E', 80 | 'base-content': '#FFF', 81 | info: '#0A84FF', 82 | success: '#30D158', 83 | warning: '#FF9F0A', 84 | error: '#FF453A', 85 | '--rounded-btn': '1.9rem', 86 | '--tab-border': '2px', 87 | '--tab-radius': '.5rem', 88 | }, 89 | }, 90 | ], 91 | darkTheme: 'dark', 92 | }, 93 | 94 | plugins: [ 95 | iconsPlugin({ 96 | collections: { 97 | ...getIconCollections(['mingcute']), 98 | }, 99 | }), 100 | 101 | typography, 102 | daisyui, 103 | 104 | require('tailwind-scrollbar'), 105 | require('@tailwindcss/container-queries'), 106 | require('tailwindcss-animated'), 107 | 108 | require('./src/styles/theme.css'), 109 | require('./src/styles/layer.css'), 110 | ], 111 | } 112 | 113 | export default withTV(twConfig) 114 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "jsx": "preserve", 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ESNext" 9 | ], 10 | "module": "ESNext", 11 | "moduleResolution": "Node", 12 | "paths": { 13 | "~/*": [ 14 | "./src/*" 15 | ], 16 | "@pkg": [ 17 | "./package.json" 18 | ], 19 | "@core/*": [ 20 | "../core/src/*" 21 | ] 22 | }, 23 | "resolveJsonModule": true, 24 | "allowJs": false, 25 | "strict": true, 26 | "noImplicitAny": false, 27 | "noEmit": true, 28 | "allowSyntheticDefaultImports": true, 29 | "esModuleInterop": false, 30 | "forceConsistentCasingInFileNames": true, 31 | "isolatedModules": true, 32 | "skipDefaultLibCheck": true, 33 | "skipLibCheck": true, 34 | }, 35 | "include": [ 36 | "./src/**/*", 37 | ] 38 | } -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import reactRefresh from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import { checker } from 'vite-plugin-checker' 4 | import tsconfigPaths from 'vite-tsconfig-paths' 5 | 6 | import PKG from './package.json' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [ 11 | reactRefresh(), 12 | tsconfigPaths(), 13 | checker({ 14 | typescript: true, 15 | enableBuild: true, 16 | }), 17 | ], 18 | define: { 19 | APP_DEV_CWD: JSON.stringify(process.cwd()), 20 | APP_NAME: JSON.stringify(PKG.name), 21 | }, 22 | server: { 23 | port: 9000, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /docker-clean.sh: -------------------------------------------------------------------------------- 1 | #!sh 2 | 3 | rm -rf /root/.cache 4 | rm -rf /root/.local 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | container_name: meta-muse 6 | image: innei/nest-drizzle:latest 7 | command: /bin/sh -c "./node_modules/.bin/prisma migrate deploy; node apps/core/dist/main.js --redis_host=redis --allowed_origins=${ALLOWED_ORIGINS} --jwt_secret=${JWT_SECRET} --color --cluster" 8 | env_file: 9 | - .env 10 | environment: 11 | - TZ=Asia/Shanghai 12 | - NODE_ENV=production 13 | - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/nest-drizzle?schema=public 14 | restart: on-failure 15 | volumes: 16 | - ./data/nest:/root/.nest 17 | 18 | ports: 19 | - '3333:3333' 20 | depends_on: 21 | - postgres 22 | - redis 23 | links: 24 | - postgres 25 | - redis 26 | networks: 27 | - app-network 28 | 29 | postgres: 30 | image: postgres:16 31 | container_name: postgres 32 | restart: always 33 | ports: 34 | - '15432:5432' 35 | env_file: 36 | - .env 37 | volumes: 38 | - nest-postgres:/var/lib/postgresql/data 39 | environment: 40 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 41 | - POSTGRES_USER=${POSTGRES_USER} 42 | healthcheck: 43 | test: [CMD-SHELL, pg_isready -U meta-muse] 44 | interval: 30s 45 | timeout: 10s 46 | retries: 5 47 | networks: 48 | - app-network 49 | 50 | redis: 51 | image: redis 52 | container_name: redis 53 | 54 | ports: 55 | - '6560:6379' 56 | networks: 57 | - app-network 58 | networks: 59 | app-network: 60 | driver: bridge 61 | 62 | volumes: 63 | nest-postgres: 64 | name: nest-postgres-db 65 | -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as builder 2 | WORKDIR /app 3 | COPY . . 4 | RUN apk add git make g++ alpine-sdk python3 py3-pip unzip 5 | RUN npm i -g pnpm 6 | RUN pnpm install 7 | RUN npm run build 8 | 9 | FROM node:20-alpine 10 | RUN apk add zip unzip bash --no-cache 11 | RUN npm i -g pnpm 12 | WORKDIR /app 13 | COPY --from=builder /app/apps/core/dist apps/core/dist 14 | 15 | ENV NODE_ENV=production 16 | COPY package.json ./ 17 | COPY pnpm-lock.yaml ./ 18 | COPY pnpm-workspace.yaml ./ 19 | COPY apps ./apps/ 20 | COPY .npmrc ./ 21 | COPY external ./external/ 22 | 23 | RUN pnpm install --prod 24 | 25 | COPY docker-clean.sh ./ 26 | RUN sh docker-clean.sh 27 | 28 | ENV TZ=Asia/Shanghai 29 | EXPOSE 3333 30 | 31 | CMD ["pnpm", "-C apps/core run start:prod"] 32 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv-expand/config' 2 | 3 | import { defineConfig } from 'drizzle-kit' 4 | 5 | export default defineConfig({ 6 | dialect: 'postgresql', 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL!, 9 | }, 10 | schema: './drizzle/schema.ts', 11 | out: './drizzle', 12 | }) 13 | -------------------------------------------------------------------------------- /drizzle/0000_ancient_masque.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "account" ( 2 | "userId" text NOT NULL, 3 | "type" text NOT NULL, 4 | "provider" text NOT NULL, 5 | "providerAccountId" text NOT NULL, 6 | "refresh_token" text, 7 | "access_token" text, 8 | "expires_at" integer, 9 | "token_type" text, 10 | "scope" text, 11 | "id_token" text, 12 | "session_state" text, 13 | CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId") 14 | ); 15 | --> statement-breakpoint 16 | CREATE TABLE IF NOT EXISTS "authenticator" ( 17 | "credentialID" text NOT NULL, 18 | "userId" text NOT NULL, 19 | "providerAccountId" text NOT NULL, 20 | "credentialPublicKey" text NOT NULL, 21 | "counter" integer NOT NULL, 22 | "credentialDeviceType" text NOT NULL, 23 | "credentialBackedUp" boolean NOT NULL, 24 | "transports" text, 25 | CONSTRAINT "authenticator_userId_credentialID_pk" PRIMARY KEY("userId","credentialID"), 26 | CONSTRAINT "authenticator_credentialID_unique" UNIQUE("credentialID") 27 | ); 28 | --> statement-breakpoint 29 | CREATE TABLE IF NOT EXISTS "session" ( 30 | "sessionToken" text PRIMARY KEY NOT NULL, 31 | "userId" text NOT NULL, 32 | "expires" timestamp NOT NULL 33 | ); 34 | --> statement-breakpoint 35 | CREATE TABLE IF NOT EXISTS "user" ( 36 | "id" text PRIMARY KEY NOT NULL, 37 | "name" text, 38 | "email" text NOT NULL, 39 | "emailVerified" timestamp, 40 | "image" text 41 | ); 42 | --> statement-breakpoint 43 | CREATE TABLE IF NOT EXISTS "verificationToken" ( 44 | "identifier" text NOT NULL, 45 | "token" text NOT NULL, 46 | "expires" timestamp NOT NULL, 47 | CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token") 48 | ); 49 | --> statement-breakpoint 50 | DO $$ BEGIN 51 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 52 | EXCEPTION 53 | WHEN duplicate_object THEN null; 54 | END $$; 55 | --> statement-breakpoint 56 | DO $$ BEGIN 57 | ALTER TABLE "authenticator" ADD CONSTRAINT "authenticator_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 58 | EXCEPTION 59 | WHEN duplicate_object THEN null; 60 | END $$; 61 | --> statement-breakpoint 62 | DO $$ BEGIN 63 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; 64 | EXCEPTION 65 | WHEN duplicate_object THEN null; 66 | END $$; 67 | -------------------------------------------------------------------------------- /drizzle/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { drizzle } from 'drizzle-orm/postgres-js' 3 | import { migrate } from 'drizzle-orm/postgres-js/migrator' 4 | import postgres from 'postgres' 5 | 6 | import * as schema from './schema' 7 | import type { DrizzleConfig } from 'drizzle-orm' 8 | 9 | export const createDrizzle = ( 10 | url: string, 11 | options: Omit, 12 | ) => { 13 | const client = postgres(url, {}) 14 | return drizzle(client, { 15 | schema, 16 | ...options, 17 | }) 18 | } 19 | 20 | export const migrateDb = async (url: string) => { 21 | const migrationConnection = postgres(url, { max: 1 }) 22 | 23 | await migrate(drizzle(migrationConnection), { 24 | migrationsFolder: resolve(__dirname, '.'), 25 | }) 26 | } 27 | 28 | export { schema } 29 | 30 | export * from 'drizzle-orm' 31 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1720777211903, 9 | "tag": "0000_ancient_masque", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /drizzle/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@packages/drizzle", 3 | "private": true, 4 | "description": "", 5 | "license": "MIT", 6 | "author": "Innei ", 7 | "main": "./index.js", 8 | "exports": { 9 | ".": "./index.js", 10 | "./schema": "./schema.js" 11 | }, 12 | "scripts": { 13 | "build": "tsc" 14 | }, 15 | "dependencies": { 16 | "@packages/compiled": "workspace:*", 17 | "@packages/utils": "workspace:*", 18 | "drizzle-kit": "0.29.1", 19 | "drizzle-orm": "0.37.0", 20 | "pg": "8.13.1", 21 | "postgres": "3.4.5" 22 | }, 23 | "devDependencies": { 24 | "typescript": "^5.7.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /drizzle/schema.ts: -------------------------------------------------------------------------------- 1 | import { snowflake } from '@packages/utils/snowflake' 2 | 3 | import { 4 | boolean, 5 | integer, 6 | pgTable, 7 | primaryKey, 8 | text, 9 | timestamp, 10 | } from 'drizzle-orm/pg-core' 11 | import type { AdapterAccountType } from '@packages/compiled' 12 | 13 | export const users = pgTable('user', { 14 | id: text('id') 15 | .primaryKey() 16 | .$defaultFn(() => snowflake.nextId().toString()), 17 | name: text('name'), 18 | email: text('email').notNull(), 19 | emailVerified: timestamp('emailVerified', { mode: 'date' }), 20 | image: text('image'), 21 | 22 | handle: text('handle'), 23 | }) 24 | 25 | export const accounts = pgTable( 26 | 'account', 27 | { 28 | userId: text('userId') 29 | .notNull() 30 | .references(() => users.id, { onDelete: 'cascade' }), 31 | type: text('type').$type().notNull(), 32 | provider: text('provider').notNull(), 33 | providerAccountId: text('providerAccountId').notNull(), 34 | refresh_token: text('refresh_token'), 35 | access_token: text('access_token'), 36 | expires_at: integer('expires_at'), 37 | token_type: text('token_type'), 38 | scope: text('scope'), 39 | id_token: text('id_token'), 40 | session_state: text('session_state'), 41 | }, 42 | (account) => ({ 43 | compoundKey: primaryKey({ 44 | columns: [account.provider, account.providerAccountId], 45 | }), 46 | }), 47 | ) 48 | 49 | export const sessions = pgTable('session', { 50 | sessionToken: text('sessionToken').primaryKey(), 51 | userId: text('userId') 52 | .notNull() 53 | .references(() => users.id, { onDelete: 'cascade' }), 54 | expires: timestamp('expires', { mode: 'date' }).notNull(), 55 | }) 56 | 57 | export const verificationTokens = pgTable( 58 | 'verificationToken', 59 | { 60 | identifier: text('identifier').notNull(), 61 | token: text('token').notNull(), 62 | expires: timestamp('expires', { mode: 'date' }).notNull(), 63 | }, 64 | (verificationToken) => ({ 65 | compositePk: primaryKey({ 66 | columns: [verificationToken.identifier, verificationToken.token], 67 | }), 68 | }), 69 | ) 70 | 71 | export const authenticators = pgTable( 72 | 'authenticator', 73 | { 74 | credentialID: text('credentialID').notNull().unique(), 75 | userId: text('userId') 76 | .notNull() 77 | .references(() => users.id, { onDelete: 'cascade' }), 78 | providerAccountId: text('providerAccountId').notNull(), 79 | credentialPublicKey: text('credentialPublicKey').notNull(), 80 | counter: integer('counter').notNull(), 81 | credentialDeviceType: text('credentialDeviceType').notNull(), 82 | credentialBackedUp: boolean('credentialBackedUp').notNull(), 83 | transports: text('transports'), 84 | }, 85 | (authenticator) => ({ 86 | compositePK: primaryKey({ 87 | columns: [authenticator.userId, authenticator.credentialID], 88 | }), 89 | }), 90 | ) 91 | -------------------------------------------------------------------------------- /drizzle/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2021", 5 | "experimentalDecorators": true, 6 | "module": "CommonJS", 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "declaration": false, 10 | "outDir": ".", 11 | "removeComments": true, 12 | "sourceMap": false, 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | }, 17 | "exclude": [ 18 | "dist", 19 | ] 20 | } -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | const { cpus } = require('node:os') 2 | const { execSync } = require('node:child_process') 3 | const nodePath = execSync(`npm root --quiet -g`, { encoding: 'utf-8' }).split( 4 | '\n', 5 | )[0] 6 | 7 | const cpuLen = cpus().length 8 | module.exports = { 9 | apps: [ 10 | { 11 | name: 'mx-server', 12 | script: 'index.js', 13 | autorestart: true, 14 | exec_mode: 'cluster', 15 | watch: false, 16 | instances: Math.min(2, cpuLen), 17 | max_memory_restart: '230M', 18 | args: '--color', 19 | env: { 20 | NODE_ENV: 'production', 21 | NODE_PATH: nodePath, 22 | }, 23 | }, 24 | ], 25 | } 26 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import react from '@eslint-react/eslint-plugin' 2 | import { sxzz } from '@sxzz/eslint-config' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | 5 | export default sxzz( 6 | [ 7 | { 8 | files: ['apps/web/**/*.{ts,tsx}'], 9 | ...react.configs.recommended, 10 | }, 11 | { 12 | languageOptions: { 13 | parserOptions: { 14 | ecmaFeatures: { 15 | jsx: true, 16 | }, 17 | }, 18 | }, 19 | files: ['apps/web/**/*.{ts,tsx}'], 20 | plugins: { 21 | 'react-hooks': reactHooks, 22 | }, 23 | rules: { 24 | 'react-hooks/rules-of-hooks': 'error', 25 | 'react-hooks/exhaustive-deps': 'warn', 26 | }, 27 | }, 28 | { 29 | files: ['apps/core/src/**/*.{ts,tsx}'], 30 | languageOptions: { 31 | parserOptions: { 32 | emitDecoratorMetadata: true, 33 | experimentalDecorators: true, 34 | }, 35 | }, 36 | rules: { 37 | '@typescript-eslint/no-var-requires': 0, 38 | }, 39 | }, 40 | { 41 | ignores: [ 42 | 'external/**/*', 43 | 'test/**/*.{ts,tsx}', 44 | 'drizzle/**/*.js', 45 | 'drizzle/meta/**', 46 | 'drizzle.config.ts', 47 | 'apps/web/cssAsPlugin.js', 48 | ], 49 | }, 50 | { 51 | files: [ 52 | 'packages/**/*.{ts,tsx}', 53 | 'drizzle/**/*.{ts,tsx}', 54 | 'apps/**/*.{ts,tsx}', 55 | ], 56 | 57 | rules: { 58 | 'import/order': 'off', 59 | 'import/first': 'error', 60 | 'import/newline-after-import': 'error', 61 | 'import/no-duplicates': 'error', 62 | 63 | eqeqeq: 'off', 64 | 65 | 'no-void': 0, 66 | '@typescript-eslint/consistent-type-imports': 'warn', 67 | '@typescript-eslint/consistent-type-assertions': 0, 68 | 'no-restricted-syntax': 0, 69 | 'unicorn/filename-case': 0, 70 | 'unicorn/prefer-math-trunc': 0, 71 | 72 | 'unused-imports/no-unused-imports': 'error', 73 | 74 | 'unused-imports/no-unused-vars': [ 75 | 'error', 76 | { 77 | vars: 'all', 78 | varsIgnorePattern: '^_', 79 | args: 'after-used', 80 | argsIgnorePattern: '^_', 81 | ignoreRestSiblings: true, 82 | }, 83 | ], 84 | 85 | // for node server runtime 86 | 'require-await': 0, 87 | 'unicorn/no-array-callback-reference': 0, 88 | 89 | 'node/prefer-global/process': 0, 90 | 'node/prefer-global/buffer': 'off', 91 | 'no-duplicate-imports': 'off', 92 | 'unicorn/explicit-length-check': 0, 93 | 'unicorn/prefer-top-level-await': 0, 94 | // readable push syntax 95 | 'unicorn/no-array-push-push': 0, 96 | 'unicorn/custom-error-definition': 0, 97 | }, 98 | }, 99 | ], 100 | { 101 | prettier: true, 102 | markdown: true, 103 | vue: false, 104 | unocss: false, 105 | }, 106 | ) 107 | -------------------------------------------------------------------------------- /external/pino/index.js: -------------------------------------------------------------------------------- 1 | // why this, because we dont need pino logger, and this logger can not bundle whole package into only one file with ncc. 2 | // only work with fastify v4+ with pino v8+ 3 | 4 | module.exports = { 5 | symbols: { 6 | // https://github.com/pinojs/pino/blob/master/lib/symbols.js 7 | serializersSym: Symbol.for('pino.serializers'), 8 | }, 9 | stdSerializers: { 10 | error: function asErrValue(err) { 11 | const obj = { 12 | type: err.constructor.name, 13 | msg: err.message, 14 | stack: err.stack, 15 | } 16 | for (const key in err) { 17 | if (obj[key] === undefined) { 18 | obj[key] = err[key] 19 | } 20 | } 21 | return obj 22 | }, 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /external/pino/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pino", 3 | "main": "./index.js" 4 | } 5 | -------------------------------------------------------------------------------- /external/readme.md: -------------------------------------------------------------------------------- 1 | This is folder used to override dependencies. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-drizzle", 3 | "private": true, 4 | "packageManager": "pnpm@9.14.4", 5 | "description": "", 6 | "type": "module", 7 | "license": "MIT", 8 | "author": "Innei ", 9 | "scripts": { 10 | "build": "npm run build:packages && pnpm -C \"apps/core\" run build", 11 | "build:packages": "sh ./scripts/pre-build.sh", 12 | "db:generate": "drizzle-kit generate", 13 | "db:migrate": "drizzle-kit migrate", 14 | "db:studio": "drizzle-kit studio", 15 | "dev": "pnpm -C \"apps/core\" run start", 16 | "dev:web": "pnpm -C \"apps/web\" run dev", 17 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 18 | "lint": "eslint --fix", 19 | "prebuild": "rimraf dist", 20 | "predev": "npm run build:packages", 21 | "prepare": "if [ \"$NODE_ENV\" = \"production\" ]; then echo 'skip prepare in production' ;else corepack enable && simple-git-hooks; fi", 22 | "pretest": "npm run predev", 23 | "test": "pnpm -C \"test\" run test" 24 | }, 25 | "dependencies": { 26 | "cross-env": "7.0.3", 27 | "lodash": "4.17.21" 28 | }, 29 | "devDependencies": { 30 | "@eslint-react/eslint-plugin": "1.17.3", 31 | "@innei/bump-version": "^1.5.10", 32 | "@innei/prettier": "^0.15.0", 33 | "@nestjs/cli": "10.4.8", 34 | "@nestjs/schematics": "10.2.3", 35 | "@sxzz/eslint-config": "4.5.1", 36 | "concurrently": "9.1.0", 37 | "dotenv-cli": "7.4.4", 38 | "drizzle-kit": "0.29.1", 39 | "eslint": "9.16.0", 40 | "eslint-plugin-react-hooks": "5.1.0", 41 | "eslint-plugin-simple-import-sort": "^12.1.1", 42 | "fastify": "^4.29.0", 43 | "husky": "9.1.7", 44 | "lint-staged": "15.2.10", 45 | "prettier": "3.4.2", 46 | "rimraf": "6.0.1", 47 | "simple-git-hooks": "2.11.1", 48 | "ts-loader": "9.5.1", 49 | "tsconfig-paths": "4.2.0", 50 | "tsup": "8.3.5", 51 | "tsx": "4.19.2", 52 | "typescript": "^5.7.2", 53 | "zx": "8.2.4" 54 | }, 55 | "resolutions": { 56 | "*>lodash": "4.17.21", 57 | "*>typescript": "^5.2.2", 58 | "pino": "./external/pino" 59 | }, 60 | "simple-git-hooks": { 61 | "pre-commit": "pnpm exec lint-staged" 62 | }, 63 | "lint-staged": { 64 | "*.{js,jsx,ts,tsx}": [ 65 | "prettier --ignore-path ./.prettierignore --write " 66 | ], 67 | "*.{js,ts,cjs,mjs,jsx,tsx,json}": [ 68 | "eslint --fix" 69 | ] 70 | }, 71 | "bump": { 72 | "before": [ 73 | "git pull --rebase" 74 | ] 75 | }, 76 | "redisMemoryServer": { 77 | "downloadDir": "./tmp/redis/binaries", 78 | "version": "6.0.10", 79 | "disablePostinstall": "1", 80 | "systemBinary": "/opt/homebrew/bin/redis-server" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/compiled/index.ts: -------------------------------------------------------------------------------- 1 | import * as AuthCore from '@auth/core' 2 | import * as AuthCoreAdapters from '@auth/core/adapters' 3 | 4 | import * as AuthCoreErrors from '@auth/core/errors' 5 | import * as AuthCoreGithub from '@auth/core/providers/github' 6 | import * as AuthCoreGoogle from '@auth/core/providers/google' 7 | 8 | export const authjs = { 9 | ...AuthCore, 10 | ...AuthCoreAdapters, 11 | 12 | ...AuthCoreErrors, 13 | providers: { 14 | google: AuthCoreGoogle.default, 15 | github: AuthCoreGithub.default, 16 | }, 17 | } 18 | 19 | export type * from '@auth/core/errors' 20 | export type * from '@auth/core/types' 21 | export type * from '@auth/core/providers/google' 22 | export type * from '@auth/core/providers/github' 23 | export * from '@auth/core' 24 | 25 | export type * from '@auth/core/adapters' 26 | 27 | export { DrizzleAdapter } from '@auth/drizzle-adapter' 28 | -------------------------------------------------------------------------------- /packages/compiled/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@packages/compiled", 3 | "private": true, 4 | "description": "", 5 | "type": "module", 6 | "license": "MIT", 7 | "author": "Innei ", 8 | "main": "dist/index.cjs", 9 | "exports": { 10 | ".": { 11 | "require": "./dist/index.cjs" 12 | } 13 | }, 14 | "scripts": { 15 | "build": "tsup" 16 | }, 17 | "devDependencies": { 18 | "@auth/core": "0.37.4", 19 | "@auth/drizzle-adapter": "1.7.4", 20 | "typescript": "^5.7.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/compiled/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "experimentalDecorators": true, 5 | "module": "CommonJS", 6 | "strict": true, 7 | "noImplicitAny": false, 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "removeComments": true, 11 | "sourceMap": false, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | }, 16 | "exclude": [ 17 | "dist", 18 | ] 19 | } -------------------------------------------------------------------------------- /packages/compiled/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | target: 'es2020', 6 | entry: ['index.ts'], 7 | dts: true, 8 | format: ['cjs'], 9 | treeshake: true, 10 | splitting: true, 11 | external: ['drizzle-orm'], 12 | }) 13 | -------------------------------------------------------------------------------- /packages/utils/_.ts: -------------------------------------------------------------------------------- 1 | export * from 'es-toolkit' 2 | -------------------------------------------------------------------------------- /packages/utils/id.ts: -------------------------------------------------------------------------------- 1 | export * from 'nanoid' 2 | -------------------------------------------------------------------------------- /packages/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './snowflake' 2 | export * from './id' 3 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@packages/utils", 3 | "private": true, 4 | "description": "", 5 | "type": "module", 6 | "license": "MIT", 7 | "author": "Innei ", 8 | "main": "dist/index.cjs", 9 | "module": "dist/index.js", 10 | "exports": { 11 | ".": { 12 | "require": "./dist/index.cjs", 13 | "import": "./dist/index.js" 14 | }, 15 | "./_": { 16 | "require": "./dist/_.cjs", 17 | "import": "./dist/_.js" 18 | }, 19 | "./snowflake": { 20 | "require": "./dist/snowflake.cjs", 21 | "import": "./dist/snowflake.js" 22 | }, 23 | "./nanoid": { 24 | "require": "./dist/nanoid.cjs", 25 | "import": "./dist/nanoid.js" 26 | } 27 | }, 28 | "scripts": { 29 | "build": "tsup" 30 | }, 31 | "devDependencies": { 32 | "es-toolkit": "^1.29.0", 33 | "nanoid": "5.0.9", 34 | "typescript": "^5.7.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/utils/snowflake.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os' 2 | 3 | class Snowflake { 4 | private static readonly epoch = 1617235200000n // 自定义起始时间(以毫秒为单位) 5 | private static readonly workerIdBits = 5n 6 | private static readonly datacenterIdBits = 5n 7 | private static readonly sequenceBits = 12n 8 | 9 | private static readonly maxWorkerId = (1n << Snowflake.workerIdBits) - 1n 10 | private static readonly maxDatacenterId = 11 | (1n << Snowflake.datacenterIdBits) - 1n 12 | private static readonly maxSequence = (1n << Snowflake.sequenceBits) - 1n 13 | 14 | private static readonly workerIdShift = Snowflake.sequenceBits 15 | private static readonly datacenterIdShift = 16 | Snowflake.sequenceBits + Snowflake.workerIdBits 17 | private static readonly timestampLeftShift = 18 | Snowflake.sequenceBits + Snowflake.workerIdBits + Snowflake.datacenterIdBits 19 | 20 | private workerId: bigint 21 | private datacenterId: bigint 22 | private sequence: bigint = 0n 23 | private lastTimestamp: bigint = -1n 24 | 25 | constructor(workerId: bigint, datacenterId: bigint) { 26 | if (workerId > Snowflake.maxWorkerId || workerId < 0n) { 27 | throw new Error( 28 | `worker Id can't be greater than ${Snowflake.maxWorkerId} or less than 0`, 29 | ) 30 | } 31 | if (datacenterId > Snowflake.maxDatacenterId || datacenterId < 0n) { 32 | throw new Error( 33 | `datacenter Id can't be greater than ${Snowflake.maxDatacenterId} or less than 0`, 34 | ) 35 | } 36 | this.workerId = workerId 37 | this.datacenterId = datacenterId 38 | } 39 | 40 | private timeGen(): bigint { 41 | return BigInt(Date.now()) 42 | } 43 | 44 | private tilNextMillis(lastTimestamp: bigint): bigint { 45 | let timestamp = this.timeGen() 46 | while (timestamp <= lastTimestamp) { 47 | timestamp = this.timeGen() 48 | } 49 | return timestamp 50 | } 51 | 52 | public nextId(): string { 53 | let timestamp = this.timeGen() 54 | 55 | if (timestamp < this.lastTimestamp) { 56 | throw new Error( 57 | `Clock moved backwards. Refusing to generate id for ${ 58 | this.lastTimestamp - timestamp 59 | } milliseconds`, 60 | ) 61 | } 62 | 63 | if (this.lastTimestamp === timestamp) { 64 | this.sequence = (this.sequence + 1n) & Snowflake.maxSequence 65 | if (this.sequence === 0n) { 66 | timestamp = this.tilNextMillis(this.lastTimestamp) 67 | } 68 | } else { 69 | this.sequence = 0n 70 | } 71 | 72 | this.lastTimestamp = timestamp 73 | 74 | return ( 75 | ((timestamp - Snowflake.epoch) << Snowflake.timestampLeftShift) | 76 | (this.datacenterId << Snowflake.datacenterIdShift) | 77 | (this.workerId << Snowflake.workerIdShift) | 78 | this.sequence 79 | ).toString() 80 | } 81 | } 82 | 83 | function getWorkerAndDatacenterId(): [number, number] { 84 | const interfaces = os.networkInterfaces() 85 | const addresses: string[] = [] 86 | 87 | for (const k in interfaces) { 88 | for (const k2 in interfaces[k]) { 89 | const address = interfaces[k]?.[k2] 90 | if (address.family === 'IPv4' && !address.internal) { 91 | addresses.push(address.address) 92 | } 93 | } 94 | } 95 | 96 | // 取第一个非内部 IPv4 地址作为示例 97 | const ip = addresses[0] 98 | 99 | // 将 IP 地址转换为一个数字,然后提取低 5 位作为 workerId,高 5 位作为 datacenterId 100 | const ipParts = ip.split('.').map((part) => Number.parseInt(part, 10)) 101 | const ipNumber = 102 | (ipParts[0] << 24) + (ipParts[1] << 16) + (ipParts[2] << 8) + ipParts[3] 103 | const workerId = ipNumber & 0x1f // 取低 5 位 104 | const datacenterId = (ipNumber >> 5) & 0x1f // 取接下来的 5 位 105 | 106 | return [workerId, datacenterId] 107 | } 108 | 109 | // 使用这个函数来初始化 Snowflake 110 | const [workerId, datacenterId] = getWorkerAndDatacenterId() 111 | 112 | export const snowflake = new Snowflake(BigInt(workerId), BigInt(datacenterId)) 113 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "experimentalDecorators": true, 5 | "module": "CommonJS", 6 | "strict": true, 7 | "noImplicitAny": false, 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "removeComments": true, 11 | "sourceMap": false, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | }, 16 | "exclude": [ 17 | "dist", 18 | ] 19 | } -------------------------------------------------------------------------------- /packages/utils/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | clean: true, 5 | target: 'es2020', 6 | entry: ['index.ts', '_.ts', 'id.ts', 'snowflake.ts'], 7 | dts: true, 8 | format: ['cjs', 'esm'], 9 | }) 10 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - apps/* 4 | - test 5 | - drizzle 6 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Nest Drizzle + Auth.js 2 | 3 | A Simple Nest.js Template Using Drizzle + Postgres, Auth.js. 4 | 5 | ## Demo 6 | ![CleanShot 2024-07-14 at 10  07 03](https://github.com/user-attachments/assets/7f545e8e-b5f5-4350-91f5-b0852cbc6f53) 7 | 8 | 9 | ## Getting Started 10 | 11 | Clone this project. Install the dependencies using pnpm. Copy the example environment variables. 12 | 13 | ```sh 14 | git clone https://github.com/innei-template/nest-drizzle.git 15 | cp .env.template .env 16 | pnpm i 17 | ``` 18 | 19 | ## Configure Auth.js 20 | 21 | The configuration is located at `/apps/core/src/modules/auth/auth.config.ts` Please change your desired Provider here, GitHub OAuth is used by default. 22 | 23 | `AUTH_SECRET` is a 64bit hash string, you can generate by this command. 24 | 25 | ``` 26 | openssl rand -hex 32 27 | ``` 28 | 29 | ### License 30 | 31 | 2024 © Innei, Released under the MIT License. 32 | 33 | > [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/) 34 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:allNonMajor", 5 | ":automergePatch", 6 | ":automergeTesters", 7 | ":automergeLinters", 8 | ":rebaseStalePrs" 9 | ], 10 | "labels": ["dependencies"], 11 | "rangeStrategy": "bump", 12 | "packageRules": [ 13 | { 14 | "updateTypes": ["major"], 15 | "labels": ["UPDATE-MAJOR"] 16 | } 17 | ], 18 | "ignoreDeps": ["nanoid", "chalk", "consola"] 19 | } 20 | -------------------------------------------------------------------------------- /scripts/pre-build.sh: -------------------------------------------------------------------------------- 1 | concurrently 'pnpm -C "packages/utils" run build' 'pnpm -C "packages/compiled" run build' 'pnpm -C "drizzle" run build' 2 | -------------------------------------------------------------------------------- /scripts/workflow/test-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MAX_RETRIES=20 4 | # Try running the docker and get the output 5 | # then try getting homepage in 3 mins 6 | 7 | docker -v 8 | 9 | if [[ $? -ne 0 ]]; then 10 | echo "failed to run docker" 11 | exit 1 12 | fi 13 | 14 | docker-compose -v 15 | 16 | if [[ $? -ne 0 ]]; then 17 | echo "failed to run docker-compose" 18 | exit 1 19 | fi 20 | 21 | curl https://cdn.jsdelivr.net/gh/Innei/meta-muse-template@master/docker-compose.yml >docker-compose.yml 22 | 23 | docker-compose up -d 24 | 25 | if [[ $? -ne 0 ]]; then 26 | echo "failed to run docker-compose instance" 27 | exit 1 28 | fi 29 | 30 | RETRY=0 31 | 32 | do_request() { 33 | curl -f -m 10 localhost:3333/ping -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36' 34 | 35 | } 36 | 37 | do_request 38 | 39 | while [[ $? -ne 0 ]] && [[ $RETRY -lt $MAX_RETRIES ]]; do 40 | sleep 5 41 | ((RETRY++)) 42 | echo -e "RETRY: ${RETRY}\n" 43 | do_request 44 | done 45 | request_exit_code=$? 46 | 47 | echo -e "\nrequest code: ${request_exit_code}\n" 48 | 49 | if [[ $RETRY -gt $MAX_RETRIES ]]; then 50 | echo -n "Unable to run, aborted" 51 | kill -9 $p 52 | exit 1 53 | 54 | elif [[ $request_exit_code -ne 0 ]]; then 55 | echo -n "Request error" 56 | kill -9 $p 57 | exit 1 58 | 59 | else 60 | echo -e "\nSuccessfully acquire homepage, passing" 61 | kill -9 $p 62 | exit 0 63 | fi 64 | -------------------------------------------------------------------------------- /scripts/workflow/test-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | MAX_RETRIES=10 4 | 5 | node -v 6 | 7 | if [[ $? -ne 0 ]]; then 8 | echo "failed to run node" 9 | exit 1 10 | fi 11 | 12 | nohup node dist/main.js 1>/dev/null & 13 | p=$! 14 | echo "started server with pid $p" 15 | 16 | if [[ $? -ne 0 ]]; then 17 | echo "failed to run node index.js" 18 | exit 1 19 | fi 20 | 21 | RETRY=0 22 | 23 | do_request() { 24 | curl -f -m 10 localhost:3333/ping -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36' 25 | 26 | } 27 | 28 | do_request 29 | 30 | while [[ $? -ne 0 ]] && [[ $RETRY -lt $MAX_RETRIES ]]; do 31 | sleep 5 32 | ((RETRY++)) 33 | echo -e "RETRY: ${RETRY}\n" 34 | do_request 35 | done 36 | request_exit_code=$? 37 | 38 | echo -e "\nrequest code: ${request_exit_code}\n" 39 | 40 | if [[ $RETRY -gt $MAX_RETRIES ]]; then 41 | echo -n "Unable to run, aborted" 42 | kill -9 $p 43 | exit 1 44 | 45 | elif [[ $request_exit_code -ne 0 ]]; then 46 | echo -n "Request error" 47 | kill -9 $p 48 | exit 1 49 | 50 | else 51 | echo -e "\nSuccessfully acquire homepage, passing" 52 | kill -9 $p 53 | exit 0 54 | fi 55 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppController } from '@core/app.controller' 2 | import { fastifyApp } from '@core/common/adapter/fastify.adapter' 3 | import { Test, type TestingModule } from '@nestjs/testing' 4 | import type { NestFastifyApplication } from '@nestjs/platform-fastify' 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: NestFastifyApplication 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | controllers: [AppController], 12 | }).compile() 13 | 14 | app = moduleFixture.createNestApplication(fastifyApp) 15 | await app.init() 16 | await app.getHttpAdapter().getInstance().ready() 17 | }) 18 | 19 | it('/ (GET)', () => { 20 | return app.inject('/').then((res) => { 21 | expect(res.statusCode).toBe(200) 22 | }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /test/helper/create-e2e-app.ts: -------------------------------------------------------------------------------- 1 | import { AllExceptionsFilter } from '@core/common/filters/all-exception.filter' 2 | import { JSONTransformerInterceptor } from '@core/common/interceptors/json-transformer.interceptor' 3 | import { ResponseInterceptor } from '@core/common/interceptors/response.interceptor' 4 | import { AuthModule } from '@core/modules/auth/auth.module' 5 | import { UserModule } from '@core/modules/user/user.module' 6 | import { ConfigModule } from '@nestjs/config' 7 | import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core' 8 | import { MockedHelperModule } from '@test/mock/helper/helper.module' 9 | import { MockedDatabaseModule } from '@test/mock/processors/database/database.module' 10 | 11 | import { redisHelper } from './redis-mock.helper' 12 | import { setupE2EApp } from './setup-e2e' 13 | import type { NestFastifyApplication } from '@nestjs/platform-fastify' 14 | import type { ModuleMetadata } from '@nestjs/common' 15 | 16 | export const createE2EApp = (module: ModuleMetadata) => { 17 | const proxy: { 18 | app: NestFastifyApplication 19 | } = {} as any 20 | 21 | beforeAll(async () => { 22 | const { CacheService, token, CacheModule } = await redisHelper 23 | const { ...nestModule } = module 24 | nestModule.imports ||= [] 25 | nestModule.imports.push( 26 | MockedDatabaseModule, 27 | ConfigModule.forRoot({ 28 | isGlobal: true, 29 | }), 30 | CacheModule, 31 | MockedHelperModule, 32 | AuthModule, 33 | UserModule, 34 | ) 35 | nestModule.providers ||= [] 36 | 37 | nestModule.providers.push( 38 | { 39 | provide: APP_INTERCEPTOR, 40 | useClass: JSONTransformerInterceptor, // 2 41 | }, 42 | 43 | { 44 | provide: APP_INTERCEPTOR, 45 | useClass: ResponseInterceptor, // 1 46 | }, 47 | { 48 | provide: APP_FILTER, 49 | useClass: AllExceptionsFilter, 50 | }, 51 | { provide: token, useValue: CacheService }, 52 | ) 53 | const app = await setupE2EApp(nestModule) 54 | 55 | proxy.app = app 56 | }) 57 | 58 | return proxy 59 | } 60 | -------------------------------------------------------------------------------- /test/helper/create-service-unit.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from '@core/processors/cache/cache.service' 2 | import { ConfigModule } from '@nestjs/config' 3 | import { Test, type TestingModule } from '@nestjs/testing' 4 | import { mockedEventManagerServiceProvider } from '@test/mock/helper/helper.event' 5 | import { MockedDatabaseModule } from '@test/mock/processors/database/database.module' 6 | import type { ModuleMetadata } from '@nestjs/common' 7 | 8 | type ClassType = new (...args: any[]) => T 9 | export const createServiceUnitTestApp = ( 10 | Service: ClassType, 11 | module?: ModuleMetadata, 12 | ) => { 13 | const proxy = {} as { 14 | service: T 15 | app: TestingModule 16 | } 17 | 18 | beforeAll(async () => { 19 | const { imports, providers } = module || {} 20 | const app = await Test.createTestingModule({ 21 | providers: [ 22 | Service, 23 | mockedEventManagerServiceProvider, 24 | { 25 | provide: CacheService, 26 | useValue: {}, 27 | }, 28 | ...(providers || []), 29 | ], 30 | imports: [ 31 | MockedDatabaseModule, 32 | ConfigModule.forRoot({ 33 | isGlobal: true, 34 | envFilePath: ['.env.test', '.env'], 35 | }), 36 | ...(imports || []), 37 | ], 38 | }).compile() 39 | await app.init() 40 | 41 | const service = app.get(Service) 42 | proxy.service = service 43 | proxy.app = app 44 | }) 45 | return proxy 46 | } 47 | -------------------------------------------------------------------------------- /test/helper/defineProvider.ts: -------------------------------------------------------------------------------- 1 | export interface Provider { 2 | provide: new (...args: any[]) => T 3 | useValue: Partial 4 | } 5 | 6 | export const defineProvider = (provider: Provider) => { 7 | return provider 8 | } 9 | 10 | export function defineProviders(providers: [Provider]): [Provider] 11 | export function defineProviders( 12 | providers: [Provider, Provider], 13 | ): [Provider, Provider] 14 | export function defineProviders( 15 | providers: [Provider, Provider, Provider], 16 | ): [Provider, Provider, Provider] 17 | 18 | export function defineProviders(providers: Provider[]) { 19 | return providers 20 | } 21 | -------------------------------------------------------------------------------- /test/helper/redis-mock.helper.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | import IORedis, { type Redis } from 'ioredis' 3 | 4 | import { CacheService } from '@core/processors/cache/cache.service' 5 | import { Global, Module } from '@nestjs/common' 6 | 7 | export class MockCacheService { 8 | private client: Redis 9 | constructor(port: number, host: string) { 10 | this.client = new IORedis(port, host) 11 | } 12 | 13 | private get redisClient() { 14 | return this.client 15 | } 16 | 17 | public get(key) { 18 | return this.client.get(key) 19 | } 20 | 21 | public set(key, value: any) { 22 | return this.client.set(key, value) 23 | } 24 | 25 | public getClient() { 26 | return this.redisClient 27 | } 28 | } 29 | 30 | const createMockRedis = async () => { 31 | let redisPort = 6379 32 | let redisHost = 'localhost' 33 | let redisServer: any 34 | if (process.env.CI) { 35 | // Skip 36 | } else { 37 | const RedisMemoryServer = require('redis-memory-server').default 38 | redisServer = new RedisMemoryServer({}) 39 | 40 | redisHost = await redisServer.getHost() 41 | redisPort = await redisServer.getPort() 42 | } 43 | const cacheService = new MockCacheService(redisPort, redisHost) 44 | 45 | const provide = { 46 | provide: CacheService, 47 | useValue: cacheService, 48 | } 49 | @Module({ 50 | providers: [provide], 51 | exports: [provide], 52 | }) 53 | @Global() 54 | class CacheModule {} 55 | 56 | return { 57 | connect: () => null, 58 | CacheService: cacheService, 59 | token: CacheService, 60 | CacheModule, 61 | 62 | async close() { 63 | await cacheService.getClient().flushall() 64 | await cacheService.getClient().quit() 65 | if (!process.env.CI) { 66 | await redisServer?.stop() 67 | } 68 | }, 69 | } 70 | } 71 | 72 | export const redisHelper = createMockRedis() 73 | -------------------------------------------------------------------------------- /test/helper/serialize-data.ts: -------------------------------------------------------------------------------- 1 | export const reDeserializeData = (data: any) => JSON.parse(JSON.stringify(data)) 2 | -------------------------------------------------------------------------------- /test/helper/setup-e2e.ts: -------------------------------------------------------------------------------- 1 | import { fastifyApp } from '@core/common/adapter/fastify.adapter' 2 | import { JSONTransformerInterceptor } from '@core/common/interceptors/json-transformer.interceptor' 3 | import { ResponseInterceptor } from '@core/common/interceptors/response.interceptor' 4 | import { ZodValidationPipe } from '@core/common/pipes/zod-validation.pipe' 5 | import { LoggerModule } from '@core/processors/logger/logger.module' 6 | import { MyLogger } from '@core/processors/logger/logger.service' 7 | import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core' 8 | import { Test } from '@nestjs/testing' 9 | import type { ModuleMetadata } from '@nestjs/common' 10 | import type { NestFastifyApplication } from '@nestjs/platform-fastify' 11 | 12 | const interceptorProviders = [JSONTransformerInterceptor, ResponseInterceptor] 13 | export const setupE2EApp = async (module: ModuleMetadata) => { 14 | const nextModule: ModuleMetadata = { 15 | exports: module.exports || [], 16 | imports: module.imports || [], 17 | providers: module.providers || [], 18 | controllers: module.controllers || [], 19 | } 20 | 21 | nextModule.imports!.unshift(LoggerModule) 22 | nextModule.providers!.unshift({ 23 | provide: APP_PIPE, 24 | useClass: ZodValidationPipe, 25 | }) 26 | nextModule.providers!.unshift( 27 | ...interceptorProviders.map((interceptor) => ({ 28 | provide: APP_INTERCEPTOR, 29 | useClass: interceptor, 30 | })), 31 | ) 32 | const testingModule = await Test.createTestingModule(nextModule).compile() 33 | 34 | const app = testingModule.createNestApplication( 35 | fastifyApp, 36 | { logger: ['log', 'warn', 'error', 'debug'] }, 37 | ) 38 | 39 | await app.init() 40 | app.useLogger(app.get(MyLogger)) 41 | await app.getHttpAdapter().getInstance().ready() 42 | 43 | return app 44 | } 45 | -------------------------------------------------------------------------------- /test/lib/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { createDrizzle } from '@packages/drizzle' 2 | 3 | const dbUrl = process.env.DATABASE_URL! 4 | 5 | export const drizzle = createDrizzle(dbUrl, {}) 6 | -------------------------------------------------------------------------------- /test/lib/reset-db.ts: -------------------------------------------------------------------------------- 1 | import { schema as schemas } from '@packages/drizzle' 2 | 3 | import { drizzle } from './drizzle' 4 | 5 | const noop = () => {} 6 | // eslint-disable-next-line import/no-default-export 7 | export default async () => { 8 | drizzle 9 | .transaction(async (db) => { 10 | // for (const key in schemas) { 11 | // console.log('key', key, schemas[key]) 12 | // await db.delete(schemas[key]) 13 | // } 14 | await db.delete(schemas.users) 15 | await db.delete(schemas.accounts) 16 | await db.delete(schemas.authenticators) 17 | await db.delete(schemas.sessions) 18 | await db.delete(schemas.verificationTokens) 19 | }) 20 | .catch(noop) 21 | } 22 | -------------------------------------------------------------------------------- /test/mock/helper/helper.event.ts: -------------------------------------------------------------------------------- 1 | import { type Mock, vi } from 'vitest' 2 | 3 | import { EventManagerService } from '@core/processors/helper/helper.event.service' 4 | import { defineProvider } from '@test/helper/defineProvider' 5 | 6 | export class MockedEventManagerService { 7 | constructor() {} 8 | 9 | emit = vi.fn().mockResolvedValue(null) as Mock 10 | 11 | event = vi.fn().mockResolvedValue(null) as Mock 12 | 13 | get broadcast() { 14 | return this.emit 15 | } 16 | } 17 | 18 | export const mockedEventManagerService = new MockedEventManagerService() 19 | 20 | export const mockedEventManagerServiceProvider = defineProvider({ 21 | provide: EventManagerService, 22 | useValue: mockedEventManagerService, 23 | }) 24 | -------------------------------------------------------------------------------- /test/mock/helper/helper.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common' 2 | 3 | import { mockedEventManagerServiceProvider } from './helper.event' 4 | 5 | @Module({ 6 | providers: [mockedEventManagerServiceProvider], 7 | exports: [mockedEventManagerServiceProvider], 8 | }) 9 | @Global() 10 | export class MockedHelperModule {} 11 | -------------------------------------------------------------------------------- /test/mock/modules/auth.mock.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from '@core/modules/auth/auth.service' 2 | import { defineProvider } from '@test/helper/defineProvider' 3 | 4 | export const authProvider = defineProvider({ 5 | useValue: { 6 | async generateAuthCode() { 7 | return 'xxxxxxxxx' 8 | }, 9 | }, 10 | provide: AuthService, 11 | }) 12 | -------------------------------------------------------------------------------- /test/mock/processors/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { DatabaseService } from '@core/processors/database/database.service' 2 | import { Global, Module } from '@nestjs/common' 3 | 4 | // import { MockedDatabaseService } from './database.service' 5 | 6 | // const mockDatabaseService = { 7 | // provide: DatabaseService, 8 | // useClass: DatabaseService, 9 | // } 10 | @Module({ 11 | providers: [DatabaseService], 12 | exports: [DatabaseService], 13 | }) 14 | @Global() 15 | export class MockedDatabaseModule {} 16 | -------------------------------------------------------------------------------- /test/mock/processors/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'node:util' 2 | 3 | import { DATABASE } from '@core/app.config' 4 | import { createDrizzle } from '@packages/drizzle' 5 | import { Injectable, Logger } from '@nestjs/common' 6 | 7 | @Injectable() 8 | export class DatabaseService { 9 | public drizzle: ReturnType 10 | 11 | constructor() { 12 | const drizzleLogger = new Logger('') 13 | this.drizzle = createDrizzle(DATABASE.url, { 14 | logger: { 15 | logQuery(query, params) { 16 | drizzleLogger.debug(query + inspect(params)) 17 | }, 18 | }, 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@private/testing", 3 | "private": true, 4 | "scripts": { 5 | "test": "dotenv -e ../.env.test vitest" 6 | }, 7 | "dependencies": { 8 | "@nestjs/cache-manager": "2.3.0", 9 | "@nestjs/common": "10.4.13", 10 | "@nestjs/config": "3.3.0", 11 | "@nestjs/core": "10.4.13", 12 | "@nestjs/event-emitter": "2.1.1", 13 | "@nestjs/jwt": "10.2.0", 14 | "@nestjs/passport": "10.0.3", 15 | "@nestjs/platform-fastify": "10.4.13", 16 | "@nestjs/platform-socket.io": "10.4.13", 17 | "@nestjs/schedule": "4.1.1", 18 | "@nestjs/testing": "10.4.13", 19 | "@nestjs/throttler": "6.2.1", 20 | "@nestjs/websockets": "10.4.13", 21 | "@packages/drizzle": "workspace:*", 22 | "@packages/utils": "workspace:*", 23 | "@swc/cli": "0.5.2", 24 | "@types/bcryptjs": "2.4.6", 25 | "@types/lodash": "4.17.13", 26 | "bcryptjs": "2.4.3", 27 | "cross-env": "7.0.3", 28 | "dayjs": "1.11.13", 29 | "dotenv": "16.4.7", 30 | "dotenv-cli": "7.4.4", 31 | "dotenv-expand": "12.0.1", 32 | "drizzle-orm": "0.37.0", 33 | "ioredis": "^5.4.1", 34 | "lodash": "4.17.21", 35 | "nanoid": "5.0.9", 36 | "redis-memory-server": "0.11.0", 37 | "slugify": "1.6.6", 38 | "snakecase-keys": "8.0.1", 39 | "unplugin-swc": "1.5.1", 40 | "vite-tsconfig-paths": "5.1.3", 41 | "vitest": "2.1.8", 42 | "zod": "3.23.8", 43 | "zod-fixture": "2.5.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/setup-file.ts: -------------------------------------------------------------------------------- 1 | import { redisHelper } from './helper/redis-mock.helper' 2 | import resetDb from './lib/reset-db' 3 | 4 | beforeAll(async () => { 5 | await resetDb() 6 | }) 7 | 8 | afterAll(async () => { 9 | await resetDb() 10 | await (await redisHelper).close() 11 | }) 12 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | module.exports = () => {} 2 | -------------------------------------------------------------------------------- /test/setupFiles/lifecycle.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2022", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "baseUrl": ".", 8 | "module": "CommonJS", 9 | "paths": { 10 | "@core": [ 11 | "../apps/core/src" 12 | ], 13 | "@core/*": [ 14 | "../apps/core/src/*" 15 | ], 16 | "@test": [ 17 | "." 18 | ], 19 | "@test/*": [ 20 | "./*" 21 | ], 22 | "@prisma/client": [ 23 | "../prisma/client" 24 | ], 25 | "@prisma/client/*": [ 26 | "../prisma/*" 27 | ] 28 | }, 29 | "resolveJsonModule": true, 30 | "types": [ 31 | "vitest/globals" 32 | ], 33 | "allowJs": true, 34 | "strict": true, 35 | "noImplicitAny": false, 36 | "declaration": true, 37 | "noEmit": true, 38 | "outDir": "./dist", 39 | "removeComments": true, 40 | "sourceMap": true, 41 | "allowSyntheticDefaultImports": true, 42 | "esModuleInterop": true, 43 | "skipLibCheck": true 44 | }, 45 | "include": [ 46 | "./src/**/*.ts", 47 | "./src/**/*.tsx", 48 | "./src/**/*.js", 49 | "./src/**/*.jsx", 50 | "./**/*.ts", 51 | "../apps/core/src/**/*.ts", 52 | "vitest.config.mts", 53 | ] 54 | } -------------------------------------------------------------------------------- /test/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import swc from 'unplugin-swc' 3 | import tsconfigPath from 'vite-tsconfig-paths' 4 | import { defineConfig } from 'vitest/config' 5 | 6 | export default defineConfig({ 7 | root: './', 8 | test: { 9 | include: ['**/*.spec.ts', '**/*.e2e-spec.ts'], 10 | 11 | globals: true, 12 | setupFiles: [resolve(__dirname, './setup-file.ts')], 13 | environment: 'node', 14 | includeSource: [resolve(__dirname, '.')], 15 | }, 16 | optimizeDeps: { 17 | needsInterop: ['lodash'], 18 | }, 19 | resolve: { 20 | alias: [ 21 | { 22 | find: '@core/app.config', 23 | replacement: resolve( 24 | __dirname, 25 | '../apps/core/src/app.config.testing.ts', 26 | ), 27 | }, 28 | { 29 | find: /^@core\/(.+)/, 30 | replacement: resolve(__dirname, '../apps/core/src/$1'), 31 | }, 32 | { 33 | find: '@packages/drizzle', 34 | replacement: resolve(__dirname, '../drizzle/index.ts'), 35 | }, 36 | 37 | { 38 | find: '@packages/utils', 39 | replacement: resolve(__dirname, '../packages/utils/index.ts'), 40 | }, 41 | ], 42 | }, 43 | 44 | // esbuild can not emit ts metadata 45 | esbuild: false, 46 | 47 | plugins: [ 48 | swc.vite(), 49 | tsconfigPath({ 50 | projects: [ 51 | resolve(__dirname, './tsconfig.json'), 52 | // resolve(__dirname, './tsconfig.json'), 53 | ], 54 | }), 55 | ], 56 | }) 57 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": true, 4 | "target": "es2021", 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "baseUrl": ".", 8 | "module": "CommonJS", 9 | "paths": { 10 | "@core": [ 11 | "./apps/core/src" 12 | ], 13 | "@core/*": [ 14 | "./apps/core/src/*" 15 | ], 16 | "@test": [ 17 | "." 18 | ], 19 | "@test/*": [ 20 | "./*" 21 | ], 22 | }, 23 | "resolveJsonModule": true, 24 | "strictNullChecks": true, 25 | "noImplicitAny": false, 26 | "declaration": true, 27 | "outDir": "./dist", 28 | "removeComments": true, 29 | "sourceMap": true, 30 | "allowSyntheticDefaultImports": true, 31 | "esModuleInterop": true, 32 | "skipLibCheck": true, 33 | }, 34 | "exclude": [ 35 | "dist", 36 | "tmp" 37 | ] 38 | } --------------------------------------------------------------------------------