├── apps ├── website │ ├── public │ │ ├── robots.txt │ │ ├── _redirects │ │ ├── favicon.ico │ │ └── assets │ │ │ └── config.json │ ├── src │ │ ├── components │ │ │ ├── Layout │ │ │ │ ├── index.ts │ │ │ │ ├── components │ │ │ │ │ ├── ThemeModeToggle.tsx │ │ │ │ │ └── UserSection.tsx │ │ │ │ └── Layout.tsx │ │ │ ├── Table │ │ │ │ ├── index.ts │ │ │ │ └── Table.tsx │ │ │ ├── LinkNoStyles.tsx │ │ │ ├── PathCell.tsx │ │ │ └── ThemeProvider.tsx │ │ ├── pages │ │ │ ├── ReportPage │ │ │ │ ├── index.ts │ │ │ │ └── components │ │ │ │ │ ├── ReportHeader │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── components │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── ReviewRecord.tsx │ │ │ │ │ └── ReportHeader.tsx │ │ │ │ │ ├── ReportTable │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── components │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── SizeCell.tsx │ │ │ │ │ │ ├── StatusCell.tsx │ │ │ │ │ │ └── ChangeSizeCell.tsx │ │ │ │ │ └── ReportTable.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── TabTitle.tsx │ │ │ ├── ReportsPage │ │ │ │ ├── index.ts │ │ │ │ ├── components │ │ │ │ │ ├── ReportsChart │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── utils.ts │ │ │ │ │ │ ├── components │ │ │ │ │ │ │ ├── ColorCell.tsx │ │ │ │ │ │ │ └── CustomTooltip.tsx │ │ │ │ │ │ └── ReportsChart.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── SubprojectsAutocomplete.tsx │ │ │ │ │ └── ReportsResult.tsx │ │ │ │ └── ReportsPage.tsx │ │ │ ├── index.ts │ │ │ └── HomePage.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useQueryParams.ts │ │ │ └── useOnMount.ts │ │ ├── consts │ │ │ ├── commitRecords.ts │ │ │ └── theme.ts │ │ ├── services │ │ │ └── FetchError.ts │ │ ├── models │ │ │ └── UserModel.ts │ │ ├── typings │ │ │ ├── styled.d.ts │ │ │ └── vite-env.d.ts │ │ ├── utils │ │ │ ├── objectUtils.ts │ │ │ └── textUtils.ts │ │ ├── Router.tsx │ │ ├── stores │ │ │ ├── UserStore.ts │ │ │ └── ConfigStore.ts │ │ └── main.tsx │ ├── .env.development │ ├── tsconfig.app.json │ ├── tsconfig.spec.json │ ├── .eslintrc.json │ ├── .bundlemonrc.json │ ├── tsconfig.json │ ├── index.html │ ├── vite.config.ts │ ├── package.json │ └── project.json ├── service │ ├── .gitignore │ ├── .vercelignore │ ├── public │ │ └── index.html │ ├── src │ │ ├── utils │ │ │ ├── utils.ts │ │ │ ├── promiseUtils.ts │ │ │ ├── website.ts │ │ │ ├── projectUtils.ts │ │ │ ├── hashUtils.ts │ │ │ ├── linkUtils.ts │ │ │ └── reportUtils.ts │ │ ├── types │ │ │ ├── schemas │ │ │ │ ├── index.ts │ │ │ │ ├── subprojects.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── projects.ts │ │ │ │ ├── common.ts │ │ │ │ ├── githubOutput.ts │ │ │ │ └── commitRecords.ts │ │ │ └── auth.ts │ │ ├── consts │ │ │ ├── __tests__ │ │ │ │ ├── commitRecords.spec.ts │ │ │ │ └── __snapshots__ │ │ │ │ │ └── commitRecords.spec.ts.snap │ │ │ └── commitRecords.ts │ │ ├── controllers │ │ │ ├── usersController.ts │ │ │ ├── subprojectsController.ts │ │ │ ├── authController.ts │ │ │ ├── utils │ │ │ │ └── markdownReportGenerator.ts │ │ │ └── projectsController.ts │ │ ├── routes │ │ │ ├── api │ │ │ │ ├── index.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── usersRoutes.spec.ts │ │ │ ├── __tests__ │ │ │ │ └── isAliveRoute.spec.ts │ │ │ └── index.ts │ │ ├── middlewares │ │ │ └── auth.ts │ │ ├── types.ts │ │ ├── framework │ │ │ ├── mongo │ │ │ │ ├── init.ts │ │ │ │ ├── commitRecords │ │ │ │ │ ├── types.ts │ │ │ │ │ └── __tests__ │ │ │ │ │ │ └── utils.spec.ts │ │ │ │ └── client.ts │ │ │ └── env.ts │ │ └── local-certs │ │ │ ├── cert.pem │ │ │ └── key.pem │ ├── .eslintrc.json │ ├── docker-compose.test.yml │ ├── vercel │ │ └── serverless.ts │ ├── vercel.json │ ├── tsconfig.app.json │ ├── .development.env │ ├── tsconfig.spec.json │ ├── scripts │ │ ├── generateSecretKey.js │ │ ├── deleteProjectRecords.ts │ │ ├── replaceProjectBranch.ts │ │ ├── generateSchemas.js │ │ └── generateLocalData.ts │ ├── tests │ │ ├── setup.ts │ │ ├── utils.ts │ │ ├── app.ts │ │ └── projectUtils.ts │ ├── jest.config.ts │ ├── tsconfig.json │ └── package.json └── platform │ ├── README.md │ ├── tests │ ├── consts.ts │ ├── website.spec.ts │ └── isAlive.spec.ts │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── .dockerignore │ ├── tsconfig.spec.json │ ├── tsconfig.json │ ├── Dockerfile │ ├── project.json │ └── package.json ├── packages ├── bundlemon │ ├── lib │ │ ├── cli │ │ │ ├── __tests__ │ │ │ │ ├── assets │ │ │ │ │ ├── empty.json │ │ │ │ │ ├── bad-format.js │ │ │ │ │ ├── bad-format.json │ │ │ │ │ ├── bad-format.yaml │ │ │ │ │ └── success.json │ │ │ │ └── configFile.spec.ts │ │ │ ├── types.ts │ │ │ ├── configFile.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── main │ │ │ ├── analyzer │ │ │ │ ├── __tests__ │ │ │ │ │ ├── fixtures │ │ │ │ │ │ ├── getAllPaths │ │ │ │ │ │ │ └── 1 │ │ │ │ │ │ │ │ ├── a.html │ │ │ │ │ │ │ │ ├── a.js │ │ │ │ │ │ │ │ └── s │ │ │ │ │ │ │ │ ├── c │ │ │ │ │ │ │ │ └── ac.css │ │ │ │ │ │ │ │ └── g.aa.bbb │ │ │ │ │ │ └── getFilesGroupByPattern │ │ │ │ │ │ │ ├── index.html │ │ │ │ │ │ │ ├── service.js │ │ │ │ │ │ │ └── static │ │ │ │ │ │ │ ├── js │ │ │ │ │ │ │ ├── other.js │ │ │ │ │ │ │ ├── about.hjasj2u.js │ │ │ │ │ │ │ ├── login.a2j21i.js │ │ │ │ │ │ │ ├── main.jh2j2ks.js │ │ │ │ │ │ │ ├── test.jks22892s.chunk.js │ │ │ │ │ │ │ └── test2.js2k2kxj.chunk.js │ │ │ │ │ │ │ └── styles │ │ │ │ │ │ │ ├── other.css │ │ │ │ │ │ │ └── main.hjsj2ks.css │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ └── pathUtils.spec.ts.snap │ │ │ │ │ └── getFileSize.spec.ts │ │ │ │ ├── index.ts │ │ │ │ ├── getFileSize.ts │ │ │ │ ├── analyzeLocalFiles.ts │ │ │ │ └── fileDetailsUtils.ts │ │ │ ├── outputs │ │ │ │ ├── index.ts │ │ │ │ ├── outputs │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── fixtures │ │ │ │ │ │ │ ├── not-a-function-custom-output.js │ │ │ │ │ │ │ ├── sync-custom-output.js │ │ │ │ │ │ │ ├── async-custom-output-throw.js │ │ │ │ │ │ │ ├── sync-custom-output-throw.js │ │ │ │ │ │ │ └── async-custom-output.js │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json.ts │ │ │ │ │ ├── custom.ts │ │ │ │ │ └── console.ts │ │ │ │ ├── types.ts │ │ │ │ ├── utils.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── utils.spec.ts │ │ │ ├── report │ │ │ │ ├── index.ts │ │ │ │ └── generateReport.ts │ │ │ ├── utils │ │ │ │ ├── ci │ │ │ │ │ ├── providers │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── codefresh.ts │ │ │ │ │ │ ├── circleCI.ts │ │ │ │ │ │ ├── travis.ts │ │ │ │ │ │ └── github.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── validationUtils.ts │ │ │ │ ├── utils.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── configUtils.ts │ │ │ ├── index.ts │ │ │ ├── initializer.ts │ │ │ └── types.ts │ │ └── common │ │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── consts.spec.ts.snap │ │ │ └── consts.spec.ts │ │ │ ├── consts.ts │ │ │ └── logger.ts │ ├── bin │ │ └── bundlemon.ts │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── README.md │ ├── tsconfig.spec.json │ ├── package.json │ └── project.json ├── bundlemon-utils │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── lib │ │ ├── __tests__ │ │ │ ├── consts.spec.ts │ │ │ └── __snapshots__ │ │ │ │ └── consts.spec.ts.snap │ │ ├── index.ts │ │ ├── consts.ts │ │ └── diffReport │ │ │ └── utils.ts │ ├── README.md │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ ├── package.json │ └── project.json └── bundlemon-markdown-output │ ├── .eslintrc.json │ ├── lib │ ├── index.ts │ ├── types.ts │ ├── markdownUtils.ts │ └── __tests__ │ │ └── markdownUtils.spec.ts │ ├── jest.config.ts │ ├── README.md │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ ├── package.json │ └── project.json ├── assets ├── check-run.png ├── history.png ├── pr-comment.png ├── build-status-pass.png ├── history-hover-commit.png └── build-status-fail-max-size.png ├── .prettierrc.yml ├── .eslintignore ├── .github ├── codeql │ └── codeql-config.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml └── workflows │ ├── codeql.yml │ ├── build-web.yml │ └── publish-packages.yml ├── .prettierignore ├── jest.preset.js ├── .husky └── pre-commit ├── jest.config.ts ├── codecov.yml ├── docs ├── self-hosted │ ├── docker-compose.yaml │ └── README.md ├── cli-flags.md ├── types.md ├── customPathLabels.md ├── migration-v1-to-v2.md └── privacy.md ├── tsconfig.base.json ├── LICENSE ├── migrations.json ├── CONTRIBUTING.md ├── package.json ├── .eslintrc.json └── .gitignore /apps/website/public/robots.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/website/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/__tests__/assets/empty.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/service/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | private 3 | .vercel 4 | api/ 5 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './main'; 2 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/a.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/a.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/website/src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Layout'; 2 | -------------------------------------------------------------------------------- /apps/website/src/components/Table/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Table'; 2 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/__tests__/assets/bad-format.js: -------------------------------------------------------------------------------- 1 | module.exports = ; 2 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/__tests__/assets/bad-format.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | } -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/s/c/ac.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getAllPaths/1/s/g.aa.bbb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ReportPage'; 2 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ReportsPage'; 2 | -------------------------------------------------------------------------------- /assets/check-run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/check-run.png -------------------------------------------------------------------------------- /assets/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/history.png -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/index.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/service.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: es5 2 | singleQuote: true 3 | printWidth: 120 4 | tabWidth: 2 5 | -------------------------------------------------------------------------------- /apps/service/.vercelignore: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | !vercel.json 4 | !api 5 | !public 6 | api/package.json 7 | -------------------------------------------------------------------------------- /assets/pr-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/pr-comment.png -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/other.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/service/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/__tests__/assets/bad-format.yaml: -------------------------------------------------------------------------------- 1 | files: 2 | - 'a.js' 3 | - 'b.js 4 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/styles/other.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nx 3 | dist/ 4 | **/__tests__/**/assets 5 | .vercel 6 | apps/service/api/ -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/about.hjasj2u.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/login.a2j21i.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/main.jh2j2ks.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportHeader/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ReportHeader'; 2 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportTable/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ReportTable'; 2 | -------------------------------------------------------------------------------- /assets/build-status-pass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/build-status-pass.png -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/test.jks22892s.chunk.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/js/test2.js2k2kxj.chunk.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/fixtures/getFilesGroupByPattern/static/styles/main.hjsj2ks.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | export * from './outputManager'; 3 | -------------------------------------------------------------------------------- /apps/website/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/apps/website/public/favicon.ico -------------------------------------------------------------------------------- /assets/history-hover-commit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/history-hover-commit.png -------------------------------------------------------------------------------- /packages/bundlemon/bin/bundlemon.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import run from '../lib/cli'; 4 | 5 | run(); 6 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/__tests__/assets/success.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseDir": "build", 3 | "verbose": true 4 | } 5 | -------------------------------------------------------------------------------- /packages/bundlemon/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /assets/build-status-fail-max-size.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LironEr/bundlemon/HEAD/assets/build-status-fail-max-size.png -------------------------------------------------------------------------------- /packages/bundlemon-utils/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /.github/codeql/codeql-config.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL config' 2 | 3 | paths-ignore: 4 | - packages/bundlemon/lib/cli/__tests__/assets 5 | -------------------------------------------------------------------------------- /apps/website/.env.development: -------------------------------------------------------------------------------- 1 | VITE_BUNDLEMON_SERVICE_URL=https://localhost:3333 2 | VITE_GITHUB_APP_ID="Iv1.d691ef09ea414f92" 3 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/not-a-function-custom-output.js: -------------------------------------------------------------------------------- 1 | module.exports = 'not a function'; 2 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/report/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | export { generateReport } from './generateReport'; 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache 5 | /.nx/workspace-data -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { generateReportMarkdown } from './generator'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface GenerateReportMarkdownOptions { 2 | showLinkToReport?: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | export { analyzeLocalFiles } from './analyzeLocalFiles'; 4 | -------------------------------------------------------------------------------- /apps/platform/README.md: -------------------------------------------------------------------------------- 1 | # BundleMon platform 2 | 3 | BundleMon platform docker image contains both the service & the website (UI) of BundleMon. 4 | -------------------------------------------------------------------------------- /apps/platform/tests/consts.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = 'http://localhost:3333'; 2 | export const BASE_URL_NO_WEBSITE = 'http://localhost:4444'; 3 | -------------------------------------------------------------------------------- /apps/website/public/assets/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bundlemonServiceUrl": "https://api.bundlemon.dev", 3 | "githubAppClientId": "Iv1.0647108b3f7bfbd3" 4 | } 5 | -------------------------------------------------------------------------------- /apps/website/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useOnMount } from './useOnMount'; 2 | export { default as useQueryParams } from './useQueryParams'; 3 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset, testEnvironment: 'node', verbose: true }; 4 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as TabTitle } from './TabTitle'; 2 | export { default as ReportTable } from './ReportTable'; 3 | -------------------------------------------------------------------------------- /apps/website/src/consts/commitRecords.ts: -------------------------------------------------------------------------------- 1 | export enum CommitRecordsQueryResolution { 2 | All = 'all', 3 | Days = 'days', 4 | Weeks = 'weeks', 5 | Months = 'months', 6 | } 7 | -------------------------------------------------------------------------------- /apps/service/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export function roundDecimals(num: number, decimals: number) { 2 | return Number(Math.round(Number(num + 'e' + decimals)) + 'e-' + decimals); 3 | } 4 | -------------------------------------------------------------------------------- /packages/bundlemon/jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | displayName: 'bundlemon', 3 | preset: '../../jest.preset.js', 4 | coverageDirectory: '../../coverage/packages/bundlemon', 5 | }; 6 | -------------------------------------------------------------------------------- /apps/platform/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "rules": { 5 | "@typescript-eslint/no-explicit-any": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/service/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "rules": { 5 | "@typescript-eslint/no-explicit-any": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/website/src/hooks/useQueryParams.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'react-router'; 2 | 3 | export default function useQueryparams() { 4 | return new URLSearchParams(useLocation().search); 5 | } 6 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportTable/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as StatusCell } from './StatusCell'; 2 | export { default as ChangeSizeCell } from './ChangeSizeCell'; 3 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | export * from './common'; 4 | export * from './commitRecords'; 5 | export * from './githubOutput'; 6 | export * from './auth'; 7 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/ReportsChart/index.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | const ReportsChart = lazy(() => import('./ReportsChart')); 4 | 5 | export default ReportsChart; 6 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportHeader/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ReviewReportModal } from './ReviewReportModal'; 2 | export { default as ReviewRecord } from './ReviewRecord'; 3 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | displayName: 'bundlemon-utils', 3 | preset: '../../jest.preset.js', 4 | coverageDirectory: '../../coverage/packages/bundlemon-utils', 5 | }; 6 | -------------------------------------------------------------------------------- /apps/platform/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | export default async (): Promise => ({ 4 | displayName: 'platform', 5 | preset: '../../jest.preset.js', 6 | }); 7 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/lib/__tests__/consts.spec.ts: -------------------------------------------------------------------------------- 1 | import * as consts from '../consts'; 2 | 3 | describe('consts', () => { 4 | test('snapshot', () => { 5 | expect(consts).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/lib/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | export * from './consts'; 4 | export { generateDiffReport } from './diffReport'; 5 | export * from './types'; 6 | export * from './textUtils'; 7 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/sync-custom-output.js: -------------------------------------------------------------------------------- 1 | const output = (report) => { 2 | console.log('Hello from fixture!'); 3 | return report; 4 | }; 5 | 6 | module.exports = output; 7 | -------------------------------------------------------------------------------- /apps/website/src/hooks/useOnMount.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function (effect: () => void) { 4 | // eslint-disable-next-line react-hooks/exhaustive-deps 5 | useEffect(effect, []); 6 | } 7 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/types.ts: -------------------------------------------------------------------------------- 1 | import type { Compression } from 'bundlemon-utils'; 2 | 3 | export interface CliOptions { 4 | config?: string; 5 | subProject?: string; 6 | defaultCompression?: Compression; 7 | } 8 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/async-custom-output-throw.js: -------------------------------------------------------------------------------- 1 | const output = (report) => { 2 | console.log('Hello from fixture!'); 3 | return report; 4 | }; 5 | 6 | module.exports = output; 7 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/sync-custom-output-throw.js: -------------------------------------------------------------------------------- 1 | const output = (report) => { 2 | console.log('Hello from fixture!'); 3 | return report; 4 | }; 5 | 6 | module.exports = output; 7 | -------------------------------------------------------------------------------- /apps/service/docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | 3 | services: 4 | mongo: 5 | image: mongo:7.0 6 | ports: 7 | - '51651:27017' 8 | logging: 9 | driver: 'none' 10 | command: --quiet 11 | -------------------------------------------------------------------------------- /apps/service/src/consts/__tests__/commitRecords.spec.ts: -------------------------------------------------------------------------------- 1 | import * as consts from '../commitRecords'; 2 | 3 | describe('consts', () => { 4 | test('snapshot', () => { 5 | expect(consts).toMatchSnapshot(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | displayName: 'bundlemon-markdown-output', 3 | preset: '../../jest.preset.js', 4 | coverageDirectory: '../../coverage/packages/bundlemon-markdown-output', 5 | }; 6 | -------------------------------------------------------------------------------- /apps/platform/.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Allow files and directories 5 | !/dist 6 | 7 | # Ignore unnecessary files inside allowed directories 8 | # This should go after the allowed directories 9 | **/.DS_Store 10 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/__tests__/fixtures/async-custom-output.js: -------------------------------------------------------------------------------- 1 | const asyncOutput = (report) => { 2 | console.log('Hello from fixture!'); 3 | return Promise.resolve(report); 4 | }; 5 | 6 | module.exports = asyncOutput; 7 | -------------------------------------------------------------------------------- /apps/platform/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["jest", "node"] 6 | }, 7 | "include": ["jest.config.ts", "tests/**/*", "scripts/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/service/src/controllers/usersController.ts: -------------------------------------------------------------------------------- 1 | import type { RouteHandlerMethod } from 'fastify'; 2 | 3 | export const meController: RouteHandlerMethod = async (req) => { 4 | const { auth, ...user } = req.getUser(); 5 | 6 | return user; 7 | }; 8 | -------------------------------------------------------------------------------- /apps/website/src/services/FetchError.ts: -------------------------------------------------------------------------------- 1 | class FetchError extends Error { 2 | constructor( 3 | public message: string, 4 | public statusCode: number 5 | ) { 6 | super(message); 7 | } 8 | } 9 | 10 | export default FetchError; 11 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/README.md: -------------------------------------------------------------------------------- 1 | # bundlemon-utils 2 | 3 | [![npm](https://img.shields.io/npm/v/bundlemon-utils)](https://www.npmjs.com/package/bundlemon-utils) 4 | 5 | **Full documentation available [here](https://github.com/LironEr/bundlemon)** 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # This loads nvm.sh and sets the correct PATH before running hook 5 | export NVM_DIR="$HOME/.nvm" 6 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 7 | 8 | 9 | yarn lint-staged 10 | -------------------------------------------------------------------------------- /apps/website/src/models/UserModel.ts: -------------------------------------------------------------------------------- 1 | class UserModel { 2 | provider: string; 3 | name: string; 4 | 5 | constructor(user: any) { 6 | this.provider = user.provider; 7 | this.name = user.name; 8 | } 9 | } 10 | 11 | export default UserModel; 12 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/subprojects.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import type { BaseGetRequestSchema, ProjectIdParams } from './common'; 4 | 5 | export interface GetSubprojectsRequestSchema extends BaseGetRequestSchema { 6 | params: ProjectIdParams; 7 | } 8 | -------------------------------------------------------------------------------- /apps/service/vercel/serverless.ts: -------------------------------------------------------------------------------- 1 | import init from '../src/app'; 2 | 3 | export default async (req: any, res: any) => { 4 | const app = await init({ isServerless: true }); 5 | 6 | await app.ready(); 7 | 8 | app.server.emit('request', req, res); 9 | }; 10 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/types.ts: -------------------------------------------------------------------------------- 1 | export interface PathRecord { 2 | friendlyName?: string; 3 | path: string; 4 | color: string; 5 | minSize: number; 6 | maxSize: number; 7 | isSelected: boolean; 8 | latestSize?: number; 9 | } 10 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | import { getJestProjectsAsync } from '@nx/jest'; 3 | 4 | export default async (): Promise => ({ 5 | projects: [...(await getJestProjectsAsync()), '/jest.config.ts'], 6 | }); 7 | -------------------------------------------------------------------------------- /apps/service/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "buildCommand": "yarn add sodium-native@^4.0.0", 4 | "outputDirectory": "public", 5 | "rewrites": [ 6 | { 7 | "source": "/(.*)", 8 | "destination": "/api/serverless.js" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /apps/website/src/components/LinkNoStyles.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const LinkNoStyles = styled(Link)` 5 | color: inherit; 6 | text-decoration: none; 7 | `; 8 | 9 | export default LinkNoStyles; 10 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/auth.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import type { BaseRequestSchema } from './common'; 4 | 5 | export interface LoginRequestSchema extends BaseRequestSchema { 6 | body: { 7 | provider: 'github'; 8 | code: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/README.md: -------------------------------------------------------------------------------- 1 | # bundlemon-markdown-output 2 | 3 | [![npm](https://img.shields.io/npm/v/bundlemon-markdown-output)](https://www.npmjs.com/package/bundlemon-markdown-output) 4 | 5 | **Full documentation available [here](https://github.com/LironEr/bundlemon)** 6 | -------------------------------------------------------------------------------- /packages/bundlemon/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/service/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["node"] 6 | }, 7 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], 8 | "include": ["src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/website/src/typings/styled.d.ts: -------------------------------------------------------------------------------- 1 | import '@emotion/react'; 2 | import type { Theme as MuiTheme } from '@mui/material/styles'; 3 | 4 | declare module '@emotion/react' { 5 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 6 | export interface Theme extends MuiTheme {} 7 | } 8 | -------------------------------------------------------------------------------- /apps/website/src/typings/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_BUNDLEMON_SERVICE_URL: string; 5 | readonly VITE_GITHUB_APP_ID: string; 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv; 10 | } 11 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/website/src/utils/objectUtils.ts: -------------------------------------------------------------------------------- 1 | export function removeEmptyValuesFromObject(obj: Record) { 2 | const newObj = { ...obj }; 3 | 4 | for (const key in newObj) { 5 | if (!newObj[key]) { 6 | delete newObj[key]; 7 | } 8 | } 9 | 10 | return newObj; 11 | } 12 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | 3 | flags: 4 | packages: 5 | paths: 6 | - packages/ 7 | service: 8 | paths: 9 | - service/ 10 | coverage: 11 | status: 12 | patch: off 13 | project: 14 | default: 15 | only_pulls: true 16 | target: auto 17 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/providers/index.ts: -------------------------------------------------------------------------------- 1 | import github from './github'; 2 | import codefresh from './codefresh'; 3 | import travis from './travis'; 4 | import circleCI from './circleCI'; 5 | 6 | const providers = [github, codefresh, travis, circleCI]; 7 | 8 | export default providers; 9 | -------------------------------------------------------------------------------- /apps/service/.development.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | HTTP_SCHEMA=http 3 | ROOT_DOMAIN=localhost:4000 4 | MONGO_URL=mongodb://localhost:51651 5 | MONGO_DB_NAME=dev 6 | SECRET_SESSION_KEY=74925e5027a05d9e31082271747a92b11a3b6988fc303bbb2aae330bef92b3a7 7 | SHOULD_SERVE_WEBSITE=false 8 | API_PATH_PREFIX=/ 9 | -------------------------------------------------------------------------------- /apps/service/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["jest", "node"] 6 | }, 7 | "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts", "tests/**/*", "scripts/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /apps/website/src/consts/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | 3 | export const lightTheme = createTheme({ 4 | palette: { 5 | mode: 'light', 6 | }, 7 | }); 8 | 9 | export const darkTheme = createTheme({ 10 | palette: { 11 | mode: 'dark', 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.lib.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/service/scripts/generateSecretKey.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const sodium = require('sodium-native'); 4 | const buf = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES); 5 | sodium.randombytes_buf(buf); 6 | const str = buf.toString('hex'); 7 | 8 | console.log(str); 9 | -------------------------------------------------------------------------------- /packages/bundlemon/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node", "jest"] 7 | }, 8 | "exclude": ["**/*.spec.ts", "jest.config.ts"], 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/service/tests/setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require('path'); 3 | 4 | require('dotenv').config({ 5 | path: path.resolve(__dirname, '../.development.env'), 6 | }); 7 | 8 | process.env.MONGO_DB_NAME = 'test'; 9 | process.env.ROOT_DOMAIN = 'localhost'; 10 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node", "jest"] 7 | }, 8 | "exclude": ["**/*.spec.ts", "jest.config.ts"], 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/service/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | export default async (): Promise => ({ 4 | displayName: 'service', 5 | preset: '../../jest.preset.js', 6 | coverageDirectory: '../../coverage/apps/service', 7 | setupFiles: ['/tests/setup.ts'], 8 | }); 9 | -------------------------------------------------------------------------------- /apps/service/src/routes/api/index.ts: -------------------------------------------------------------------------------- 1 | import v1Routes from './v1'; 2 | 3 | import type { FastifyPluginCallback } from 'fastify'; 4 | 5 | const apiRoutes: FastifyPluginCallback = (app, _opts, done) => { 6 | app.register(v1Routes, { prefix: '/v1' }); 7 | 8 | done(); 9 | }; 10 | 11 | export default apiRoutes; 12 | -------------------------------------------------------------------------------- /packages/bundlemon/README.md: -------------------------------------------------------------------------------- 1 | # BundleMon 2 | 3 | [![npm](https://img.shields.io/npm/v/bundlemon)](https://www.npmjs.com/package/bundlemon) 4 | [![node](https://img.shields.io/node/v/bundlemon.svg)](https://github.com/LironEr/bundlemon) 5 | 6 | **Full documentation available [here](https://github.com/LironEr/bundlemon)** 7 | -------------------------------------------------------------------------------- /packages/bundlemon/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "lib/**/*.test.ts", "lib/**/*.spec.ts", "lib/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node", "jest"] 7 | }, 8 | "exclude": ["**/*.spec.ts", "jest.config.ts"], 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportTable/components/SizeCell.tsx: -------------------------------------------------------------------------------- 1 | import bytes from 'bytes'; 2 | 3 | interface SizeCellProps { 4 | size?: number; 5 | } 6 | 7 | const SizeCell = ({ size }: SizeCellProps) => { 8 | return {size ? bytes(size) : '-'}; 9 | }; 10 | 11 | export default SizeCell; 12 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/__snapshots__/pathUtils.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getFiles getAllPaths empty directory 1`] = `[]`; 4 | 5 | exports[`getFiles getRegexHash 1`] = `"(?[a-zA-Z0-9]+)"`; 6 | 7 | exports[`getFiles getRegexHash 2`] = `"(?[a-zA-Z0-9]+)"`; 8 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node", "jest-extended", "standard-version"] 7 | }, 8 | "include": ["**/*.spec.ts", "**/*.spec.js", "**/*.d.ts", "jest.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node", "jest-extended", "standard-version"] 7 | }, 8 | "include": ["**/*.spec.ts", "**/*.spec.js", "**/*.d.ts", "jest.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/platform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "baseUrl": ".", 6 | "paths": { 7 | "@tests/*": ["./tests/*"] 8 | } 9 | }, 10 | "references": [ 11 | { 12 | "path": "./tsconfig.spec.json" 13 | } 14 | ], 15 | "files": [], 16 | "include": [] 17 | } 18 | -------------------------------------------------------------------------------- /apps/website/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | 3 | export { default as HomePage } from './HomePage'; 4 | export const CreateProjectPage = lazy(() => import('./CreateProjectPage')); 5 | export const ReportPage = lazy(() => import('./ReportPage')); 6 | export const ReportsPage = lazy(() => import('./ReportsPage')); 7 | export const LoginPage = lazy(() => import('./LoginPage')); 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | **Describe the solution you'd like** 13 | 14 | **Describe alternatives you've considered** 15 | 16 | **Additional context** 17 | -------------------------------------------------------------------------------- /docs/self-hosted/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | bundlemon: 3 | image: ghcr.io/lironer/bundlemon-platform:v1 4 | ports: 5 | - '8080:8080' 6 | environment: 7 | MONGO_URL: mongodb://mongo:27017 8 | depends_on: 9 | - mongo 10 | 11 | mongo: 12 | image: mongo:7.0 13 | ports: 14 | - '27017:27017' 15 | logging: 16 | driver: 'none' 17 | command: --quiet 18 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportTable/components/StatusCell.tsx: -------------------------------------------------------------------------------- 1 | import { Chip } from '@mui/material'; 2 | import { Status } from 'bundlemon-utils'; 3 | 4 | interface StatusCellProps { 5 | status: Status; 6 | } 7 | 8 | const StatusCell = ({ status }: StatusCellProps) => { 9 | return ; 10 | }; 11 | 12 | export default StatusCell; 13 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import consoleOutput from './console'; 3 | import githubOutput from './github'; 4 | import jsonOutput from './json'; 5 | import customOutput from './custom'; 6 | 7 | import type { Output } from '../types'; 8 | 9 | const outputs: Output[] = [consoleOutput, githubOutput, jsonOutput, customOutput]; 10 | 11 | export const getAllOutputs = (): Output[] => outputs; 12 | -------------------------------------------------------------------------------- /apps/website/src/utils/textUtils.ts: -------------------------------------------------------------------------------- 1 | export function textEllipsis(str: string, maxLength: number, { side = 'end', ellipsis = '...' } = {}) { 2 | if (str.length > maxLength) { 3 | switch (side) { 4 | case 'start': 5 | return ellipsis + str.slice(-(maxLength - ellipsis.length)); 6 | case 'end': 7 | default: 8 | return str.slice(0, maxLength - ellipsis.length) + ellipsis; 9 | } 10 | } 11 | return str; 12 | } 13 | -------------------------------------------------------------------------------- /apps/platform/tests/website.spec.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL, BASE_URL_NO_WEBSITE } from './consts'; 2 | 3 | describe('serve website', () => { 4 | test('platform', async () => { 5 | const response = await fetch(BASE_URL); 6 | 7 | expect(response.status).toEqual(200); 8 | }); 9 | 10 | test('no website', async () => { 11 | const response = await fetch(BASE_URL_NO_WEBSITE); 12 | 13 | expect(response.status).toEqual(404); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/service/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2022", 5 | "baseUrl": ".", 6 | "paths": { 7 | "@tests/*": ["./tests/*"], 8 | "@/*": ["src/*"] 9 | } 10 | }, 11 | "references": [ 12 | { 13 | "path": "./tsconfig.app.json" 14 | }, 15 | { 16 | "path": "./tsconfig.spec.json" 17 | } 18 | ], 19 | "files": [], 20 | "include": [] 21 | } 22 | -------------------------------------------------------------------------------- /apps/platform/tests/isAlive.spec.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL, BASE_URL_NO_WEBSITE } from './consts'; 2 | 3 | describe('is alive', () => { 4 | test('platform', async () => { 5 | const response = await fetch(`${BASE_URL}/is-alive`); 6 | 7 | expect(response.status).toEqual(200); 8 | }); 9 | 10 | test('no website', async () => { 11 | const response = await fetch(`${BASE_URL_NO_WEBSITE}/is-alive`); 12 | 13 | expect(response.status).toEqual(200); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/service/src/controllers/subprojectsController.ts: -------------------------------------------------------------------------------- 1 | import { getSubprojects } from '../framework/mongo/commitRecords'; 2 | 3 | import type { FastifyValidatedRoute } from '../types/schemas'; 4 | import type { GetSubprojectsRequestSchema } from '../types/schemas/subprojects'; 5 | 6 | export const getSubprojectsController: FastifyValidatedRoute = async (req, res) => { 7 | const subprojects = await getSubprojects(req.params.projectId); 8 | 9 | res.send(subprojects); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/types.ts: -------------------------------------------------------------------------------- 1 | export interface CIEnvVars { 2 | ci: boolean; 3 | raw?: Record; 4 | provider?: 'github' | 'codefresh' | 'travis' | 'circleci'; 5 | owner?: string; 6 | repo?: string; 7 | branch?: string; 8 | commitSha?: string; 9 | targetBranch?: string; 10 | prNumber?: string; 11 | buildId?: string; 12 | commitMsg?: string; 13 | } 14 | 15 | export interface Provider { 16 | isItMe: boolean; 17 | getVars: () => CIEnvVars; 18 | } 19 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/projects.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { ProjectProvider } from 'bundlemon-utils'; 4 | import { GitDetails } from '../../types'; 5 | 6 | type GithubProviderAuthQuery = { 7 | runId: string; 8 | commitSha: string; 9 | }; 10 | 11 | type GithubProviderRequest = { 12 | body: Omit & { provider: ProjectProvider.GitHub }; 13 | query: GithubProviderAuthQuery; 14 | }; 15 | 16 | export type GetOrCreateProjectIdRequestSchema = GithubProviderRequest; 17 | -------------------------------------------------------------------------------- /apps/service/src/utils/promiseUtils.ts: -------------------------------------------------------------------------------- 1 | // tasks: Record> 2 | export async function promiseAllObject(tasks: any) { 3 | const items = await Promise.all( 4 | Object.keys(tasks).map(async (key) => { 5 | const val = await Promise.resolve(tasks[key]); 6 | 7 | return { key, val }; 8 | }) 9 | ); 10 | 11 | const result: Record = {}; 12 | 13 | items.forEach((item) => { 14 | result[item.key] = item.val; 15 | }); 16 | 17 | return result; 18 | } 19 | -------------------------------------------------------------------------------- /apps/website/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["node"] 6 | }, 7 | "exclude": [ 8 | "src/**/*.spec.ts", 9 | "src/**/*.test.ts", 10 | "src/**/*.spec.tsx", 11 | "src/**/*.test.tsx", 12 | "src/**/*.spec.js", 13 | "src/**/*.test.js", 14 | "src/**/*.spec.jsx", 15 | "src/**/*.test.jsx" 16 | ], 17 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] 18 | } 19 | -------------------------------------------------------------------------------- /apps/service/src/types/auth.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | declare module '@fastify/secure-session' { 4 | interface SessionData { 5 | user: UserSessionData; 6 | } 7 | } 8 | 9 | export interface LoggedInUser { 10 | provider: 'github'; 11 | name: string; 12 | } 13 | 14 | export interface UserSessionData extends LoggedInUser { 15 | auth: { 16 | token: string; 17 | }; 18 | } 19 | 20 | declare module 'fastify' { 21 | interface FastifyRequest { 22 | getUser: () => UserSessionData; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/types.ts: -------------------------------------------------------------------------------- 1 | import type { Report } from 'bundlemon-utils'; 2 | import type { NormalizedConfig } from '../types'; 3 | 4 | export interface OutputCreateParams { 5 | config: NormalizedConfig; 6 | options: unknown; 7 | } 8 | 9 | export interface OutputInstance { 10 | generate(report: Report): Promise | void; 11 | } 12 | 13 | export interface Output { 14 | name: string; 15 | create: (params: OutputCreateParams) => OutputInstance | undefined | Promise; 16 | } 17 | -------------------------------------------------------------------------------- /apps/service/src/routes/__tests__/isAliveRoute.spec.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify'; 2 | import { createTestApp } from '../../../tests/app'; 3 | 4 | describe('is alive route', () => { 5 | let app: FastifyInstance; 6 | 7 | beforeAll(async () => { 8 | app = await createTestApp(); 9 | }); 10 | 11 | test('success', async () => { 12 | const response = await app.inject({ 13 | method: 'GET', 14 | url: '/is-alive', 15 | }); 16 | 17 | expect(response.statusCode).toEqual(200); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | **Expected behavior** 14 | 15 | 16 | **Environment** 17 | BundleMon version: `` 18 | 19 | Please run this command inside your project and paste its contents here (it automatically copies to your clipboard) 20 | `npx envinfo --system --binaries --markdown | npx clipboard-cli` 21 | 22 | 23 | **Additional context** 24 | -------------------------------------------------------------------------------- /apps/service/src/consts/commitRecords.ts: -------------------------------------------------------------------------------- 1 | export enum CommitRecordsQueryResolution { 2 | All = 'all', 3 | Days = 'days', 4 | Weeks = 'weeks', 5 | Months = 'months', 6 | } 7 | 8 | export enum BaseRecordCompareTo { 9 | PreviousCommit = 'PREVIOUS_COMMIT', 10 | LatestCommit = 'LATEST_COMMIT', 11 | } 12 | 13 | export enum CreateCommitRecordAuthType { 14 | ProjectApiKey = 'PROJECT_API_KEY', 15 | GithubActions = 'GITHUB_ACTIONS', 16 | } 17 | 18 | export const MAX_COMMIT_MSG_LENGTH = 72; 19 | export const MAX_QUERY_RECORDS = 100; 20 | -------------------------------------------------------------------------------- /apps/website/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": ["vite/client", "node", "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts"] 6 | }, 7 | "include": [ 8 | "vite.config.ts", 9 | "src/**/*.test.ts", 10 | "src/**/*.spec.ts", 11 | "src/**/*.test.tsx", 12 | "src/**/*.spec.tsx", 13 | "src/**/*.test.js", 14 | "src/**/*.spec.js", 15 | "src/**/*.test.jsx", 16 | "src/**/*.spec.jsx", 17 | "src/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /apps/service/src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyReply, FastifyRequest } from 'fastify'; 2 | import type { UserSessionData } from '../types/auth'; 3 | 4 | export async function authMiddleware( 5 | req: FastifyRequest<{ Body: any; Params: any; Querystring: any; Headers: any }>, 6 | res: FastifyReply 7 | ) { 8 | const userSessionData: UserSessionData | undefined = req.session.get('user'); 9 | 10 | if (!userSessionData) { 11 | res.status(401).send({ message: 'unauthorized' }); 12 | return res; 13 | } 14 | 15 | req.getUser = () => userSessionData; 16 | return; 17 | } 18 | -------------------------------------------------------------------------------- /docs/cli-flags.md: -------------------------------------------------------------------------------- 1 | # CLI flags 2 | 3 | | Flag | Description | Type | 4 | | -------------------- | ------------------------------------------ | ------------------------------------------- | 5 | | -c, --config | Config file path | `string` optional | 6 | | --subProject | Override `subProject` config value | `string` optional | 7 | | --defaultCompression | Override `defaultCompression` config value | `"none"` \| `"gzip"` \| `"brotli"` optional | 8 | -------------------------------------------------------------------------------- /apps/service/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectProvider } from 'bundlemon-utils'; 2 | 3 | export interface ProjectApiKey { 4 | hash: string; 5 | startKey: string; 6 | } 7 | 8 | export interface Project { 9 | id: string; 10 | creationDate: string; 11 | apiKey: ProjectApiKey; 12 | } 13 | 14 | export interface GitDetails { 15 | provider: ProjectProvider; 16 | /** 17 | * @minLength 1 18 | * @maxLength 100 19 | * @pattern ^[a-zA-Z0-9_.-]*$ 20 | */ 21 | owner: string; 22 | /** 23 | * @minLength 1 24 | * @maxLength 100 25 | * @pattern ^[a-zA-Z0-9_.-]*$ 26 | */ 27 | repo: string; 28 | } 29 | -------------------------------------------------------------------------------- /apps/website/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json", "plugin:@nx/react"], 3 | "ignorePatterns": ["!**/*"], 4 | "env": { "browser": true }, 5 | "parserOptions": { 6 | "ecmaFeatures": { 7 | "jsx": true 8 | } 9 | }, 10 | "rules": { 11 | "@typescript-eslint/no-explicit-any": "off", 12 | "react/prop-types": "off", 13 | "react/react-in-jsx-scope": "off", 14 | "@typescript-eslint/ban-ts-comment": "off", 15 | "@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true, "argsIgnorePattern": "^_" }], 16 | "@typescript-eslint/no-empty-interface": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/website/.bundlemonrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "baseDir": "../../dist/apps/website", 4 | "files": [ 5 | { 6 | "path": "index.html" 7 | }, 8 | { 9 | "path": "assets/Main-*-.js" 10 | }, 11 | { 12 | "friendlyName": "JS files", 13 | "path": "assets/**/*-.js" 14 | } 15 | ], 16 | "groups": [ 17 | { 18 | "friendlyName": "JS files", 19 | "path": "**/*.js" 20 | } 21 | ], 22 | "defaultCompression": "none", 23 | "reportOutput": ["github"], 24 | "includeCommitMessage": true, 25 | "pathLabels": { 26 | "hash": "[a-zA-Z0-9\\-_]+" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /apps/website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ES2022", 4 | "jsx": "react-jsx", 5 | "allowJs": false, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "jsxImportSource": "@emotion/react", 9 | "types": ["vite/client"], 10 | "lib": ["dom"], 11 | "baseUrl": "./src", 12 | "paths": { 13 | "@/*": ["*"] 14 | } 15 | }, 16 | "files": [], 17 | "include": [], 18 | "references": [ 19 | { 20 | "path": "./tsconfig.app.json" 21 | }, 22 | { 23 | "path": "./tsconfig.spec.json" 24 | } 25 | ], 26 | "extends": "../../tsconfig.base.json" 27 | } 28 | -------------------------------------------------------------------------------- /apps/service/src/consts/__tests__/__snapshots__/commitRecords.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`consts snapshot 1`] = ` 4 | { 5 | "BaseRecordCompareTo": { 6 | "LatestCommit": "LATEST_COMMIT", 7 | "PreviousCommit": "PREVIOUS_COMMIT", 8 | }, 9 | "CommitRecordsQueryResolution": { 10 | "All": "all", 11 | "Days": "days", 12 | "Months": "months", 13 | "Weeks": "weeks", 14 | }, 15 | "CreateCommitRecordAuthType": { 16 | "GithubActions": "GITHUB_ACTIONS", 17 | "ProjectApiKey": "PROJECT_API_KEY", 18 | }, 19 | "MAX_COMMIT_MSG_LENGTH": 72, 20 | "MAX_QUERY_RECORDS": 100, 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /apps/service/src/utils/website.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { httpSchema, rootDomain, githubAppClientId } from '@/framework/env'; 3 | 4 | const WEBSITE_CONFIG_PATH = '/app/service/public/assets/config.json'; 5 | 6 | export function overrideWebsiteConfig() { 7 | const websiteConfig = fs.readFileSync(WEBSITE_CONFIG_PATH); 8 | let websiteConfigJson = JSON.parse(websiteConfig.toString()); 9 | 10 | websiteConfigJson = { 11 | ...websiteConfigJson, 12 | bundlemonServiceUrl: `${httpSchema}://${rootDomain}/api`, 13 | githubAppClientId, 14 | }; 15 | 16 | fs.writeFileSync(WEBSITE_CONFIG_PATH, JSON.stringify(websiteConfigJson)); 17 | } 18 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/validationUtils.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import logger from '../../common/logger'; 3 | 4 | export function validateYup( 5 | schema: yup.SchemaOf, 6 | value: unknown, 7 | configName: string 8 | ): yup.Asserts | undefined { 9 | try { 10 | return schema.validateSync(value, { abortEarly: false, strict: false }); 11 | } catch (err) { 12 | let error = err; 13 | 14 | if (err instanceof yup.ValidationError) { 15 | error = err.errors; 16 | } 17 | 18 | logger.error(`Validation error in ${configName} config`, error); 19 | } 20 | 21 | return undefined; 22 | } 23 | -------------------------------------------------------------------------------- /apps/service/tests/utils.ts: -------------------------------------------------------------------------------- 1 | import { UserSessionData } from '@/types/auth'; 2 | import { randomBytes } from 'crypto'; 3 | 4 | export function generateRandomString(length = 10) { 5 | return randomBytes(length / 2).toString('hex'); 6 | } 7 | 8 | export function generateRandomInt(min: number, max: number) { 9 | min = Math.ceil(min); 10 | max = Math.floor(max); 11 | return Math.floor(Math.random() * (max - min) + min); 12 | } 13 | 14 | export function generateUserSessionData(): UserSessionData { 15 | return { 16 | provider: 'github', 17 | name: generateRandomString(), 18 | auth: { 19 | token: generateRandomString(), 20 | }, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /apps/website/src/Router.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from 'react-router-dom'; 2 | import { HomePage, CreateProjectPage, ReportPage, ReportsPage, LoginPage } from '@/pages'; 3 | 4 | const Router = () => ( 5 | 6 | } /> 7 | 8 | 9 | } /> 10 | } /> 11 | 12 | 13 | } /> 14 | } /> 15 | 16 | ); 17 | 18 | export default Router; 19 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/getFileSize.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { file as calcGzipFileSize } from 'gzip-size'; 3 | import { file as calcBrotliFileSize } from 'brotli-size'; 4 | import { Compression } from 'bundlemon-utils'; 5 | 6 | export async function getFileSize(path: string, compression: Compression): Promise { 7 | switch (compression) { 8 | case Compression.Gzip: { 9 | return await calcGzipFileSize(path); 10 | } 11 | case Compression.Brotli: { 12 | return await calcBrotliFileSize(path); 13 | } 14 | case Compression.None: 15 | default: 16 | return (await fs.promises.readFile(path)).byteLength; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/service/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { apiPathPrefix } from '@/framework/env'; 2 | import apiRoutes from './api'; 3 | 4 | import type { FastifyPluginCallback } from 'fastify'; 5 | import { getDB } from '@/framework/mongo/client'; 6 | 7 | const routes: FastifyPluginCallback = (app, _opts, done) => { 8 | app.register(apiRoutes, { prefix: apiPathPrefix }); 9 | 10 | app.get('/is-alive', (_req, reply) => { 11 | reply.send('OK'); 12 | }); 13 | 14 | app.get('/health', async (_req, reply) => { 15 | const db = await getDB(); 16 | await db.admin().ping({ maxTimeMS: 5000 }); 17 | 18 | reply.send('OK'); 19 | }); 20 | 21 | done(); 22 | }; 23 | 24 | export default routes; 25 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/lib/consts.ts: -------------------------------------------------------------------------------- 1 | export enum Compression { 2 | None = 'none', 3 | Gzip = 'gzip', 4 | Brotli = 'brotli', 5 | } 6 | 7 | export enum DiffChange { 8 | NoChange = 'No change', 9 | Update = 'Update', 10 | Add = 'Add', 11 | Remove = 'Remove', 12 | } 13 | 14 | export enum Status { 15 | Pass = 'Pass', 16 | Fail = 'Fail', 17 | } 18 | 19 | export enum FailReason { 20 | MaxSize = 'MaxSize', 21 | MaxPercentIncrease = 'MaxPercentIncrease', 22 | } 23 | 24 | export enum ProjectProvider { 25 | GitHub = 'github', 26 | } 27 | 28 | export enum CommitRecordReviewResolution { 29 | Approved = 'approved', 30 | Rejected = 'rejected', 31 | Reset = 'reset', 32 | } 33 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-utils", 3 | "version": "2.0.1", 4 | "description": "", 5 | "keywords": [], 6 | "author": "Liron Er", 7 | "license": "MIT", 8 | "homepage": "https://github.com/LironEr/bundlemon.git", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/LironEr/bundlemon.git", 12 | "directory": "packages/bundlemon-utils" 13 | }, 14 | "engines": { 15 | "node": ">=18" 16 | }, 17 | "main": "lib/index", 18 | "types": "lib/index.d.ts", 19 | "scripts": {}, 20 | "dependencies": { 21 | "bytes": "^3.1.0" 22 | }, 23 | "devDependencies": { 24 | "@types/bytes": "^3.1.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/common/__tests__/__snapshots__/consts.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`consts default snapshot 1`] = ` 4 | { 5 | "CreateCommitRecordAuthType": { 6 | "GithubActions": "GITHUB_ACTIONS", 7 | "ProjectApiKey": "PROJECT_API_KEY", 8 | }, 9 | "DEFAULT_PATH_LABELS": { 10 | "hash": "[a-zA-Z0-9]+", 11 | }, 12 | "EnvVar": { 13 | "projectApiKey": "BUNDLEMON_PROJECT_APIKEY", 14 | "projectId": "BUNDLEMON_PROJECT_ID", 15 | "remoteFlag": "BUNDLEMON_REMOTE", 16 | "serviceURL": "BUNDLEMON_SERVICE_URL", 17 | "subProject": "BUNDLEMON_SUB_PROJECT", 18 | }, 19 | "serviceUrl": "https://api.bundlemon.dev", 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportHeader/components/ReviewRecord.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { CommitRecordReview } from 'bundlemon-utils'; 3 | import { capitalize } from '@mui/material'; 4 | 5 | interface ReviewRecordProps { 6 | review: CommitRecordReview; 7 | } 8 | 9 | const ReviewRecord = observer( 10 | ({ 11 | review: { 12 | user: { name }, 13 | createdAt, 14 | resolution, 15 | }, 16 | }: ReviewRecordProps) => { 17 | return ( 18 |
19 | {capitalize(resolution)} by {name} at {new Date(createdAt).toLocaleString()} 20 |
21 | ); 22 | } 23 | ); 24 | 25 | export default ReviewRecord; 26 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const getEnvVar = (name: string): string | undefined => { 2 | const value = process.env[name]; 3 | 4 | // Convert empty string to undefined 5 | return value || undefined; 6 | }; 7 | 8 | type ObjectFromList> = { 9 | [K in T extends ReadonlyArray ? U : never]: string | undefined; 10 | }; 11 | 12 | export function envVarsListToObject>(envVars: T): ObjectFromList { 13 | return envVars.reduce( 14 | (acc, envVar) => { 15 | acc[envVar] = getEnvVar(envVar); 16 | return acc; 17 | }, 18 | {} as Record 19 | ) as ObjectFromList; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "strict": true, 5 | "rootDir": ".", 6 | "sourceMap": false, 7 | "declaration": false, 8 | "moduleResolution": "node", 9 | "emitDecoratorMetadata": true, 10 | "experimentalDecorators": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "importHelpers": false, 14 | "allowJs": true, 15 | "target": "ES2022", 16 | "module": "CommonJS", 17 | "typeRoots": ["node_modules/@types"], 18 | "lib": ["ES2022"], 19 | "skipLibCheck": true, 20 | "skipDefaultLibCheck": true, 21 | "baseUrl": "." 22 | }, 23 | "exclude": ["node_modules", "tmp"] 24 | } 25 | -------------------------------------------------------------------------------- /apps/service/src/utils/projectUtils.ts: -------------------------------------------------------------------------------- 1 | import { ProjectProvider } from 'bundlemon-utils'; 2 | import { Project, GitProject } from '../framework/mongo/projects'; 3 | import type { FastifyBaseLogger } from 'fastify'; 4 | 5 | export function isGitHubProject(project: Project, log: FastifyBaseLogger): project is GitProject { 6 | if (!('provider' in project && 'owner' in project && 'repo' in project)) { 7 | log.warn({ projectId: project.id }, 'project missing provider details'); 8 | return false; 9 | } 10 | const { provider } = project; 11 | 12 | if (provider !== ProjectProvider.GitHub) { 13 | log.warn({ projectId: project.id }, 'project provider is not GitHub'); 14 | return false; 15 | } 16 | 17 | return true; 18 | } 19 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: LironEr 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: lironer 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://www.paypal.com/donate?hosted_button_id=YZL6KM2UGB5ML'] -------------------------------------------------------------------------------- /apps/platform/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | ENV NODE_ENV=production 4 | 5 | WORKDIR /app 6 | 7 | RUN addgroup --system service && adduser --system -G service service 8 | 9 | # Needed by @fastify/secure-session & for source maps 10 | RUN set -ex; \ 11 | apk add --no-cache --virtual .gyp \ 12 | # Gyp build dependencies 13 | python3 make g++; \ 14 | npm i sodium-native@4.2.0 source-map-support@0.5.21; \ 15 | apk del .gyp 16 | 17 | COPY dist service 18 | 19 | RUN chown -R service:service . 20 | 21 | CMD [ "node", "-r", "source-map-support/register", "service/app.js" ] 22 | 23 | HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ 24 | CMD sh -c "curl -f http://localhost:${PORT-8080}/is-alive || exit 1" 25 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-markdown-output", 3 | "version": "2.0.1", 4 | "description": "", 5 | "keywords": [], 6 | "author": "Liron Er", 7 | "license": "MIT", 8 | "homepage": "https://github.com/LironEr/bundlemon.git", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/LironEr/bundlemon.git", 12 | "directory": "packages/bundlemon-markdown-output" 13 | }, 14 | "engines": { 15 | "node": ">=18" 16 | }, 17 | "main": "lib/index", 18 | "types": "lib/index.d.ts", 19 | "scripts": {}, 20 | "dependencies": { 21 | "bundlemon-utils": "^2.0.1", 22 | "bytes": "^3.1.0" 23 | }, 24 | "devDependencies": { 25 | "@types/bytes": "^3.1.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/providers/codefresh.ts: -------------------------------------------------------------------------------- 1 | import type { Provider } from '../types'; 2 | import { getEnvVar } from '../../utils'; 3 | 4 | // https://codefresh.io/docs/docs/codefresh-yaml/variables/#system-provided-variables 5 | 6 | const provider: Provider = { 7 | isItMe: !!getEnvVar('CF_BUILD_URL'), 8 | getVars: () => ({ 9 | ci: true, 10 | provider: 'codefresh', 11 | owner: getEnvVar('CF_REPO_OWNER'), 12 | repo: getEnvVar('CF_REPO_NAME'), 13 | branch: getEnvVar('CF_BRANCH'), 14 | commitSha: getEnvVar('CF_REVISION'), 15 | targetBranch: getEnvVar('CF_PULL_REQUEST_TARGET'), 16 | prNumber: getEnvVar('CF_PULL_REQUEST_NUMBER'), 17 | commitMsg: getEnvVar('CF_COMMIT_MESSAGE'), 18 | }), 19 | }; 20 | 21 | export default provider; 22 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/common/__tests__/consts.spec.ts: -------------------------------------------------------------------------------- 1 | import * as consts from '../consts'; 2 | 3 | describe('consts', () => { 4 | const OLD_ENV = process.env; 5 | 6 | beforeEach(() => { 7 | jest.resetModules(); 8 | process.env = { ...OLD_ENV }; 9 | }); 10 | 11 | afterAll(() => { 12 | process.env = OLD_ENV; 13 | }); 14 | 15 | test('default snapshot', () => { 16 | const { version, ...rest } = consts; 17 | 18 | expect(rest).toMatchSnapshot(); 19 | }); 20 | 21 | test('serviceUrl env var exists', () => { 22 | const newUrl = 'https://my-bundlemon-service.com'; 23 | process.env[consts.EnvVar.serviceURL] = newUrl; 24 | 25 | const { serviceUrl } = require('../consts'); 26 | 27 | expect(serviceUrl).toEqual(newUrl); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /apps/service/src/utils/hashUtils.ts: -------------------------------------------------------------------------------- 1 | import * as crypto from 'crypto'; 2 | 3 | export async function createHash(secret: string): Promise { 4 | return new Promise((resolve, reject) => { 5 | const salt = crypto.randomBytes(8).toString('hex'); 6 | 7 | crypto.scrypt(secret, salt, 64, (err, derivedKey) => { 8 | if (err) reject(err); 9 | resolve(salt + ':' + derivedKey.toString('hex')); 10 | }); 11 | }); 12 | } 13 | 14 | export async function verifyHash(secret: string, hash: string): Promise { 15 | return new Promise((resolve, reject) => { 16 | const [salt, key] = hash.split(':'); 17 | crypto.scrypt(secret, salt, 64, (err, derivedKey) => { 18 | if (err) reject(err); 19 | resolve(key == derivedKey.toString('hex')); 20 | }); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/utils.ts: -------------------------------------------------------------------------------- 1 | import bytes from 'bytes'; 2 | import { NormalizedConfig } from '../types'; 3 | 4 | export function parseOutput(output: NormalizedConfig['reportOutput'][0]): { name: string; options: unknown } { 5 | if (Array.isArray(output)) { 6 | return { name: output[0], options: output[1] }; 7 | } 8 | 9 | return { name: output, options: undefined }; 10 | } 11 | 12 | export function getSignText(num: number): string { 13 | return num > 0 ? '+' : ''; 14 | } 15 | 16 | export function getDiffSizeText(size: number): string { 17 | return `${getSignText(size)}${bytes(size)}`; 18 | } 19 | 20 | export function getDiffPercentText(percent: number): string { 21 | if (percent === Infinity) { 22 | return ''; 23 | } 24 | 25 | return `${getSignText(percent)}${percent}%`; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [main, release/*, next] 6 | pull_request: 7 | branches: [main, release/*, next] 8 | schedule: 9 | - cron: '45 12 * * 4' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Initialize CodeQL 25 | uses: github/codeql-action/init@v3 26 | with: 27 | config-file: ./.github/codeql/codeql-config.yml 28 | languages: 'javascript-typescript' 29 | 30 | - name: Perform CodeQL Analysis 31 | uses: github/codeql-action/analyze@v3 32 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/lib/__tests__/__snapshots__/consts.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`consts snapshot 1`] = ` 4 | { 5 | "CommitRecordReviewResolution": { 6 | "Approved": "approved", 7 | "Rejected": "rejected", 8 | "Reset": "reset", 9 | }, 10 | "Compression": { 11 | "Brotli": "brotli", 12 | "Gzip": "gzip", 13 | "None": "none", 14 | }, 15 | "DiffChange": { 16 | "Add": "Add", 17 | "NoChange": "No change", 18 | "Remove": "Remove", 19 | "Update": "Update", 20 | }, 21 | "FailReason": { 22 | "MaxPercentIncrease": "MaxPercentIncrease", 23 | "MaxSize": "MaxSize", 24 | }, 25 | "ProjectProvider": { 26 | "GitHub": "github", 27 | }, 28 | "Status": { 29 | "Fail": "Fail", 30 | "Pass": "Pass", 31 | }, 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/common/consts.ts: -------------------------------------------------------------------------------- 1 | import { PathLabels } from '../main/types'; 2 | 3 | export enum EnvVar { 4 | remoteFlag = 'BUNDLEMON_REMOTE', 5 | projectId = 'BUNDLEMON_PROJECT_ID', 6 | projectApiKey = 'BUNDLEMON_PROJECT_APIKEY', 7 | serviceURL = 'BUNDLEMON_SERVICE_URL', 8 | subProject = 'BUNDLEMON_SUB_PROJECT', 9 | } 10 | 11 | export const serviceUrl = process.env[EnvVar.serviceURL] || 'https://api.bundlemon.dev'; 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-var-requires 14 | const packageJSON = require('../../package.json'); 15 | 16 | export const version = packageJSON.version; 17 | 18 | export enum CreateCommitRecordAuthType { 19 | ProjectApiKey = 'PROJECT_API_KEY', 20 | GithubActions = 'GITHUB_ACTIONS', 21 | } 22 | 23 | export const DEFAULT_PATH_LABELS: PathLabels = { 24 | hash: '[a-zA-Z0-9]+', 25 | }; 26 | -------------------------------------------------------------------------------- /apps/service/src/framework/mongo/init.ts: -------------------------------------------------------------------------------- 1 | import { FastifyBaseLogger } from 'fastify'; 2 | import { getDB } from './client'; 3 | import { getCommitRecordsCollection } from './commitRecords'; 4 | 5 | export async function initDb(logger: FastifyBaseLogger) { 6 | logger.info('Initializing DB indexes'); 7 | const db = await getDB(); 8 | 9 | await db.admin().ping({ maxTimeMS: 5000 }); 10 | 11 | const commitRecordCol = await getCommitRecordsCollection(); 12 | 13 | commitRecordCol.createIndex({ projectId: 1, subProject: 1, branch: 1, creationDate: -1 }); 14 | 15 | // TTL index - remove commit records on PRs after 30 days 16 | commitRecordCol.createIndex( 17 | { creationDate: 1 }, 18 | { expireAfterSeconds: 60 * 60 * 24 * 30, partialFilterExpression: { prNumber: { $exists: true } } } 19 | ); 20 | 21 | logger.info('DB indexes initialized'); 22 | } 23 | -------------------------------------------------------------------------------- /docs/self-hosted/README.md: -------------------------------------------------------------------------------- 1 | # Self hosted BundleMon platform 2 | 3 | ## Running with Docker 4 | 5 | ```sh 6 | docker run --rm -p 8080:8080 -e MONGO_URL="mongodb://localhost:27017" ghcr.io/lironer/bundlemon-platform:v1 7 | ``` 8 | 9 | Full Docker compose example [here](./docker-compose.yaml). 10 | 11 | You can also run with env var `SHOULD_SERVE_WEBSITE=false` to disable serving the website. 12 | 13 | **Full details on all environment variables [here](../../apps/service/README.md).** 14 | 15 | ## MongoDB setup 16 | 17 | Create your MongoDB, you can either create one in docker, your own machine or host on another service like MongoDB Atlas. 18 | 19 | > you can create a free MongoDB [here](https://www.mongodb.com) (500 MB free). 20 | 21 | ## Setup BundleMon CLI 22 | 23 | Set env var `BUNDLEMON_SERVICE_URL=https://your-bundlemon-service.domain/api` in your CI/CD. 24 | -------------------------------------------------------------------------------- /apps/website/src/components/PathCell.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { textEllipsis } from '@/utils/textUtils'; 3 | 4 | import type { FileDetailsDiff } from 'bundlemon-utils'; 5 | import type { PathRecord } from '@/pages/ReportsPage/components/types'; 6 | 7 | const Container = styled.div` 8 | max-width: 300px; 9 | `; 10 | 11 | interface PathCellProps { 12 | file: FileDetailsDiff | PathRecord; 13 | } 14 | 15 | const PathCell = ({ file }: PathCellProps) => { 16 | const path = {textEllipsis(file.path, 45)}; 17 | 18 | return ( 19 | 20 | {file.friendlyName ? ( 21 | <> 22 | {file.friendlyName} 23 |
24 | {path} 25 | 26 | ) : ( 27 | path 28 | )} 29 |
30 | ); 31 | }; 32 | 33 | export default PathCell; 34 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/TabTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Tab, Tooltip, TabProps } from '@mui/material'; 2 | 3 | import FailIcon from '@mui/icons-material/Close'; 4 | 5 | interface TabTitleProps extends TabProps { 6 | value: string; 7 | label: string; 8 | failsCount: number; 9 | } 10 | 11 | const TabTitle = ({ value, label, failsCount, ...tabProps }: TabTitleProps) => { 12 | return ( 13 | 17 | {label} 18 | {failsCount > 0 && ( 19 | 20 | 21 | 22 | )} 23 | 24 | } 25 | value={value} 26 | /> 27 | ); 28 | }; 29 | 30 | export default TabTitle; 31 | -------------------------------------------------------------------------------- /docs/types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | ## File 4 | 5 | #### `path` 6 | 7 | type: `string` **required** 8 | 9 | relative path to the file from `baseDir` 10 | 11 | glob pattern is supported 12 | 13 | ``` 14 | "js/*.js" 15 | "**/*.css" 16 | "**/*.{js,css}" 17 | "**/*..js" 18 | ``` 19 | 20 | #### `friendlyName` 21 | 22 | type: `string` optional 23 | 24 | friendly name for the path pattern option 25 | 26 | #### `compression` 27 | 28 | value: `"none"` \| `"gzip"` optional 29 | 30 | override default compression 31 | 32 | #### `maxSize` 33 | 34 | type: `string` optional 35 | 36 | max size allowed for match file/files 37 | 38 | ``` 39 | "2000b" 40 | "20kb" 41 | "1mb" 42 | ``` 43 | 44 | #### `maxPercentIncrease` 45 | 46 | type: `number` optional 47 | 48 | max percent increase allowed for match file/files from base branch 49 | 50 | ``` 51 | 0.5 = 0.5% 52 | 4 = 4% 53 | 200 = 200% 54 | ``` 55 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/configFile.ts: -------------------------------------------------------------------------------- 1 | import { cosmiconfig } from 'cosmiconfig'; 2 | import logger from '../common/logger'; 3 | import type { Config } from '../main/types'; 4 | 5 | const explorer = cosmiconfig('bundlemon'); 6 | 7 | export async function loadConfigFile(configPath?: string): Promise { 8 | if (configPath) { 9 | logger.debug(`Load config file from "${configPath}"`); 10 | } 11 | 12 | try { 13 | const cosmiconfigResult = await (configPath ? explorer.load(configPath) : explorer.search()); 14 | 15 | if (!cosmiconfigResult || cosmiconfigResult.isEmpty) { 16 | return undefined; 17 | } 18 | 19 | logger.debug(`Config file loaded from "${cosmiconfigResult.filepath}"`); 20 | 21 | return cosmiconfigResult.config; 22 | } catch (e) { 23 | logger.error(`Error loading config file: ${e}`); 24 | return undefined; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/ReportsChart/utils.ts: -------------------------------------------------------------------------------- 1 | import bytes from 'bytes'; 2 | 3 | import type { CommitRecord } from 'bundlemon-utils'; 4 | 5 | export const stringToColor = function (str: string): string { 6 | let hash = 0; 7 | 8 | for (let i = 0; i < str.length; i++) { 9 | hash = str.charCodeAt(i) + ((hash << 5) - hash); 10 | } 11 | 12 | let color = '#'; 13 | for (let i = 0; i < 3; i++) { 14 | const value = (hash >> (i * 8)) & 0xff; 15 | color += ('00' + value.toString(16)).substr(-2); 16 | } 17 | return color; 18 | }; 19 | 20 | export const getVal = 21 | (path: string, type: 'files' | 'groups') => 22 | (value: CommitRecord): number | undefined => 23 | value[type].find((f) => f.path === path)?.size; 24 | 25 | export const bytesTickFormatter = (value: number) => bytes(value); 26 | export const dateTickFormatter = (value: string) => new Date(value).toLocaleDateString(); 27 | -------------------------------------------------------------------------------- /apps/website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BundleMon 6 | 7 | 8 | 9 | 10 | 11 | 12 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /apps/website/src/components/Layout/components/ThemeModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { IconButton, Tooltip } from '@mui/material'; 3 | import DarkModeOutlined from '@mui/icons-material/DarkModeOutlined'; 4 | import LightModeOutlined from '@mui/icons-material/LightModeOutlined'; 5 | import { ThemeContext } from '@/components/ThemeProvider'; 6 | 7 | const ThemeModeToggle = () => { 8 | const { isDarkMode, setDarkMode } = useContext(ThemeContext); 9 | 10 | return ( 11 | 12 | { 16 | setDarkMode(!isDarkMode); 17 | }} 18 | > 19 | {isDarkMode ? : } 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default ThemeModeToggle; 26 | -------------------------------------------------------------------------------- /apps/service/tests/app.ts: -------------------------------------------------------------------------------- 1 | import { UserSessionData } from '@/types/auth'; 2 | import { FastifyInstance, InjectOptions } from 'fastify'; 3 | import { generateUserSessionData } from './utils'; 4 | import initApp from '@/app'; 5 | 6 | export async function createTestApp() { 7 | const app = await initApp({ isServerless: false }); 8 | await app.ready(); 9 | 10 | return app; 11 | } 12 | 13 | export async function injectAuthorizedRequest( 14 | app: FastifyInstance, 15 | injectOptions: InjectOptions, 16 | overrideUserData?: UserSessionData 17 | ) { 18 | const userSessionData = overrideUserData ?? generateUserSessionData(); 19 | 20 | const session = app.createSecureSession({ user: userSessionData }); 21 | const cookieData = app.encodeSecureSession(session); 22 | 23 | const response = await app.inject({ 24 | ...injectOptions, 25 | cookies: { 26 | ...injectOptions.cookies, 27 | session: cookieData, 28 | }, 29 | }); 30 | 31 | return response; 32 | } 33 | -------------------------------------------------------------------------------- /docs/customPathLabels.md: -------------------------------------------------------------------------------- 1 | # Custom Path Labels 2 | 3 | By default path labels replace only ``, but you can customize it and add more labels by defining `pathLabels` in your config. 4 | 5 | ### Default labels: 6 | 7 | ```json 8 | { 9 | "pathLabels": { 10 | "hash": "[a-zA-Z0-9]+" 11 | } 12 | } 13 | ``` 14 | 15 | ### Adding more labels: 16 | 17 | ```json 18 | { 19 | "baseDir": "./build", 20 | "pathLabels": { 21 | "chunkId": "[\\w-]+" 22 | }, 23 | "files": [ 24 | { 25 | "path": "*..chunk..js" 26 | }, 27 | { 28 | "path": "*..js" 29 | } 30 | ] 31 | } 32 | ``` 33 | 34 | ### Customizing and adding more labels: 35 | 36 | ```json 37 | { 38 | "baseDir": "./build", 39 | "pathLabels": { 40 | "hash": "[a-z]+", 41 | "chunkId": "[\\w-]+" 42 | }, 43 | "files": [ 44 | { 45 | "path": "*..chunk..js" 46 | }, 47 | { 48 | "path": "*..js" 49 | } 50 | ] 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportTable/components/ChangeSizeCell.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { FileDetailsDiff, DiffChange, FailReason } from 'bundlemon-utils'; 3 | import { getDiffPercentText, getDiffSizeText } from 'bundlemon-utils'; 4 | 5 | const Text = styled.span<{ $bold?: boolean }>` 6 | font-weight: ${({ $bold }) => ($bold ? '700' : '400')}; 7 | `; 8 | 9 | interface ChangeSizeCellProps { 10 | file: FileDetailsDiff; 11 | } 12 | 13 | const ChangeSizeCell = ({ file }: ChangeSizeCellProps) => { 14 | if (file.diff.change !== DiffChange.Update) { 15 | return -; 16 | } 17 | 18 | return ( 19 | <> 20 | {getDiffSizeText(file.diff.bytes)} |{' '} 21 | 22 | {getDiffPercentText(file.diff.percent)} 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default ChangeSizeCell; 29 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/common.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import type { 4 | RawReplyDefaultExpression, 5 | RawRequestDefaultExpression, 6 | RawServerDefault, 7 | RouteHandlerMethod, 8 | } from 'fastify'; 9 | 10 | export interface BaseRequestSchema { 11 | body?: unknown; 12 | query?: unknown; 13 | params?: unknown; 14 | headers?: unknown; 15 | } 16 | 17 | export interface BaseGetRequestSchema { 18 | query?: unknown; 19 | params?: unknown; 20 | headers?: unknown; 21 | } 22 | 23 | export type FastifyValidatedRoute = RouteHandlerMethod< 24 | RawServerDefault, 25 | RawRequestDefaultExpression, 26 | RawReplyDefaultExpression, 27 | { 28 | Body: RouteGeneric['body']; 29 | Querystring: RouteGeneric['query']; 30 | Params: RouteGeneric['params']; 31 | Headers: RouteGeneric['headers']; 32 | } 33 | >; 34 | 35 | export interface ProjectIdParams { 36 | /** 37 | * @pattern ^[0-9a-fA-F]{24}$ 38 | */ 39 | projectId: string; 40 | } 41 | -------------------------------------------------------------------------------- /apps/service/src/local-certs/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICmDCCAYACCQCKcUaw+AIiojANBgkqhkiG9w0BAQUFADANMQswCQYDVQQGEwJV 3 | UzAgFw0yMjA5MTUyMTMxMDNaGA8yMDUwMDEzMDIxMzEwM1owDTELMAkGA1UEBhMC 4 | VVMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtukc5UCM/hx5iqpXB 5 | ZrGs0eu6c3RcxbNcaP7MbUhppku8BjG1dtZAm6EFoMrfAQNH2+NlMiRKvcNGQEgA 6 | xuDVerKWD9+IS0FnfoOgETUZ4RN2we0qIxuh7tPG2Ad8OMaVWj0uUrYT2f5zvVPx 7 | zdZZLMPpNFDPC93/pMdsxgs+8JKb9yNVjsWsjnuxdYC+lHcN49dHYSyxT+vCQsNw 8 | Ig9n0PJ/ZUZDroEgIXqpeu6k1q4VtHFbA18HsegAW4V1XkmcgEBijklZfgeH41FC 9 | Q/ITKfAw/bib6YJIsv2BrjTglL1Ox6MqABP4+mBuv9p7M/8L0T9Yt4uE3YKwdLpB 10 | t9xpAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAJj7v+5FyN0nT1eBF6gty9OdeTpE 11 | qWNoloAkzcwDNni679/LLGjDv0alHdjnvw3PpwukGayTrIkZIBTqq3fqWbK7qR+a 12 | 8nWhEsLJ6mGGW3Hq4hBMOTO+n1RHptnLVE/lDPMfnJsQ4Tl+4nLQ94232itUdoU1 13 | nR7Y0UTw7XZvmWpN0Ux9P3pSvP8c4Eq31uJEXScQgCAUh65+472f764Dkb91Many 14 | A/CGtCG7tPZCw9QXirOsWyw6piHyex3mdTMdIw6vhbckwKZX9MF7ANfH2uvAcWCs 15 | NsmrP3v7se9XiaTI/8RnC3RZHYFPYyPo0JLFP2vnQchy0ss6oUJPG/RjI2o= 16 | -----END CERTIFICATE----- 17 | -------------------------------------------------------------------------------- /apps/service/src/utils/linkUtils.ts: -------------------------------------------------------------------------------- 1 | import { appDomain } from '../framework/env'; 2 | import { URLSearchParams } from 'url'; 3 | import { CommitRecordsQueryResolution } from '../consts/commitRecords'; 4 | 5 | interface GenerateLinkToReport { 6 | projectId: string; 7 | commitRecordId: string; 8 | } 9 | 10 | export function generateLinkToReport({ projectId, commitRecordId }: GenerateLinkToReport) { 11 | return `https://${appDomain}/projects/${projectId}/reports/${commitRecordId}`; 12 | } 13 | 14 | export interface GenerateLinkToReportskParams { 15 | projectId: string; 16 | subProject?: string; 17 | branch: string; 18 | resolution: CommitRecordsQueryResolution; 19 | } 20 | 21 | export function generateLinkToReports({ projectId, subProject, branch, resolution }: GenerateLinkToReportskParams) { 22 | const query = new URLSearchParams({ branch, resolution }); 23 | 24 | if (subProject) { 25 | query.append('subProject', subProject); 26 | } 27 | 28 | return `https://${appDomain}/projects/${projectId}/reports?${query.toString()}`; 29 | } 30 | -------------------------------------------------------------------------------- /apps/website/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | import tsconfigPaths from 'vite-tsconfig-paths'; 5 | import basicSsl from '@vitejs/plugin-basic-ssl'; 6 | import { analyzer } from 'vite-bundle-analyzer'; 7 | 8 | export default defineConfig({ 9 | root: __dirname, 10 | cacheDir: '../../node_modules/.vite/apps/website', 11 | publicDir: path.join(__dirname, 'public'), 12 | server: { 13 | port: 4000, 14 | host: 'localhost', 15 | }, 16 | preview: { 17 | port: 4000, 18 | host: 'localhost', 19 | }, 20 | plugins: [tsconfigPaths(), react(), basicSsl(), analyzer({ analyzerMode: 'static' })], 21 | build: { 22 | outDir: '../../dist/apps/website', 23 | emptyOutDir: true, 24 | reportCompressedSize: true, 25 | commonjsOptions: { 26 | transformMixedEsModules: true, 27 | }, 28 | rollupOptions: { 29 | output: { 30 | entryFileNames: `assets/Main-[name]-[hash].js`, 31 | }, 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/lib/markdownUtils.ts: -------------------------------------------------------------------------------- 1 | export function escapeMarkdown(str: string): string { 2 | return str.replace(/[~|]/g, '\\$&'); 3 | } 4 | 5 | interface FormatTextOptions { 6 | bold?: boolean; 7 | } 8 | 9 | export function formatText(text: string, { bold = false }: FormatTextOptions) { 10 | if (bold) { 11 | return `**${text}**`; 12 | } 13 | 14 | return text; 15 | } 16 | 17 | interface Column { 18 | label: string; 19 | center?: boolean; 20 | } 21 | 22 | interface GenerateTableParams { 23 | columns: Column[]; 24 | rows: string[][]; 25 | } 26 | 27 | export function generateMarkdownTable({ columns, rows }: GenerateTableParams) { 28 | let table = ''; 29 | 30 | table += columns 31 | .map((column) => column.label) 32 | .join(' | ') 33 | .concat('\n'); 34 | table += columns 35 | .map((column) => (column.center ? ':------------:' : '------------')) 36 | .join(' | ') 37 | .concat('\n'); 38 | table += rows 39 | .map((row) => row.join(' | ')) 40 | .join('\n') 41 | .concat('\n'); 42 | 43 | return table; 44 | } 45 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/analyzeLocalFiles.ts: -------------------------------------------------------------------------------- 1 | import { getAllPaths } from './pathUtils'; 2 | import { getFilesDetails, groupFilesByPattern } from './fileDetailsUtils'; 3 | import logger from '../../common/logger'; 4 | 5 | import type { FileDetails } from 'bundlemon-utils'; 6 | import type { NormalizedConfig } from '../types'; 7 | 8 | export async function analyzeLocalFiles( 9 | config: NormalizedConfig 10 | ): Promise<{ files: FileDetails[]; groups: FileDetails[] }> { 11 | logger.info(`Start analyzing`); 12 | 13 | const { baseDir, files: filesConfig, groups: groupsConfig, pathLabels } = config; 14 | 15 | const allFiles = await getAllPaths(config.baseDir); 16 | 17 | const [files, groupFiles] = await Promise.all([ 18 | getFilesDetails({ baseDir, allFiles, pathLabels, config: filesConfig, stopOnMatch: true }), 19 | getFilesDetails({ baseDir, allFiles, pathLabels, config: groupsConfig, stopOnMatch: false }), 20 | ]); 21 | 22 | const groups = groupFilesByPattern(groupFiles); 23 | 24 | logger.info(`Finished analyzing`); 25 | 26 | return { files, groups }; 27 | } 28 | -------------------------------------------------------------------------------- /apps/platform/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "platform", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/platform", 5 | "projectType": "application", 6 | "tags": [], 7 | "targets": { 8 | "prepare-image": { 9 | "dependsOn": ["service:build", "website:build"], 10 | "command": "rm -rf {projectRoot}/dist && cp -r apps/service/dist {projectRoot}/dist && cp -r dist/apps/website/* {projectRoot}/dist/public" 11 | }, 12 | "build-image": { 13 | "dependsOn": ["prepare-image"], 14 | "command": "docker build -f {projectRoot}/Dockerfile {projectRoot} -t bundlemon-platform" 15 | }, 16 | "lint": { 17 | "executor": "@nx/eslint:lint", 18 | "outputs": ["{options.outputFile}"], 19 | "options": { 20 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"], 21 | "maxWarnings": 0 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nx/jest:jest", 26 | "options": { 27 | "jestConfig": "{projectRoot}/jest.config.ts", 28 | "runInBand": true 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 LironEr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/ReportsChart/components/ColorCell.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import styled from '@emotion/styled'; 3 | 4 | const IconWrapper = styled.svg` 5 | display: inline-block; 6 | vertical-align: middle; 7 | margin-right: 4px; 8 | `; 9 | 10 | interface ColorCellProps { 11 | color: string; 12 | } 13 | 14 | const ColorCell = memo( 15 | ({ color }: ColorCellProps) => { 16 | return ( 17 | 18 | 28 | 29 | ); 30 | }, 31 | (prevProps, nextProps) => prevProps.color === nextProps.color 32 | ); 33 | 34 | ColorCell.displayName = 'ColorCell'; 35 | 36 | export default ColorCell; 37 | -------------------------------------------------------------------------------- /apps/website/src/components/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import MaterialReactTable, { MaterialReactTableProps } from 'material-react-table'; 3 | 4 | interface TableProps = Record> extends MaterialReactTableProps { 5 | maxHeight?: number; 6 | } 7 | 8 | const Table = observer( 9 | = Record>({ maxHeight, initialState, ...rest }: TableProps) => { 10 | return ( 11 | 30 | ); 31 | } 32 | ); 33 | 34 | export default Table; 35 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/index.ts: -------------------------------------------------------------------------------- 1 | import { Report, Status, getReportConclusionText } from 'bundlemon-utils'; 2 | import logger from '../common/logger'; 3 | import { analyzeLocalFiles } from './analyzer'; 4 | import { generateOutputs } from './outputs'; 5 | import { generateReport } from './report'; 6 | import { initializer } from './initializer'; 7 | import type { Config } from './types'; 8 | 9 | export default async (config: Config): Promise => { 10 | const normalizedConfig = await initializer(config); 11 | 12 | if (!normalizedConfig) { 13 | throw new Error('Failed to initialize'); 14 | } 15 | 16 | const { files, groups } = await analyzeLocalFiles(normalizedConfig); 17 | 18 | if (files.length === 0 && groups.length === 0) { 19 | throw new Error('No files or groups found'); 20 | } 21 | 22 | const report = await generateReport(normalizedConfig, { files, groups }); 23 | 24 | if (!report) { 25 | throw new Error('Failed to generate report'); 26 | } 27 | 28 | await generateOutputs(report); 29 | 30 | logger.info(`Done - ${report.status === Status.Pass ? 'Success' : 'Failure'} - ${getReportConclusionText(report)}`); 31 | 32 | return report; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/providers/circleCI.ts: -------------------------------------------------------------------------------- 1 | import type { Provider } from '../types'; 2 | import { envVarsListToObject, getEnvVar } from '../../utils'; 3 | 4 | // https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables 5 | 6 | const provider: Provider = { 7 | isItMe: getEnvVar('CIRCLECI') === 'true', 8 | getVars: () => { 9 | const raw = envVarsListToObject([ 10 | 'CIRCLE_PROJECT_USERNAME', 11 | 'CIRCLE_PROJECT_REPONAME', 12 | 'CIRCLE_BRANCH', 13 | 'CIRCLE_SHA1', 14 | 'CIRCLE_PULL_REQUEST', 15 | ] as const); 16 | 17 | return { 18 | raw, 19 | ci: true, 20 | provider: 'circleci', 21 | owner: raw.CIRCLE_PROJECT_USERNAME, 22 | repo: raw.CIRCLE_PROJECT_REPONAME, 23 | branch: raw.CIRCLE_BRANCH, 24 | commitSha: raw.CIRCLE_SHA1, 25 | // target branch not available in CircleCI 26 | // https://ideas.circleci.com/cloud-feature-requests/p/provide-env-variable-for-branch-name-targeted-by-pull-request 27 | // use CI_TARGET_BRANCH to override 28 | targetBranch: undefined, 29 | prNumber: raw.CIRCLE_PULL_REQUEST?.split('/').pop(), 30 | }; 31 | }, 32 | }; 33 | 34 | export default provider; 35 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/lib/__tests__/markdownUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { escapeMarkdown, formatText } from '../markdownUtils'; 2 | 3 | describe('markdown utils', () => { 4 | describe('escapeMarkdown', () => { 5 | test.each(['text', '**/*..js'])('no escape', (str) => { 6 | const result = escapeMarkdown(str); 7 | 8 | expect(result).toBe(str); 9 | }); 10 | 11 | test.each([ 12 | { str: 'some~text~2', expected: 'some\\~text\\~2' }, 13 | { str: '*.js | *.png', expected: '*.js \\| *.png' }, 14 | { str: 'text~text | file', expected: 'text\\~text \\| file' }, 15 | ])('escape', ({ str, expected }) => { 16 | const result = escapeMarkdown(str); 17 | 18 | expect(result).toBe(expected); 19 | }); 20 | }); 21 | 22 | describe('formatText', () => { 23 | test.each([ 24 | { str: 'text', options: {}, expected: 'text' }, 25 | { str: 'text', options: { bold: false }, expected: 'text' }, 26 | { str: 'text', options: { bold: true }, expected: '**text**' }, 27 | ])('options: $options', ({ str, options, expected }) => { 28 | const result = formatText(str, options); 29 | 30 | expect(result).toBe(expected); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /apps/service/src/framework/mongo/commitRecords/types.ts: -------------------------------------------------------------------------------- 1 | import type { CommitRecordReview, Compression, CommitRecord } from 'bundlemon-utils'; 2 | 3 | export interface CommitRecordReviewDB extends Omit { 4 | createdAt: Date; 5 | } 6 | 7 | export interface AssetLimits { 8 | maxSize?: number; 9 | maxPercentIncrease?: number; 10 | } 11 | 12 | export interface AssetMatchCriteria { 13 | pattern: string; 14 | friendlyName?: string; 15 | compression: Compression; 16 | limits?: AssetLimits; 17 | } 18 | 19 | export interface AssetMatch { 20 | path: string; 21 | size: number; 22 | } 23 | 24 | export interface WatchedFileHits extends AssetMatchCriteria { 25 | matches: AssetMatch[]; 26 | } 27 | 28 | export interface WatchedGroupHits extends AssetMatchCriteria, Omit {} 29 | 30 | export interface CommitRecordDB { 31 | projectId: string; 32 | creationDate: Date; 33 | subProject?: string; 34 | branch: string; 35 | commitSha: string; 36 | baseBranch?: string; 37 | prNumber?: string; 38 | commitMsg?: string; 39 | files?: WatchedFileHits[]; 40 | groups?: WatchedGroupHits[]; 41 | reviews?: CommitRecordReviewDB[]; 42 | outputs?: CommitRecord['outputs']; 43 | } 44 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/SubprojectsAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from '@mui/material'; 2 | import { useQuery } from 'react-query'; 3 | import { getSubprojects } from '@/services/bundlemonService'; 4 | import FetchError from '@/services/FetchError'; 5 | import { observer } from 'mobx-react-lite'; 6 | 7 | interface SubprojectsAutocompleteProps { 8 | projectId: string; 9 | value?: string; 10 | setValue: (v: string | undefined) => void; 11 | } 12 | 13 | const SubprojectsAutocomplete = observer(({ projectId, value, setValue }: SubprojectsAutocompleteProps) => { 14 | const { isLoading, data: subProjects } = useQuery(['projects', projectId, 'subprojects'], () => 15 | getSubprojects(projectId) 16 | ); 17 | 18 | return ( 19 | } 24 | value={value} 25 | onChange={(_e, newValue) => { 26 | setValue(newValue || undefined); 27 | }} 28 | loadingText="Loading..." 29 | /> 30 | ); 31 | }); 32 | 33 | export default SubprojectsAutocomplete; 34 | -------------------------------------------------------------------------------- /apps/service/src/routes/api/__tests__/usersRoutes.spec.ts: -------------------------------------------------------------------------------- 1 | import { createTestApp, injectAuthorizedRequest } from '@tests/app'; 2 | import { generateUserSessionData } from '@tests/utils'; 3 | import { FastifyInstance } from 'fastify'; 4 | 5 | describe('users routes', () => { 6 | let app: FastifyInstance; 7 | 8 | beforeAll(async () => { 9 | app = await createTestApp(); 10 | }); 11 | 12 | describe('me', () => { 13 | test('user not logged in', async () => { 14 | const response = await app.inject({ 15 | method: 'GET', 16 | url: `/v1/users/me`, 17 | }); 18 | 19 | expect(response.statusCode).toEqual(401); 20 | }); 21 | 22 | test('success', async () => { 23 | const userSessionData = generateUserSessionData(); 24 | 25 | const response = await injectAuthorizedRequest( 26 | app, 27 | { 28 | method: 'GET', 29 | url: `/v1/users/me`, 30 | }, 31 | userSessionData 32 | ); 33 | 34 | expect(response.statusCode).toEqual(200); 35 | 36 | const responseJson = response.json(); 37 | 38 | expect(responseJson).toEqual({ 39 | provider: userSessionData.provider, 40 | name: userSessionData.name, 41 | }); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /apps/service/scripts/deleteProjectRecords.ts: -------------------------------------------------------------------------------- 1 | // Delete branches that have not been active (pushed new commit records) for x days. 2 | 3 | import { closeMongoClient } from '@/framework/mongo/client'; 4 | import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; 5 | 6 | (async () => { 7 | try { 8 | const projectId = ''; 9 | const commitRecordsCollection = await getCommitRecordsCollection(); 10 | const recordsCount = await commitRecordsCollection.countDocuments({ projectId }); 11 | 12 | console.log(`Total records to delete: ${recordsCount}`); 13 | 14 | console.log('Are you sure you want to delete all these records? (y/n)'); 15 | const answer = await new Promise((resolve) => { 16 | process.stdin.on('data', (data) => { 17 | resolve(data.toString().trim()); 18 | }); 19 | }); 20 | 21 | if (answer !== 'y') { 22 | console.log('Abort'); 23 | process.exit(0); 24 | } 25 | 26 | console.log('Deleting records...'); 27 | 28 | const result = await commitRecordsCollection.deleteMany({ 29 | projectId, 30 | }); 31 | 32 | console.log(`Deleted records: ${result.deletedCount}`); 33 | 34 | process.exit(0); 35 | } finally { 36 | await closeMongoClient(); 37 | } 38 | })(); 39 | -------------------------------------------------------------------------------- /docs/migration-v1-to-v2.md: -------------------------------------------------------------------------------- 1 | # Migration guide from v1 to v2 2 | 3 | - Upgrade node version to at least v14 4 | 5 | - If you are using GitHub actions you don't need to set project ID and API key anymore, make sure [BundleMon GitHub App](https://github.com/apps/bundlemon) is installed. 6 | 7 | - BundleMon will automatically create a new project linked to your repo on GitHub. If you want to keep your current project history you can add your details [here](https://github.com/LironEr/bundlemon/issues/125) or send me an email (lironerm@gmail.com) with the owner and repo name and your current project id. 8 | 9 | - If you are using a different CI provider (Travis, CircleCI, etc) you must provide a project API key. **From now on you will need to provide a GitHub access token** if you want to integrate with GitHub (post commit status / pr comment). 10 | 11 | - [Create GitHub access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) without any scopes, the token owner must have write permission to the repo you would want to post outputs to. 12 | 13 | - Add the token to `BUNDLEMON_GITHUB_TOKEN` environment variable in your CI. 14 | 15 | > The token is not saved in BundleMon service, ONLY used to verify the username that created the token. 16 | -------------------------------------------------------------------------------- /apps/platform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-platform", 3 | "version": "1.0.1", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "build:image": "yarn --cwd ../../ nx build-image platform --verbose", 8 | "test": "yarn --cwd ../../ nx test platform --verbose", 9 | "lint": "yarn --cwd ../../ nx lint platform --verbose", 10 | "start:mock-services": "docker compose -f ../service/docker-compose.test.yml up --remove-orphans", 11 | "stop:mock-services": "docker compose -f ../service/docker-compose.test.yml down", 12 | "start:base-platform": "docker run --rm -d --env-file ../service/.development.env -e MONGO_DB_NAME=test -e MONGO_URL=mongodb://host.docker.internal:51651 -e SHOULD_RUN_DB_INIT=false", 13 | "start:platform": "yarn start:base-platform --name bundlemon-platform -e SHOULD_SERVE_WEBSITE=true -e ROOT_DOMAIN=localhost:3333 -p 3333:8080 bundlemon-platform", 14 | "stop:platform": "docker stop bundlemon-platform", 15 | "start:platform-no-website": "yarn start:base-platform --name bundlemon-platform-no-website -e SHOULD_SERVE_WEBSITE=false -e ROOT_DOMAIN=localhost:4444 -p 4444:8080 bundlemon-platform", 16 | "stop:platform-no-website": "docker stop bundlemon-platform-no-website" 17 | }, 18 | "dependencies": {}, 19 | "devDependencies": {} 20 | } 21 | -------------------------------------------------------------------------------- /packages/bundlemon/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon", 3 | "version": "3.1.0", 4 | "description": "Monitor your bundle size", 5 | "keywords": [ 6 | "bundle", 7 | "size", 8 | "bundlesize", 9 | "monitor" 10 | ], 11 | "engines": { 12 | "node": ">=18" 13 | }, 14 | "author": "Liron Er", 15 | "funding": "https://github.com/sponsors/LironEr", 16 | "license": "MIT", 17 | "homepage": "https://github.com/LironEr/bundlemon.git", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/LironEr/bundlemon.git" 21 | }, 22 | "main": "lib/index", 23 | "types": "lib/index.d.ts", 24 | "bin": { 25 | "bundlemon": "bin/bundlemon.js" 26 | }, 27 | "scripts": { 28 | "bundlemon": "node -r @swc-node/register ./bin/bundlemon.ts" 29 | }, 30 | "dependencies": { 31 | "axios": "^1.8.2", 32 | "axios-retry": "^4.5.0", 33 | "brotli-size": "^4.0.0", 34 | "bundlemon-utils": "^2.0.1", 35 | "bytes": "^3.1.2", 36 | "chalk": "^4.0.0", 37 | "commander": "^11.1.0", 38 | "cosmiconfig": "^8.3.6", 39 | "gzip-size": "^6.0.0", 40 | "micromatch": "^4.0.8", 41 | "yup": "^0.32.11" 42 | }, 43 | "devDependencies": { 44 | "@types/bytes": "^3.1.1", 45 | "@types/micromatch": "^4.0.2", 46 | "@types/node": "^14.0.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /apps/website/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import Paper from '@mui/material/Paper'; 3 | import Typography from '@mui/material/Typography'; 4 | import Button from '@mui/material/Button'; 5 | 6 | const Container = styled(Paper)` 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | align-self: center; 11 | padding: ${({ theme }) => theme.spacing(6)}; 12 | max-width: 700px; 13 | `; 14 | 15 | const HomePage = () => { 16 | return ( 17 | 18 | 19 | BundleMon helps you to monitor your bundle size. 20 |

