├── .gitignore ├── .prettierrc ├── __tests__ ├── __fixtures__ │ ├── custom-output-directory │ │ ├── next.config.js │ │ ├── pages │ │ │ └── index.jsx │ │ └── package.json │ └── basic │ │ ├── pages │ │ └── index.jsx │ │ ├── package.json │ │ └── package-lock.json └── index.js ├── .github └── workflows │ ├── release.yml │ ├── canary-release.yml │ └── analyze.yml ├── CHANGELOG.md ├── .changeset ├── README.md └── config.json ├── template.yml ├── utils.js ├── package.json ├── generate.js ├── report.js ├── README.md ├── generate.mjs ├── compare.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } -------------------------------------------------------------------------------- /__tests__/__fixtures__/custom-output-directory/next.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) HashiCorp, Inc. 3 | * SPDX-License-Identifier: MPL-2.0 4 | */ 5 | 6 | module.exports = { 7 | distDir: 'dist', 8 | } 9 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/basic/pages/index.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) HashiCorp, Inc. 3 | * SPDX-License-Identifier: MPL-2.0 4 | */ 5 | 6 | export default function IndexPage() { 7 | return
hello world
8 | } 9 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/custom-output-directory/pages/index.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) HashiCorp, Inc. 3 | * SPDX-License-Identifier: MPL-2.0 4 | */ 5 | 6 | export default function IndexPage() { 7 | returnhello world
8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | uses: hashicorp/web-platform-packages/.github/workflows/release.yml@5eee43c8a4a8f311e08f473132c617f757a8c6b5 11 | secrets: 12 | CHANGESETS_PAT: ${{ secrets.CHANGESETS_PAT }} 13 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/canary-release.yml: -------------------------------------------------------------------------------- 1 | name: Canary Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - labeled 10 | 11 | jobs: 12 | release-canary: 13 | uses: hashicorp/web-platform-packages/.github/workflows/canary-release.yml@5eee43c8a4a8f311e08f473132c617f757a8c6b5 14 | secrets: 15 | CHANGESETS_PAT: ${{ secrets.CHANGESETS_PAT }} 16 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 17 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundle-analysis-test-fixture", 3 | "version": "1.0.0", 4 | "description": "its a test", 5 | "main": "index.js", 6 | "author": "", 7 | "license": "GPL-2.0", 8 | "dependencies": { 9 | "next": "^11.0.1", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2" 12 | }, 13 | "scripts": { 14 | "build": "next build" 15 | }, 16 | "nextBundleAnalysis": { 17 | "budget": 358400, 18 | "budgetPercentIncreaseRed": 20 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # nextjs-bundle-analysis 2 | 3 | ## 0.5.0 4 | 5 | ### Minor Changes 6 | 7 | - [#44](https://github.com/hashicorp/nextjs-bundle-analysis/pull/44) [`067cb5d`](https://github.com/hashicorp/nextjs-bundle-analysis/commit/067cb5de682d51ad30b8aac2f69437303825c3b8) Thanks [@dstaley](https://github.com/dstaley)! - Improve monorepo support by adding the `name` from `package.json` to the generated comment. Add support for a `skipCommentIfEmpty` configuration option that will set the comment to an empty string when no pages have changed size. 8 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "hashicorp/nextjs-bundle-analysis" } 6 | ], 7 | "commit": false, 8 | "linked": [], 9 | "access": "public", 10 | "baseBranch": "main", 11 | "updateInternalDependencies": "patch", 12 | "ignore": [], 13 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 14 | "useCalculatedVersionForSnapshots": true, 15 | "onlyUpdatePeerDependentsWhenOutOfRange": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /__tests__/__fixtures__/custom-output-directory/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bundle-analysis-test-fixture", 3 | "version": "1.0.0", 4 | "description": "its a test", 5 | "main": "index.js", 6 | "author": "", 7 | "license": "GPL-2.0", 8 | "dependencies": { 9 | "next": "^11.0.1", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2" 12 | }, 13 | "scripts": { 14 | "build": "next build" 15 | }, 16 | "nextBundleAnalysis": { 17 | "budget": 358400, 18 | "budgetPercentIncreaseRed": 20, 19 | "buildOutputDirectory": "dist" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /template.yml: -------------------------------------------------------------------------------- 1 | # Copyright (c) HashiCorp, Inc. 2 | # SPDX-License-Identifier: MPL-2.0 3 | name: 'Next.js Bundle Analysis' 4 | 5 | on: 6 | pull_request: 7 | push: 8 | branches: 9 | # change this if your default branch is named differently 10 | - main 11 | workflow_dispatch: 12 | 13 | jobs: 14 | analyze: 15 | uses: hashicorp/nextjs-bundle-analysis/.github/workflows/analyze.yml@{PACKAGE_VERSION} 16 | # with: 17 | # node-version: 18 18 | # package-manager: npm 19 | # working-directory: ./ 20 | # build-output-directory: .next 21 | # build-command: ./node_modules/.bin/next build 22 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) HashiCorp, Inc. 3 | * SPDX-License-Identifier: MPL-2.0 4 | */ 5 | 6 | const path = require('path') 7 | 8 | /** 9 | * Reads options from `package.json` 10 | */ 11 | const getOptions = (pathPrefix = process.cwd()) => { 12 | const pkg = require(path.join(pathPrefix, 'package.json')) 13 | return { ...pkg.nextBundleAnalysis, name: pkg.name } 14 | } 15 | 16 | /** 17 | * Gets the output build directory, defaults to `.next` 18 | * 19 | * @param {object} options the options parsed from package.json.nextBundleAnalysis using `getOptions` 20 | * @returns {string} 21 | */ 22 | const getBuildOutputDirectory = (options) => { 23 | return options.buildOutputDirectory || '.next' 24 | } 25 | 26 | module.exports = { 27 | getOptions, 28 | getBuildOutputDirectory, 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-bundle-analysis", 3 | "version": "0.5.0", 4 | "description": "Analyzes each PR's impact on your next.js app's bundle size and displays it using a comment", 5 | "bin": { 6 | "generate": "./generate.mjs", 7 | "report": "./report.js", 8 | "compare": "./compare.js" 9 | }, 10 | "scripts": { 11 | "test": "jest", 12 | "release": "changeset publish", 13 | "release:canary": "changeset publish --tag canary" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/hashicorp/nextjs-bundle-analysis.git" 18 | }, 19 | "keywords": [ 20 | "next", 21 | "nextjs", 22 | "bundle", 23 | "analysis", 24 | "github", 25 | "action" 26 | ], 27 | "author": "Jeff Escalante", 28 | "license": "MPL-2.0", 29 | "jest": { 30 | "testPathIgnorePatterns": [ 31 | "/node_modules/", 32 | "/__fixtures__/" 33 | ] 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/hashicorp/nextjs-bundle-analysis/issues" 37 | }, 38 | "homepage": "https://github.com/hashicorp/nextjs-bundle-analysis#readme", 39 | "dependencies": { 40 | "@clack/prompts": "^0.5.1", 41 | "filesize": "^7.0.0", 42 | "gzip-size": "^6.0.0", 43 | "mkdirp": "^1.0.4", 44 | "number-to-words": "^1.2.4", 45 | "picocolors": "^1.0.0" 46 | }, 47 | "devDependencies": { 48 | "@changesets/changelog-github": "^0.4.8", 49 | "@changesets/cli": "^2.26.1", 50 | "jest": "^27.0.6", 51 | "release": "^6.3.0", 52 | "rimraf": "^3.0.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /generate.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) HashiCorp, Inc. 4 | * SPDX-License-Identifier: MPL-2.0 5 | */ 6 | 7 | 8 | const path = require('path') 9 | const fs = require('fs') 10 | const mkdirp = require('mkdirp') 11 | const inquirer = require('inquirer') 12 | 13 | inquirer 14 | .prompt([ 15 | { 16 | type: 'confirm', 17 | name: 'useBudget', 18 | message: 'Would you like to set a performance budget?', 19 | default: true, 20 | }, 21 | { 22 | type: 'number', 23 | name: 'budget', 24 | message: 25 | 'What would you like the maximum javascript on first load to be (in kb)?', 26 | default: 350, 27 | when: (answers) => answers.useBudget, 28 | }, 29 | { 30 | type: 'number', 31 | name: 'redIndicatorPercentage', 32 | message: 33 | 'If you exceed this percentage of the budget or filesize, it will be highlighted in red', 34 | default: 20, 35 | }, 36 | { 37 | type: 'number', 38 | name: 'minimumChangeThreshold', 39 | message: `If a page's size change is below this threshold (in bytes), it will be considered unchanged`, 40 | default: 0, 41 | }, 42 | ]) 43 | .then((answers) => { 44 | // write the config values to package.json 45 | const packageJsonPath = path.join(process.cwd(), 'package.json') 46 | const packageJsonContent = require(packageJsonPath) 47 | packageJsonContent.nextBundleAnalysis = { 48 | budget: answers.budget * 1024, 49 | budgetPercentIncreaseRed: answers.redIndicatorPercentage, 50 | minimumChangeThreshold: answers.minimumChangeThreshold, 51 | showDetails: true, // add a default "showDetails" argument 52 | } 53 | fs.writeFileSync( 54 | packageJsonPath, 55 | JSON.stringify(packageJsonContent, null, 2) 56 | ) 57 | // mkdir -p the .workflows directory 58 | const workflowsPath = path.join(process.cwd(), '.github/workflows') 59 | mkdirp.sync(workflowsPath) 60 | 61 | // copy the template to it 62 | const templatePath = path.join(__dirname, 'template.yml') 63 | const destinationPath = path.join( 64 | workflowsPath, 65 | 'nextjs_bundle_analysis.yml' 66 | ) 67 | fs.copyFileSync(templatePath, destinationPath) 68 | }) 69 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) HashiCorp, Inc. 3 | * SPDX-License-Identifier: MPL-2.0 4 | */ 5 | 6 | const path = require('path') 7 | const fs = require('fs') 8 | const { execSync } = require('child_process') 9 | const rimraf = require('rimraf') 10 | const { afterEach, beforeEach, expect } = require('@jest/globals') 11 | const mkdirp = require('mkdirp') 12 | const { getBuildOutputDirectory, getOptions } = require('../utils') 13 | 14 | const fixturesPath = path.join(__dirname, '__fixtures__') 15 | 16 | // Get all test suites (fixtures) 17 | const fixtures = fs 18 | .readdirSync(fixturesPath, { withFileTypes: true }) 19 | .filter((dirent) => dirent.isDirectory()) 20 | .map((dirent) => dirent.name) 21 | 22 | describe('sort of integration', () => { 23 | fixtures.forEach((dirName) => { 24 | describe(`fixture ${dirName}`, () => { 25 | const cwd = path.join(fixturesPath, dirName) 26 | const options = getOptions(cwd) 27 | const buildOutputDirectory = getBuildOutputDirectory(options) 28 | 29 | beforeEach(() => { 30 | process.chdir(cwd) 31 | execSync('npm install') 32 | execSync('npm run build') 33 | }) 34 | 35 | afterEach(() => { 36 | rimraf.sync(path.join(cwd, buildOutputDirectory)) 37 | }) 38 | 39 | test(`bundle analysis action generates report and compares artifacts correctly ${dirName}`, () => { 40 | // make sure the 'report' command works 41 | execSync('node ../../../report.js') 42 | const bundleAnalysis = fs.readFileSync( 43 | path.join( 44 | process.cwd(), 45 | buildOutputDirectory, 46 | 'analyze/__bundle_analysis.json' 47 | ), 48 | 'utf8' 49 | ) 50 | expect(bundleAnalysis.length).toBeGreaterThan(1) 51 | 52 | // create a fake artifact download - in the real world this would pull from 53 | // github as part of the action flow 54 | mkdirp.sync( 55 | path.join(process.cwd(), buildOutputDirectory, 'analyze/base/bundle') 56 | ) 57 | fs.writeFileSync( 58 | path.join( 59 | process.cwd(), 60 | buildOutputDirectory, 61 | 'analyze/base/bundle/__bundle_analysis.json' 62 | ), 63 | bundleAnalysis 64 | ) 65 | 66 | // make sure the 'compare' command works 67 | execSync('node ../../../compare.js') 68 | const comment = fs.readFileSync( 69 | path.join( 70 | process.cwd(), 71 | buildOutputDirectory, 72 | 'analyze/__bundle_analysis_comment.txt' 73 | ), 74 | 'utf8' 75 | ) 76 | expect(comment).toMatch(/no changes to the javascript bundle/) 77 | }) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /report.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright (c) HashiCorp, Inc. 4 | * SPDX-License-Identifier: MPL-2.0 5 | */ 6 | 7 | 8 | const path = require('path') 9 | const fs = require('fs') 10 | const gzSize = require('gzip-size') 11 | const mkdirp = require('mkdirp') 12 | const { getBuildOutputDirectory, getOptions } = require('./utils') 13 | 14 | // Pull options from `package.json` 15 | const options = getOptions() 16 | const BUILD_OUTPUT_DIRECTORY = getBuildOutputDirectory(options) 17 | 18 | // first we check to make sure that the build output directory exists 19 | const nextMetaRoot = path.join(process.cwd(), BUILD_OUTPUT_DIRECTORY) 20 | try { 21 | fs.accessSync(nextMetaRoot, fs.constants.R_OK) 22 | } catch (err) { 23 | console.error( 24 | `No build output found at "${nextMetaRoot}" - you may not have your working directory set correctly, or not have run "next build".` 25 | ) 26 | process.exit(1) 27 | } 28 | 29 | // if so, we can import the build manifest 30 | const buildMeta = require(path.join(nextMetaRoot, 'build-manifest.json')) 31 | 32 | // this memory cache ensures we dont read any script file more than once 33 | // bundles are often shared between pages 34 | const memoryCache = {} 35 | 36 | // since _app is the template that all other pages are rendered into, 37 | // every page must load its scripts. we'll measure its size here 38 | const globalBundle = buildMeta.pages['/_app'] 39 | const globalBundleSizes = getScriptSizes(globalBundle) 40 | 41 | // next, we calculate the size of each page's scripts, after 42 | // subtracting out the global scripts 43 | const allPageSizes = Object.values(buildMeta.pages).reduce( 44 | (acc, scriptPaths, i) => { 45 | const pagePath = Object.keys(buildMeta.pages)[i] 46 | const scriptSizes = getScriptSizes( 47 | scriptPaths.filter((scriptPath) => !globalBundle.includes(scriptPath)) 48 | ) 49 | 50 | acc[pagePath] = scriptSizes 51 | return acc 52 | }, 53 | {} 54 | ) 55 | 56 | // format and write the output 57 | const rawData = JSON.stringify({ 58 | ...allPageSizes, 59 | __global: globalBundleSizes, 60 | }) 61 | 62 | // log ouputs to the gh actions panel 63 | console.log(rawData) 64 | 65 | mkdirp.sync(path.join(nextMetaRoot, 'analyze/')) 66 | fs.writeFileSync( 67 | path.join(nextMetaRoot, 'analyze/__bundle_analysis.json'), 68 | rawData 69 | ) 70 | 71 | // -------------- 72 | // Util Functions 73 | // -------------- 74 | 75 | // given an array of scripts, return the total of their combined file sizes 76 | function getScriptSizes(scriptPaths) { 77 | const res = scriptPaths.reduce( 78 | (acc, scriptPath) => { 79 | const [rawSize, gzipSize] = getScriptSize(scriptPath) 80 | acc.raw += rawSize 81 | acc.gzip += gzipSize 82 | return acc 83 | }, 84 | { raw: 0, gzip: 0 } 85 | ) 86 | return res 87 | } 88 | 89 | // given an individual path to a script, return its file size 90 | function getScriptSize(scriptPath) { 91 | const encoding = 'utf8' 92 | const p = path.join(nextMetaRoot, scriptPath) 93 | 94 | let rawSize, gzipSize 95 | if (Object.keys(memoryCache).includes(p)) { 96 | rawSize = memoryCache[p][0] 97 | gzipSize = memoryCache[p][1] 98 | } else { 99 | const textContent = fs.readFileSync(p, encoding) 100 | rawSize = Buffer.byteLength(textContent, encoding) 101 | gzipSize = gzSize.sync(textContent) 102 | memoryCache[p] = [rawSize, gzipSize] 103 | } 104 | 105 | return [rawSize, gzipSize] 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Bundle Analysis Github Action 2 | 3 | Analyzes each PR's impact on your next.js app's bundle size and displays it using a comment. Optionally supports performance budgets. 4 | 5 |  6 | 7 | ## Installation 8 | 9 | It's pretty simple to get this set up. Run the following command and answer the prompts. The command will create a `.github/workflows` directory in your project root and add a `next_bundle_analysis.yml` file to it - that's all it takes! 10 | 11 | ```sh 12 | $ npx -p nextjs-bundle-analysis generate 13 | ``` 14 | 15 | ## Configuration 16 | 17 | Config values are written to `package.json` under the key `nextBundleAnalysis`, and can be changed there any time. You can directly edit the workflow file if you want to adjust your default branch or the directory that your nextjs app lives in (especially if you are using a `srcDir` or something similar). 18 | 19 | ### `showDetails (boolean)` 20 | 21 | (Optional, defaults to `true`) This option renders a collapsed "details" section under each section of the bundle analysis comment explaining some of the finer details of the numbers provided. If you feel like this is not necessary and you and/or those working on your project understand the details, you can set this option to `false` and that section will not render. 22 | 23 | ### `buildOutputDirectory (string)` 24 | 25 | (Optional, defaults to `.next`) If your application [builds to a custom directory](https://nextjs.org/docs/api-reference/next.config.js/setting-a-custom-build-directory), you can specify this with the key `buildOutputDirectory`. You will also need to replace all instances of `.next` in `next_bundle_analysis.yml` with your custom output directory. 26 | 27 | For example, if you build to `dist`, you should: 28 | 29 | - Set `package.json.nextBundleAnalysis.buildOutputDirectory` to `"dist"`. 30 | - In `next_bundle_analysis.yml`, update the `build-output-directory` input to `dist`. 31 | 32 | ### `budget (number)` 33 | 34 | (Optional) The file size, in bytes, to budget for first page load size. For example, if `budget` was set to `358400` (350 KB) and a page's first load size was 248 KB, the report would list that page as having used 70% of the performance budget. 35 | 36 | ### `budgetPercentIncreaseRed (number)` 37 | 38 | (Optional, but required if `budget` is specified) If a page's first load size has increased more than `budgetPercentIncreaseRed` percent, display a 🔴 to draw attention to the change. 39 | 40 | ### `minimumChangeThreshold (number)` 41 | 42 | (Optional, defaults to `0`) The threshold under which pages will be considered unchanged. For example, if `minimumChangeThreshold` was set to `500` and a page's size increased by `300 B`, it will be considered unchanged. 43 | 44 | ### `alwaysShowGzipDiff (boolean)` 45 | 46 | (Optional, defaults to `false`) If set, the display table will show the gzip size difference for routes even when a budget is set. 47 | 48 | ### `skipCommentIfEmpty (boolean)` 49 | 50 | (Optional, defaults to `false`) When set to `true`, if no pages have changed size the generated comment will be an empty string. 51 | 52 | ## Caveats 53 | 54 | - This plugin only analyzes the direct bundle output from next.js. If you have added any other scripts via the `