├── .dockerignore ├── .eslintrc.js ├── .github-src ├── NOTES.md ├── build.sh ├── includes │ ├── apps.yaml │ ├── common-anchors.yaml │ ├── common-envs.yaml │ ├── image_helpers.sh │ └── push-envs.yaml ├── process.ts ├── tsconfig.json └── workflows │ ├── closed-pull-requests.yaml │ ├── new-pull-requests.yaml │ └── push.yaml ├── .github └── workflows │ ├── closed-pull-requests.yaml │ ├── new-pull-requests.yaml │ └── push.yaml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .scripts ├── build-image.ts ├── ci │ ├── needs-build.ts │ ├── prepare-e2e-if-needed.ts │ └── remote-image-exists.ts ├── common │ ├── app-name-utils.spec.ts │ ├── app-name-utils.ts │ ├── consts.ts │ ├── dedicated-lockfile.ts │ ├── find-changed-packages.ts │ ├── find-packages.ts │ ├── log.ts │ ├── pnpm-helpers.ts │ └── types │ │ └── package-info.ts ├── dir-for-app.ts ├── make-dedicated-lockfile.ts ├── needs-build.ts ├── pre-commit.ts ├── run-lint.ts ├── run-tests.ts ├── test-docker-builds.ts └── tsconfig.json ├── .vscode └── settings.json ├── README.md ├── apps ├── internal-api │ ├── .eslintrc.js │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── jest.config.js │ ├── nest-cli.json │ ├── package.json │ ├── src │ │ ├── app.controller.spec.ts │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ └── main.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest.config.js │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsconfig.test.json └── web-api │ ├── .eslintrc.js │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── jest.config.js │ ├── nest-cli.json │ ├── package.json │ ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── main.ts │ ├── test │ ├── app.e2e-spec.ts │ ├── docker-compose.ci.yaml │ ├── docker-compose.dev.yaml │ ├── docker-compose.yaml │ ├── jest.config.js │ └── run.sh │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── tsconfig.test.json ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── shared └── lib │ ├── .eslintrc.js │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── functions.spec.ts │ ├── functions.ts │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint/eslint-plugin'], 4 | extends: [ 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier/@typescript-eslint', 7 | 'plugin:prettier/recommended', 8 | ], 9 | root: true, 10 | env: { 11 | node: true, 12 | jest: true, 13 | }, 14 | ignorePatterns: [ 15 | '!.scripts' 16 | ], 17 | rules: { 18 | 'prettier/prettier': 'warn', 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.github-src/NOTES.md: -------------------------------------------------------------------------------- 1 | - ghcr.io access using GITHUB_TOKEN: 2 | https://docs.github.com/en/packages/guides/using-github-packages-with-github-actions#upgrading-a-workflow-that-accesses-ghcrio 3 | -------------------------------------------------------------------------------- /.github-src/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | rm -rf ./.github/workflows/* 4 | .github-src/process.ts 5 | { git diff-files --name-only; git ls-files --others --exclude-standard; } | grep .github/workflows/ | xargs --no-run-if-empty git add 6 | -------------------------------------------------------------------------------- /.github-src/includes/apps.yaml: -------------------------------------------------------------------------------- 1 | new-pull-requests-build-matrix: &new-pull-requests-build-matrix 2 | - name: web-api 3 | - name: internal-api 4 | new-pull-requests-integration-tests-matrix: &new-pull-requests-integration-tests-matrix 5 | - name: web-api 6 | deps: internal-api 7 | - name: internal-api 8 | push-publish-for-pr-matrix: &push-publish-for-pr-matrix 9 | - name: web-api 10 | heroku_app_key: WEB_API_APP 11 | - name: internal-api 12 | push-publish-for-push-matrix: &push-publish-for-push-matrix 13 | - name: web-api 14 | heroku_app_key: WEB_API_APP 15 | push-deploy-apps: &push-deploy-apps 16 | - run: heroku container:release web --app=$WEB_API_APP 17 | -------------------------------------------------------------------------------- /.github-src/includes/common-anchors.yaml: -------------------------------------------------------------------------------- 1 | docker-login: &docker-login 2 | name: Docker login 3 | run: echo ${{ env.DOCKER_TOKEN }} | docker login -u ${{ env.DOCKER_USER }} --password-stdin 4 | pnpm-store-cache: &pnpm-store-cache 5 | name: Cache pnpm store 6 | uses: actions/cache@v2 7 | with: 8 | path: ~/.pnpm-store 9 | key: ${{ runner.os }}-pnpmstore-${{ hashFiles('**/pnpm-lock.yaml') }} 10 | restore-keys: | 11 | ${{ runner.os }}-pnpmstore- 12 | setup-node: &setup-node 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '14.15.4' 16 | setup-pnpm: &setup-pnpm 17 | - name: Setup pnpm 18 | uses: pnpm/action-setup@v1.2.1 19 | with: 20 | version: 5.17.2 21 | - run: echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH 22 | -------------------------------------------------------------------------------- /.github-src/includes/common-envs.yaml: -------------------------------------------------------------------------------- 1 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 2 | DOCKER_USER: rhyek 3 | DOCKER_REPOSITORY: rhyek 4 | -------------------------------------------------------------------------------- /.github-src/includes/image_helpers.sh: -------------------------------------------------------------------------------- 1 | get_image_id () { 2 | local PACKAGE_NAME=$1 3 | local TAG=$2 4 | 5 | curl -s \ 6 | -H "Authorization: Bearer $DOCKER_TOKEN" \ 7 | -H 'Accept: application/vnd.github.v3+json' \ 8 | "https://api.github.com/$PACKAGES_PATH_PREFIX/packages/container/$PACKAGE_NAME/versions" \ 9 | | jq -rM "first(.[] | select(.metadata.container.tags | index(\"$TAG\"))).id // null" \ 10 | || echo null 11 | } 12 | 13 | delete_image () { 14 | local PACKAGE_NAME=$1 15 | local IMAGE_ID=$2 16 | 17 | curl -i \ 18 | -X DELETE \ 19 | -H "Authorization: Bearer $DOCKER_TOKEN" \ 20 | -H "Accept: application/vnd.github.v3+json" \ 21 | "https://api.github.com/$PACKAGES_PATH_PREFIX/packages/container/$PACKAGE_NAME/versions/$IMAGE_ID" 22 | } 23 | -------------------------------------------------------------------------------- /.github-src/includes/push-envs.yaml: -------------------------------------------------------------------------------- 1 | HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} 2 | WEB_API_APP: typescript-monorepo 3 | -------------------------------------------------------------------------------- /.github-src/process.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import yaml from 'js-yaml'; 5 | 6 | function flattenSteps(obj: object) { 7 | for (const [key, value] of Object.entries(obj)) { 8 | if (key === 'steps') { 9 | if (!Array.isArray(value)) { 10 | throw new Error( 11 | `Steps value is not an array: ${JSON.stringify(value, null, 2)}`, 12 | ); 13 | } 14 | const flat = value.flat(); 15 | value.splice(0, value.length, ...flat); 16 | break; 17 | } else if (typeof value === 'object' && value !== null) { 18 | flattenSteps(value); 19 | } 20 | } 21 | } 22 | 23 | async function main() { 24 | const srcDir = path.resolve(__dirname, './workflows'); 25 | const files = await fs.readdir(srcDir); 26 | const outFiles: [string, string][] = []; 27 | const includeRegex = new RegExp(/( +)?!include\((.+)\)/); 28 | for (const file of files) { 29 | const srcFile = path.resolve(srcDir, file); 30 | let srcContent = await fs.readFile(srcFile, 'utf8'); 31 | let match: RegExpMatchArray | null = null; 32 | while ((match = srcContent.match(includeRegex))) { 33 | const [matchedString, spaces, includePath] = match; 34 | const { index } = match; 35 | const realPath = path.basename(includePath).includes('.') 36 | ? includePath 37 | : `${includePath}.yaml`; 38 | const includeContent = ( 39 | await fs.readFile(path.resolve(path.dirname(srcFile), realPath), 'utf8') 40 | ) 41 | .split('\n') 42 | .map((line) => `${spaces ?? ''}${line}`) 43 | .join('\n'); 44 | srcContent = 45 | srcContent.substr(0, index! + (spaces ? spaces.length : 0)) + 46 | includeContent.trim() + 47 | srcContent.substr(index! + matchedString.length); 48 | } 49 | const outFile = path.resolve(__dirname, '../.github/workflows', file); 50 | const doc = yaml.load(srcContent); 51 | if (typeof doc !== 'object' || doc === null) { 52 | throw new Error(`Invalid type for document: ${typeof doc}`); 53 | } 54 | flattenSteps(doc); 55 | delete (doc as any).anchors; 56 | const dump = yaml.dump(doc, { 57 | noRefs: true, 58 | }); 59 | outFiles.push([outFile, dump]); 60 | } 61 | if (outFiles.length > 0) { 62 | for (const [outFile, content] of outFiles) { 63 | await fs.writeFile(outFile, content, { encoding: 'utf8' }); 64 | } 65 | } 66 | } 67 | 68 | if (require.main === module) { 69 | main(); 70 | 71 | process.on('unhandledRejection', (error) => { 72 | throw error; 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /.github-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /.github-src/workflows/closed-pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Delete images for closed PRs 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | types: [closed] 8 | 9 | #prettier-ignore 10 | env: 11 | !include(../includes/common-envs) 12 | 13 | jobs: 14 | delete-unused-images: 15 | name: Delete unused image tags 16 | runs-on: ubuntu-20.04 17 | if: github.event.pull_request.merged == false 18 | strategy: 19 | matrix: 20 | name: [web-api, internal-api] 21 | steps: 22 | - name: Inject slug/short variables 23 | uses: rlespinasse/github-slug-action@v3.x 24 | - name: Delete PR image if exists 25 | run: | 26 | # DOCKER_TOKEN=${{ env.DOCKER_TOKEN }} 27 | # PACKAGES_PATH_PREFIX=${{ env.PACKAGES_PATH_PREFIX }} 28 | # PACKAGE_NAME=${{ env.GITHUB_REPOSITORY_NAME_PART }}-${{ env.GITHUB_BASE_REF_SLUG }}-${{ matrix.name }} 29 | # PR_NUMBER=${{ github.event.pull_request.number }} 30 | 31 | !include(../includes/image_helpers.sh) 32 | 33 | # find_and_delete_image () { 34 | # local PACKAGE_NAME=$1 35 | # local TAG=$2 36 | 37 | # IMAGE_ID=$(get_image_id $PACKAGE_NAME $TAG) 38 | # if [ "$IMAGE_ID" != 'null' ]; then 39 | # delete_image $PACKAGE_NAME $IMAGE_ID 40 | # echo "Deleted $PACKAGE_NAME:$TAG with image id $IMAGE_ID" 41 | # else 42 | # echo "Image $PACKAGE_NAME:$TAG not found" 43 | # fi 44 | # } 45 | 46 | # find_and_delete_image $PACKAGE_NAME $PR_NUMBER 47 | # find_and_delete_image "${PACKAGE_NAME}-cache" $PR_NUMBER 48 | -------------------------------------------------------------------------------- /.github-src/workflows/new-pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests and Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | #prettier-ignore 9 | env: 10 | !include(../includes/common-envs) 11 | 12 | anchors: 13 | !include(../includes/common-anchors) 14 | !include(../includes/apps) 15 | node-modules-cache: &node-modules-cache 16 | name: Cache node_modules 17 | uses: actions/cache@v2 18 | with: 19 | path: '**/node_modules' 20 | key: ${{ runner.os }}-tests-node-modules-${{ github.sha }} 21 | fetch-target-branch: &fetch-target-branch 22 | name: Fetch target branch 23 | run: git fetch --no-tags --prune --depth=1 origin +refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} 24 | set-image-name: &set-image-name 25 | name: Set image name 26 | run: echo "IMAGE=${{ env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ env.GITHUB_BASE_REF_SLUG }}-${{ matrix.name }}" >> $GITHUB_ENV 27 | 28 | jobs: 29 | lint-unit-tests: 30 | name: Lint and Unit Tests 31 | runs-on: ubuntu-20.04 32 | steps: 33 | - uses: actions/checkout@v2 34 | - <<: *pnpm-store-cache 35 | - <<: *node-modules-cache 36 | - *setup-node 37 | - *setup-pnpm 38 | - <<: *fetch-target-branch 39 | - run: pnpm i --frozen-lockfile 40 | - run: .scripts/run-lint.ts 41 | - run: .scripts/run-tests.ts unit 42 | build: 43 | name: Build 44 | runs-on: ubuntu-20.04 45 | needs: [lint-unit-tests] 46 | strategy: 47 | matrix: 48 | include: 49 | *new-pull-requests-build-matrix 50 | steps: 51 | - uses: actions/checkout@v2 52 | - <<: *node-modules-cache 53 | - *setup-node 54 | - *setup-pnpm 55 | - <<: *fetch-target-branch 56 | - name: Inject slug/short variables 57 | uses: rlespinasse/github-slug-action@v3.x 58 | - <<: *docker-login 59 | - <<: *set-image-name 60 | - name: Should build docker image? 61 | id: needs-build 62 | run: | 63 | NEEDS_BUILD=$(.scripts/ci/needs-build.ts \ 64 | --repository ${{ env.DOCKER_REPOSITORY }} \ 65 | --image ${{ env.IMAGE }} \ 66 | ${{ matrix.name }}) 67 | echo "::set-output name=result::$NEEDS_BUILD" 68 | - uses: docker/setup-buildx-action@v1 69 | if: steps.needs-build.outputs.result == 'true' 70 | - name: Build docker image 71 | if: steps.needs-build.outputs.result == 'true' 72 | run: | 73 | docker -v 74 | docker buildx version 75 | 76 | .scripts/build-image.ts \ 77 | ${{ matrix.name }} \ 78 | --repository ${{ env.DOCKER_REPOSITORY }} \ 79 | --image ${{ env.IMAGE }} \ 80 | --tag ${{ github.event.pull_request.number }} 81 | integration-tests: 82 | name: Integration Tests 83 | runs-on: ubuntu-20.04 84 | needs: [build] 85 | strategy: 86 | matrix: 87 | include: 88 | *new-pull-requests-integration-tests-matrix 89 | steps: 90 | - uses: actions/checkout@v2 91 | - <<: *node-modules-cache 92 | - *setup-node 93 | - *setup-pnpm 94 | - <<: *fetch-target-branch 95 | - name: Inject slug/short variables 96 | uses: rlespinasse/github-slug-action@v3.x 97 | - <<: *docker-login 98 | - <<: *set-image-name 99 | - name: Prepare e2e tests if needed 100 | id: prepare-e2e 101 | run: | 102 | PREPARED=$(.scripts/ci/prepare-e2e-if-needed.ts \ 103 | ${{ matrix.name }} \ 104 | --repository ${{ env.DOCKER_REPOSITORY }} \ 105 | --image ${{ env.IMAGE }} \ 106 | --tag ${{ github.event.pull_request.number }} \ 107 | --deps ${{ matrix.deps }}) 108 | echo "::set-output name=prepared::$PREPARED" 109 | - name: Run integration tests 110 | if: steps.prepare-e2e.outputs.prepared == 'true' 111 | run: pnpm test:e2e --filter {${{ matrix.name }}} 112 | -------------------------------------------------------------------------------- /.github-src/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: Publish and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | env: 8 | !include(../includes/common-envs) 9 | !include(../includes/push-envs) 10 | 11 | anchors: 12 | !include(../includes/common-anchors) 13 | !include(../includes/apps) 14 | report: &report 15 | name: Slack Notification 16 | uses: rtCamp/action-slack-notify@v2 17 | if: cancelled() || failure() 18 | env: 19 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 20 | SLACK_USERNAME: github-actions 21 | SLACK_COLOR: ${{ job.status == 'success' && 'good' || job.status == 'cancelled' && '#808080' || 'danger' }} 22 | set-image-names: &set-image-names 23 | name: Set image names 24 | id: image-names 25 | run: | 26 | echo "::set-output name=github::${{ env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ env.GITHUB_REF_SLUG }}-${{ matrix.name }}" 27 | echo "::set-output name=heroku::registry.heroku.com/${{ env[matrix.heroku_app_key] }}/web" 28 | heroku-docker-login: &heroku-docker-login 29 | name: Heroku Docker login 30 | run: echo ${{ secrets.HEROKU_API_KEY }} | docker login registry.heroku.com --username=_ --password-stdin 31 | 32 | jobs: 33 | pr-number: 34 | name: Get PR number 35 | runs-on: ubuntu-20.04 36 | outputs: 37 | result: ${{ steps.pr-number.outputs.result }} 38 | steps: 39 | - name: Get PR number 40 | id: pr-number 41 | run: | 42 | NUMBER=$(curl \ 43 | -s \ 44 | -H 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 45 | -H 'accept: application/vnd.github.groot-preview+json' \ 46 | 'https://api.github.com/repos/${{ github.repository }}/commits/${{ github.sha }}/pulls' \ 47 | | jq -rM '.[0].number' 2> /dev/null \ 48 | || echo null 49 | ) 50 | echo "::set-output name=result::$NUMBER" 51 | 52 | pnpm-store-cache: # https://docs.github.com/en/actions/guides/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache 53 | name: Create pnpm store cache for future PRs 54 | runs-on: ubuntu-20.04 55 | needs: pr-number 56 | if: needs.pr-number.outputs.result != 'null' 57 | steps: 58 | - uses: actions/checkout@v2 59 | - <<: *pnpm-store-cache 60 | - *setup-pnpm 61 | - run: pnpm i --frozen-lockfile 62 | 63 | publish-for-pr: 64 | name: Publish images for merged PR 65 | runs-on: ubuntu-20.04 66 | needs: pr-number 67 | if: needs.pr-number.outputs.result != 'null' 68 | strategy: 69 | matrix: 70 | include: 71 | *push-publish-for-pr-matrix 72 | steps: 73 | - *setup-node 74 | - name: Inject slug/short variables 75 | uses: rlespinasse/github-slug-action@v3.x 76 | - <<: *docker-login 77 | - <<: *set-image-names 78 | - name: Check if image for PR exists 79 | id: image-exists 80 | run: | 81 | docker pull ${{ env.DOCKER_REPOSITORY }}/${{ steps.image-names.outputs.github }}:${{ needs.pr-number.outputs.result }} && EXIT_CODE=0 || EXIT_CODE=$? 82 | RESULT=$([ $EXIT_CODE = 0 ] && echo 'true' || echo 'false') 83 | echo "::set-output name=result::$RESULT" 84 | - <<: *heroku-docker-login 85 | if: steps.image-exists.outputs.result == 'true' && matrix.heroku_app_key != '' 86 | - name: Tag and push to heroku 87 | if: steps.image-exists.outputs.result == 'true' && matrix.heroku_app_key != '' 88 | run: | 89 | docker tag ${{ env.DOCKER_REPOSITORY }}/${{ steps.image-names.outputs.github }}:${{ needs.pr-number.outputs.result }} ${{ steps.image-names.outputs.heroku }} 90 | docker push ${{ steps.image-names.outputs.heroku }} 91 | - name: Tag 'latest' and push to ghcr 92 | id: push-to-ghcr 93 | if: steps.image-exists.outputs.result == 'true' 94 | run: | 95 | tag_latest_if_pr_image_exists () { 96 | local IMAGE_NAME=$1 97 | 98 | docker buildx imagetools inspect \ 99 | "${{ env.DOCKER_REPOSITORY }}/$IMAGE_NAME:${{ needs.pr-number.outputs.result }}" &> /dev/null && EXIT_CODE=0 || EXIT_CODE=$? 100 | 101 | if [ $EXIT_CODE = 0 ]; then 102 | docker buildx imagetools create \ 103 | -t "${{ env.DOCKER_REPOSITORY }}/$IMAGE_NAME:latest" \ 104 | "${{ env.DOCKER_REPOSITORY }}/$IMAGE_NAME:${{ needs.pr-number.outputs.result }}" 105 | fi 106 | } 107 | 108 | tag_latest_if_pr_image_exists ${{ steps.image-names.outputs.github }} 109 | tag_latest_if_pr_image_exists ${{ steps.image-names.outputs.github }}-cache 110 | - <<: *report 111 | publish-for-push: 112 | name: Publish images for direct push 113 | runs-on: ubuntu-20.04 114 | needs: pr-number 115 | if: needs.pr-number.outputs.result == 'null' 116 | strategy: 117 | matrix: 118 | include: 119 | *push-publish-for-push-matrix 120 | steps: 121 | - uses: actions/checkout@v2 122 | - <<: *pnpm-store-cache 123 | - *setup-pnpm 124 | - run: pnpm i --frozen-lockfile 125 | - name: Inject slug/short variables 126 | uses: rlespinasse/github-slug-action@v3.x 127 | - <<: *docker-login 128 | - <<: *heroku-docker-login 129 | - <<: *set-image-names 130 | - uses: docker/setup-buildx-action@v1 131 | - name: Build and Push docker image 132 | run: | 133 | .scripts/make-dedicated-lockfile.ts ${{ matrix.name }} --replace 134 | docker -v 135 | docker buildx version 136 | docker buildx build \ 137 | --cache-from=${{ env.DOCKER_REPOSITORY }}/${{ steps.image-names.outputs.github }}:latest \ 138 | --cache-to=type=inline \ 139 | -t ${{ env.DOCKER_REPOSITORY }}/${{ steps.image-names.outputs.github }}:latest \ 140 | -t ${{ steps.image-names.outputs.heroku }} \ 141 | -f $(.scripts/dir-for-app.ts ${{ matrix.name }})/Dockerfile \ 142 | --push \ 143 | . 144 | - <<: *report 145 | deploy: 146 | name: Deploy apps 147 | runs-on: ubuntu-20.04 148 | needs: [publish-for-pr, publish-for-push] 149 | if: always() && (needs.publish-for-pr.result == 'success' || needs.publish-for-push.result == 'success') 150 | steps: 151 | - *setup-node 152 | - run: curl -s https://cli-assets.heroku.com/install.sh | sh 153 | - run: heroku container:login 154 | - *push-deploy-apps 155 | - <<: *report 156 | if: always() 157 | -------------------------------------------------------------------------------- /.github/workflows/closed-pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Delete images for closed PRs 2 | 'on': 3 | pull_request: 4 | branches: 5 | - main 6 | types: 7 | - closed 8 | env: 9 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 10 | DOCKER_USER: rhyek 11 | DOCKER_REPOSITORY: rhyek 12 | jobs: 13 | delete-unused-images: 14 | name: Delete unused image tags 15 | runs-on: ubuntu-20.04 16 | if: github.event.pull_request.merged == false 17 | strategy: 18 | matrix: 19 | name: 20 | - web-api 21 | - internal-api 22 | steps: 23 | - name: Inject slug/short variables 24 | uses: rlespinasse/github-slug-action@v3.x 25 | - name: Delete PR image if exists 26 | run: > 27 | # DOCKER_TOKEN=${{ env.DOCKER_TOKEN }} 28 | 29 | # PACKAGES_PATH_PREFIX=${{ env.PACKAGES_PATH_PREFIX }} 30 | 31 | # PACKAGE_NAME=${{ env.GITHUB_REPOSITORY_NAME_PART }}-${{ 32 | env.GITHUB_BASE_REF_SLUG }}-${{ matrix.name }} 33 | 34 | # PR_NUMBER=${{ github.event.pull_request.number }} 35 | 36 | 37 | get_image_id () { 38 | local PACKAGE_NAME=$1 39 | local TAG=$2 40 | 41 | curl -s \ 42 | -H "Authorization: Bearer $DOCKER_TOKEN" \ 43 | -H 'Accept: application/vnd.github.v3+json' \ 44 | "https://api.github.com/$PACKAGES_PATH_PREFIX/packages/container/$PACKAGE_NAME/versions" \ 45 | | jq -rM "first(.[] | select(.metadata.container.tags | index(\"$TAG\"))).id // null" \ 46 | || echo null 47 | } 48 | 49 | 50 | delete_image () { 51 | local PACKAGE_NAME=$1 52 | local IMAGE_ID=$2 53 | 54 | curl -i \ 55 | -X DELETE \ 56 | -H "Authorization: Bearer $DOCKER_TOKEN" \ 57 | -H "Accept: application/vnd.github.v3+json" \ 58 | "https://api.github.com/$PACKAGES_PATH_PREFIX/packages/container/$PACKAGE_NAME/versions/$IMAGE_ID" 59 | } 60 | 61 | 62 | # find_and_delete_image () { 63 | 64 | # local PACKAGE_NAME=$1 65 | 66 | # local TAG=$2 67 | 68 | 69 | # IMAGE_ID=$(get_image_id $PACKAGE_NAME $TAG) 70 | 71 | # if [ "$IMAGE_ID" != 'null' ]; then 72 | 73 | # delete_image $PACKAGE_NAME $IMAGE_ID 74 | 75 | # echo "Deleted $PACKAGE_NAME:$TAG with image id $IMAGE_ID" 76 | 77 | # else 78 | 79 | # echo "Image $PACKAGE_NAME:$TAG not found" 80 | 81 | # fi 82 | 83 | # } 84 | 85 | 86 | # find_and_delete_image $PACKAGE_NAME $PR_NUMBER 87 | 88 | # find_and_delete_image "${PACKAGE_NAME}-cache" $PR_NUMBER 89 | -------------------------------------------------------------------------------- /.github/workflows/new-pull-requests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests and Build 2 | 'on': 3 | pull_request: 4 | branches: 5 | - main 6 | env: 7 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 8 | DOCKER_USER: rhyek 9 | DOCKER_REPOSITORY: rhyek 10 | jobs: 11 | lint-unit-tests: 12 | name: Lint and Unit Tests 13 | runs-on: ubuntu-20.04 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Cache pnpm store 17 | uses: actions/cache@v2 18 | with: 19 | path: ~/.pnpm-store 20 | key: ${{ runner.os }}-pnpmstore-${{ hashFiles('**/pnpm-lock.yaml') }} 21 | restore-keys: | 22 | ${{ runner.os }}-pnpmstore- 23 | - name: Cache node_modules 24 | uses: actions/cache@v2 25 | with: 26 | path: '**/node_modules' 27 | key: ${{ runner.os }}-tests-node-modules-${{ github.sha }} 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: 14.15.4 31 | - name: Setup pnpm 32 | uses: pnpm/action-setup@v1.2.1 33 | with: 34 | version: 5.17.2 35 | - run: echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH 36 | - name: Fetch target branch 37 | run: >- 38 | git fetch --no-tags --prune --depth=1 origin +refs/heads/${{ 39 | github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} 40 | - run: pnpm i --frozen-lockfile 41 | - run: .scripts/run-lint.ts 42 | - run: .scripts/run-tests.ts unit 43 | build: 44 | name: Build 45 | runs-on: ubuntu-20.04 46 | needs: 47 | - lint-unit-tests 48 | strategy: 49 | matrix: 50 | include: 51 | - name: web-api 52 | - name: internal-api 53 | steps: 54 | - uses: actions/checkout@v2 55 | - name: Cache node_modules 56 | uses: actions/cache@v2 57 | with: 58 | path: '**/node_modules' 59 | key: ${{ runner.os }}-tests-node-modules-${{ github.sha }} 60 | - uses: actions/setup-node@v2 61 | with: 62 | node-version: 14.15.4 63 | - name: Setup pnpm 64 | uses: pnpm/action-setup@v1.2.1 65 | with: 66 | version: 5.17.2 67 | - run: echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH 68 | - name: Fetch target branch 69 | run: >- 70 | git fetch --no-tags --prune --depth=1 origin +refs/heads/${{ 71 | github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} 72 | - name: Inject slug/short variables 73 | uses: rlespinasse/github-slug-action@v3.x 74 | - name: Docker login 75 | run: >- 76 | echo ${{ env.DOCKER_TOKEN }} | docker login -u ${{ env.DOCKER_USER }} 77 | --password-stdin 78 | - name: Set image name 79 | run: >- 80 | echo "IMAGE=${{ env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ 81 | env.GITHUB_BASE_REF_SLUG }}-${{ matrix.name }}" >> $GITHUB_ENV 82 | - name: Should build docker image? 83 | id: needs-build 84 | run: | 85 | NEEDS_BUILD=$(.scripts/ci/needs-build.ts \ 86 | --repository ${{ env.DOCKER_REPOSITORY }} \ 87 | --image ${{ env.IMAGE }} \ 88 | ${{ matrix.name }}) 89 | echo "::set-output name=result::$NEEDS_BUILD" 90 | - uses: docker/setup-buildx-action@v1 91 | if: steps.needs-build.outputs.result == 'true' 92 | - name: Build docker image 93 | if: steps.needs-build.outputs.result == 'true' 94 | run: | 95 | docker -v 96 | docker buildx version 97 | 98 | .scripts/build-image.ts \ 99 | ${{ matrix.name }} \ 100 | --repository ${{ env.DOCKER_REPOSITORY }} \ 101 | --image ${{ env.IMAGE }} \ 102 | --tag ${{ github.event.pull_request.number }} 103 | integration-tests: 104 | name: Integration Tests 105 | runs-on: ubuntu-20.04 106 | needs: 107 | - build 108 | strategy: 109 | matrix: 110 | include: 111 | - name: web-api 112 | deps: internal-api 113 | - name: internal-api 114 | steps: 115 | - uses: actions/checkout@v2 116 | - name: Cache node_modules 117 | uses: actions/cache@v2 118 | with: 119 | path: '**/node_modules' 120 | key: ${{ runner.os }}-tests-node-modules-${{ github.sha }} 121 | - uses: actions/setup-node@v2 122 | with: 123 | node-version: 14.15.4 124 | - name: Setup pnpm 125 | uses: pnpm/action-setup@v1.2.1 126 | with: 127 | version: 5.17.2 128 | - run: echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH 129 | - name: Fetch target branch 130 | run: >- 131 | git fetch --no-tags --prune --depth=1 origin +refs/heads/${{ 132 | github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} 133 | - name: Inject slug/short variables 134 | uses: rlespinasse/github-slug-action@v3.x 135 | - name: Docker login 136 | run: >- 137 | echo ${{ env.DOCKER_TOKEN }} | docker login -u ${{ env.DOCKER_USER }} 138 | --password-stdin 139 | - name: Set image name 140 | run: >- 141 | echo "IMAGE=${{ env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ 142 | env.GITHUB_BASE_REF_SLUG }}-${{ matrix.name }}" >> $GITHUB_ENV 143 | - name: Prepare e2e tests if needed 144 | id: prepare-e2e 145 | run: | 146 | PREPARED=$(.scripts/ci/prepare-e2e-if-needed.ts \ 147 | ${{ matrix.name }} \ 148 | --repository ${{ env.DOCKER_REPOSITORY }} \ 149 | --image ${{ env.IMAGE }} \ 150 | --tag ${{ github.event.pull_request.number }} \ 151 | --deps ${{ matrix.deps }}) 152 | echo "::set-output name=prepared::$PREPARED" 153 | - name: Run integration tests 154 | if: steps.prepare-e2e.outputs.prepared == 'true' 155 | run: pnpm test:e2e --filter {${{ matrix.name }}} 156 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: Publish and Deploy 2 | 'on': 3 | push: 4 | branches: 5 | - main 6 | env: 7 | DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} 8 | DOCKER_USER: rhyek 9 | DOCKER_REPOSITORY: rhyek 10 | HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} 11 | WEB_API_APP: typescript-monorepo 12 | jobs: 13 | pr-number: 14 | name: Get PR number 15 | runs-on: ubuntu-20.04 16 | outputs: 17 | result: ${{ steps.pr-number.outputs.result }} 18 | steps: 19 | - name: Get PR number 20 | id: pr-number 21 | run: | 22 | NUMBER=$(curl \ 23 | -s \ 24 | -H 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \ 25 | -H 'accept: application/vnd.github.groot-preview+json' \ 26 | 'https://api.github.com/repos/${{ github.repository }}/commits/${{ github.sha }}/pulls' \ 27 | | jq -rM '.[0].number' 2> /dev/null \ 28 | || echo null 29 | ) 30 | echo "::set-output name=result::$NUMBER" 31 | pnpm-store-cache: 32 | name: Create pnpm store cache for future PRs 33 | runs-on: ubuntu-20.04 34 | needs: pr-number 35 | if: needs.pr-number.outputs.result != 'null' 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Cache pnpm store 39 | uses: actions/cache@v2 40 | with: 41 | path: ~/.pnpm-store 42 | key: ${{ runner.os }}-pnpmstore-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpmstore- 45 | - name: Setup pnpm 46 | uses: pnpm/action-setup@v1.2.1 47 | with: 48 | version: 5.17.2 49 | - run: echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH 50 | - run: pnpm i --frozen-lockfile 51 | publish-for-pr: 52 | name: Publish images for merged PR 53 | runs-on: ubuntu-20.04 54 | needs: pr-number 55 | if: needs.pr-number.outputs.result != 'null' 56 | strategy: 57 | matrix: 58 | include: 59 | - name: web-api 60 | heroku_app_key: WEB_API_APP 61 | - name: internal-api 62 | steps: 63 | - uses: actions/setup-node@v2 64 | with: 65 | node-version: 14.15.4 66 | - name: Inject slug/short variables 67 | uses: rlespinasse/github-slug-action@v3.x 68 | - name: Docker login 69 | run: >- 70 | echo ${{ env.DOCKER_TOKEN }} | docker login -u ${{ env.DOCKER_USER }} 71 | --password-stdin 72 | - name: Set image names 73 | id: image-names 74 | run: > 75 | echo "::set-output name=github::${{ 76 | env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ env.GITHUB_REF_SLUG }}-${{ 77 | matrix.name }}" 78 | 79 | echo "::set-output name=heroku::registry.heroku.com/${{ 80 | env[matrix.heroku_app_key] }}/web" 81 | - name: Check if image for PR exists 82 | id: image-exists 83 | run: > 84 | docker pull ${{ env.DOCKER_REPOSITORY }}/${{ 85 | steps.image-names.outputs.github }}:${{ needs.pr-number.outputs.result 86 | }} && EXIT_CODE=0 || EXIT_CODE=$? 87 | 88 | RESULT=$([ $EXIT_CODE = 0 ] && echo 'true' || echo 'false') 89 | 90 | echo "::set-output name=result::$RESULT" 91 | - name: Heroku Docker login 92 | run: >- 93 | echo ${{ secrets.HEROKU_API_KEY }} | docker login registry.heroku.com 94 | --username=_ --password-stdin 95 | if: >- 96 | steps.image-exists.outputs.result == 'true' && matrix.heroku_app_key 97 | != '' 98 | - name: Tag and push to heroku 99 | if: >- 100 | steps.image-exists.outputs.result == 'true' && matrix.heroku_app_key 101 | != '' 102 | run: > 103 | docker tag ${{ env.DOCKER_REPOSITORY }}/${{ 104 | steps.image-names.outputs.github }}:${{ needs.pr-number.outputs.result 105 | }} ${{ steps.image-names.outputs.heroku }} 106 | 107 | docker push ${{ steps.image-names.outputs.heroku }} 108 | - name: Tag 'latest' and push to ghcr 109 | id: push-to-ghcr 110 | if: steps.image-exists.outputs.result == 'true' 111 | run: > 112 | tag_latest_if_pr_image_exists () { 113 | local IMAGE_NAME=$1 114 | 115 | docker buildx imagetools inspect \ 116 | "${{ env.DOCKER_REPOSITORY }}/$IMAGE_NAME:${{ needs.pr-number.outputs.result }}" &> /dev/null && EXIT_CODE=0 || EXIT_CODE=$? 117 | 118 | if [ $EXIT_CODE = 0 ]; then 119 | docker buildx imagetools create \ 120 | -t "${{ env.DOCKER_REPOSITORY }}/$IMAGE_NAME:latest" \ 121 | "${{ env.DOCKER_REPOSITORY }}/$IMAGE_NAME:${{ needs.pr-number.outputs.result }}" 122 | fi 123 | } 124 | 125 | 126 | tag_latest_if_pr_image_exists ${{ steps.image-names.outputs.github }} 127 | 128 | tag_latest_if_pr_image_exists ${{ steps.image-names.outputs.github 129 | }}-cache 130 | - name: Slack Notification 131 | uses: rtCamp/action-slack-notify@v2 132 | if: cancelled() || failure() 133 | env: 134 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 135 | SLACK_USERNAME: github-actions 136 | SLACK_COLOR: >- 137 | ${{ job.status == 'success' && 'good' || job.status == 'cancelled' 138 | && '#808080' || 'danger' }} 139 | publish-for-push: 140 | name: Publish images for direct push 141 | runs-on: ubuntu-20.04 142 | needs: pr-number 143 | if: needs.pr-number.outputs.result == 'null' 144 | strategy: 145 | matrix: 146 | include: 147 | - name: web-api 148 | heroku_app_key: WEB_API_APP 149 | steps: 150 | - uses: actions/checkout@v2 151 | - name: Cache pnpm store 152 | uses: actions/cache@v2 153 | with: 154 | path: ~/.pnpm-store 155 | key: ${{ runner.os }}-pnpmstore-${{ hashFiles('**/pnpm-lock.yaml') }} 156 | restore-keys: | 157 | ${{ runner.os }}-pnpmstore- 158 | - name: Setup pnpm 159 | uses: pnpm/action-setup@v1.2.1 160 | with: 161 | version: 5.17.2 162 | - run: echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH 163 | - run: pnpm i --frozen-lockfile 164 | - name: Inject slug/short variables 165 | uses: rlespinasse/github-slug-action@v3.x 166 | - name: Docker login 167 | run: >- 168 | echo ${{ env.DOCKER_TOKEN }} | docker login -u ${{ env.DOCKER_USER }} 169 | --password-stdin 170 | - name: Heroku Docker login 171 | run: >- 172 | echo ${{ secrets.HEROKU_API_KEY }} | docker login registry.heroku.com 173 | --username=_ --password-stdin 174 | - name: Set image names 175 | id: image-names 176 | run: > 177 | echo "::set-output name=github::${{ 178 | env.GITHUB_REPOSITORY_NAME_PART_SLUG }}-${{ env.GITHUB_REF_SLUG }}-${{ 179 | matrix.name }}" 180 | 181 | echo "::set-output name=heroku::registry.heroku.com/${{ 182 | env[matrix.heroku_app_key] }}/web" 183 | - uses: docker/setup-buildx-action@v1 184 | - name: Build and Push docker image 185 | run: | 186 | .scripts/make-dedicated-lockfile.ts ${{ matrix.name }} --replace 187 | docker -v 188 | docker buildx version 189 | docker buildx build \ 190 | --cache-from=${{ env.DOCKER_REPOSITORY }}/${{ steps.image-names.outputs.github }}:latest \ 191 | --cache-to=type=inline \ 192 | -t ${{ env.DOCKER_REPOSITORY }}/${{ steps.image-names.outputs.github }}:latest \ 193 | -t ${{ steps.image-names.outputs.heroku }} \ 194 | -f $(.scripts/dir-for-app.ts ${{ matrix.name }})/Dockerfile \ 195 | --push \ 196 | . 197 | - name: Slack Notification 198 | uses: rtCamp/action-slack-notify@v2 199 | if: cancelled() || failure() 200 | env: 201 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 202 | SLACK_USERNAME: github-actions 203 | SLACK_COLOR: >- 204 | ${{ job.status == 'success' && 'good' || job.status == 'cancelled' 205 | && '#808080' || 'danger' }} 206 | deploy: 207 | name: Deploy apps 208 | runs-on: ubuntu-20.04 209 | needs: 210 | - publish-for-pr 211 | - publish-for-push 212 | if: >- 213 | always() && (needs.publish-for-pr.result == 'success' || 214 | needs.publish-for-push.result == 'success') 215 | steps: 216 | - uses: actions/setup-node@v2 217 | with: 218 | node-version: 14.15.4 219 | - run: curl -s https://cli-assets.heroku.com/install.sh | sh 220 | - run: heroku container:login 221 | - run: heroku container:release web --app=$WEB_API_APP 222 | - name: Slack Notification 223 | uses: rtCamp/action-slack-notify@v2 224 | if: always() 225 | env: 226 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 227 | SLACK_USERNAME: github-actions 228 | SLACK_COLOR: >- 229 | ${{ job.status == 'success' && 'good' || job.status == 'cancelled' 230 | && '#808080' || 'danger' }} 231 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/node_modules 3 | buildkit* 4 | Dockerfile.1 5 | Dockerfile.2 6 | token 7 | dockerhub-token 8 | .pnpm-debug.log 9 | **/pnpm-lock.yaml 10 | out.txt 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-prefix='' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.json 2 | *.js -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.scripts/build-image.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import 'colors'; 3 | import execa from 'execa'; 4 | import parseArgs from 'minimist'; 5 | import { getDirForAppName } from './common/app-name-utils'; 6 | import { findWorkspaceDir } from './common/pnpm-helpers'; 7 | import { makeDedicationLockfile } from './make-dedicated-lockfile'; 8 | 9 | type BuildImageOptions = { 10 | push?: { 11 | repository: string; 12 | image: string; 13 | tag: string; 14 | } | null; 15 | pipeLogs?: boolean; 16 | debug?: boolean; 17 | }; 18 | 19 | export async function buildImage( 20 | appName: string, 21 | options: BuildImageOptions = {}, 22 | ) { 23 | const { push, debug, pipeLogs } = options; 24 | await makeDedicationLockfile(appName); 25 | try { 26 | console.log('🐳', 'Building docker image for'.blue, appName); 27 | const appPath = await getDirForAppName(appName); 28 | const workspaceRoot = await findWorkspaceDir(); 29 | const args = [ 30 | 'buildx', 31 | 'build', 32 | ...(push 33 | ? [ 34 | `--cache-from=type=registry,ref=${push.repository}/${push.image}-cache:latest`, 35 | `--cache-from=type=registry,ref=${push.repository}/${push.image}-cache:${push.tag}`, 36 | `--cache-to=type=registry,ref=${push.repository}/${push.image}-cache:${push.tag}`, 37 | ] 38 | : []), 39 | '-t', 40 | push 41 | ? `${push.repository}/${push.image}:${push.tag}` 42 | : `test-docker-build-${appName}`, 43 | '-f', 44 | `${appPath}/Dockerfile`, 45 | push ? '--push' : '', 46 | workspaceRoot, 47 | ].filter((arg) => arg); 48 | if (debug) { 49 | console.debug('docker build args:', JSON.stringify(args, null, 2)); 50 | } 51 | await execa('docker', args, { stdio: pipeLogs ? 'inherit' : 'ignore' }); 52 | console.log('✔️ Sucessfully built image for'.green, appName); 53 | } catch (error) { 54 | console.error('\n❌ Build for'.red, appName.bold, 'failed.'.red); 55 | throw error; 56 | } 57 | } 58 | 59 | if (require.main === module) { 60 | async function main() { 61 | const argv = parseArgs(process.argv.slice(2), { boolean: ['debug'] }); 62 | const [appName] = argv._; 63 | const { repository, image, tag, debug } = argv; 64 | let push: BuildImageOptions['push'] = { 65 | repository, 66 | image, 67 | tag, 68 | }; 69 | // check if some push options are specified 70 | if (Object.values(push).some((v) => typeof v !== 'undefined')) { 71 | // ensure all push options are specified 72 | if (!Object.values(push).every((v) => typeof v !== 'undefined')) { 73 | throw new Error('All or no push options must be specified.'); 74 | } 75 | } 76 | // if not then send null 77 | else { 78 | push = null; 79 | } 80 | await buildImage(appName, { push, debug, pipeLogs: true }); 81 | } 82 | main(); 83 | 84 | process.on('unhandledRejection', (error) => { 85 | throw error; 86 | }); 87 | } 88 | -------------------------------------------------------------------------------- /.scripts/ci/needs-build.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import 'colors'; 3 | import parseArgs from 'minimist'; 4 | import { needsBuild } from '../needs-build'; 5 | import { remoteImageExists } from './remote-image-exists'; 6 | 7 | export async function ciNeedsBuild( 8 | appName: string, 9 | repository: string, 10 | image: string, 11 | ) { 12 | if ( 13 | (await needsBuild(appName)) || 14 | !(await remoteImageExists(repository, image, 'latest')) 15 | ) { 16 | return true; 17 | } 18 | return false; 19 | } 20 | 21 | if (require.main === module) { 22 | async function main() { 23 | const argv = parseArgs(process.argv.slice(2)); 24 | const [appName] = argv._; 25 | const { repository, image } = argv; 26 | console.log( 27 | (await ciNeedsBuild(appName, repository, image)) ? 'true' : 'false', 28 | ); 29 | } 30 | main(); 31 | 32 | process.on('unhandledRejection', (error) => { 33 | throw error; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /.scripts/ci/prepare-e2e-if-needed.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import fs from 'fs/promises'; 3 | import 'colors'; 4 | import parseArgs from 'minimist'; 5 | import Handlebars from 'handlebars'; 6 | import { needsBuild } from '../needs-build'; 7 | import { getDirForAppName } from '../common/app-name-utils'; 8 | import { remoteImageExists } from './remote-image-exists'; 9 | 10 | type Options = { 11 | repository: string; 12 | image: string; 13 | tag: string; 14 | deps: null | string[]; 15 | pipeLogs?: boolean; 16 | debug?: boolean; 17 | }; 18 | 19 | // prettier-ignore 20 | const dockerComposeTemplate = 21 | `services: 22 | {{#deps}} 23 | {{appName}}: 24 | image: {{../repository}}/{{image}}:{{tag}} 25 | {{/deps}} 26 | `; 27 | 28 | // strategy is to see if sut package changed or any of its 29 | // dependencies were built 30 | async function prepareE2EIfNeeded(appName: string, options: Options) { 31 | const { repository, image, tag, deps } = options; 32 | const sutChanged = await needsBuild(appName); 33 | let anyDepChanged = false; 34 | if (deps) { 35 | const templateData = { repository, deps: [] as any[] }; 36 | for (const dep of deps) { 37 | const depImage = image.replace(appName, dep); 38 | let depTag: string; 39 | if (await remoteImageExists(repository, depImage, tag)) { 40 | anyDepChanged = true; 41 | depTag = tag; 42 | } else { 43 | depTag = 'latest'; 44 | } 45 | templateData.deps.push({ appName: dep, image: depImage, tag: depTag }); 46 | } 47 | const composeContent = Handlebars.compile(dockerComposeTemplate)( 48 | templateData, 49 | ); 50 | const sutDir = await getDirForAppName(appName); 51 | const composeFile = `${sutDir}/test/docker-compose.ci.yaml`; 52 | await fs.writeFile(composeFile, composeContent, 'utf8'); 53 | console.error('Created', composeFile); 54 | console.error(composeContent); 55 | } 56 | const prepared = sutChanged || anyDepChanged; 57 | return prepared; 58 | } 59 | 60 | if (require.main === module) { 61 | async function main() { 62 | const argv = parseArgs(process.argv.slice(2), { string: ['deps'] }); 63 | const [appName] = argv._; 64 | const { repository, image, tag, deps } = argv; 65 | const finalDeps = deps 66 | ? (deps as string).split(',').filter((d: string) => d) 67 | : null; 68 | const prepared = await prepareE2EIfNeeded(appName, { 69 | repository, 70 | image, 71 | tag, 72 | deps: finalDeps, 73 | }); 74 | console.log(prepared ? 'true' : 'false'); 75 | } 76 | main(); 77 | 78 | process.on('unhandledRejection', (error) => { 79 | throw error; 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /.scripts/ci/remote-image-exists.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa'; 2 | import parseArgs from 'minimist'; 3 | 4 | export async function remoteImageExists( 5 | repository: string, 6 | image: string, 7 | tag: string, 8 | ) { 9 | return execa('docker', [ 10 | 'manifest', 11 | 'inspect', 12 | `${repository}/${image}:${tag}`, 13 | ]) 14 | .then(() => true) 15 | .catch(() => false); 16 | } 17 | 18 | if (require.main === module) { 19 | async function main() { 20 | const argv = parseArgs(process.argv.slice(2)); 21 | const { repository, image, tag } = argv; 22 | return (await remoteImageExists(repository, image, tag)) ? 'true' : 'false'; 23 | } 24 | main(); 25 | 26 | process.on('unhandledRejection', (error) => { 27 | throw error; 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /.scripts/common/app-name-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { getAppNameForDir } from './app-name-utils'; 2 | 3 | describe('get app name utils', () => { 4 | it('from dir', () => { 5 | expect(getAppNameForDir('/monorepo/apps/some-app')).toEqual('some-app'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /.scripts/common/app-name-utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { findWorkspaceDir } from './pnpm-helpers'; 3 | 4 | export function getAppNameForDir(dir: string) { 5 | return path.basename(dir); 6 | } 7 | 8 | export async function getDirForAppName(appName: string) { 9 | return `${await findWorkspaceDir()}/apps/${appName}`; 10 | } 11 | 12 | export async function getDirWithoutWorkspaceRootForDir(dir: string) { 13 | const workspaceRoot = await findWorkspaceDir(); 14 | return dir.split(workspaceRoot)[1]; 15 | } 16 | -------------------------------------------------------------------------------- /.scripts/common/consts.ts: -------------------------------------------------------------------------------- 1 | export const lockfileName = 'pnpm-lock.yaml'; 2 | -------------------------------------------------------------------------------- /.scripts/common/dedicated-lockfile.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import tempy from 'tempy'; 4 | import { 5 | filterPkgsBySelectorObjects, 6 | readProjects, 7 | } from '@pnpm/filter-workspace-packages'; 8 | import { readWantedLockfile, writeWantedLockfile } from '@pnpm/lockfile-file'; 9 | import { pruneSharedLockfile } from '@pnpm/prune-lockfile'; 10 | import { lockfileName } from './consts'; 11 | import { findWorkspaceDir } from './pnpm-helpers'; 12 | import { getDirForAppName } from './app-name-utils'; 13 | 14 | export async function _internalMakeDedicatedLockfileForPackage( 15 | originLockfileDir: string, 16 | forAppName: string, 17 | includeDependencies: boolean, 18 | ) { 19 | const workspaceDir = await findWorkspaceDir(); 20 | const { allProjects } = await readProjects(workspaceDir, []); 21 | const filtered = await filterPkgsBySelectorObjects( 22 | allProjects, 23 | [ 24 | { 25 | parentDir: await getDirForAppName(forAppName), 26 | includeDependencies, 27 | }, 28 | ], 29 | { workspaceDir }, 30 | ); 31 | const filteredPaths = Object.keys(filtered.selectedProjectsGraph); 32 | const lockfile = await readWantedLockfile(originLockfileDir, { 33 | ignoreIncompatible: false, 34 | }); 35 | if (!lockfile) { 36 | throw new Error('No lockfile found.'); 37 | } 38 | const allImporters = lockfile.importers; 39 | lockfile.importers = {}; 40 | for (const [importerId, importer] of Object.entries(allImporters)) { 41 | const fullPath = path.resolve(workspaceDir, importerId); 42 | if (filteredPaths.some((p) => p === fullPath)) { 43 | lockfile.importers[importerId] = importer; 44 | } 45 | } 46 | const dedicatedLockfile = pruneSharedLockfile(lockfile); 47 | if (Object.keys(dedicatedLockfile.importers).length === 0) { 48 | return null; 49 | } else { 50 | const tempDir = tempy.directory(); 51 | await writeWantedLockfile(tempDir, dedicatedLockfile); 52 | const dedicatedLockfilePath = path.resolve(tempDir, lockfileName); 53 | const dedicatedLockfileContent = await fs.readFile(dedicatedLockfilePath, { 54 | encoding: 'utf8', 55 | }); 56 | await fs.rm(dedicatedLockfilePath); 57 | 58 | return { 59 | content: dedicatedLockfileContent, 60 | }; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.scripts/common/find-changed-packages.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import fsLegacy from 'fs'; 3 | import path from 'path'; 4 | import 'colors'; 5 | import execa from 'execa'; 6 | import mem from 'mem'; 7 | import tempy from 'tempy'; 8 | import { 9 | readProjects as slowReadProjects, 10 | filterPkgsBySelectorObjects, 11 | PackageGraph, 12 | PackageSelector, 13 | } from '@pnpm/filter-workspace-packages'; 14 | import { Project } from '@pnpm/find-workspace-packages'; 15 | import { findWorkspaceDir } from '../common/pnpm-helpers'; 16 | import { lockfileName } from '../common/consts'; 17 | import { _internalMakeDedicatedLockfileForPackage } from './dedicated-lockfile'; 18 | import { debug } from './log'; 19 | import { PackageInfo } from './types/package-info'; 20 | import { getAppNameForDir } from './app-name-utils'; 21 | 22 | export type Selector = PackageSelector & { 23 | diffInclude?: RegExp | RegExp[]; 24 | diffExclude?: RegExp | RegExp[]; 25 | }; 26 | 27 | export function isCI() { 28 | return process.env.CI === 'true'; 29 | } 30 | 31 | function changedBasedOnDiffFiles( 32 | packageBaseDir: string, 33 | diffFiles: string[], 34 | diffInclude: RegExp[], 35 | diffExclude: RegExp[], 36 | ) { 37 | for (const diffFile of diffFiles) { 38 | if ( 39 | (!diffFile.includes('/') || diffFile.startsWith(packageBaseDir)) && 40 | (diffInclude.length === 0 || 41 | diffInclude.some((regex) => regex.exec(diffFile))) && 42 | diffExclude.every((regex) => !regex.exec(diffFile)) 43 | ) { 44 | return true; 45 | } 46 | } 47 | return false; 48 | } 49 | 50 | const changedBasedOnLockfileDiff = mem( 51 | async (workspaceDir: string, refLockfileDir: string, packageName: string) => { 52 | const current = await _internalMakeDedicatedLockfileForPackage( 53 | workspaceDir, 54 | packageName, 55 | false, 56 | ); 57 | const old = await _internalMakeDedicatedLockfileForPackage( 58 | refLockfileDir, 59 | packageName, 60 | false, 61 | ); 62 | const changed = 63 | (current === null) !== (old === null) || 64 | (current !== null && old !== null && current.content !== old.content); 65 | return changed; 66 | }, 67 | { cacheKey: (args) => args.join(',') }, 68 | ); 69 | 70 | const readProjects = mem((workspaceDir: string) => { 71 | return slowReadProjects(workspaceDir, []); 72 | }); 73 | 74 | async function filterProjects( 75 | workspaceDir: string, 76 | diffFiles: string[] | false, 77 | refLockfileDir: string | false, 78 | selectors: Selector[], 79 | ) { 80 | const matchedPackages: PackageGraph = {}; 81 | const { allProjects } = await readProjects(workspaceDir); 82 | await Promise.all( 83 | selectors.map(async (selector) => { 84 | const { 85 | namePattern, 86 | parentDir, 87 | excludeSelf, 88 | includeDependencies, 89 | includeDependents, 90 | } = selector; 91 | let { diffInclude = [], diffExclude = [] } = selector; 92 | if (!Array.isArray(diffInclude)) { 93 | diffInclude = [diffInclude]; 94 | } 95 | if (!Array.isArray(diffExclude)) { 96 | diffExclude = [diffExclude]; 97 | } 98 | const packages = await filterPkgsBySelectorObjects( 99 | allProjects, 100 | [{ namePattern, parentDir }], 101 | { workspaceDir }, 102 | ); 103 | // eslint-disable-next-line prefer-const 104 | for (let [packageBaseDir, config] of Object.entries( 105 | packages.selectedProjectsGraph, 106 | )) { 107 | packageBaseDir = packageBaseDir.substr(workspaceDir.length + 1); 108 | const packageName = config.package.manifest.name; 109 | if (!packageName) { 110 | throw new Error(`Package at ${packageBaseDir} is missing a name.`); 111 | } 112 | const changed = 113 | (diffFiles === false 114 | ? false 115 | : changedBasedOnDiffFiles( 116 | packageBaseDir, 117 | diffFiles, 118 | diffInclude, 119 | diffExclude, 120 | )) || 121 | (refLockfileDir === false 122 | ? false 123 | : await changedBasedOnLockfileDiff( 124 | workspaceDir, 125 | refLockfileDir, 126 | packageName, 127 | )); 128 | if (changed) { 129 | const { selectedProjectsGraph } = await filterPkgsBySelectorObjects( 130 | allProjects, 131 | [ 132 | { 133 | parentDir, 134 | namePattern: config.package.manifest.name, 135 | excludeSelf, 136 | includeDependencies, 137 | includeDependents, 138 | }, 139 | ], 140 | { workspaceDir }, 141 | ); 142 | Object.assign(matchedPackages, selectedProjectsGraph); 143 | } 144 | } 145 | }), 146 | ); 147 | return matchedPackages; 148 | } 149 | 150 | const getTargetComparisonGitRef = mem(async () => { 151 | const commit = isCI() 152 | ? `origin/${process.env.GITHUB_BASE_REF}` 153 | : (await execa('git', ['rev-parse', 'HEAD'])).stdout; // get last commit on current branch 154 | 155 | if (!commit) { 156 | throw new Error('No ref to compare to.'); 157 | } 158 | return commit; 159 | }); 160 | 161 | const getDiffFiles = mem(async (comparisonRef: string) => { 162 | const { stdout } = await execa('git', ['diff', '--name-only', comparisonRef]); 163 | const files = stdout.split('\n'); 164 | return files; 165 | }); 166 | 167 | const generateLockfileFromRef = mem(async (ref: string) => { 168 | const tempDir = tempy.directory(); 169 | const { stdout } = await execa('git', ['show', `${ref}:${lockfileName}`]); 170 | const oldLockfilePath = path.resolve(tempDir, lockfileName); 171 | await fs.writeFile(oldLockfilePath, stdout, { encoding: 'utf8' }); 172 | process.on('exit', () => { 173 | fsLegacy.rmSync(oldLockfilePath); 174 | }); 175 | return { content: stdout, dir: tempDir, path: oldLockfilePath }; 176 | }); 177 | 178 | Object.defineProperty(RegExp.prototype, 'toJSON', { 179 | value: RegExp.prototype.toString, 180 | }); 181 | 182 | function logParams(selectors: Selector[], ref: string) { 183 | debug('Finding changed packages with:'); 184 | debug(' Selectors:'); 185 | for (const selector of selectors) { 186 | let i = 0; 187 | for (const [key, value] of Object.entries(selector)) { 188 | if (i === 0) { 189 | debug(` - ${key}: ${value}`); 190 | } else { 191 | debug(` ${key}: ${value}`); 192 | } 193 | i++; 194 | } 195 | } 196 | debug(` Against ref: ${ref}`); 197 | } 198 | 199 | function logResult(packageNames: string[]) { 200 | debug('Changed:'); 201 | if (packageNames.length > 0) { 202 | for (const packageName of packageNames) { 203 | debug(` - ${packageName}`); 204 | } 205 | } else { 206 | debug(' No changes.'.blue); 207 | } 208 | } 209 | 210 | export interface FindChangePackagesOptions { 211 | useDiffFiles?: boolean; 212 | diffFiles?: string[]; 213 | useLockFile?: boolean; 214 | onlyPackages?: string[]; 215 | } 216 | 217 | export async function findChangedPackages( 218 | selectors: Selector[], 219 | options?: FindChangePackagesOptions, 220 | ): Promise { 221 | const comparisonRef = await getTargetComparisonGitRef(); 222 | const { 223 | useDiffFiles = true, 224 | diffFiles = await getDiffFiles(comparisonRef), 225 | useLockFile = true, 226 | onlyPackages, 227 | } = options ?? {}; 228 | logParams(selectors, comparisonRef); 229 | const workspaceDir = await findWorkspaceDir(); 230 | const refLockfile = await generateLockfileFromRef(comparisonRef); 231 | const graph = await filterProjects( 232 | workspaceDir, 233 | useDiffFiles ? diffFiles : false, 234 | useLockFile ? refLockfile.dir : false, 235 | selectors, 236 | ); 237 | const packageInfos = Object.values(graph) 238 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 239 | .map( 240 | (node): PackageInfo => ({ 241 | name: getAppNameForDir(node.package.dir), 242 | dir: node.package.dir, 243 | }), 244 | ) 245 | .filter( 246 | ({ name }) => 247 | typeof onlyPackages === 'undefined' || onlyPackages.includes(name), 248 | ); 249 | logResult(packageInfos.map((i) => i.name)); 250 | return packageInfos; 251 | } 252 | -------------------------------------------------------------------------------- /.scripts/common/find-packages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | filterPkgsBySelectorObjects, 3 | PackageSelector, 4 | readProjects, 5 | } from '@pnpm/filter-workspace-packages'; 6 | import { getAppNameForDir } from './app-name-utils'; 7 | import { findWorkspaceDir } from './pnpm-helpers'; 8 | import { PackageInfo } from './types/package-info'; 9 | 10 | export async function findPackages( 11 | selectors: PackageSelector[], 12 | ): Promise { 13 | const workspaceDir = await findWorkspaceDir(); 14 | const { allProjects } = await readProjects(workspaceDir, []); 15 | const { selectedProjectsGraph } = await filterPkgsBySelectorObjects( 16 | allProjects, 17 | selectors, 18 | { 19 | workspaceDir, 20 | }, 21 | ); 22 | const packageInfos = Object.values(selectedProjectsGraph).map((node) => ({ 23 | name: getAppNameForDir(node.package.dir), 24 | dir: node.package.dir, 25 | })); 26 | return packageInfos; 27 | } 28 | -------------------------------------------------------------------------------- /.scripts/common/log.ts: -------------------------------------------------------------------------------- 1 | export function debug(...args: any[]) { 2 | if (process.env.DEBUG === 'true') { 3 | console.debug(...args); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.scripts/common/pnpm-helpers.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import 'colors'; 3 | import execa from 'execa'; 4 | import findUp from 'find-up'; 5 | import { getDirWithoutWorkspaceRootForDir } from './app-name-utils'; 6 | 7 | async function run( 8 | command: string, 9 | appDirs: string[], 10 | args: string[], 11 | silent: boolean, 12 | ) { 13 | appDirs = await Promise.all(appDirs.map(getDirWithoutWorkspaceRootForDir)); 14 | if (appDirs.length > 0) { 15 | const allArgs = [ 16 | command, 17 | '--parallel', 18 | '--parseable', 19 | ...appDirs.map((appDir) => ['--filter', `{${appDir}}`]), 20 | ...args, 21 | ].flat(1); 22 | await execa('pnpm', allArgs, silent ? undefined : { stdio: 'inherit' }); 23 | } else if (!silent) { 24 | console.log('No packages selected.'.blue.italic); 25 | } 26 | } 27 | 28 | export async function pnpmExec( 29 | command: string[], 30 | appDirs: string[], 31 | silent = false, 32 | ) { 33 | await run('exec', appDirs, ['--', ...command], silent); 34 | } 35 | 36 | export async function pnpmRun( 37 | command: string, 38 | appDirs: string[], 39 | silent = false, 40 | ) { 41 | await run('run', appDirs, [command], silent); 42 | } 43 | 44 | export async function findWorkspaceDir() { 45 | const workspaceFile = await findUp('pnpm-workspace.yaml'); 46 | if (!workspaceFile) { 47 | throw new Error('pnpm-workspace.yaml file not found.'); 48 | } 49 | return path.dirname(workspaceFile); 50 | } 51 | -------------------------------------------------------------------------------- /.scripts/common/types/package-info.ts: -------------------------------------------------------------------------------- 1 | export type PackageInfo = { 2 | name: string; 3 | dir: string; 4 | }; 5 | -------------------------------------------------------------------------------- /.scripts/dir-for-app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import 'colors'; 3 | import parseArgs from 'minimist'; 4 | import { getDirForAppName } from './common/app-name-utils'; 5 | 6 | if (require.main === module) { 7 | async function main() { 8 | const argv = parseArgs(process.argv.slice(2)); 9 | const [appName] = argv._; 10 | return await getDirForAppName(appName); 11 | } 12 | main(); 13 | 14 | process.on('unhandledRejection', (error) => { 15 | throw error; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /.scripts/make-dedicated-lockfile.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import yargs from 'yargs'; 5 | import { findWorkspaceDir } from './common/pnpm-helpers'; 6 | import { lockfileName } from './common/consts'; 7 | import { _internalMakeDedicatedLockfileForPackage } from './common/dedicated-lockfile'; 8 | import { getDirForAppName } from './common/app-name-utils'; 9 | 10 | export async function makeDedicationLockfile( 11 | appName: string, 12 | outFile?: string, 13 | ) { 14 | if (!outFile) { 15 | const pkgPath = await getDirForAppName(appName); 16 | outFile = path.resolve(pkgPath, lockfileName); 17 | } 18 | if (!path.isAbsolute(outFile)) { 19 | outFile = path.resolve(process.cwd(), outFile); 20 | } 21 | const workspaceDir = await findWorkspaceDir(); 22 | const result = await _internalMakeDedicatedLockfileForPackage( 23 | workspaceDir, 24 | appName, 25 | true, 26 | ); 27 | if (result === null) { 28 | throw new Error('No lockfile generated.'); 29 | } 30 | const { content } = result; 31 | await fs.writeFile(outFile, content, { encoding: 'utf8' }); 32 | return { content, outFile }; 33 | } 34 | 35 | if (require.main === module) { 36 | yargs(process.argv.slice(2)) 37 | .command( 38 | '$0 [outFile]', 39 | 'generate a new pruned pnpm lock file for the specified package', 40 | (yargs) => { 41 | yargs 42 | .positional('package', { 43 | type: 'string', 44 | describe: 'package name as shown in its package.json', 45 | }) 46 | .positional('outFile', { 47 | type: 'string', 48 | describe: 'path to the new lockfile', 49 | }) 50 | .version(false) 51 | .help(); 52 | }, 53 | async (argv) => { 54 | const { package: packageName } = (argv as unknown) as { 55 | package: string; 56 | }; 57 | const { outFile: paramOutFile } = argv as { outFile?: string }; 58 | const { outFile } = await makeDedicationLockfile( 59 | packageName, 60 | paramOutFile, 61 | ); 62 | console.log(`Created ${outFile}`); 63 | }, 64 | ) 65 | .parse(); 66 | 67 | process.on('unhandledRejection', (error) => { 68 | throw error; 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /.scripts/needs-build.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import yargs from 'yargs'; 3 | import { getDirForAppName } from './common/app-name-utils'; 4 | import { findChangedPackages } from './common/find-changed-packages'; 5 | 6 | const overrides: any = { 7 | 'admin-backend': { 8 | diffInclude: [], 9 | }, 10 | }; 11 | 12 | export async function needsBuild(appName: string) { 13 | const changed = await findChangedPackages([ 14 | { 15 | parentDir: 'shared', 16 | excludeSelf: true, 17 | includeDependents: true, 18 | diffInclude: [/tsconfig\.(build\.)?json/, /\/package\.json/, /\.ts$/], 19 | diffExclude: /\.(e2e-)?spec\.ts$/, 20 | }, 21 | { 22 | parentDir: await getDirForAppName(appName), 23 | diffInclude: [ 24 | /tsconfig\.(build\.)?json$/, 25 | /\/package\.json$/, 26 | /Dockerfile$/, 27 | /\.ts$/, 28 | ], 29 | diffExclude: /\.(e2e-)?spec\.ts$/, 30 | ...overrides[appName], 31 | }, 32 | ]); 33 | return changed.map((c) => c.name).includes(appName); 34 | } 35 | 36 | if (require.main === module) { 37 | yargs(process.argv.slice(2)) 38 | .command( 39 | '$0 ', 40 | 'decide wether to docker build an app', 41 | (yargs) => { 42 | yargs 43 | .positional('appName', { 44 | type: 'string', 45 | describe: 'app name same as its base folder name', 46 | }) 47 | .version(false) 48 | .help(); 49 | }, 50 | async (argv) => { 51 | const { appName } = (argv as unknown) as { 52 | appName: string; 53 | }; 54 | if (await needsBuild(appName)) { 55 | console.log('true'); 56 | } else { 57 | console.log('false'); 58 | } 59 | }, 60 | ) 61 | .parse(); 62 | } 63 | -------------------------------------------------------------------------------- /.scripts/pre-commit.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import 'colors'; 3 | import { main as runLint } from './run-lint'; 4 | import { main as runTests } from './run-tests'; 5 | 6 | async function main() { 7 | await runLint(); 8 | await runTests('unit'); 9 | await runTests('e2e'); 10 | } 11 | 12 | main(); 13 | 14 | process.on('unhandledRejection', (error) => { 15 | throw error; 16 | }); 17 | -------------------------------------------------------------------------------- /.scripts/run-lint.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import { findChangedPackages } from './common/find-changed-packages'; 3 | import { pnpmRun } from './common/pnpm-helpers'; 4 | 5 | export async function main() { 6 | console.log('Running lint.'.cyan.bold); 7 | const all = await findChangedPackages([ 8 | { 9 | parentDir: 'shared', 10 | diffInclude: [/\/.eslintrc/, /\.eslintrc/, /.*\.ts$/], 11 | }, 12 | { 13 | parentDir: 'apps', 14 | diffInclude: [/\/.eslintrc/, /\.eslintrc/, /.*\.ts$/], 15 | }, 16 | ]); 17 | await pnpmRun( 18 | 'lint', 19 | all.map((p) => p.dir), 20 | ); 21 | } 22 | 23 | if (require.main === module) { 24 | main(); 25 | 26 | process.on('unhandledRejection', (error) => { 27 | throw error; 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /.scripts/run-tests.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import { 3 | findChangedPackages, 4 | FindChangePackagesOptions, 5 | } from './common/find-changed-packages'; 6 | import { findPackages } from './common/find-packages'; 7 | import { pnpmRun } from './common/pnpm-helpers'; 8 | 9 | const config = { 10 | unit: [ 11 | { 12 | // any shared package who's own files changed 13 | parentDir: 'shared', 14 | diffInclude: [/tsconfig/, /\/package\.json/, /\bjest\b/, /src\/.+\.ts$/], 15 | diffExclude: [/test\/jest\b/], 16 | }, 17 | { 18 | // any shared package and its dependents who's own files changed except test files 19 | parentDir: 'shared', 20 | excludeSelf: true, 21 | includeDependents: true, 22 | diffInclude: [ 23 | /tsconfig\.(build\.)?json/, 24 | /\/package\.json/, 25 | /src\/.+\.ts$/, 26 | ], 27 | diffExclude: [/\.spec\.ts$/], 28 | }, 29 | { 30 | // any app package who's own files changed 31 | parentDir: 'apps', 32 | diffInclude: [/tsconfig/, /\/package\.json/, /\bjest\b/, /src\/.+\.ts$/], 33 | diffExclude: [/test\/jest\b/], 34 | }, 35 | ], 36 | e2e: [ 37 | { 38 | // any shared package and its dependents who's own files changed except test files 39 | parentDir: 'shared', 40 | excludeSelf: true, 41 | includeDependents: true, 42 | diffInclude: [ 43 | /tsconfig\.(build\.)?json/, 44 | /\/package\.json/, 45 | /src\/.+\.ts$/, 46 | ], 47 | diffExclude: [/\.spec\.ts$/], 48 | }, 49 | { 50 | // any app package who's own files changed 51 | parentDir: 'apps', 52 | diffInclude: [ 53 | /tsconfig\.((build|test-e2e)\.)?json/, 54 | /\/package\.json/, 55 | /\bjest\b/, 56 | /\/test\//, 57 | /src\/.+\.ts$/, 58 | ], 59 | diffExclude: [/\.spec\.ts$/], 60 | }, 61 | ], 62 | }; 63 | 64 | export async function getPackagesToTest( 65 | testType: 'unit' | 'e2e', 66 | options?: FindChangePackagesOptions, 67 | ) { 68 | const packages = await findChangedPackages(config[testType], options); 69 | return packages; 70 | } 71 | 72 | export async function getLibsToPrebuild( 73 | testType: 'unit' | 'e2e', 74 | options?: FindChangePackagesOptions, 75 | ) { 76 | const packagesToTest = await getPackagesToTest(testType, options); 77 | if (packagesToTest.length > 0) { 78 | const libsToPrebuild = ( 79 | await findPackages( 80 | packagesToTest.map((packageInfo) => ({ 81 | parentDir: packageInfo.dir, 82 | excludeSelf: true, 83 | includeDependencies: true, 84 | })), 85 | ) 86 | ).map((p) => p.dir); 87 | return libsToPrebuild; 88 | } 89 | return []; 90 | } 91 | 92 | /** 93 | * gather all shared packages that need to be built and build them first. 94 | * this is helpful in avoiding them each being built more than once simultaneously. 95 | * when running dependent packages' tests (which implicitly build dependencies) 96 | * in parallel as we do, if shared packages were to be built simultaneously 97 | * more than once (two apps depend on the same lib) there is a risk of 98 | * collision while writing output files. 99 | */ 100 | export async function main(testType: 'unit' | 'e2e') { 101 | if (!['unit', 'e2e'].includes(testType)) { 102 | throw new Error(`Invalid test type: ${testType}.`); 103 | } 104 | console.log(`Running ${testType} tests.`.cyan.bold); 105 | const libs = await getLibsToPrebuild(testType); 106 | await pnpmRun('build', libs, true); 107 | const all = await getPackagesToTest(testType); 108 | await pnpmRun( 109 | `test:${testType}`, 110 | all.map((p) => p.dir), 111 | ); 112 | } 113 | 114 | if (require.main === module) { 115 | main(process.argv[2] as 'unit' | 'e2e'); 116 | 117 | process.on('unhandledRejection', (error) => { 118 | throw error; 119 | }); 120 | } 121 | -------------------------------------------------------------------------------- /.scripts/test-docker-builds.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-transpile-only 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import parseArgs from 'minimist'; 5 | import 'colors'; 6 | import { buildImage } from './build-image'; 7 | import { findPackages } from './common/find-packages'; 8 | import { findWorkspaceDir } from './common/pnpm-helpers'; 9 | import { needsBuild } from './needs-build'; 10 | 11 | export async function testDockerBuilds(options: { 12 | onlyChanged: boolean; 13 | debug?: boolean; 14 | }) { 15 | const { onlyChanged, debug = false } = options; 16 | const workspaceRoot = await findWorkspaceDir(); 17 | const apps = await findPackages([{ parentDir: `${workspaceRoot}/apps` }]); 18 | type Config = { name: string; dir: string; dockerfile: string }; 19 | const configs: Config[] = ( 20 | await Promise.all( 21 | apps.map(async (app) => { 22 | const dockerfile = path.resolve(app.dir, 'Dockerfile'); 23 | if ( 24 | (await fs.stat(dockerfile).catch(() => false)) && 25 | (!onlyChanged || (await needsBuild(app.name))) 26 | ) { 27 | return { 28 | ...app, 29 | dockerfile, 30 | }; 31 | } 32 | return false; 33 | }), 34 | ) 35 | ).filter((r) => r !== false) as Config[]; 36 | if (configs.length === 0) { 37 | console.log('No apps to test docker builds for.'); 38 | } else { 39 | await Promise.all( 40 | configs.map(async (config) => { 41 | const { name } = config; 42 | await buildImage(name, { debug }); 43 | }), 44 | ); 45 | } 46 | console.log('✔️ Success!'.green); 47 | } 48 | 49 | if (require.main === module) { 50 | async function main() { 51 | const argv = parseArgs(process.argv.slice(2), { boolean: ['debug'] }); 52 | const { debug } = argv; 53 | testDockerBuilds({ onlyChanged: false, debug }); 54 | } 55 | main(); 56 | 57 | process.on('unhandledRejection', (error) => { 58 | throw error; 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /.scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json" 3 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.preferences.importModuleSpecifier": "non-relative" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript monorepo example 2 | 3 | The perfect typescript monorepo. 4 | 5 | ## Features 6 | 7 | - [TypeScript project references](https://www.typescriptlang.org/docs/handbook/project-references.html) 8 | - incremental builds for faster rebuilds during development 9 | - [tsc-watch](https://github.com/gilamran/tsc-watch) 10 | - [pnpm](https://pnpm.js.org/) 11 | - [ttypescript](https://github.com/cevek/ttypescript) 12 | - [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint) 13 | - automatic build of dependencies defined as TS project references when running dev, etc 14 | - automatic import suggestions in vscode for symbols within the current project or a referenced one (_does not require building dependencies for intellisense to reflect current changes!_) 15 | - base tsconfig.json, jest.config.js, .eslintrc.js, .prettierrc 16 | - non-relative paths for all imports 17 | - path alias replacement during compilation based on configured tsconfig paths using ttypescript, and [typescript-transform-paths](https://github.com/LeDDGroup/typescript-transform-paths) 18 | 19 | ### Bonus 20 | 21 | - Integration tests per SUT (system under test) each with its own docker-compose definition for dependencies (external or internal) 22 | - Includes CI/CD pipelines for Github Actions (currently deploying to Heroku) 23 | - Docker images built using multi-stage 24 | - Docker images built for integration tests are also used for production 25 | 26 | ## Getting Started 27 | 28 | - clone the repo 29 | - install pnpm globally 30 | - run `pnpm install` in the workspace root 31 | - run any of the scripts defined in the root package.json (dev, lint, etc) 32 | 33 | --- 34 | 35 | You can review all the needed changes from the initial to final commit [here](https://github.com/rhyek/typescript-monorepo-example/compare/d5a703c9304376297fa39418e20255e8dd60cc90..7e732e7179ad82066f8e5655bd35babc38764a2c). 36 | -------------------------------------------------------------------------------- /apps/internal-api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../../.eslintrc.js', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | rules: { 9 | '@typescript-eslint/no-explicit-any': 'error', 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /apps/internal-api/.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 -------------------------------------------------------------------------------- /apps/internal-api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:15-alpine as builder 2 | # dependencies 3 | RUN npm i -g pnpm 4 | WORKDIR /monorepo 5 | COPY pnpm-workspace.yaml ./ 6 | COPY apps/internal-api/pnpm-lock.yaml ./pnpm-lock.yaml 7 | COPY shared/lib/package.json shared/lib/ 8 | COPY apps/internal-api/package.json apps/internal-api/ 9 | RUN pnpm i --frozen-lockfile --prod 10 | # build 11 | COPY tsconfig.json ./ 12 | COPY shared/lib/tsconfig.json shared/lib/tsconfig.build.json shared/lib/ 13 | COPY shared/lib/src shared/lib/src 14 | COPY apps/internal-api/tsconfig.json apps/internal-api/tsconfig.build.json apps/internal-api/ 15 | COPY apps/internal-api/src apps/internal-api/src 16 | WORKDIR /monorepo/apps/internal-api 17 | RUN pnpm build 18 | 19 | FROM node:15-alpine 20 | # prod dependencies 21 | RUN npm i -g pnpm 22 | WORKDIR /monorepo 23 | COPY pnpm-workspace.yaml ./ 24 | COPY apps/internal-api/pnpm-lock.yaml ./pnpm-lock.yaml 25 | COPY shared/lib/package.json shared/lib/ 26 | COPY apps/internal-api/package.json apps/internal-api/ 27 | RUN pnpm i --frozen-lockfile --prod --no-optional 28 | # copy build 29 | COPY --from=builder /monorepo/shared/lib/dist shared/lib/dist 30 | COPY --from=builder /monorepo/apps/internal-api/dist apps/internal-api/dist 31 | WORKDIR /monorepo/apps/internal-api 32 | # run 33 | CMD ["pnpm", "start:prod"] 34 | -------------------------------------------------------------------------------- /apps/internal-api/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /apps/internal-api/jest.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('deepmerge'); 2 | const base = require('../../jest.config'); 3 | 4 | module.exports = { 5 | ...merge(base, { 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: 'tsconfig.test.json', 9 | }, 10 | }, 11 | }), 12 | rootDir: 'src', 13 | testRegex: [/.*\.spec\.ts$/], 14 | }; 15 | -------------------------------------------------------------------------------- /apps/internal-api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /apps/internal-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@my/internal-api", 3 | "version": "0.0.2", 4 | "author": "", 5 | "private": true, 6 | "license": "UNLICENSED", 7 | "scripts": { 8 | "dev": "PORT=3001 NODE_ENV=development tsc-watch --compiler ttypescript/bin/tsc -b tsconfig.build.json --onSuccess \"node --inspect dist/main\"", 9 | "build": "ttsc --build tsconfig.build.json", 10 | "start:prod": "node dist/main", 11 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 12 | "test:unit": "ttsc -b tsconfig.test.json && jest", 13 | "test:watch": "ttsc -b tsconfig.test.json && jest --watch", 14 | "test:e2e": "cd test; ttsc -b ../tsconfig.test.json && jest --runInBand" 15 | }, 16 | "dependencies": { 17 | "@my/lib": "workspace:^1.0.0", 18 | "@nestjs/common": "^7.5.1", 19 | "@nestjs/core": "^7.5.1", 20 | "@nestjs/platform-express": "^7.5.1", 21 | "@types/express": "^4.17.8", 22 | "@types/jest": "^26.0.15", 23 | "@types/node": "^14.14.6", 24 | "@types/supertest": "^2.0.10", 25 | "reflect-metadata": "^0.1.13", 26 | "rimraf": "^3.0.2", 27 | "rxjs": "^6.6.3", 28 | "ttypescript": "^1.5.12", 29 | "typescript": "^4.3.5", 30 | "typescript-transform-paths": "^2.2.2" 31 | }, 32 | "devDependencies": { 33 | "@nestjs/cli": "^7.5.1", 34 | "@nestjs/schematics": "^7.1.3", 35 | "@nestjs/testing": "^7.5.1", 36 | "deepmerge": "^4.2.2", 37 | "eslint": "^7.12.1", 38 | "jest": "^26.6.3", 39 | "prettier": "^2.1.2", 40 | "supertest": "^6.0.0", 41 | "ts-jest": "^26.4.3", 42 | "ts-loader": "^8.0.8", 43 | "ts-node": "^9.0.0", 44 | "tsc-watch": "^4.2.9", 45 | "tsconfig-paths": "^3.9.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/internal-api/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from 'src/app.controller'; 3 | import { AppService } from 'src/app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toEqual('HELLO'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/internal-api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from 'src/app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get() 9 | getHello(): string { 10 | return this.appService.getHello(); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/internal-api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from 'src/app.controller'; 3 | import { AppService } from 'src/app.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [AppController], 8 | providers: [AppService], 9 | }) 10 | export class AppModule {} 11 | -------------------------------------------------------------------------------- /apps/internal-api/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from '@my/lib'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | getHello(): string { 7 | return capitalize('hello'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/internal-api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from 'src/app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | const port = process.env.PORT; 8 | if (typeof port === 'undefined') { 9 | throw new Error('Port not defined.'); 10 | } 11 | await app.listen(port, () => { 12 | Logger.log(`Listening on port ${port}.`); 13 | }); 14 | } 15 | bootstrap(); 16 | -------------------------------------------------------------------------------- /apps/internal-api/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import request from 'supertest'; 4 | import { AppModule } from 'src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | // prettier-ignore 20 | return request(app.getHttpServer()) 21 | .get('/') 22 | .expect('HELLO'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /apps/internal-api/test/jest.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('deepmerge'); 2 | const base = require('../jest.config'); 3 | 4 | module.exports = { 5 | ...merge(base, { 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: '../tsconfig.test.json', 9 | }, 10 | }, 11 | }), 12 | rootDir: '.', 13 | testRegex: [/.*\.e2e-spec\.ts$/], 14 | }; 15 | -------------------------------------------------------------------------------- /apps/internal-api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "paths": { 7 | "src": ["./src"], 8 | }, 9 | "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo" 10 | }, 11 | "include": ["./src"], 12 | "exclude": ["**/*.spec.ts"], 13 | "references": [ 14 | { "path": "../../shared/lib/tsconfig.build.json" } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /apps/internal-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "src": ["./src"], 7 | "test": ["./test"], 8 | "@my/lib": ["../../shared/lib/src"], 9 | } 10 | }, 11 | "include": [ 12 | "./src", 13 | "./test" 14 | ], 15 | "references": [ 16 | { "path": "../../shared/lib" } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/internal-api/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "paths": {} 5 | }, 6 | "include": [], 7 | "references": [ 8 | { "path": "../../shared/lib/tsconfig.build.json" } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /apps/web-api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../../.eslintrc.js', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | rules: { 9 | '@typescript-eslint/no-explicit-any': 'error', 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /apps/web-api/.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 -------------------------------------------------------------------------------- /apps/web-api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:15-alpine as builder 2 | # dependencies 3 | RUN npm i -g pnpm 4 | WORKDIR /monorepo 5 | COPY pnpm-workspace.yaml ./ 6 | COPY apps/web-api/pnpm-lock.yaml ./pnpm-lock.yaml 7 | COPY shared/lib/package.json shared/lib/ 8 | COPY apps/web-api/package.json apps/web-api/ 9 | RUN pnpm i --frozen-lockfile --prod 10 | # build 11 | COPY tsconfig.json ./ 12 | COPY shared/lib/tsconfig.json shared/lib/tsconfig.build.json shared/lib/ 13 | COPY shared/lib/src shared/lib/src 14 | COPY apps/web-api/tsconfig.json apps/web-api/tsconfig.build.json apps/web-api/ 15 | COPY apps/web-api/src apps/web-api/src 16 | WORKDIR /monorepo/apps/web-api 17 | RUN pnpm build 18 | 19 | FROM node:15-alpine 20 | # prod dependencies 21 | RUN npm i -g pnpm 22 | WORKDIR /monorepo 23 | COPY pnpm-workspace.yaml ./ 24 | COPY apps/web-api/pnpm-lock.yaml ./pnpm-lock.yaml 25 | COPY shared/lib/package.json shared/lib/ 26 | COPY apps/web-api/package.json apps/web-api/ 27 | RUN pnpm i --frozen-lockfile --prod --no-optional 28 | # copy build 29 | COPY --from=builder /monorepo/shared/lib/dist shared/lib/dist 30 | COPY --from=builder /monorepo/apps/web-api/dist apps/web-api/dist 31 | WORKDIR /monorepo/apps/web-api 32 | # run 33 | CMD ["pnpm", "start:prod"] 34 | -------------------------------------------------------------------------------- /apps/web-api/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /apps/web-api/jest.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('deepmerge'); 2 | const base = require('../../jest.config'); 3 | 4 | module.exports = { 5 | ...merge(base, { 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: 'tsconfig.test.json', 9 | }, 10 | }, 11 | }), 12 | rootDir: 'src', 13 | testRegex: [/.*\.spec\.ts$/], 14 | }; 15 | -------------------------------------------------------------------------------- /apps/web-api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /apps/web-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@my/web-api", 3 | "version": "0.0.2", 4 | "author": "", 5 | "private": true, 6 | "license": "UNLICENSED", 7 | "scripts": { 8 | "dev": "PORT=3000 INTERNAL_API_PORT=3001 NODE_ENV=development tsc-watch --compiler ttypescript/bin/tsc -b tsconfig.build.json --onSuccess \"node --inspect dist/main\"", 9 | "build": "ttsc --build tsconfig.build.json", 10 | "start:prod": "node dist/main", 11 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 12 | "test:unit": "ttsc -b tsconfig.test.json && jest", 13 | "test:e2e": "cd test && ./run.sh" 14 | }, 15 | "dependencies": { 16 | "@my/lib": "workspace:^1.0.0", 17 | "@nestjs/common": "^7.5.1", 18 | "@nestjs/core": "^7.5.1", 19 | "@nestjs/platform-express": "^7.5.1", 20 | "@types/express": "^4.17.8", 21 | "@types/jest": "^26.0.15", 22 | "@types/node": "^14.14.6", 23 | "@types/supertest": "^2.0.10", 24 | "axios": "^0.21.1", 25 | "reflect-metadata": "^0.1.13", 26 | "rimraf": "^3.0.2", 27 | "rxjs": "^6.6.3", 28 | "ttypescript": "^1.5.12", 29 | "typescript": "^4.3.5", 30 | "typescript-transform-paths": "^2.2.2" 31 | }, 32 | "devDependencies": { 33 | "@nestjs/cli": "^7.5.1", 34 | "@nestjs/schematics": "^7.1.3", 35 | "@nestjs/testing": "^7.5.1", 36 | "deepmerge": "^4.2.2", 37 | "eslint": "^7.12.1", 38 | "jest": "^26.6.3", 39 | "prettier": "^2.1.2", 40 | "supertest": "^6.0.0", 41 | "ts-jest": "^26.4.3", 42 | "ts-loader": "^8.0.8", 43 | "ts-node": "^9.0.0", 44 | "tsc-watch": "^4.2.9", 45 | "tsconfig-paths": "^3.9.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/web-api/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from 'src/app.controller'; 3 | import { AppService } from 'src/app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toEqual('HELLO'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/web-api/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from 'src/app.service'; 3 | 4 | // hi! 5 | @Controller() 6 | export class AppController { 7 | constructor(private readonly appService: AppService) {} 8 | 9 | @Get() 10 | getHello(): string { 11 | return this.appService.getMyHello(); 12 | } 13 | 14 | @Get('/ia') 15 | async getInternalApiHello(): Promise { 16 | return this.appService.getInternalApiHello(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/web-api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from 'src/app.controller'; 3 | import { AppService } from 'src/app.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [AppController], 8 | providers: [AppService], 9 | }) 10 | export class AppModule {} 11 | -------------------------------------------------------------------------------- /apps/web-api/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import axios from 'axios'; 3 | import { capitalize } from '@my/lib'; 4 | 5 | @Injectable() 6 | export class AppService { 7 | getMyHello(): string { 8 | return capitalize('hello'); 9 | } 10 | 11 | async getInternalApiHello(): Promise { 12 | const { data } = await axios.get( 13 | `http://localhost:${process.env.INTERNAL_API_PORT}`, 14 | ); 15 | return data; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/web-api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from 'src/app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | const port = process.env.PORT; 8 | if (typeof port === 'undefined') { 9 | throw new Error('Port not defined.'); 10 | } 11 | app.enableShutdownHooks(); 12 | await app.listen(port, () => { 13 | Logger.log(`Listening on port ${port}!!`); 14 | }); 15 | } 16 | bootstrap(); 17 | -------------------------------------------------------------------------------- /apps/web-api/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import request from 'supertest'; 4 | import { AppModule } from 'src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | // prettier-ignore 20 | return request(app.getHttpServer()) 21 | .get('/') 22 | .expect('HELLO'); 23 | }); 24 | 25 | it('/ (GET) on internal-api', () => { 26 | // prettier-ignore 27 | return request(app.getHttpServer()) 28 | .get('/ia') 29 | .expect('HELLO'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /apps/web-api/test/docker-compose.ci.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | internal-api: 3 | image: rhyek/internal-api:latest 4 | -------------------------------------------------------------------------------- /apps/web-api/test/docker-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | internal-api: 3 | build: 4 | context: ../../.. 5 | dockerfile: apps/internal-api/Dockerfile 6 | -------------------------------------------------------------------------------- /apps/web-api/test/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | internal-api: 4 | environment: 5 | - PORT=3000 6 | ports: 7 | - '$INTERNAL_API_PORT:3000' 8 | -------------------------------------------------------------------------------- /apps/web-api/test/jest.config.js: -------------------------------------------------------------------------------- 1 | const merge = require('deepmerge'); 2 | const base = require('../jest.config'); 3 | 4 | module.exports = { 5 | ...merge(base, { 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: '../tsconfig.test.json', 9 | }, 10 | }, 11 | }), 12 | rootDir: '.', 13 | testRegex: [/.*\.e2e-spec\.ts$/], 14 | }; 15 | -------------------------------------------------------------------------------- /apps/web-api/test/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | export COMPOSE_PROJECT_NAME=web-api-e2e 6 | # Disabled due to https://github.com/moby/buildkit/issues/1981 7 | # TODO: BUILDKIT: re-enable after that issue is fixed 8 | # export COMPOSE_DOCKER_CLI_BUILD=1 9 | # export DOCKER_BUILDKIT=1 10 | export INTERNAL_API_PORT=4001 11 | 12 | ../../../.scripts/make-dedicated-lockfile.ts internal-api 13 | 14 | WHICH=$([ "$CI" == "true" ] && echo 'ci' || echo 'dev') 15 | docker-compose -f docker-compose.yaml -f "docker-compose.$WHICH.yaml" up --build --detach 16 | sh -s -- "localhost:$INTERNAL_API_PORT" -t 30 -- echo "Internal API available at $INTERNAL_API_PORT." < <(curl -s https://raw.githubusercontent.com/eficode/wait-for/v1.2.0/wait-for) 17 | 18 | ttsc -b ../tsconfig.test.json 19 | jest --runInBand 20 | -------------------------------------------------------------------------------- /apps/web-api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "paths": { 7 | "src": ["./src"], 8 | }, 9 | "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo", 10 | }, 11 | "include": ["./src"], 12 | "exclude": ["**/*.spec.ts"], 13 | "references": [ 14 | { "path": "../../shared/lib/tsconfig.build.json" } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /apps/web-api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "paths": { 6 | "src": ["./src"], 7 | "test": ["./test"], 8 | "@my/lib": ["../../shared/lib/src"], 9 | } 10 | }, 11 | "include": [ 12 | "./src", 13 | "./test", 14 | ], 15 | "references": [ 16 | { "path": "../../shared/lib" } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/web-api/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "paths": {} 5 | }, 6 | "include": [], 7 | "references": [ 8 | { "path": "../../shared/lib/tsconfig.build.json" } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | globals: { 4 | 'ts-jest': { 5 | compiler: 'ttypescript' 6 | }, 7 | }, 8 | testEnvironment: 'node', 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-monorepo", 3 | "private": true, 4 | "scripts": { 5 | "build:libs": "pnpm run --parallel --filter \"{apps}^...\" build", 6 | "dev": "pnpm build:libs && pnpm run --parallel --filter {apps} dev", 7 | "lint": "pnpm build:libs && pnpm run --parallel --filter {apps} --filter {shared} lint", 8 | "test:unit": "pnpm build:libs && pnpm run --parallel --filter {apps} --filter {shared} test:unit", 9 | "test:e2e": "pnpm build:libs && pnpm run --parallel --filter {apps} test:e2e" 10 | }, 11 | "husky": { 12 | "hooks": { 13 | "pre-commit": "jest -o .scripts/**/*.spec.ts && .scripts/pre-commit.ts && .github-src/build.sh" 14 | } 15 | }, 16 | "devDependencies": { 17 | "@pnpm/filter-workspace-packages": "^2.3.10", 18 | "@pnpm/find-workspace-packages": "^2.3.38", 19 | "@pnpm/lockfile-file": "^3.2.0", 20 | "@pnpm/logger": "^3.2.3", 21 | "@pnpm/prune-lockfile": "^2.0.19", 22 | "@types/jest": "26.0.24", 23 | "@types/js-yaml": "^4.0.0", 24 | "@types/minimist": "1.2.2", 25 | "@types/node": "^14.14.28", 26 | "@typescript-eslint/eslint-plugin": "^4.6.1", 27 | "@typescript-eslint/parser": "^4.6.1", 28 | "colors": "^1.4.0", 29 | "eslint": "^7.31.0", 30 | "eslint-config-prettier": "7.1.0", 31 | "eslint-plugin-prettier": "^3.1.4", 32 | "execa": "^5.1.1", 33 | "find-up": "^5.0.0", 34 | "handlebars": "4.7.7", 35 | "husky": "^4.3.8", 36 | "jest": "27.0.6", 37 | "js-yaml": "^4.0.0", 38 | "mem": "^8.0.0", 39 | "minimist": "1.2.5", 40 | "prettier": "^2.2.1", 41 | "tempy": "^1.0.0", 42 | "ts-jest": "27.0.4", 43 | "ts-node": "^9.1.1", 44 | "typescript": "4.3.5", 45 | "uuid": "8.3.2", 46 | "yargs": "^16.2.0" 47 | }, 48 | "dependencies": { 49 | "@types/uuid": "8.3.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'apps/*' 3 | - 'shared/*' 4 | -------------------------------------------------------------------------------- /shared/lib/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../../.eslintrc.js', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /shared/lib/jest.config.js: -------------------------------------------------------------------------------- 1 | const base = require('../../jest.config'); 2 | 3 | module.exports = { 4 | ...base, 5 | rootDir: 'src', 6 | testRegex: [/.*\.spec\.ts$/], 7 | }; 8 | -------------------------------------------------------------------------------- /shared/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@my/lib", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "lint": "eslint \"src/**/*.ts\" --fix", 9 | "test:unit": "jest", 10 | "test:watch": "jest --watch", 11 | "build": "ttsc --build tsconfig.build.json" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@types/jest": "^26.0.20", 18 | "eslint": "^7.18.0", 19 | "jest": "^26.6.3", 20 | "ts-jest": "^26.4.4", 21 | "ttypescript": "^1.5.12", 22 | "typescript": "^4.3.5", 23 | "typescript-transform-paths": "^2.2.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /shared/lib/src/functions.spec.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from 'src/functions'; 2 | 3 | describe('functions', () => { 4 | it('can capitalize', () => { 5 | expect(capitalize('hello')).toBe('HELLO'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /shared/lib/src/functions.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(str: string): string { 2 | return str.toUpperCase(); 3 | } 4 | -------------------------------------------------------------------------------- /shared/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'src/functions'; 2 | -------------------------------------------------------------------------------- /shared/lib/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist", 6 | "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo" 7 | }, 8 | "include": ["./src"], 9 | "exclude": ["**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /shared/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es5", 6 | "baseUrl": "./", 7 | "paths": { 8 | "src": ["./src"] 9 | } 10 | }, 11 | "include": ["./src"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "commonjs", 5 | "target": "es2019", 6 | "lib": ["ES2019.Array", "ES2020.String"], 7 | "composite": true, 8 | "incremental": true, 9 | "sourceMap": true, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "removeComments": true, 17 | "plugins": [ 18 | { 19 | "transform": "typescript-transform-paths" 20 | }, 21 | { 22 | "transform": "typescript-transform-paths", 23 | "afterDeclarations": true 24 | } 25 | ] 26 | } 27 | } 28 | --------------------------------------------------------------------------------