21 | Your goal is to keep your bundle size as small as possible to reduce the amount of time it takes for users to 22 | load your website/application. This is particularly important for users on low bandwidth connections. 23 |

24 |

25 | BundleMon helps you achieve that by constantly monitoring your bundle size on every commit and alerts you on 26 | changes. 27 |

28 |
29 | 30 | 33 |
34 | ); 35 | }; 36 | 37 | export default HomePage; 38 | -------------------------------------------------------------------------------- /apps/service/scripts/replaceProjectBranch.ts: -------------------------------------------------------------------------------- 1 | // Replace branch name for all records for a specific project 2 | 3 | import { closeMongoClient } from '@/framework/mongo/client'; 4 | import { getCommitRecordsCollection } from '@/framework/mongo/commitRecords'; 5 | 6 | (async () => { 7 | try { 8 | const projectId = ''; 9 | const sourceBranch = ''; 10 | const targetBranch = ''; 11 | 12 | console.log( 13 | `Are you sure you want to replace branch "${sourceBranch}" to "${targetBranch}" for project "${projectId}"? (y/n)` 14 | ); 15 | const answer = await new Promise((resolve) => { 16 | process.stdin.on('data', (data) => { 17 | resolve(data.toString().trim()); 18 | }); 19 | }); 20 | 21 | if (answer !== 'y') { 22 | console.log('Abort'); 23 | process.exit(0); 24 | } 25 | 26 | console.log('Replacing branch...'); 27 | 28 | const commitRecordsCollection = await getCommitRecordsCollection(); 29 | const result = await commitRecordsCollection.updateMany( 30 | { 31 | projectId, 32 | branch: sourceBranch, 33 | }, 34 | { $set: { branch: targetBranch } } 35 | ); 36 | 37 | console.log(`records updated: ${result.modifiedCount}`); 38 | 39 | process.exit(0); 40 | } finally { 41 | await closeMongoClient(); 42 | } 43 | })(); 44 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportHeader/ReportHeader.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { getReportConclusionText, Report, Status } from 'bundlemon-utils'; 3 | import { Alert, AlertColor } from '@mui/material'; 4 | import { ReviewReportModal, ReviewRecord } from './components'; 5 | 6 | interface ReportHeaderProps { 7 | report: Report; 8 | setReport: (r: Report) => void; 9 | } 10 | 11 | const ReportHeader = observer(({ report, setReport }: ReportHeaderProps) => { 12 | const conclusionText = getReportConclusionText(report); 13 | let severity: AlertColor = 'info'; 14 | 15 | if (report.status === Status.Fail) { 16 | severity = 'error'; 17 | } else if (report.status === Status.Pass) { 18 | if (report.metadata.record?.reviews?.length) { 19 | severity = 'warning'; 20 | } else { 21 | severity = 'success'; 22 | } 23 | } 24 | 25 | return ( 26 | } 29 | > 30 | {conclusionText} 31 | {report.metadata.record?.reviews?.length && 32 | report.metadata.record?.reviews.map((review, index) => )} 33 | 34 | ); 35 | }); 36 | 37 | export default ReportHeader; 38 | -------------------------------------------------------------------------------- /migrations.json: -------------------------------------------------------------------------------- 1 | { 2 | "migrations": [ 3 | { 4 | "cli": "nx", 5 | "version": "19.2.0-beta.2", 6 | "description": "Updates the default workspace data directory to .nx/workspace-data", 7 | "implementation": "./src/migrations/update-19-2-0/move-workspace-data-directory", 8 | "package": "nx", 9 | "name": "19-2-0-move-graph-cache-directory" 10 | }, 11 | { 12 | "cli": "nx", 13 | "version": "19.2.2-beta.0", 14 | "description": "Updates the nx wrapper.", 15 | "implementation": "./src/migrations/update-17-3-0/update-nxw", 16 | "package": "nx", 17 | "name": "19-2-2-update-nx-wrapper" 18 | }, 19 | { 20 | "version": "19.2.4-beta.0", 21 | "description": "Set project name in nx.json explicitly", 22 | "implementation": "./src/migrations/update-19-2-4/set-project-name", 23 | "x-repair-skip": true, 24 | "package": "nx", 25 | "name": "19-2-4-set-project-name" 26 | }, 27 | { 28 | "cli": "nx", 29 | "version": "19.1.0-beta.6", 30 | "description": "Migrate no-extra-semi rules into user config, out of nx extendable configs", 31 | "implementation": "./src/migrations/update-19-1-0-migrate-no-extra-semi/migrate-no-extra-semi", 32 | "package": "@nx/eslint-plugin", 33 | "name": "update-19-1-0-rename-no-extra-semi" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/build-web.yml: -------------------------------------------------------------------------------- 1 | name: Build web 2 | 3 | on: 4 | push: 5 | branches: [main, release/*, next] 6 | pull_request: 7 | types: [synchronize, opened, reopened] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '22' 18 | cache: 'yarn' 19 | 20 | - name: Install dependencies 21 | run: yarn 22 | 23 | - name: Build 24 | working-directory: ./apps/website 25 | run: yarn build 26 | 27 | - name: BundleMon 28 | working-directory: ./apps/website 29 | run: yarn bundlemon 30 | env: 31 | CI_COMMIT_SHA: ${{github.event.pull_request.head.sha || github.sha}} # important! 32 | CI_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} 33 | 34 | - name: Deploy 35 | if: ${{ github.ref_name == 'main' }} 36 | working-directory: ./apps/website 37 | env: 38 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 39 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 40 | # https://securitylab.github.com/research/github-actions-untrusted-input/ 41 | COMMIT_MSG: ${{ github.event.head_commit.message }} 42 | run: yarn deploy --prod --message "$COMMIT_MSG" 43 | -------------------------------------------------------------------------------- /apps/service/src/controllers/authController.ts: -------------------------------------------------------------------------------- 1 | import type { RouteHandlerMethod } from 'fastify'; 2 | import { RequestError as OctokitRequestError } from '@octokit/request-error'; 3 | import { loginWithCode } from '@/framework/github'; 4 | import { maxSessionAgeSeconds } from '@/framework/env'; 5 | import type { FastifyValidatedRoute, LoginRequestSchema } from '@/types/schemas'; 6 | 7 | export const loginController: FastifyValidatedRoute = async (req, res) => { 8 | try { 9 | const { code } = req.body; 10 | 11 | const { sessionData, expiresAt } = await loginWithCode(code); 12 | const expires = expiresAt ?? new Date(new Date().getTime() + 1000 * maxSessionAgeSeconds); 13 | 14 | req.session.options({ expires }); 15 | req.session.set('user', sessionData); 16 | 17 | res.setCookie('isSessionExists', 'true', { 18 | httpOnly: false, 19 | expires, 20 | }); 21 | 22 | return { status: 'ok' }; 23 | } catch (err) { 24 | if (err instanceof OctokitRequestError) { 25 | return res.status(401).send({ 26 | message: `GitHub error: ${err.message}`, 27 | }); 28 | } 29 | 30 | throw err; 31 | } 32 | }; 33 | 34 | export const logoutController: RouteHandlerMethod = async (req, res) => { 35 | req.session.delete(); 36 | 37 | res.clearCookie('isSessionExists', { httpOnly: false }); 38 | 39 | return { status: 'ok' }; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/service/tests/projectUtils.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | import { createHash } from '../src/utils/hashUtils'; 3 | import { createProject, getProjectsCollection, GitProject, ProjectDB } from '../src/framework/mongo/projects'; 4 | import { ProjectProvider } from 'bundlemon-utils'; 5 | import { generateRandomString } from './utils'; 6 | 7 | export async function createTestProjectWithApiKey() { 8 | const apiKey = randomBytes(32).toString('hex'); 9 | const startKey = apiKey.substring(0, 3); 10 | 11 | const hash = await createHash(apiKey); 12 | const projectId = await createProject({ hash, startKey }); 13 | 14 | return { projectId, apiKey }; 15 | } 16 | 17 | export async function createTestGithubProject(overrides: Partial = {}): Promise { 18 | const provider = ProjectProvider.GitHub; 19 | const owner = generateRandomString(); 20 | const repo = generateRandomString(); 21 | 22 | const newProject: ProjectDB = { 23 | provider, 24 | owner, 25 | repo, 26 | creationDate: new Date(), 27 | lastAccessed: new Date(), 28 | ...overrides, 29 | }; 30 | 31 | const projectsCollection = await getProjectsCollection(); 32 | const id = (await projectsCollection.insertOne(newProject)).insertedId; 33 | 34 | return { id: id.toHexString(), ...newProject }; 35 | } 36 | 37 | export function generateProjectId() { 38 | return generateRandomString(24); 39 | } 40 | -------------------------------------------------------------------------------- /apps/service/scripts/generateSchemas.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const fs = require('node:fs'); 4 | const path = require('node:path'); 5 | const { execSync } = require('node:child_process'); 6 | const { createGenerator } = require('ts-json-schema-generator'); 7 | 8 | const startTime = Date.now(); 9 | 10 | const TMP_OUTPUT_PATH = '.tmp/schemas.ts'; 11 | const OUTPUT_PATH = './src/consts/schemas.ts'; 12 | 13 | // Create output directory 14 | fs.mkdirSync(path.dirname(TMP_OUTPUT_PATH), { recursive: true }); 15 | fs.rmSync(TMP_OUTPUT_PATH, { force: true }); 16 | 17 | const schema = createGenerator({ 18 | path: './src/types/schemas/**/*.ts', 19 | tsconfig: './tsconfig.json', 20 | }).createSchema('*'); 21 | 22 | let fileString = ''; 23 | 24 | for (let name of Object.keys(schema.definitions)) { 25 | console.log(`create ${name} schema`); 26 | fileString += `export const ${name} = ${JSON.stringify( 27 | { $id: `#/definitions/${name}`, ...schema.definitions[name] }, 28 | null, 29 | 2 30 | )};\n\n`; 31 | } 32 | 33 | fs.writeFileSync(TMP_OUTPUT_PATH, fileString); 34 | 35 | if (!process.env.CI) { 36 | execSync(`yarn prettier --write "${TMP_OUTPUT_PATH}"`, { cwd: path.join(__dirname, '../'), stdio: 'inherit' }); 37 | } 38 | 39 | console.log(`move ${TMP_OUTPUT_PATH} to ${OUTPUT_PATH}`); 40 | fs.renameSync(TMP_OUTPUT_PATH, OUTPUT_PATH); 41 | 42 | console.log(`Done - ${Date.now() - startTime}ms`); 43 | -------------------------------------------------------------------------------- /apps/website/src/stores/UserStore.ts: -------------------------------------------------------------------------------- 1 | import UserModel from '@/models/UserModel'; 2 | import { getMe, logout } from '@/services/bundlemonService'; 3 | import { makeAutoObservable, observable, runInAction } from 'mobx'; 4 | 5 | export class UserStore { 6 | @observable.ref user: UserModel | undefined; 7 | 8 | constructor() { 9 | makeAutoObservable(this); 10 | 11 | this.init(); 12 | } 13 | 14 | init = async () => { 15 | try { 16 | const isSessionExists = getCookie('isSessionExists') === 'true'; 17 | 18 | if (!isSessionExists) { 19 | // session not exists, no reason to try to get user details 20 | return; 21 | } 22 | 23 | const user = await getMe(); 24 | 25 | if (user) { 26 | runInAction(() => { 27 | this.user = new UserModel(user); 28 | }); 29 | } 30 | } catch (e) { 31 | console.error('Failed to load user', e); 32 | } 33 | }; 34 | 35 | logout = async () => { 36 | await logout(); 37 | 38 | runInAction(() => { 39 | this.user = undefined; 40 | }); 41 | }; 42 | } 43 | 44 | export const userStore = new UserStore(); 45 | 46 | function getCookie(cookieName: string): string | undefined { 47 | const cookies: Record = {}; 48 | document.cookie.split(';').forEach(function (el) { 49 | const [key, value] = el.split('='); 50 | cookies[key.trim()] = value; 51 | }); 52 | return cookies[cookieName]; 53 | } 54 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/providers/travis.ts: -------------------------------------------------------------------------------- 1 | import type { Provider } from '../types'; 2 | import { envVarsListToObject, getEnvVar } from '../../utils'; 3 | 4 | // https://docs.travis-ci.com/user/environment-variables#default-environment-variables 5 | 6 | const provider: Provider = { 7 | isItMe: getEnvVar('TRAVIS') === 'true', 8 | getVars: () => { 9 | const raw = envVarsListToObject([ 10 | 'TRAVIS_REPO_SLUG', 11 | 'TRAVIS_EVENT_TYPE', 12 | 'TRAVIS_PULL_REQUEST', 13 | 'TRAVIS_BRANCH', 14 | 'TRAVIS_PULL_REQUEST_BRANCH', 15 | 'TRAVIS_COMMIT', 16 | 'TRAVIS_COMMIT_MESSAGE', 17 | ] as const); 18 | 19 | const fullRepoName = raw.TRAVIS_REPO_SLUG; 20 | const [owner, repo] = fullRepoName?.split('/') ?? [undefined, undefined]; 21 | 22 | const isPushEvent = raw.TRAVIS_EVENT_TYPE === 'push'; 23 | const prNumber = raw.TRAVIS_PULL_REQUEST; 24 | 25 | return { 26 | raw, 27 | ci: true, 28 | provider: 'travis', 29 | owner, 30 | repo, 31 | branch: isPushEvent ? raw.TRAVIS_BRANCH : raw.TRAVIS_PULL_REQUEST_BRANCH, 32 | commitSha: raw.TRAVIS_COMMIT, 33 | targetBranch: isPushEvent ? undefined : raw.TRAVIS_BRANCH, 34 | prNumber: prNumber === 'false' ? undefined : prNumber, // "false" if it’s not a pull request, set as undefined 35 | commitMsg: raw.TRAVIS_COMMIT_MESSAGE, 36 | }; 37 | }, 38 | }; 39 | 40 | export default provider; 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to BundleMon 2 | 3 | Thanks for taking the time to contribute! ❤️ 4 | 5 | ## Local development 6 | 7 | ### Prerequisites 8 | 9 | - [Node.js](https://nodejs.org/) >= v18 10 | - [Yarn](https://yarnpkg.com/en/docs/install) 11 | 12 | #### Install dependencies 13 | 14 | ```bash 15 | yarn install 16 | ``` 17 | 18 | #### Build packages 19 | 20 | ```bash 21 | yarn build-packages 22 | ``` 23 | 24 | ### BundleMon CLI 25 | 26 | #### Test packages 27 | 28 | ```bash 29 | yarn test-packages 30 | ``` 31 | 32 | ### BundleMon Service 33 | 34 | Requires `docker` & `docker compose` 35 | 36 | #### Start service 37 | 38 | When changing code in `apps/service/` directory the service will reload itself 39 | 40 | Run from `apps/service/` directory 41 | 42 | ``` 43 | yarn serve 44 | ``` 45 | 46 | By default the service will start on port `3333` 47 | 48 | #### Generate local data 49 | 50 | The script will generate 3 projects, run it when the local service is running 51 | 52 | ``` 53 | yarn gen-local-data 54 | ``` 55 | 56 | #### Run tests 57 | 58 | Run from `apps/service/` directory 59 | 60 | ```bash 61 | yarn start:mock-services 62 | ``` 63 | 64 | ```bash 65 | yarn test 66 | ``` 67 | 68 | ### BundleMon website 69 | 70 | ```bash 71 | yarn serve 72 | ``` 73 | 74 | After running the command the website will be available at https://localhost:4000/ 75 | 76 | By default the local website will expect a local BundleMon service on port `3333`. 77 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/providers/github.ts: -------------------------------------------------------------------------------- 1 | import type { Provider } from '../types'; 2 | import { getEnvVar, envVarsListToObject } from '../../utils'; 3 | 4 | // https://docs.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables 5 | 6 | type GitHubEvent = undefined | '' | 'pull_request' | 'push'; 7 | 8 | const provider: Provider = { 9 | isItMe: !!getEnvVar('GITHUB_ACTION'), 10 | getVars: () => { 11 | const raw = envVarsListToObject([ 12 | 'GITHUB_REPOSITORY', 13 | 'GITHUB_EVENT_NAME', 14 | 'GITHUB_REF', 15 | 'GITHUB_HEAD_REF', 16 | 'GITHUB_SHA', 17 | 'GITHUB_BASE_REF', 18 | 'GITHUB_RUN_ID', 19 | ] as const); 20 | 21 | const fullRepoName = raw.GITHUB_REPOSITORY; 22 | 23 | const [owner, repo] = fullRepoName?.split('/') ?? [undefined, undefined]; 24 | 25 | const event = raw.GITHUB_EVENT_NAME as GitHubEvent; 26 | const isPr = event === 'pull_request'; 27 | const ref = raw.GITHUB_REF?.split('/'); 28 | 29 | return { 30 | raw, 31 | ci: true, 32 | provider: 'github', 33 | owner, 34 | repo, 35 | branch: isPr ? raw.GITHUB_HEAD_REF : ref?.slice(2).join('/'), 36 | commitSha: raw.GITHUB_SHA, 37 | targetBranch: raw.GITHUB_BASE_REF, 38 | prNumber: isPr ? ref?.[2] : undefined, 39 | buildId: raw.GITHUB_RUN_ID, 40 | }; 41 | }, 42 | }; 43 | 44 | export default provider; 45 | -------------------------------------------------------------------------------- /packages/bundlemon/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "{projectRoot}/lib", 5 | "projectType": "library", 6 | "tags": ["type:lib"], 7 | "generators": {}, 8 | "targets": { 9 | "build": { 10 | "executor": "@nx/js:tsc", 11 | "options": { 12 | "outputPath": "dist/{projectRoot}", 13 | "tsConfig": "{projectRoot}/tsconfig.lib.json", 14 | "packageJson": "{projectRoot}/package.json", 15 | "main": "{projectRoot}/lib/index.ts", 16 | "updateBuildableProjectDepsInPackageJson": false, 17 | "assets": [ 18 | "README.md", 19 | "LICENSE", 20 | { 21 | "input": "{projectRoot}", 22 | "glob": "**/*.d.ts", 23 | "ignore": ["node_modules/**"], 24 | "output": "/" 25 | } 26 | ] 27 | }, 28 | "outputs": ["{options.outputPath}"] 29 | }, 30 | "lint": { 31 | "executor": "@nx/eslint:lint", 32 | "outputs": ["{options.outputFile}"], 33 | "options": { 34 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"], 35 | "maxWarnings": 0 36 | } 37 | }, 38 | "test": { 39 | "executor": "@nx/jest:jest", 40 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 41 | "options": { 42 | "jestConfig": "{projectRoot}/jest.config.ts" 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /apps/service/src/framework/mongo/client.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, ReadPreference, Db, Document } from 'mongodb'; 2 | import { mongoUrl, mongoDbName, mongoDbUser, mongoDbPassword } from '../env'; 3 | 4 | let client: MongoClient | undefined; 5 | let db: Db | undefined; 6 | 7 | const getClient = async () => { 8 | if (!client) { 9 | try { 10 | client = await MongoClient.connect(mongoUrl, { 11 | auth: { username: mongoDbUser, password: mongoDbPassword }, 12 | readPreference: ReadPreference.PRIMARY, 13 | writeConcern: { 14 | w: 'majority', 15 | }, 16 | retryWrites: true, 17 | connectTimeoutMS: 5000, 18 | serverSelectionTimeoutMS: 5000, 19 | }); 20 | } catch (err) { 21 | throw new Error('Could not connect to mongo\n ' + err); 22 | } 23 | } 24 | 25 | return client; 26 | }; 27 | 28 | export async function closeMongoClient() { 29 | if (client) { 30 | await client.close(); 31 | 32 | db = undefined; 33 | client = undefined; 34 | } 35 | } 36 | 37 | export const getDB = async () => { 38 | if (!db) { 39 | try { 40 | const client = await getClient(); 41 | 42 | db = client.db(mongoDbName); 43 | } catch (err) { 44 | throw new Error('Could not connect to mongo\n ' + err); 45 | } 46 | } 47 | 48 | return db; 49 | }; 50 | 51 | export const getCollection = async (collectionName: string) => 52 | (await getDB()).collection(collectionName); 53 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/ReportsChart/components/CustomTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import styled from '@emotion/styled'; 3 | import { TooltipProps } from 'recharts'; 4 | import { CommitRecord } from 'bundlemon-utils'; 5 | import { Paper, Stack } from '@mui/material'; 6 | import bytes from 'bytes'; 7 | 8 | const Container = styled(Paper)` 9 | padding: ${({ theme }) => theme.spacing(2)}; 10 | min-width: 300px; 11 | `; 12 | 13 | const Title = styled.span` 14 | font-weight: 700; 15 | `; 16 | 17 | const CommitMsgText = styled.span` 18 | font-weight: 500; 19 | overflow: hidden; 20 | white-space: wrap; 21 | max-width: 300px; 22 | `; 23 | 24 | const CustomTooltip = observer(({ active, payload }: TooltipProps) => { 25 | if (!active) { 26 | return null; 27 | } 28 | 29 | const commitRecord: CommitRecord | undefined = payload?.[0]?.payload; 30 | 31 | if (!commitRecord) { 32 | return null; 33 | } 34 | 35 | return ( 36 | 37 | 38 | {new Date(commitRecord.creationDate).toLocaleString()} 39 | {commitRecord.commitMsg && {commitRecord.commitMsg}} 40 | {payload?.map((p) => ( 41 | 42 | {p.name}: {bytes(p.value as number)} 43 | 44 | ))} 45 | 46 | 47 | ); 48 | }); 49 | 50 | export default CustomTooltip; 51 | -------------------------------------------------------------------------------- /apps/website/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode, Suspense } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import CssBaseline from '@mui/material/CssBaseline'; 4 | import { SnackbarProvider } from 'notistack'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | import { BrowserRouter } from 'react-router-dom'; 7 | import Layout from './components/Layout'; 8 | import Router from './Router'; 9 | import FetchError from './services/FetchError'; 10 | import ThemeProvider from './components/ThemeProvider'; 11 | 12 | const queryClient = new QueryClient({ 13 | defaultOptions: { 14 | queries: { 15 | retry: (failureCount, error) => { 16 | if (error instanceof FetchError) { 17 | return error.statusCode >= 500 && failureCount <= 2 ? true : false; 18 | } 19 | 20 | return false; 21 | }, 22 | refetchOnWindowFocus: false, 23 | cacheTime: 0, 24 | }, 25 | }, 26 | }); 27 | 28 | createRoot(document.getElementById('root') as HTMLElement).render( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | -------------------------------------------------------------------------------- /apps/service/scripts/generateLocalData.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'mongodb'; 2 | import { getDB } from '../src/framework/mongo/client'; 3 | import { getProjectsCollection } from '../src/framework/mongo/projects'; 4 | import { createHash } from '../src/utils/hashUtils'; 5 | import { mongoUrl, nodeEnv } from '../src/framework/env'; 6 | 7 | async function createProject(char: string) { 8 | const projectId = char.repeat(24); 9 | const apiKey = char.repeat(64); 10 | const startKey = apiKey.substring(0, 3); 11 | const hash = await createHash(apiKey); 12 | const projectsCollection = await getProjectsCollection(); 13 | const creationDate = new Date(); 14 | creationDate.setDate(creationDate.getDate() - 7); // one week ago 15 | 16 | await projectsCollection.insertOne({ 17 | _id: new ObjectId(projectId), 18 | apiKey: { hash, startKey }, 19 | creationDate, 20 | lastAccessed: new Date(), 21 | }); 22 | 23 | console.log(`project ${projectId} created with api key: "${apiKey}"`); 24 | } 25 | 26 | (async () => { 27 | try { 28 | if (!mongoUrl.includes('localhost') || nodeEnv !== 'development') { 29 | throw new Error('This script should only run on local env'); 30 | } 31 | 32 | console.log('Clear DB'); 33 | await (await getDB()).dropDatabase(); 34 | 35 | console.log('Create projects'); 36 | await createProject('a'); 37 | await createProject('b'); 38 | await createProject('c'); 39 | 40 | process.exit(0); 41 | } catch (e) { 42 | console.error(e); 43 | process.exit(1); 44 | } 45 | })(); 46 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/__tests__/configFile.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { loadConfigFile } from '../configFile'; 3 | 4 | const SUCCESS_FILE_CONFIG = { 5 | baseDir: 'build', 6 | verbose: true, 7 | }; 8 | describe('load config file', () => { 9 | beforeEach(() => { 10 | jest.resetAllMocks(); 11 | }); 12 | 13 | test('success', async () => { 14 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'success.json')); 15 | 16 | expect(config).toEqual(SUCCESS_FILE_CONFIG); 17 | }); 18 | 19 | describe('failure', () => { 20 | test('empty', async () => { 21 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'empty.json')); 22 | 23 | expect(config).toBeUndefined(); 24 | }); 25 | 26 | test('bad format JSON', async () => { 27 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'bad-format.json')); 28 | 29 | expect(config).toBeUndefined(); 30 | }); 31 | 32 | test('bad format YAML', async () => { 33 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'bad-format.yaml')); 34 | 35 | expect(config).toBeUndefined(); 36 | }); 37 | 38 | test('bad format JS', async () => { 39 | const config = await loadConfigFile(path.join(__dirname, 'assets', 'bad-format.js')); 40 | 41 | expect(config).toBeUndefined(); 42 | }); 43 | 44 | test('not found', async () => { 45 | const config = await loadConfigFile('not-found'); 46 | 47 | expect(config).toBeUndefined(); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /apps/service/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-service", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "serve": "yarn --cwd ../../ nx serve service --verbose", 8 | "build": "yarn --cwd ../../ nx build service --verbose", 9 | "test": "yarn --cwd ../../ nx test service --verbose", 10 | "lint": "yarn --cwd ../../ nx lint service --verbose", 11 | "generate-schemas": "node ./scripts/generateSchemas.js", 12 | "prevercel-deploy": "yarn --cwd ../../ nx build service -c vercel --verbose", 13 | "vercel-deploy": "vercel deploy", 14 | "start:mock-services": "docker compose -f docker-compose.test.yml up --remove-orphans", 15 | "stop:mock-services": "docker compose -f docker-compose.test.yml down", 16 | "gen-local-data": "node -r @swc-node/register -r dotenv/config ./scripts/generateLocalData.ts dotenv_config_path=.development.env" 17 | }, 18 | "dependencies": { 19 | "@fastify/cookie": "^11.0.2", 20 | "@fastify/cors": "^11.1.0", 21 | "@fastify/secure-session": "^8.2.0", 22 | "@fastify/static": "^8.2.0", 23 | "@octokit/auth-app": "^6.0.0", 24 | "@octokit/rest": "^20.0.1", 25 | "bundlemon-markdown-output": "^2.0.1", 26 | "bundlemon-utils": "^2.0.1", 27 | "env-var": "^7.3.1", 28 | "fastify": "^5.5.0", 29 | "mongodb": "^6.18.0" 30 | }, 31 | "devDependencies": { 32 | "@types/sodium-native": "^2.3.9", 33 | "dotenv": "^16.3.1", 34 | "ts-json-schema-generator": "^1.3.0", 35 | "typescript": "^5.1.6", 36 | "vercel": "^31.4.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-utils", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "{projectRoot}/lib", 5 | "projectType": "library", 6 | "tags": ["type:lib"], 7 | "generators": {}, 8 | "targets": { 9 | "build": { 10 | "executor": "@nx/js:tsc", 11 | "options": { 12 | "outputPath": "dist/{projectRoot}", 13 | "tsConfig": "{projectRoot}/tsconfig.lib.json", 14 | "packageJson": "{projectRoot}/package.json", 15 | "main": "{projectRoot}/lib/index.ts", 16 | "updateBuildableProjectDepsInPackageJson": false, 17 | "assets": [ 18 | { 19 | "input": "{projectRoot}", 20 | "glob": "README.md", 21 | "output": "/" 22 | }, 23 | "LICENSE", 24 | { 25 | "input": "{projectRoot}", 26 | "glob": "**/*.d.ts", 27 | "ignore": ["node_modules/**"], 28 | "output": "/" 29 | } 30 | ] 31 | }, 32 | "outputs": ["{options.outputPath}"] 33 | }, 34 | "lint": { 35 | "executor": "@nx/eslint:lint", 36 | "outputs": ["{options.outputFile}"], 37 | "options": { 38 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"], 39 | "maxWarnings": 0 40 | } 41 | }, 42 | "test": { 43 | "executor": "@nx/jest:jest", 44 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 45 | "options": { 46 | "jestConfig": "{projectRoot}/jest.config.ts" 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/website/src/stores/ConfigStore.ts: -------------------------------------------------------------------------------- 1 | import { computed, makeAutoObservable, observable, runInAction } from 'mobx'; 2 | 3 | interface Config { 4 | bundlemonServiceUrl: string; 5 | githubAppClientId?: string; 6 | } 7 | 8 | export class ConfigStore { 9 | @observable.ref _config: Config | undefined = undefined; 10 | @observable.ref error: string | undefined = undefined; 11 | 12 | constructor() { 13 | makeAutoObservable(this); 14 | 15 | this.init(); 16 | } 17 | 18 | @computed get isLoaded(): boolean { 19 | return this._config !== undefined; 20 | } 21 | 22 | init = async () => { 23 | try { 24 | const response = await fetch('/assets/config.json'); 25 | const config = await response.json(); 26 | 27 | runInAction(() => { 28 | this._config = config; 29 | }); 30 | } catch (error) { 31 | console.error('Failed to load config', error); 32 | 33 | runInAction(() => { 34 | this.error = `Failed to load config: ${(error as Error).message}`; 35 | }); 36 | } 37 | }; 38 | 39 | get = (key: T): Config[T] => { 40 | if (this._config === undefined) { 41 | throw new Error('Config is not loaded yet'); 42 | } 43 | 44 | return this._config[key]; 45 | }; 46 | 47 | get bundlemonServiceUrl() { 48 | return import.meta.env.VITE_BUNDLEMON_SERVICE_URL || this.get('bundlemonServiceUrl'); 49 | } 50 | 51 | get githubAppClientId() { 52 | return import.meta.env.VITE_GITHUB_APP_ID || this.get('githubAppClientId'); 53 | } 54 | } 55 | 56 | export const configStore = new ConfigStore(); 57 | -------------------------------------------------------------------------------- /packages/bundlemon-markdown-output/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-markdown-output", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "{projectRoot}/lib", 5 | "projectType": "library", 6 | "tags": ["type:lib"], 7 | "generators": {}, 8 | "targets": { 9 | "build": { 10 | "executor": "@nx/js:tsc", 11 | "options": { 12 | "outputPath": "dist/{projectRoot}", 13 | "tsConfig": "{projectRoot}/tsconfig.lib.json", 14 | "packageJson": "{projectRoot}/package.json", 15 | "main": "{projectRoot}/lib/index.ts", 16 | "updateBuildableProjectDepsInPackageJson": false, 17 | "assets": [ 18 | { 19 | "input": "{projectRoot}", 20 | "glob": "README.md", 21 | "output": "/" 22 | }, 23 | "LICENSE", 24 | { 25 | "input": "{projectRoot}", 26 | "glob": "**/*.d.ts", 27 | "ignore": ["node_modules/**"], 28 | "output": "/" 29 | } 30 | ] 31 | }, 32 | "outputs": ["{options.outputPath}"] 33 | }, 34 | "lint": { 35 | "executor": "@nx/eslint:lint", 36 | "outputs": ["{options.outputFile}"], 37 | "options": { 38 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,js,json}"], 39 | "maxWarnings": 0 40 | } 41 | }, 42 | "test": { 43 | "executor": "@nx/jest:jest", 44 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 45 | "options": { 46 | "jestConfig": "{projectRoot}/jest.config.ts" 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/website/src/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles'; 2 | import { ThemeProvider as EmotionThemeProvider } from '@emotion/react'; 3 | import { createContext, ReactNode, useState } from 'react'; 4 | import { lightTheme, darkTheme } from '@/consts/theme'; 5 | import { useOnMount } from '@/hooks'; 6 | 7 | export const ThemeContext = createContext<{ isDarkMode: boolean; setDarkMode: (isDarkMode: boolean) => void }>({ 8 | isDarkMode: false, 9 | // eslint-disable-next-line @typescript-eslint/no-empty-function 10 | setDarkMode: () => {}, 11 | }); 12 | 13 | interface ThemeProviderProps { 14 | children: ReactNode; 15 | } 16 | 17 | const ThemeProvider = ({ children }: ThemeProviderProps) => { 18 | const [isDarkMode, setDarkModeState] = useState(false); 19 | 20 | useOnMount(() => { 21 | const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; 22 | const storedValue = localStorage.getItem('isDarkMode'); 23 | 24 | setDarkModeState(storedValue ? storedValue === 'true' : prefersDark); 25 | }); 26 | 27 | const setDarkMode = (isDark: boolean) => { 28 | setDarkModeState(isDark); 29 | localStorage.setItem('isDarkMode', String(isDark)); 30 | }; 31 | 32 | const theme = isDarkMode ? darkTheme : lightTheme; 33 | 34 | return ( 35 | 36 | 37 | {children} 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default ThemeProvider; 44 | -------------------------------------------------------------------------------- /apps/service/src/utils/reportUtils.ts: -------------------------------------------------------------------------------- 1 | import { CommitRecordReviewResolution, generateDiffReport, Report, Status } from 'bundlemon-utils'; 2 | import { generateLinkToReport } from './linkUtils'; 3 | 4 | import type { CommitRecordWithBase } from '../framework/mongo/commitRecords'; 5 | 6 | export function generateReport({ record, baseRecord }: CommitRecordWithBase): Report { 7 | const diffReport = generateDiffReport(record, baseRecord); 8 | 9 | const report: Report = { 10 | ...diffReport, 11 | metadata: { 12 | subProject: record.subProject, 13 | linkToReport: generateLinkToReport({ projectId: record.projectId, commitRecordId: record.id }), 14 | record, 15 | baseRecord, 16 | }, 17 | }; 18 | 19 | // If the record and the base record have the same branch, that probably mean it's a merge commit, so no need to fail the report 20 | if (report.status === Status.Fail && record.branch === baseRecord?.branch) { 21 | report.status = Status.Pass; 22 | } 23 | 24 | // Set report status to the last review 25 | if (record.reviews?.length) { 26 | const lastReviewResolution = record.reviews[record.reviews.length - 1].resolution; 27 | 28 | if (lastReviewResolution === CommitRecordReviewResolution.Approved) { 29 | report.status = Status.Pass; 30 | } else if (lastReviewResolution === CommitRecordReviewResolution.Rejected) { 31 | report.status = Status.Fail; 32 | } 33 | } 34 | 35 | return report; 36 | } 37 | 38 | export function truncateString(input: string, maxLength: number) { 39 | if (input.length > maxLength) { 40 | return input.substring(0, maxLength) + '...'; 41 | } 42 | 43 | return input; 44 | } 45 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/ci/index.ts: -------------------------------------------------------------------------------- 1 | import providers from './providers'; 2 | import { envVarsListToObject } from '../utils'; 3 | import type { CIEnvVars } from './types'; 4 | 5 | const rawOverrideVars = envVarsListToObject([ 6 | 'CI', 7 | 'CI_REPO_OWNER', 8 | 'CI_REPO_NAME', 9 | 'CI_BRANCH', 10 | 'CI_COMMIT_SHA', 11 | 'CI_TARGET_BRANCH', 12 | 'CI_PR_NUMBER', 13 | 'CI_COMMIT_MESSAGE', 14 | ] as const); 15 | 16 | const overrideVars: CIEnvVars = { 17 | raw: rawOverrideVars, 18 | ci: rawOverrideVars.CI === 'true', 19 | provider: undefined, 20 | owner: rawOverrideVars.CI_REPO_OWNER, 21 | repo: rawOverrideVars.CI_REPO_NAME, 22 | branch: rawOverrideVars.CI_BRANCH, 23 | commitSha: rawOverrideVars.CI_COMMIT_SHA, 24 | targetBranch: rawOverrideVars.CI_TARGET_BRANCH, 25 | prNumber: rawOverrideVars.CI_PR_NUMBER, 26 | commitMsg: rawOverrideVars.CI_COMMIT_MESSAGE, 27 | }; 28 | 29 | const providerVars = providers.find((p) => p.isItMe)?.getVars(); 30 | const vars = { ...overrideVars }; 31 | 32 | if (providerVars) { 33 | // Use provider var if override var is undefined 34 | (Object.keys(providerVars) as (keyof CIEnvVars)[]).forEach((varName) => { 35 | // @ts-expect-error bad types 36 | vars[varName] = vars[varName] ?? providerVars[varName]; 37 | }); 38 | 39 | vars.raw = { 40 | ...providerVars.raw, 41 | ...overrideVars.raw, 42 | }; 43 | } 44 | 45 | export const getCIVars = () => { 46 | return vars; 47 | }; 48 | 49 | export default vars; 50 | 51 | const { ci, provider, owner, repo, branch, commitSha, prNumber, targetBranch } = vars; 52 | 53 | export { ci, provider, owner, repo, branch, commitSha, prNumber, targetBranch }; 54 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/initializer.ts: -------------------------------------------------------------------------------- 1 | import { existsSync as isDirExists } from 'fs'; 2 | import logger, { setVerbose } from '../common/logger'; 3 | import { validateConfig, getNormalizedConfig } from './utils/configUtils'; 4 | import { Config, NormalizedConfig } from './types'; 5 | import { initOutputs } from './outputs'; 6 | import ciVars from './utils/ci'; 7 | import { version } from '../common/consts'; 8 | 9 | export async function initializer(config: Config): Promise { 10 | setVerbose(config.verbose ?? false); 11 | 12 | logger.info(`Start BundleMon v${version}`); 13 | 14 | const validatedConfig = validateConfig(config); 15 | 16 | if (!validatedConfig) { 17 | logger.error('Invalid config'); 18 | logger.debug(`Config\n${JSON.stringify(config, null, 2)}`); 19 | 20 | return undefined; 21 | } 22 | 23 | const normalizedConfig = Object.freeze(await getNormalizedConfig(validatedConfig)); 24 | 25 | if (!normalizedConfig) { 26 | logger.debug(`Config\n${JSON.stringify(config, null, 2)}`); 27 | return undefined; 28 | } 29 | 30 | logger.debug(`Normalized config:\n${JSON.stringify(normalizedConfig, null, 2)}`); 31 | logger.debug(`CI vars:\n${JSON.stringify(ciVars, null, 2)}`); 32 | 33 | const { baseDir } = normalizedConfig; 34 | 35 | logger.info(`base directory: "${baseDir}"`); 36 | 37 | if (!isDirExists(baseDir)) { 38 | logger.error(`base directory "${baseDir}" not found`); 39 | 40 | return undefined; 41 | } 42 | 43 | try { 44 | await initOutputs(normalizedConfig); 45 | } catch (err) { 46 | logger.error((err as Error).message); 47 | return undefined; 48 | } 49 | 50 | return normalizedConfig; 51 | } 52 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/json.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import fs from 'fs'; 3 | import { Report } from 'bundlemon-utils'; 4 | import { createLogger } from '../../../common/logger'; 5 | import { validateYup } from '../../utils/validationUtils'; 6 | import type { Output, OutputInstance } from '../types'; 7 | 8 | const NAME = 'json'; 9 | const DEFAULT_FILENAME = 'bundlemon-results.json'; 10 | 11 | const logger = createLogger(`${NAME} output`); 12 | 13 | interface JsonOutputOptions { 14 | fileName: string; 15 | } 16 | 17 | function validateOptions(options: unknown): JsonOutputOptions | undefined { 18 | const schema: yup.SchemaOf = yup 19 | .object() 20 | .required() 21 | .shape({ 22 | fileName: yup.string().optional().default(DEFAULT_FILENAME), 23 | }); 24 | 25 | return validateYup(schema, options, `${NAME} output`); 26 | } 27 | 28 | const saveAsJson = (filename: string, payload: Report) => { 29 | try { 30 | fs.writeFileSync(`${filename}`, JSON.stringify(payload, null, 2)); 31 | } catch (error) { 32 | logger.error(`Could not save a file ${filename}`); 33 | throw error; 34 | } 35 | }; 36 | 37 | const output: Output = { 38 | name: NAME, 39 | create: ({ options }): OutputInstance | Promise | undefined => { 40 | const normalizedOptions = validateOptions(options); 41 | 42 | if (!normalizedOptions) { 43 | throw new Error(`validation error in output "${NAME}" options`); 44 | } 45 | return { 46 | generate: (report: Report): void => { 47 | saveAsJson(normalizedOptions.fileName, report); 48 | }, 49 | }; 50 | }, 51 | }; 52 | 53 | export default output; 54 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/cli/index.ts: -------------------------------------------------------------------------------- 1 | import { program, Option } from 'commander'; 2 | import { Compression, Status } from 'bundlemon-utils'; 3 | import bundlemon from '../main'; 4 | import logger from '../common/logger'; 5 | import { version } from '../common/consts'; 6 | import { loadConfigFile } from './configFile'; 7 | 8 | import type { CliOptions } from './types'; 9 | import type { Config } from '../main/types'; 10 | 11 | program 12 | .version(version) 13 | .addOption(new Option('-c, --config ', 'config file path')) 14 | .addOption(new Option('--subProject ', 'sub project name')) 15 | .addOption( 16 | new Option('--defaultCompression ', 'default compression').choices(Object.values(Compression)) 17 | ); 18 | 19 | export default async (): Promise => { 20 | try { 21 | program.parse(process.argv); 22 | 23 | const options: CliOptions = program.opts(); 24 | 25 | const config = await loadConfigFile(options.config); 26 | 27 | if (!config) { 28 | logger.error('Cant find config or the config file is empty'); 29 | process.exit(1); 30 | } 31 | 32 | const report = await bundlemon(mergeCliOptions(config, options)); 33 | 34 | process.exit(report.status === Status.Pass ? 0 : 1); 35 | } catch (err) { 36 | logger.error('Unhandled error', err); 37 | process.exit(1); 38 | } 39 | }; 40 | 41 | function mergeCliOptions(config: Config, options: CliOptions): Config { 42 | const newConfig = { ...config }; 43 | 44 | if (options.subProject) { 45 | newConfig.subProject = options.subProject; 46 | } 47 | 48 | if (options.defaultCompression) { 49 | newConfig.defaultCompression = options.defaultCompression; 50 | } 51 | 52 | return newConfig; 53 | } 54 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/common/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | let _verbose = false; 4 | 5 | export const setVerbose = (verbose: boolean): void => { 6 | _verbose = verbose; 7 | }; 8 | 9 | class Logger { 10 | prefix: string | undefined; 11 | 12 | constructor(prefix?: string) { 13 | this.prefix = prefix; 14 | } 15 | 16 | log = (message: string): void => { 17 | console.log(this.messageWithPrefix(message)); 18 | }; 19 | 20 | debug = (message: string): void => { 21 | if (!_verbose) { 22 | return; 23 | } 24 | 25 | console.log(chalk.grey(`[DEBUG] ${this.messageWithPrefix(message)}`)); 26 | }; 27 | 28 | info = (message: string): void => { 29 | console.log(chalk.cyan(`[INFO] ${this.messageWithPrefix(message)}`)); 30 | }; 31 | 32 | warn = (message: string): void => { 33 | console.log(chalk.yellow(`[WARN] ${this.messageWithPrefix(message)}`)); 34 | }; 35 | 36 | error = (message: string, err?: unknown): void => { 37 | console.error(chalk.red(`[ERROR] ${this.messageWithPrefix(message)}`)); 38 | if (err) { 39 | if (err instanceof Error) { 40 | console.error(err); 41 | } else { 42 | console.error(chalk.red(err)); 43 | } 44 | } 45 | }; 46 | 47 | private messageWithPrefix = (message: string) => { 48 | return (this.prefix ? this.prefix + ': ' : '').concat(message); 49 | }; 50 | 51 | clone = (prefix: string) => { 52 | const newLogger = new Logger((this.prefix ? `${this.prefix} - ` : '') + prefix); 53 | 54 | return newLogger; 55 | }; 56 | } 57 | 58 | const logger = new Logger(); 59 | 60 | export default logger; 61 | 62 | export function createLogger(prefix: string): Logger { 63 | return logger.clone(prefix); 64 | } 65 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/utils/__tests__/configUtils.ts: -------------------------------------------------------------------------------- 1 | import { Compression } from 'bundlemon-utils'; 2 | import { CreateCommitRecordAuthType, DEFAULT_PATH_LABELS } from '../../../common/consts'; 3 | import { 4 | BaseNormalizedConfig, 5 | NormalizedConfigRemoteOn, 6 | NormalizedConfigRemoteOff, 7 | CreateCommitRecordAuthParams, 8 | } from '../../types'; 9 | 10 | const baseNormalizedConfig: Omit = { 11 | baseDir: '', 12 | defaultCompression: Compression.Gzip, 13 | files: [], 14 | groups: [], 15 | pathLabels: DEFAULT_PATH_LABELS, 16 | reportOutput: [], 17 | verbose: false, 18 | includeCommitMessage: false, 19 | }; 20 | 21 | export function generateNormalizedConfigRemoteOn( 22 | override: Partial = {} 23 | ): NormalizedConfigRemoteOn { 24 | const authHeaders: CreateCommitRecordAuthParams = { 25 | authType: CreateCommitRecordAuthType.ProjectApiKey, 26 | token: generateRandomString(), 27 | }; 28 | 29 | return { 30 | ...baseNormalizedConfig, 31 | remote: true, 32 | projectId: generateRandomString(), 33 | gitVars: { 34 | branch: generateRandomString(), 35 | commitSha: generateRandomString(), 36 | }, 37 | getCreateCommitRecordAuthParams: () => authHeaders, 38 | ...override, 39 | }; 40 | } 41 | 42 | export function generateNormalizedConfigRemoteOff( 43 | override: Partial = {} 44 | ): NormalizedConfigRemoteOff { 45 | return { 46 | ...baseNormalizedConfig, 47 | remote: false, 48 | ...override, 49 | }; 50 | } 51 | 52 | export function generateRandomString(length = 10) { 53 | return Math.round(Math.pow(36, length + 1) - Math.random() * Math.pow(36, length)) 54 | .toString(36) 55 | .slice(1); 56 | } 57 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/ReportsPage.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { GetCommitRecordsQuery } from '@/services/bundlemonService'; 3 | import { useNavigate, useParams } from 'react-router'; 4 | import { useQueryParams } from '@/hooks'; 5 | import { CommitRecordsQueryResolution } from '@/consts/commitRecords'; 6 | import { removeEmptyValuesFromObject } from '@/utils/objectUtils'; 7 | import QueryParamsForm from './components/QueryParamsForm'; 8 | import ReportsResult from './components/ReportsResult'; 9 | import { Stack } from '@mui/material'; 10 | 11 | const Container = styled(Stack)` 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | align-self: center; 16 | margin: 0 auto; 17 | width: 100%; 18 | `; 19 | 20 | const ReportsPage = () => { 21 | const { projectId } = useParams() as { projectId: string }; 22 | const query = useQueryParams(); 23 | const navigate = useNavigate(); 24 | 25 | const getCommitRecordsQuery: GetCommitRecordsQuery = { 26 | subProject: query.get('subProject') ?? undefined, 27 | branch: query.get('branch') || 'main', 28 | resolution: (query.get('resolution') as CommitRecordsQueryResolution | null) || CommitRecordsQueryResolution.Days, 29 | }; 30 | 31 | const setGetCommitRecordsQuery = (params: GetCommitRecordsQuery) => { 32 | navigate({ 33 | search: `?${new URLSearchParams(removeEmptyValuesFromObject(params))}`, 34 | }); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default ReportsPage; 46 | -------------------------------------------------------------------------------- /apps/service/src/local-certs/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA7bpHOVAjP4ceYqqVwWaxrNHrunN0XMWzXGj+zG1IaaZLvAYx 3 | tXbWQJuhBaDK3wEDR9vjZTIkSr3DRkBIAMbg1Xqylg/fiEtBZ36DoBE1GeETdsHt 4 | KiMboe7TxtgHfDjGlVo9LlK2E9n+c71T8c3WWSzD6TRQzwvd/6THbMYLPvCSm/cj 5 | VY7FrI57sXWAvpR3DePXR2EssU/rwkLDcCIPZ9Dyf2VGQ66BICF6qXrupNauFbRx 6 | WwNfB7HoAFuFdV5JnIBAYo5JWX4Hh+NRQkPyEynwMP24m+mCSLL9ga404JS9Tsej 7 | KgAT+Ppgbr/aezP/C9E/WLeLhN2CsHS6QbfcaQIDAQABAoIBAQCiWNhTF5s6wzfJ 8 | Ad4Lmeo0r5dgWYBZ6tm2fi2jxe3x2JNX8JL57hIbRS0N/uUMrlBjPpNohHmsYTN+ 9 | Ql/px+e7YnObb3OkTGB6ITgalCXDaqY0L/ObFybDy6ns3ZMfDlbvoBSwEeQuYm0W 10 | 9XDibUO42o1gMU4OV3hgIVPfwM/lRmyi+uL3G237HCf7slA45wh5rm9mdLXnMsru 11 | ZSH8yNL6cty7WUv6aXnz5t18WfDFsc/enbuGdflR1bV28hTm664r56Z5mUt1nCqw 12 | QuLPqgcRhnpd0bXqGp3txtKlHhtgyRYqLhULzzLWfyBJaa5a/kI7CqKn3/we8yTC 13 | w3iZDCpBAoGBAPj69S/HU1jHxDwKAp0kUm3MjIsrqpgwwLzLfYjNi0kPvaYzYUEl 14 | LtJAMLnTooZJqr1IbIJyQiqh+Ttg/ZLj/fDRKkEB53la+kyvyt9MxYs60vYNMcG1 15 | CQ8ZjasXSp96qrrsE8nPYn7aOXuZati3IfkLlaB0WQki/nbQ8V7nusK1AoGBAPRu 16 | GmvkFethbQrTbuK2+eOLqQRgJT8paClaBZZTXRi82Xxmbhf/GYAramVvjcpxcQ0O 17 | RdbFJTxQlg+7t6jMW06df2Vj/kxOLvtobxh2UvXp8r381RJzMcOO2M9iZlOIthn6 18 | h73Xg5N8aVFTS8PN1iP3PHr6xq4iXJamYaLNNL9lAoGBAKXbP0Oxq1Lj2FQKcw1N 19 | Kd/ct+7pir3RFENv5tMf4V4tLy+s4GduJo+GlS7kzUpZfnSS7z3CcVNHDOjCRoj4 20 | eaxXGaeuZg0QTtaQ8DrqQFnsOKYRygh42W0Gn7nOTTaJl3vnUZNJJBrOsiYk3+k3 21 | rVjin60AdGNCvXJW48NN6LpVAoGBAKibNoCL8g7OwqAAHvImk5NBqFILXEYIcwBr 22 | R4Vddc91nXQxV+oXnuiJijf0TlOCEyCVYtl2XmwPjqPFsjeu16EQBWvUIPtTxxbH 23 | ADNYk3tsaHRjbjru2TnzVF0hnEItAKhE59OtUOawoBloItArMXbXuZF/YQOHUmTc 24 | 2maptKP9AoGAFTmY9b6h0pe70tUYnmCyZa2yS6WTTkTSKkG1dp25aTmlnQaZ56zR 25 | YEeNIJB1ZnBf4gV/Wt+LENgdS/Nm0qIPM14BmExxCi8nimPxL9ylQnsQhAhs6oBO 26 | RmGAs/E3Sjc9V/oKDTrLrGGlEVbJ5/gMMAnuulCHtGomRc0EdAey6FI= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /apps/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundlemon-website", 3 | "version": "1.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "yarn --cwd ../../ nx build website --verbose", 8 | "build:analyze": "yarn --cwd ../../ nx build:analyze website --verbose", 9 | "serve": "yarn --cwd ../../ nx serve website --verbose", 10 | "lint": "yarn --cwd ../../ nx lint website --verbose", 11 | "serve:preview": "yarn --cwd ../../ nx preview website --verbose", 12 | "bundlemon": "node -r @swc-node/register ../../packages/bundlemon/bin/bundlemon.ts", 13 | "deploy": "netlify deploy --no-build --dir dist/apps/website" 14 | }, 15 | "dependencies": { 16 | "@emotion/react": "^11.11.1", 17 | "@emotion/styled": "^11.11.0", 18 | "@mui/icons-material": "^5.14.13", 19 | "@mui/lab": "^5.0.0-alpha.148", 20 | "@mui/material": "^5.14.13", 21 | "bundlemon-utils": "^2.0.1", 22 | "material-react-table": "^1.15.0", 23 | "mobx": "^6.10.2", 24 | "mobx-react-lite": "^4.0.5", 25 | "notistack": "^3.0.1", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "react-query": "^3.39.3", 29 | "react-router": "^6.16.0", 30 | "react-router-dom": "^6.16.0", 31 | "recharts": "^2.8.0" 32 | }, 33 | "devDependencies": { 34 | "@types/react": "^18.2.28", 35 | "@types/react-dom": "^18.2.13", 36 | "@types/react-router-dom": "^5.3.3", 37 | "@types/recharts": "^1.8.25", 38 | "@vitejs/plugin-basic-ssl": "^1.1.0", 39 | "eslint-plugin-jsx-a11y": "^6.8.0", 40 | "eslint-plugin-react": "^7.33.2", 41 | "eslint-plugin-react-hooks": "^4.6.0", 42 | "netlify-cli": "^21.6.0", 43 | "vite": "^5.4.21", 44 | "vite-bundle-analyzer": "^0.17.0", 45 | "vite-tsconfig-paths": "^4.3.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/ReportsChart/ReportsChart.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; 3 | import LegendDataTable from './components/LegendDataTable'; 4 | import { getVal, bytesTickFormatter, dateTickFormatter } from './utils'; 5 | import CustomTooltip from './components/CustomTooltip'; 6 | 7 | import type ReportsStore from './ReportsStore'; 8 | 9 | interface ReportsChartProps { 10 | store: ReportsStore; 11 | } 12 | 13 | const toolTipStyle = { 14 | zIndex: '3', 15 | }; 16 | 17 | const ReportsChart = observer(({ store }: ReportsChartProps) => { 18 | return ( 19 | <> 20 | 21 | 30 | 31 | 32 | 33 | } /> 34 | {store.pathRecords.map((record) => ( 35 | 44 | ))} 45 | 46 | 47 | 48 | 49 | ); 50 | }); 51 | 52 | export default ReportsChart; 53 | -------------------------------------------------------------------------------- /apps/service/src/controllers/utils/markdownReportGenerator.ts: -------------------------------------------------------------------------------- 1 | import { generateReportMarkdown } from 'bundlemon-markdown-output'; 2 | import { CommitRecordsQueryResolution } from '../../consts/commitRecords'; 3 | import { generateLinkToReports, GenerateLinkToReportskParams } from '../../utils/linkUtils'; 4 | 5 | import type { Report, CommitRecord } from 'bundlemon-utils'; 6 | 7 | interface GetReportsPageLinkParams extends GenerateLinkToReportskParams { 8 | text: string; 9 | } 10 | 11 | function getReportsPageLink({ text, ...linkParams }: GetReportsPageLinkParams): string { 12 | return `${text}`; 13 | } 14 | 15 | // TODO: max 65535 chars 16 | export function generateReportMarkdownWithLinks(report: Report): string { 17 | const { 18 | metadata: { record, baseRecord }, 19 | } = report; 20 | 21 | let body = generateReportMarkdown(report); 22 | 23 | if (record || baseRecord) { 24 | const { projectId, subProject } = (record || baseRecord) as CommitRecord; 25 | 26 | const links: string[] = []; 27 | 28 | if (record) { 29 | links.push( 30 | getReportsPageLink({ 31 | projectId, 32 | subProject, 33 | branch: record.branch, 34 | resolution: CommitRecordsQueryResolution.All, 35 | text: 'Current branch size history', 36 | }) 37 | ); 38 | } 39 | 40 | if (baseRecord) { 41 | links.push( 42 | getReportsPageLink({ 43 | projectId, 44 | subProject, 45 | branch: baseRecord.branch, 46 | resolution: CommitRecordsQueryResolution.Days, 47 | text: 'Target branch size history', 48 | }) 49 | ); 50 | } 51 | 52 | body += `\n\n---\n

${links.join(' | ')}

`; 53 | } 54 | 55 | return body; 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/publish-packages.yml: -------------------------------------------------------------------------------- 1 | name: publish-packages 2 | 3 | on: 4 | push: 5 | tags: 6 | - bundlemon@v*.*.* 7 | - bundlemon-markdown-output@v*.*.* 8 | - bundlemon-utils@v*.*.* 9 | 10 | jobs: 11 | test: 12 | name: publish-packages 13 | runs-on: ubuntu-22.04 14 | permissions: 15 | contents: read 16 | id-token: write # needed for provenance data generation 17 | timeout-minutes: 10 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Extract package name 25 | id: package_name 26 | run: echo "value=$(echo $GITHUB_REF_NAME | cut -d '@' -f 1)" >> $GITHUB_OUTPUT 27 | 28 | # validate the package.json version is the same as the tag 29 | - name: Validate version 30 | run: | 31 | ACTUAL_VERSION=$(jq -r '.version' packages/${{ steps.package_name.outputs.value }}/package.json) 32 | EXPECTED_VERSION=$(echo $GITHUB_REF_NAME | sed 's/.*@v//') 33 | if [ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]; then 34 | echo "Version mismatch between package.json ($ACTUAL_VERSION) and tag ($EXPECTED_VERSION)" 35 | exit 1 36 | fi 37 | 38 | - name: Install Node 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: 22 42 | registry-url: https://registry.npmjs.org/ 43 | cache: yarn 44 | 45 | - name: Install dependencies 46 | run: yarn 47 | 48 | - name: Build 49 | run: yarn nx build ${{ steps.package_name.outputs.value }} 50 | 51 | - name: Publish packages 52 | run: npx nx release publish --verbose --projects ${{ steps.package_name.outputs.value }} 53 | env: 54 | NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 55 | NPM_CONFIG_PROVENANCE: true 56 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/custom.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import { Report } from 'bundlemon-utils'; 5 | import { createLogger } from '../../../common/logger'; 6 | import { validateYup } from '../../utils/validationUtils'; 7 | import type { Output } from '../types'; 8 | 9 | const NAME = 'custom'; 10 | 11 | const logger = createLogger(`${NAME} output`); 12 | 13 | interface CustomOutputOptions { 14 | path?: string; 15 | } 16 | interface NormalizedCustomOutputOptions { 17 | path: string; 18 | } 19 | 20 | function validateOptions(options: unknown): NormalizedCustomOutputOptions { 21 | const schema: yup.SchemaOf = yup.object().required().shape({ 22 | path: yup.string().required(), 23 | }); 24 | 25 | const normalizedOptions = validateYup(schema, options, `${NAME} output`); 26 | 27 | if (!normalizedOptions) { 28 | throw new Error(`validation error in output "${NAME}" options`); 29 | } 30 | 31 | return normalizedOptions as NormalizedCustomOutputOptions; 32 | } 33 | 34 | const output: Output = { 35 | name: NAME, 36 | create: async ({ options }) => { 37 | const normalizedOptions = validateOptions(options); 38 | 39 | const resolvedPath = path.resolve(normalizedOptions.path); 40 | 41 | if (!fs.existsSync(resolvedPath)) { 42 | throw new Error(`custom output file not found: ${resolvedPath}`); 43 | } 44 | 45 | logger.debug(`Importing ${resolvedPath}`); 46 | const customOutput = await import(resolvedPath); 47 | 48 | if (typeof customOutput.default !== 'function') { 49 | throw new Error('custom output should export default function'); 50 | } 51 | 52 | return { 53 | generate: async (report: Report): Promise => { 54 | await customOutput.default(report); 55 | }, 56 | }; 57 | }, 58 | }; 59 | 60 | export default output; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "MIT", 4 | "scripts": { 5 | "test-packages": "yarn nx run-many --target=test --projects tag:type:lib --verbose", 6 | "build-packages": "nx run-many --target=build --projects tag:type:lib --verbose", 7 | "build-packages:watch": "nx watch --projects tag:type:lib -- nx run \\$NX_PROJECT_NAME:build", 8 | "lint-packages": "yarn nx run-many --target=lint --projects tag:type:lib --verbose", 9 | "prepare": "husky install" 10 | }, 11 | "devDependencies": { 12 | "@emotion/babel-plugin": "11.11.0", 13 | "@nx/esbuild": "19.3.0", 14 | "@nx/eslint": "19.3.0", 15 | "@nx/eslint-plugin": "19.3.0", 16 | "@nx/jest": "19.3.0", 17 | "@nx/js": "19.3.0", 18 | "@nx/react": "19.3.0", 19 | "@nx/vite": "19.3.0", 20 | "@nx/web": "19.3.0", 21 | "@swc-node/register": "1.9.2", 22 | "@swc/core": "1.5.7", 23 | "@types/jest": "^29.5.1", 24 | "@types/jest-when": "^3.5.2", 25 | "@types/node": "^18.16.9", 26 | "@typescript-eslint/eslint-plugin": "^6.4.0", 27 | "@typescript-eslint/parser": "^6.4.0", 28 | "@vitejs/plugin-react": "^4.3.4", 29 | "esbuild": "^0.26.0", 30 | "eslint": "^8.47.0", 31 | "eslint-config-prettier": "^9.0.0", 32 | "eslint-plugin-import": "2.27.5", 33 | "eslint-plugin-jest": "^27.2.1", 34 | "eslint-plugin-prettier": "^5.0.0", 35 | "husky": "^8.0.3", 36 | "jest": "^29.7.0", 37 | "jest-when": "^3.5.2", 38 | "lint-staged": "^15.2.2", 39 | "nx": "19.3.0", 40 | "prettier": "^3.0.2", 41 | "ts-jest": "^29.1.0", 42 | "typescript": "^5.0.4" 43 | }, 44 | "engines": { 45 | "yarn": "^1.10.0" 46 | }, 47 | "lint-staged": { 48 | "*.{js,jsx,ts,tsx}": [ 49 | "eslint --fix" 50 | ], 51 | "*.{json,md}": [ 52 | "prettier --write" 53 | ] 54 | }, 55 | "workspaces": [ 56 | "packages/*", 57 | "apps/*", 58 | "examples/*" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /apps/service/src/framework/mongo/commitRecords/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { Compression, FileDetails } from 'bundlemon-utils'; 2 | import { filesToWatchedFileHits, watchedFileHitsToFiles } from '../utils'; 3 | 4 | describe('DB commit records utils', () => { 5 | test('files transform', () => { 6 | const files: FileDetails[] = [ 7 | { path: 'file.js', pattern: '*.js', size: 100, compression: Compression.None }, 8 | { path: 'file2.js', pattern: '*.js', size: 150, compression: Compression.None }, 9 | { path: 'file.css', pattern: '*.css', size: 150, compression: Compression.Gzip, maxPercentIncrease: 5 }, 10 | { path: 'index.html', pattern: 'index.html', size: 100, compression: Compression.Brotli, maxSize: 500 }, 11 | { 12 | path: 'file.png', 13 | pattern: '*.png', 14 | size: 150, 15 | compression: Compression.Gzip, 16 | maxSize: 500, 17 | maxPercentIncrease: 5, 18 | }, 19 | ]; 20 | 21 | const hits = filesToWatchedFileHits(files); 22 | const filesAfterTransform = watchedFileHitsToFiles(hits); 23 | 24 | expect(filesAfterTransform).toEqual(files); 25 | }); 26 | 27 | test('groups transform', () => { 28 | const groups: FileDetails[] = [ 29 | { path: '*.js', pattern: '*.js', size: 100, compression: Compression.None }, 30 | { path: '*.css', pattern: '*.css', size: 150, compression: Compression.Gzip, maxPercentIncrease: 5 }, 31 | { path: 'index.html', pattern: 'index.html', size: 100, compression: Compression.Brotli, maxSize: 500 }, 32 | { 33 | path: '*.png', 34 | pattern: '*.png', 35 | size: 150, 36 | compression: Compression.Gzip, 37 | maxSize: 500, 38 | maxPercentIncrease: 5, 39 | }, 40 | ]; 41 | 42 | const hits = filesToWatchedFileHits(groups); 43 | const groupsAfterTransform = watchedFileHitsToFiles(hits); 44 | 45 | expect(groupsAfterTransform).toEqual(groups); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/fileDetailsUtils.ts: -------------------------------------------------------------------------------- 1 | import { getFileSize } from './getFileSize'; 2 | import { getMatchFiles } from './pathUtils'; 3 | 4 | import type { FileDetails } from 'bundlemon-utils'; 5 | import type { NormalizedFileConfig, PathLabels } from '../types'; 6 | 7 | interface GetFilesDetailsParams { 8 | baseDir: string; 9 | pathLabels: PathLabels; 10 | config: NormalizedFileConfig[]; 11 | allFiles: string[]; 12 | stopOnMatch: boolean; 13 | } 14 | 15 | export async function getFilesDetails({ 16 | baseDir, 17 | pathLabels, 18 | config, 19 | allFiles, 20 | stopOnMatch, 21 | }: GetFilesDetailsParams): Promise { 22 | const filesConfigMap: Record = config.reduce((acc, curr) => { 23 | return { ...acc, [curr.path]: curr }; 24 | }, {}); 25 | 26 | const matchFiles = await getMatchFiles(baseDir, allFiles, pathLabels, Object.keys(filesConfigMap), stopOnMatch); 27 | 28 | const files: FileDetails[] = []; 29 | 30 | await Promise.all( 31 | Object.entries(matchFiles).map(async ([pattern, matchFiles]) => { 32 | const { path, ...restFileConfig } = filesConfigMap[pattern]; 33 | 34 | for (const { fullPath, prettyPath } of matchFiles) { 35 | const size = await getFileSize(fullPath, restFileConfig.compression); 36 | 37 | files.push({ 38 | ...restFileConfig, 39 | pattern, 40 | path: prettyPath, 41 | size, 42 | }); 43 | } 44 | }) 45 | ); 46 | 47 | return files; 48 | } 49 | 50 | export function groupFilesByPattern(files: FileDetails[]): FileDetails[] { 51 | const groupsMap: Record = {}; 52 | 53 | for (const file of files) { 54 | const { pattern, size } = file; 55 | 56 | if (!groupsMap[pattern]) { 57 | groupsMap[pattern] = { 58 | ...file, 59 | path: pattern, 60 | pattern, 61 | size: 0, 62 | }; 63 | } 64 | 65 | groupsMap[pattern].size += size; 66 | } 67 | 68 | return Object.values(groupsMap); 69 | } 70 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/githubOutput.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import type { GithubOutputTypes, Report } from 'bundlemon-utils'; 4 | import type { GetCommitRecordRequestParams } from './commitRecords'; 5 | import type { BaseRequestSchema } from './common'; 6 | 7 | interface ProjectIdParams { 8 | /** 9 | * @pattern ^[0-9a-fA-F]{24}$ 10 | */ 11 | projectId: string; 12 | } 13 | 14 | interface ProjectApiKeyHeaders { 15 | /** 16 | * @minLength 1 17 | */ 18 | 'x-api-key': string; 19 | } 20 | 21 | interface CreateGithubCheckBody { 22 | report: Report; 23 | git: { 24 | owner: string; 25 | repo: string; 26 | commitSha: string; 27 | }; 28 | } 29 | 30 | export interface CreateGithubCheckRequestSchema extends BaseRequestSchema { 31 | body: CreateGithubCheckBody; 32 | params: ProjectIdParams; 33 | headers: ProjectApiKeyHeaders; 34 | } 35 | 36 | interface CreateGithubCommitStatusBody { 37 | report: Report; 38 | git: { 39 | owner: string; 40 | repo: string; 41 | commitSha: string; 42 | }; 43 | } 44 | 45 | export interface CreateGithubCommitStatusRequestSchema extends BaseRequestSchema { 46 | body: CreateGithubCommitStatusBody; 47 | params: ProjectIdParams; 48 | headers: ProjectApiKeyHeaders; 49 | } 50 | 51 | interface CreateGithubPrCommentBody { 52 | report: Report; 53 | git: { 54 | owner: string; 55 | repo: string; 56 | prNumber: string; 57 | }; 58 | } 59 | 60 | export interface PostGithubPRCommentRequestSchema extends BaseRequestSchema { 61 | body: CreateGithubPrCommentBody; 62 | params: ProjectIdParams; 63 | headers: ProjectApiKeyHeaders; 64 | } 65 | 66 | interface GithubOutputBody { 67 | git: { 68 | owner: string; 69 | repo: string; 70 | commitSha: string; 71 | prNumber?: string; 72 | }; 73 | output: Partial>; 74 | auth: { token: string } | { runId: string }; 75 | } 76 | 77 | export interface GithubOutputRequestSchema extends BaseRequestSchema { 78 | body: GithubOutputBody; 79 | params: GetCommitRecordRequestParams; 80 | } 81 | -------------------------------------------------------------------------------- /packages/bundlemon-utils/lib/diffReport/utils.ts: -------------------------------------------------------------------------------- 1 | import { DiffChange, Status, FailReason } from '../consts'; 2 | import type { FileDetails, FileStatusObject } from '../types'; 3 | 4 | function roundDecimals(num: number, decimals: number) { 5 | return Number(Math.round(Number(num + 'e' + decimals)) + 'e-' + decimals); 6 | } 7 | 8 | export function getPercentageDiff(a: number, b: number): number { 9 | const percent = ((a - b) / b) * 100; 10 | 11 | const diff = Number.isFinite(percent) ? roundDecimals(percent, 2) : percent; 12 | 13 | return Number.isNaN(diff) ? 0 : diff; 14 | } 15 | 16 | interface CalcChangeParams { 17 | isExistsInCurrBranch: boolean; 18 | isExistsInBaseBranch: boolean; 19 | diffBytes: number; 20 | } 21 | 22 | export function calcChange({ isExistsInCurrBranch, isExistsInBaseBranch, diffBytes }: CalcChangeParams): DiffChange { 23 | if (isExistsInCurrBranch && isExistsInBaseBranch) { 24 | // return update only if the change is greater than 10 bytes 25 | if (Math.abs(diffBytes) > 10) { 26 | return DiffChange.Update; 27 | } else { 28 | return DiffChange.NoChange; 29 | } 30 | } 31 | 32 | if (isExistsInCurrBranch) { 33 | return DiffChange.Add; 34 | } 35 | 36 | return DiffChange.Remove; 37 | } 38 | 39 | interface GetStatusParams { 40 | currBranchFile?: FileDetails; 41 | change: DiffChange; 42 | diffPercent: number; 43 | } 44 | 45 | export function getStatusObject({ currBranchFile, change, diffPercent }: GetStatusParams): FileStatusObject { 46 | const failReasons: FailReason[] = []; 47 | 48 | if (currBranchFile?.maxSize && currBranchFile.size > currBranchFile.maxSize) { 49 | failReasons.push(FailReason.MaxSize); 50 | } 51 | 52 | if ( 53 | change === DiffChange.Update && 54 | currBranchFile?.maxPercentIncrease && 55 | diffPercent > currBranchFile.maxPercentIncrease 56 | ) { 57 | failReasons.push(FailReason.MaxPercentIncrease); 58 | } 59 | 60 | if (failReasons.length === 0) { 61 | return { status: Status.Pass }; 62 | } 63 | 64 | return { status: Status.Fail, failReasons }; 65 | } 66 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["plugin:prettier/recommended"], 4 | "ignorePatterns": ["**/*"], 5 | "plugins": ["@nx"], 6 | "overrides": [ 7 | { 8 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 9 | "rules": { 10 | "@nx/enforce-module-boundaries": [ 11 | "error", 12 | { 13 | "enforceBuildableLibDependency": true, 14 | "allow": [], 15 | "depConstraints": [ 16 | { 17 | "sourceTag": "*", 18 | "onlyDependOnLibsWithTags": ["*"] 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | }, 25 | { 26 | "files": ["*.ts", "*.tsx"], 27 | "extends": ["plugin:@nx/typescript", "prettier"], 28 | "parserOptions": { 29 | "project": "./tsconfig.*?.json" 30 | }, 31 | "rules": { 32 | "@typescript-eslint/no-extra-semi": "error", 33 | "no-extra-semi": "off" 34 | } 35 | }, 36 | { 37 | "files": ["*.js", "*.jsx"], 38 | "extends": ["plugin:@nx/javascript"], 39 | "rules": { 40 | "@typescript-eslint/no-extra-semi": "error", 41 | "no-extra-semi": "off" 42 | } 43 | }, 44 | { 45 | "files": ["*.json"], 46 | "parser": "jsonc-eslint-parser", 47 | "rules": {} 48 | }, 49 | { 50 | "files": ["**/__tests__/**/*.spec.ts"], 51 | "extends": ["plugin:jest/recommended"], 52 | "rules": { 53 | "@typescript-eslint/no-explicit-any": "off", 54 | "@typescript-eslint/no-empty-function": "off", 55 | "@typescript-eslint/no-var-requires": "off", 56 | "jest/no-disabled-tests": "off" 57 | } 58 | } 59 | ], 60 | "rules": { 61 | "@typescript-eslint/ban-ts-comment": "off", 62 | "@typescript-eslint/no-unused-vars": [ 63 | "error", 64 | { 65 | "ignoreRestSiblings": true, 66 | "argsIgnorePattern": "^_" 67 | } 68 | ], 69 | "@typescript-eslint/explicit-module-boundary-types": "off", 70 | "@typescript-eslint/no-explicit-any": "off" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/analyzer/__tests__/getFileSize.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { file as calcGzipFileSize } from 'gzip-size'; 3 | import { file as calcBrotliFileSize } from 'brotli-size'; 4 | import { getFileSize } from '../getFileSize'; 5 | import { Compression } from 'bundlemon-utils'; 6 | 7 | jest.mock('fs', () => ({ readFile: jest.fn(), readFileSync: jest.fn(), promises: { readFile: jest.fn() } })); 8 | jest.mock('gzip-size'); 9 | jest.mock('brotli-size'); 10 | 11 | function randomInt(min: number, max: number) { 12 | return Math.floor(Math.random() * (max - min + 1) + min); 13 | } 14 | 15 | let expectedSize = 0; 16 | 17 | describe('getFileSize', () => { 18 | beforeEach(() => { 19 | jest.resetAllMocks(); 20 | expectedSize = randomInt(1000, 5000); 21 | }); 22 | 23 | test('comperssion: none', async () => { 24 | // @ts-expect-error mock only the needed properties that we need for the test 25 | jest.mocked(fs.promises.readFile).mockResolvedValue({ byteLength: expectedSize }); 26 | 27 | const size = await getFileSize('path', Compression.None); 28 | 29 | expect(size).toEqual(expectedSize); 30 | }); 31 | 32 | test('comperssion: gzip', async () => { 33 | jest.mocked(calcGzipFileSize).mockResolvedValue(expectedSize); 34 | 35 | const size = await getFileSize('path', Compression.Gzip); 36 | 37 | expect(size).toEqual(expectedSize); 38 | }); 39 | 40 | test('comperssion: brotli', async () => { 41 | jest.mocked(calcBrotliFileSize).mockResolvedValue(expectedSize); 42 | 43 | const size = await getFileSize('path', Compression.Brotli); 44 | 45 | expect(size).toEqual(expectedSize); 46 | }); 47 | 48 | test('comperssion: unknown', async () => { 49 | // @ts-expect-error mock only the needed properties that we need for the test 50 | jest.mocked(fs.promises.readFile).mockResolvedValue({ byteLength: expectedSize }); 51 | 52 | // @ts-expect-error mock only the needed properties that we need for the test 53 | const size = await getFileSize('path', 'kjasdkjaskd'); 54 | 55 | expect(size).toEqual(expectedSize); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/outputs/console.ts: -------------------------------------------------------------------------------- 1 | import bytes from 'bytes'; 2 | import chalk from 'chalk'; 3 | import { Status, DiffChange, FileDetailsDiff, getReportConclusionText } from 'bundlemon-utils'; 4 | import logger from '../../../common/logger'; 5 | import { getDiffSizeText, getDiffPercentText } from '../utils'; 6 | import type { Output } from '../types'; 7 | 8 | function print(status: Status, changeText: string, message: string) { 9 | const color = status === Status.Pass ? 'green' : 'red'; 10 | 11 | logger.log(` ${chalk[color](`[${status.toUpperCase()}]`)} ${changeText}${message}`); 12 | } 13 | 14 | function printDiffSection(files: FileDetailsDiff[], haveBaseRecord: boolean) { 15 | files.forEach((f) => { 16 | const changeText = haveBaseRecord ? `(${f.diff.change}) ` : ''; 17 | const diffPercentText = f.diff.change === DiffChange.Update ? ' ' + getDiffPercentText(f.diff.percent) : ''; 18 | const diffText = haveBaseRecord ? ` (${getDiffSizeText(f.diff.bytes)}${diffPercentText})` : ''; 19 | const maxSizeText = f.maxSize ? ` ${f.size <= f.maxSize ? '<' : '>'} ${bytes(f.maxSize)}` : ''; 20 | 21 | print(f.status, changeText, `${f.path}: ${bytes(f.size)}${diffText}${maxSizeText}`); 22 | }); 23 | } 24 | 25 | const output: Output = { 26 | name: 'console', 27 | create: () => { 28 | return { 29 | generate: (report) => { 30 | const { 31 | files, 32 | groups, 33 | metadata: { linkToReport, baseRecord }, 34 | } = report; 35 | 36 | logger.log('\n'); 37 | 38 | logger.log('Files:'); 39 | printDiffSection(files, !!baseRecord); 40 | logger.log('\n'); 41 | 42 | if (groups.length > 0) { 43 | logger.log('Groups:'); 44 | printDiffSection(groups, !!baseRecord); 45 | logger.log('\n'); 46 | } 47 | 48 | logger.log(getReportConclusionText(report)); 49 | 50 | if (linkToReport) { 51 | logger.log(`\nView report: ${linkToReport}`); 52 | } 53 | 54 | logger.log('\n'); 55 | }, 56 | }; 57 | }, 58 | }; 59 | 60 | export default output; 61 | -------------------------------------------------------------------------------- /apps/website/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/website/src", 5 | "projectType": "application", 6 | "tags": [], 7 | "targets": { 8 | "build": { 9 | "dependsOn": ["^build"], 10 | "executor": "@nx/vite:build", 11 | "outputs": ["{options.outputPath}"], 12 | "defaultConfiguration": "production", 13 | "options": { 14 | "outputPath": "dist/{projectRoot}", 15 | "generatePackageJson": false 16 | }, 17 | "configurations": { 18 | "development": { 19 | "mode": "development" 20 | }, 21 | "production": { 22 | "mode": "production" 23 | } 24 | } 25 | }, 26 | "build:analyze": { 27 | "dependsOn": ["^build"], 28 | "executor": "nx:run-commands", 29 | "options": { 30 | "cwd": "{projectRoot}", 31 | "commands": ["analyze"] 32 | } 33 | }, 34 | "serve": { 35 | "executor": "@nx/vite:dev-server", 36 | "defaultConfiguration": "development", 37 | "options": { 38 | "buildTarget": "website:build" 39 | }, 40 | "configurations": { 41 | "development": { 42 | "buildTarget": "website:build:development", 43 | "hmr": true 44 | }, 45 | "production": { 46 | "buildTarget": "website:build:production", 47 | "hmr": false 48 | } 49 | } 50 | }, 51 | "preview": { 52 | "executor": "@nx/vite:preview-server", 53 | "defaultConfiguration": "development", 54 | "options": { 55 | "buildTarget": "website:build" 56 | }, 57 | "configurations": { 58 | "development": { 59 | "buildTarget": "website:build:development" 60 | }, 61 | "production": { 62 | "buildTarget": "website:build:production" 63 | } 64 | } 65 | }, 66 | "lint": { 67 | "executor": "@nx/eslint:lint", 68 | "outputs": ["{options.outputFile}"], 69 | "options": { 70 | "lintFilePatterns": ["{projectRoot}/**/*.{ts,tsx,js,json}"], 71 | "maxWarnings": 0 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /apps/service/src/framework/env.ts: -------------------------------------------------------------------------------- 1 | import * as env from 'env-var'; 2 | import { Buffer } from 'buffer'; 3 | import * as sodium from 'sodium-native'; 4 | 5 | const getRequiredString = (key: string) => env.get(key).required().asString(); 6 | const getOptionalString = (key: string) => env.get(key).asString(); 7 | const getOptionalIntPositive = (key: string) => env.get(key).asIntPositive(); 8 | const getOptionalBoolean = (key: string) => env.get(key).asBool(); 9 | 10 | function generateSecretKey() { 11 | const buf = Buffer.allocUnsafe(sodium.crypto_secretbox_KEYBYTES); 12 | sodium.randombytes_buf(buf); 13 | return buf.toString('hex'); 14 | } 15 | 16 | export const nodeEnv = getRequiredString('NODE_ENV'); 17 | export const mongoUrl = getRequiredString('MONGO_URL'); 18 | 19 | export const mongoDbName = getOptionalString('MONGO_DB_NAME') || 'bundlemon'; 20 | export const mongoDbUser = getOptionalString('MONGO_DB_USER'); 21 | export const mongoDbPassword = getOptionalString('MONGO_DB_PASSWORD'); 22 | export const httpSchema = getOptionalString('HTTP_SCHEMA') || 'https'; 23 | export const host = getOptionalString('HOST') || '0.0.0.0'; 24 | export const port = getOptionalIntPositive('PORT') || 8080; 25 | export const rootDomain = getOptionalString('ROOT_DOMAIN') || 'bundlemon.dev'; 26 | export const appDomain = getOptionalString('APP_DOMAIN') || rootDomain; 27 | export const apiPathPrefix = getOptionalString('API_PATH_PREFIX') || '/api'; 28 | export const secretSessionKey = getOptionalString('SECRET_SESSION_KEY') || generateSecretKey(); 29 | export const isTestEnv = getOptionalBoolean('IS_TEST_ENV') ?? false; 30 | export const shouldServeWebsite = getOptionalBoolean('SHOULD_SERVE_WEBSITE') ?? true; 31 | export const maxSessionAgeSeconds = getOptionalIntPositive('MAX_SESSION_AGE_SECONDS') || 60 * 60 * 6; // 6 hours 32 | export const maxBodySizeBytes = getOptionalIntPositive('MAX_BODY_SIZE_BYTES') || 1024 * 1024; // 1MB 33 | export const shouldRunDbInit = getOptionalBoolean('SHOULD_RUN_DB_INIT') ?? true; 34 | 35 | export const githubAppId = getOptionalString('GITHUB_APP_ID'); 36 | export const githubAppPrivateKey = getOptionalString('GITHUB_APP_PRIVATE_KEY'); 37 | export const githubAppClientId = getOptionalString('GITHUB_APP_CLIENT_ID'); 38 | export const githubAppClientSecret = getOptionalString('GITHUB_APP_CLIENT_SECRET'); 39 | -------------------------------------------------------------------------------- /apps/service/src/types/schemas/commitRecords.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import type { CommitRecordPayload, CommitRecordReviewResolution } from 'bundlemon-utils'; 4 | import type { 5 | CommitRecordsQueryResolution, 6 | BaseRecordCompareTo, 7 | CreateCommitRecordAuthType, 8 | } from '../../consts/commitRecords'; 9 | import type { BaseRequestSchema, BaseGetRequestSchema, ProjectIdParams } from './common'; 10 | 11 | export type CreateCommitRecordProjectApiKeyAuthQuery = { 12 | authType: CreateCommitRecordAuthType.ProjectApiKey; 13 | token: string; 14 | }; 15 | 16 | export type CreateCommitRecordGithubActionsAuthQuery = { 17 | authType: CreateCommitRecordAuthType.GithubActions; 18 | runId: string; 19 | }; 20 | 21 | export type CreateCommitRecordRequestQuery = 22 | | CreateCommitRecordProjectApiKeyAuthQuery 23 | | CreateCommitRecordGithubActionsAuthQuery 24 | | Record; 25 | 26 | export interface CreateCommitRecordRequestSchema extends BaseRequestSchema { 27 | body: CommitRecordPayload; 28 | params: ProjectIdParams; 29 | query: CreateCommitRecordRequestQuery; 30 | } 31 | 32 | export interface GetCommitRecordRequestParams extends ProjectIdParams { 33 | /** 34 | * @pattern ^[0-9a-fA-F]{24}$ 35 | */ 36 | commitRecordId: string; 37 | } 38 | 39 | interface GetCommitRecordRequestQuery { 40 | /** 41 | * @default "PREVIOUS_COMMIT" 42 | */ 43 | compareTo?: BaseRecordCompareTo; 44 | } 45 | 46 | export interface GetCommitRecordRequestSchema extends BaseGetRequestSchema { 47 | params: GetCommitRecordRequestParams; 48 | query: GetCommitRecordRequestQuery; 49 | } 50 | 51 | export interface GetCommitRecordsQuery { 52 | branch: string; 53 | latest?: boolean; 54 | resolution?: CommitRecordsQueryResolution; 55 | subProject?: string; 56 | olderThan?: Date; 57 | } 58 | 59 | export interface GetCommitRecordsRequestSchema extends BaseGetRequestSchema { 60 | params: ProjectIdParams; 61 | query: GetCommitRecordsQuery; 62 | } 63 | 64 | interface ReviewCommitRecordBody { 65 | resolution: CommitRecordReviewResolution; 66 | /** 67 | * @minLength 1 68 | * @maxLength 100 69 | */ 70 | reason?: string; 71 | } 72 | 73 | export interface ReviewCommitRecordRequestSchema extends BaseRequestSchema { 74 | params: GetCommitRecordRequestParams; 75 | body: ReviewCommitRecordBody; 76 | } 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript cache 45 | *.tsbuildinfo 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Microbundle cache 54 | .rpt2_cache/ 55 | .rts2_cache_cjs/ 56 | .rts2_cache_es/ 57 | .rts2_cache_umd/ 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # Next.js build output 76 | .next 77 | 78 | # Nuxt.js build / generate output 79 | .nuxt 80 | dist 81 | 82 | # Gatsby files 83 | .cache/ 84 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 85 | # https://nextjs.org/blog/next-9-1#public-directory-support 86 | # public 87 | 88 | # vuepress build output 89 | .vuepress/dist 90 | 91 | # Serverless directories 92 | .serverless/ 93 | 94 | # FuseBox cache 95 | .fusebox/ 96 | 97 | # DynamoDB Local files 98 | .dynamodb/ 99 | 100 | # TernJS port file 101 | .tern-port 102 | 103 | .npmrc 104 | 105 | .vscode 106 | examples 107 | .DS_STORE 108 | build 109 | .vercel 110 | deploy.env 111 | 112 | # Local Netlify folder 113 | .netlify 114 | 115 | 116 | .nx/cache 117 | .nx/workspace-data 118 | .tmp 119 | tmp 120 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/report/generateReport.ts: -------------------------------------------------------------------------------- 1 | import { generateDiffReport, Report, CommitRecord, DiffReportInput, Status } from 'bundlemon-utils'; 2 | import logger from '../../common/logger'; 3 | import { createCommitRecord } from '../../common/service'; 4 | 5 | import type { NormalizedConfig } from '../types'; 6 | 7 | export async function generateReport(config: NormalizedConfig, input: DiffReportInput): Promise { 8 | logger.info('Start generating report'); 9 | 10 | const subProject = config.subProject; 11 | let record: CommitRecord | undefined; 12 | let baseRecord: CommitRecord | undefined; 13 | let linkToReport: string | undefined; 14 | 15 | if (!config.remote) { 16 | logger.warn('remote flag is OFF, showing only local results'); 17 | } else { 18 | const { gitVars } = config; 19 | 20 | logger.info(`Create commit record for branch "${gitVars.branch}"`); 21 | 22 | const result = await createCommitRecord( 23 | config.projectId, 24 | { 25 | subProject, 26 | ...gitVars, 27 | ...input, 28 | }, 29 | config.getCreateCommitRecordAuthParams() 30 | ); 31 | 32 | if (!result) { 33 | logger.error('Failed to create commit record'); 34 | return undefined; 35 | } 36 | 37 | try { 38 | ({ record, baseRecord, linkToReport } = result); 39 | 40 | logger.info(`Commit record "${result.record.id}" has been successfully created`); 41 | } catch (e) { 42 | logger.error(`Failed to create commit record. result: ${JSON.stringify(result)}`, e); 43 | return undefined; 44 | } 45 | } 46 | 47 | const diffReport = generateDiffReport( 48 | input, 49 | baseRecord ? { files: baseRecord.files, groups: baseRecord.groups } : undefined 50 | ); 51 | 52 | const report = { 53 | ...diffReport, 54 | metadata: { subProject, linkToReport, record, baseRecord }, 55 | }; 56 | 57 | logger.info('Finished generating report'); 58 | 59 | // If the record and the base record have the same branch, that probably mean it's a merge commit, so no need to fail the report 60 | if (report.status === Status.Fail && record?.branch && record?.branch === baseRecord?.branch) { 61 | report.status = Status.Pass; 62 | logger.info('Merge commit detected, change report status to "Pass"'); 63 | } 64 | 65 | return report; 66 | } 67 | -------------------------------------------------------------------------------- /apps/website/src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { AppBar, Box, IconButton, Stack, Tooltip } from '@mui/material'; 4 | import GitHubIcon from '@mui/icons-material/GitHub'; 5 | import LinkNoStyles from '@/components/LinkNoStyles'; 6 | import Logo from './components/Logo'; 7 | import ThemeModeToggle from './components/ThemeModeToggle'; 8 | import UserSection from './components/UserSection'; 9 | import { configStore } from '@/stores/ConfigStore'; 10 | 11 | const StyledAppBar = styled(AppBar)` 12 | display: flex; 13 | flex-direction: row; 14 | align-items: center; 15 | height: 64px; 16 | padding: ${({ theme }) => theme.spacing(1, 3)}; 17 | 18 | color: ${({ theme }) => theme.palette.text.primary}; 19 | background-color: ${({ theme }) => theme.palette.background.default}; 20 | 21 | svg: { 22 | margin-right: ${({ theme }) => theme.spacing(2)}; 23 | } 24 | `; 25 | 26 | const LogoText = styled(LinkNoStyles)` 27 | font-weight: 500; 28 | font-size: 1.25rem; 29 | `; 30 | 31 | const MainContainer = styled.main` 32 | display: flex; 33 | flex-direction: column; 34 | min-height: 100%; 35 | width: 100%; 36 | padding: ${({ theme }) => theme.spacing(11, 6, 2, 6)}; 37 | background-color: ${({ theme }) => theme.palette.background.default}; 38 | `; 39 | 40 | const Layout = observer(({ children }: React.PropsWithChildren) => { 41 | if (configStore.error) { 42 | return {configStore.error}; 43 | } 44 | 45 | if (!configStore.isLoaded) { 46 | return null; 47 | } 48 | 49 | return ( 50 | <> 51 | 52 | 53 | BundleMon 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | {children ||
} 66 | 67 | ); 68 | }); 69 | 70 | export default Layout; 71 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportPage/components/ReportTable/ReportTable.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { MRT_ColumnDef } from 'material-react-table'; 4 | import { DiffChange, FileDetailsDiff, getLimitsCellText, Status } from 'bundlemon-utils'; 5 | import { StatusCell, ChangeSizeCell } from './components'; 6 | import PathCell from '@/components/PathCell'; 7 | import bytes from 'bytes'; 8 | import Table from '@/components/Table'; 9 | 10 | interface ReportTableProps { 11 | data: FileDetailsDiff[]; 12 | } 13 | 14 | const ReportTable = observer(({ data }: ReportTableProps) => { 15 | const columns = useMemo[]>( 16 | () => [ 17 | { 18 | header: 'Status', 19 | accessorKey: 'status', 20 | Cell: ({ cell }) => ()} />, 21 | filterVariant: 'multi-select', 22 | filterSelectOptions: Object.values(Status), 23 | }, 24 | { 25 | id: 'state', 26 | header: 'State', 27 | accessorKey: 'diff.change', 28 | filterVariant: 'multi-select', 29 | filterSelectOptions: Object.values(DiffChange), 30 | }, 31 | { 32 | id: 'path', 33 | header: 'Path', 34 | accessorKey: 'path', 35 | Cell: ({ row }) => , 36 | }, 37 | { 38 | id: 'size', 39 | header: 'Size', 40 | accessorKey: 'size', 41 | Cell: ({ cell }) => sizeToText(cell.getValue()), 42 | enableColumnActions: false, 43 | enableColumnFilter: false, 44 | }, 45 | { 46 | id: 'changeSize', 47 | header: 'Change size', 48 | accessorKey: 'diff.bytes', 49 | Cell: ({ row }) => , 50 | enableColumnActions: false, 51 | enableColumnFilter: false, 52 | enableSorting: false, 53 | }, 54 | { 55 | header: 'Limits', 56 | Cell: ({ row }) => getLimitsCellText(row.original), 57 | enableColumnActions: false, 58 | enableColumnFilter: false, 59 | enableSorting: false, 60 | }, 61 | ], 62 | [] 63 | ); 64 | 65 | return ; 66 | }); 67 | 68 | export default ReportTable; 69 | 70 | function sizeToText(size?: number) { 71 | return size ? bytes(size) : '-'; 72 | } 73 | -------------------------------------------------------------------------------- /docs/privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy 2 | 3 | ## Data stored in the free hosted service 4 | 5 | When using the free hosted service, the following information is stored for each record (commit) ([exact type definition](../apps/service/src/framework/mongo/commitRecords/types.ts#L30)): 6 | 7 | - Project ID - stored in a separate collection 8 | - For GitHub projects, the collection contains owner and repository names 9 | - Sub project name (if specified) 10 | - Branch name 11 | - Commit SHA 12 | - Base branch name (for pull requests) 13 | - Pull request number 14 | - Commit message - Only when `includeCommitMessage` is enabled 15 | - Files / Groups 16 | - Pattern 17 | - Friendly name (if specified) 18 | - File path 19 | - Size 20 | - Compression type 21 | - Size limits 22 | 23 | Example record: 24 | 25 | ```json 26 | { 27 | "_id": "6897284dd96dcac9c6449c84", 28 | "branch": "dependabot/npm_and_yarn/npm_and_yarn-45ede89f0a", 29 | "commitSha": "472e405f5261fc02f3af8ba491b5fc121dbff98d", 30 | "baseBranch": "main", 31 | "prNumber": "245", 32 | "projectId": "60a928cfc1ab380009f5cc0b", 33 | "creationDate": "2025-08-09T10:51:57.069Z", 34 | "files": [ 35 | { 36 | "compression": "none", 37 | "pattern": "index.html", 38 | "matches": [ 39 | { 40 | "path": "index.html", 41 | "size": 869 42 | } 43 | ] 44 | }, 45 | { 46 | "compression": "none", 47 | "friendlyName": "JS files", 48 | "pattern": "assets/**/*-.js", 49 | "matches": [ 50 | { 51 | "path": "assets/Alert-(hash).js", 52 | "size": 5913 53 | }, 54 | { 55 | "path": "assets/AlertTitle-(hash).js", 56 | "size": 659 57 | } 58 | ] 59 | }, 60 | { 61 | "compression": "none", 62 | "pattern": "assets/Main-*-.js", 63 | "matches": [ 64 | { 65 | "path": "assets/Main-index-(hash).js", 66 | "size": 473258 67 | } 68 | ] 69 | } 70 | ], 71 | "groups": [ 72 | { 73 | "compression": "none", 74 | "friendlyName": "JS files", 75 | "pattern": "**/*.js", 76 | "size": 1252544 77 | } 78 | ], 79 | "outputs": { 80 | "github": { 81 | "owner": "LironEr", 82 | "repo": "bundlemon", 83 | "outputs": { 84 | "commitStatus": 38346868576, 85 | "prComment": 3170605595 86 | } 87 | } 88 | } 89 | } 90 | ``` 91 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/outputs/__tests__/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseOutput, getSignText, getDiffSizeText, getDiffPercentText } from '../utils'; 2 | 3 | describe('output utils', () => { 4 | beforeEach(() => { 5 | jest.resetAllMocks(); 6 | }); 7 | 8 | describe('parseOutput', () => { 9 | test('only name', () => { 10 | const expectedName = 'test'; 11 | const { name, options } = parseOutput(expectedName); 12 | 13 | expect(name).toEqual(expectedName); 14 | expect(options).toBeUndefined(); 15 | }); 16 | 17 | test('array, no options', () => { 18 | const expectedName = 'test'; 19 | const { name, options } = parseOutput([expectedName, undefined]); 20 | 21 | expect(name).toEqual(expectedName); 22 | expect(options).toBeUndefined(); 23 | }); 24 | 25 | test('array, with options', () => { 26 | const expectedName = 'test'; 27 | const expectedOptions = { 28 | op: { 29 | t: 'aaa', 30 | }, 31 | check: true, 32 | n: 100, 33 | }; 34 | const { name, options } = parseOutput([expectedName, expectedOptions]); 35 | 36 | expect(name).toEqual(expectedName); 37 | expect(options).toEqual(expectedOptions); 38 | }); 39 | }); 40 | 41 | describe('getSignText', () => { 42 | test('positive number', () => { 43 | const sign = getSignText(100); 44 | 45 | expect(sign).toEqual('+'); 46 | }); 47 | 48 | test('negative number', () => { 49 | const sign = getSignText(-8); 50 | 51 | expect(sign).toEqual(''); 52 | }); 53 | }); 54 | 55 | describe('getDiffSizeText', () => { 56 | test('positive number', () => { 57 | const text = getDiffSizeText(100); 58 | 59 | expect(text).toEqual('+100B'); 60 | }); 61 | 62 | test('negative number', () => { 63 | const text = getDiffSizeText(-7823); 64 | 65 | expect(text).toEqual('-7.64KB'); 66 | }); 67 | }); 68 | 69 | describe('getDiffPercentText', () => { 70 | test('positive number', () => { 71 | const text = getDiffPercentText(100); 72 | 73 | expect(text).toEqual('+100%'); 74 | }); 75 | 76 | test('negative number', () => { 77 | const text = getDiffPercentText(-42); 78 | 79 | expect(text).toEqual('-42%'); 80 | }); 81 | 82 | test('infinity number', () => { 83 | const text = getDiffPercentText(Infinity); 84 | 85 | expect(text).toEqual(''); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /apps/website/src/pages/ReportsPage/components/ReportsResult.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, Suspense } from 'react'; 2 | import styled from '@emotion/styled'; 3 | import { Alert, CircularProgress, Paper, Tabs, Tab } from '@mui/material'; 4 | import { useQuery } from 'react-query'; 5 | import { getCommitRecords, GetCommitRecordsQuery } from '@/services/bundlemonService'; 6 | import FetchError from '@/services/FetchError'; 7 | import type { CommitRecord } from 'bundlemon-utils'; 8 | import ReportsChart from './ReportsChart'; 9 | import { observer } from 'mobx-react-lite'; 10 | import ReportsStore from './ReportsChart/ReportsStore'; 11 | 12 | const Container = styled(Paper)` 13 | width: 100%; 14 | padding: ${({ theme }) => theme.spacing(2)}; 15 | `; 16 | 17 | interface ReportsResultProps { 18 | projectId: string; 19 | query: GetCommitRecordsQuery; 20 | } 21 | 22 | const ReportsResult = observer(({ projectId, query }: ReportsResultProps) => { 23 | const [store] = useState(() => new ReportsStore()); 24 | const { 25 | isLoading, 26 | data: commitRecords, 27 | error, 28 | } = useQuery(['projects', projectId, 'reports', query], () => 29 | getCommitRecords(projectId, query) 30 | ); 31 | 32 | useEffect(() => { 33 | store.setCommitRecords(commitRecords ?? []); 34 | }, [store, commitRecords]); 35 | 36 | const handleTabChange = (_event: React.SyntheticEvent, newTag: 'files' | 'groups') => { 37 | store.setType(newTag); 38 | }; 39 | 40 | if (isLoading) { 41 | return ; 42 | } 43 | 44 | if (error) { 45 | return {error.message}; 46 | } 47 | 48 | if (!commitRecords) { 49 | return Missing results; 50 | } 51 | 52 | if (commitRecords.length === 0) { 53 | return ( 54 | 55 | No records found for project "{projectId}", branch: "{query.branch}" 56 | {query.subProject ? `, sub project: "${query.subProject}"` : ''} 57 | 58 | ); 59 | } 60 | 61 | return ( 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ); 72 | }); 73 | 74 | export default ReportsResult; 75 | -------------------------------------------------------------------------------- /apps/service/src/controllers/projectsController.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from 'crypto'; 2 | import { ProjectProvider } from 'bundlemon-utils'; 3 | import { createProject, getOrCreateProjectId } from '../framework/mongo/projects'; 4 | import { createHash } from '../utils/hashUtils'; 5 | import { createOctokitClientByAction } from '../framework/github'; 6 | 7 | import type { CreateProjectResponse } from 'bundlemon-utils'; 8 | import type { FastifyBaseLogger, RouteHandlerMethod } from 'fastify'; 9 | import type { FastifyValidatedRoute } from '../types/schemas'; 10 | import type { GetOrCreateProjectIdRequestSchema } from '../types/schemas/projects'; 11 | 12 | export const createProjectController: RouteHandlerMethod = async (_req, res) => { 13 | const apiKey = randomBytes(32).toString('hex'); 14 | const startKey = apiKey.substring(0, 3); 15 | 16 | const hash = await createHash(apiKey); 17 | const projectId = await createProject({ hash, startKey }); 18 | 19 | const response: CreateProjectResponse = { projectId, apiKey }; 20 | 21 | res.send(response); 22 | }; 23 | 24 | export const getOrCreateProjectIdController: FastifyValidatedRoute = async ( 25 | req, 26 | res 27 | ) => { 28 | const { body, query } = req; 29 | 30 | const authResult = await checkGetOrCreateProjectIdAuth({ body, query }, req.log); 31 | 32 | if (!authResult.authenticated) { 33 | res.status(403).send({ message: authResult.error, extraData: authResult.extraData }); 34 | return; 35 | } 36 | 37 | const { provider, owner, repo } = body; 38 | const id = await getOrCreateProjectId({ provider, owner: owner.toLowerCase(), repo: repo.toLowerCase() }); 39 | 40 | res.send({ id }); 41 | }; 42 | 43 | type CheckAuthResponse = 44 | | { 45 | authenticated: false; 46 | error: string; 47 | extraData?: Record; 48 | } 49 | | { authenticated: true }; 50 | 51 | async function checkGetOrCreateProjectIdAuth( 52 | { body, query }: GetOrCreateProjectIdRequestSchema, 53 | log: FastifyBaseLogger 54 | ): Promise { 55 | const { provider, owner, repo } = body; 56 | 57 | switch (provider) { 58 | case ProjectProvider.GitHub: { 59 | return createOctokitClientByAction({ owner, repo, runId: query.runId, commitSha: query.commitSha }, log); 60 | } 61 | default: { 62 | log.warn({ provider }, 'unknown provider'); 63 | 64 | return { authenticated: false, error: 'forbidden' }; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/bundlemon/lib/main/types.ts: -------------------------------------------------------------------------------- 1 | import type { Compression, ProjectProvider } from 'bundlemon-utils'; 2 | import type { CreateCommitRecordAuthType } from '../common/consts'; 3 | 4 | export type PathLabels = Record; 5 | 6 | export interface FileConfig { 7 | friendlyName?: string; 8 | path: string; 9 | compression?: Compression; 10 | maxSize?: string; 11 | maxPercentIncrease?: number; 12 | } 13 | 14 | export interface NormalizedFileConfig extends Omit { 15 | compression: Compression; 16 | maxSize?: number; 17 | } 18 | 19 | export interface Config { 20 | subProject?: string; 21 | baseDir?: string; 22 | files?: FileConfig[]; 23 | groups?: FileConfig[]; 24 | pathLabels?: PathLabels; 25 | verbose?: boolean; 26 | defaultCompression?: Compression; 27 | reportOutput?: (string | [string, unknown])[]; 28 | includeCommitMessage?: boolean; 29 | } 30 | 31 | export interface BaseNormalizedConfig extends Omit, 'files' | 'groups' | 'subProject' | 'pathLabels'> { 32 | subProject?: string; 33 | files: NormalizedFileConfig[]; 34 | groups: NormalizedFileConfig[]; 35 | pathLabels: PathLabels; 36 | remote: boolean; 37 | } 38 | 39 | export interface NormalizedConfigRemoteOn extends BaseNormalizedConfig { 40 | remote: true; 41 | projectId: string; 42 | gitVars: GitVars; 43 | getCreateCommitRecordAuthParams: () => CreateCommitRecordAuthParams; 44 | } 45 | 46 | export interface NormalizedConfigRemoteOff extends BaseNormalizedConfig { 47 | remote: false; 48 | } 49 | 50 | export type NormalizedConfig = NormalizedConfigRemoteOn | NormalizedConfigRemoteOff; 51 | 52 | export interface MatchFile { 53 | fullPath: string; 54 | prettyPath: string; 55 | } 56 | 57 | export interface GitVars { 58 | branch: string; 59 | commitSha: string; 60 | baseBranch?: string; 61 | prNumber?: string; 62 | commitMsg?: string; 63 | } 64 | 65 | export type CreateCommitRecordProjectApiKeyAuthQuery = { 66 | authType: CreateCommitRecordAuthType.ProjectApiKey; 67 | token: string; 68 | }; 69 | 70 | export type CreateCommitRecordGithubActionsAuthQuery = { 71 | authType: CreateCommitRecordAuthType.GithubActions; 72 | runId: string; 73 | }; 74 | 75 | export type CreateCommitRecordAuthParams = 76 | | CreateCommitRecordProjectApiKeyAuthQuery 77 | | CreateCommitRecordGithubActionsAuthQuery; 78 | 79 | export interface GitDetails { 80 | provider: ProjectProvider; 81 | owner: string; 82 | repo: string; 83 | } 84 | -------------------------------------------------------------------------------- /apps/website/src/components/Layout/components/UserSection.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { observer } from 'mobx-react-lite'; 3 | import { Link } from 'react-router-dom'; 4 | import { Button, Divider, IconButton, List, ListItem, ListItemIcon, ListItemText, Popover } from '@mui/material'; 5 | import { userStore } from '@/stores/UserStore'; 6 | import AccountIcon from '@mui/icons-material/AccountCircle'; 7 | import LogoutIcon from '@mui/icons-material/Close'; 8 | import { useSnackbar } from 'notistack'; 9 | import { configStore } from '@/stores/ConfigStore'; 10 | 11 | const UserSection = observer(() => { 12 | const { user } = userStore; 13 | const isLoggedIn = !!user; 14 | const [anchorEl, setAnchorEl] = useState(null); 15 | const { enqueueSnackbar } = useSnackbar(); 16 | 17 | const handleMenu = (event: React.MouseEvent) => { 18 | setAnchorEl(event.currentTarget); 19 | }; 20 | 21 | const closeMenu = () => { 22 | setAnchorEl(null); 23 | }; 24 | 25 | const handleLogout = async () => { 26 | closeMenu(); 27 | 28 | await userStore.logout(); 29 | 30 | enqueueSnackbar('Successfully logged out', { variant: 'success' }); 31 | }; 32 | 33 | if (!configStore.githubAppClientId) { 34 | return null; 35 | } 36 | 37 | if (isLoggedIn) { 38 | return ( 39 | <> 40 | 41 | 42 | 43 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | return ( 72 | 75 | ); 76 | }); 77 | 78 | export default UserSection; 79 | --------------------------------------------------------------------------------