Compare commits

..

No commits in common. "main" and "v2.0.0" have entirely different histories.
main ... v2.0.0

29 changed files with 8742 additions and 5420 deletions

View file

@ -25,8 +25,7 @@
"profile": "http://msrose.github.io",
"contributions": [
"infra",
"doc",
"code"
"doc"
]
}
],

6
.eslintrc.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
extends: [
"@tophat/eslint-config/base",
"@tophat/eslint-config/jest",
]
}

View file

@ -1,102 +1,71 @@
name: packwatch CI
on:
pull_request:
push:
branches: [main]
on: [push, pull_request]
jobs:
dependencies:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14, 16, 18]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- uses: actions/cache@v3
id: dependencies-cache
env:
cache-name: dependencies-cache
with:
path: .yarn
key: ${{ runner.os }}-build-${{env.cache-name}}-${{ hashFiles('**/yarn.lock') }}-node-${{ matrix.node-version }}
- if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }}
run: yarn
lint:
runs-on: ubuntu-latest
needs: dependencies
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v2
- name: Use Node.js 12.x
uses: actions/setup-node@v1
with:
node-version: 18
- uses: actions/cache@v3
id: dependencies-cache
env:
cache-name: dependencies-cache
with:
path: .yarn
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}-node-18
- run: |
yarn
yarn lint
node-version: 12.x
- run: yarn
- run: yarn lint
test:
runs-on: ubuntu-latest
needs: dependencies
strategy:
matrix:
node-version: [14, 16, 18]
node-version: [10.x, 12.x]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- uses: actions/cache@v3
id: dependencies-cache
env:
cache-name: dependencies-cache
with:
path: .yarn
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}-node-${{ matrix.node-version }}
- run: |
yarn
yarn test:coverage
- run: yarn
- run: yarn test:coverage
- name: Coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
build:
runs-on: ubuntu-latest
needs: dependencies
needs: test
strategy:
matrix:
node-version: [14, 16, 18]
node-version: [10.x, 12.x]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- uses: actions/cache@v3
id: dependencies-cache
env:
cache-name: dependencies-cache
- run: yarn --frozen-lockfile
- run: yarn build
release:
name: Release
runs-on: ubuntu-latest
needs: [build, lint, test]
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Node setup
uses: actions/setup-node@v1
with:
path: .yarn
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }}-node-${{ matrix.node-version }}
- run: |
yarn
yarn build
node-version: 12
- name: Prepare
run: yarn && yarn build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: yarn semantic-release

View file

@ -1,30 +0,0 @@
name: packwatch release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
needs: [build, lint, test]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Node setup
uses: actions/setup-node@v3
with:
node-version: 14
- name: Prepare
run: yarn && yarn build
- name: get tag
id: get_tag
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/v}
- name: Release
env:
NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: yarn publish --access public --tag latest --new-version ${{steps.get_tag.outputs.tag}}

10
.gitignore vendored
View file

@ -6,14 +6,6 @@ yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Yarntifacts
.yarn/*
!.yarn/releases
!.yarn/plugins
!.yarn/versions
!.yarn/sdks
.pnp.*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
@ -110,5 +102,3 @@ dist
# TernJS port file
.tern-port
*.sw[a-z]

2
.nvmrc
View file

@ -1 +1 @@
18.15.0
12

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
yarnPath: .yarn/releases/yarn-3.4.1.cjs

1
.yvmrc Normal file
View file

@ -0,0 +1 @@
1.22.0

View file

@ -1,31 +0,0 @@
# Best Practices: tips and tricks to use Packwatch to its fullest
## Overview
Monitoring the footprint of the packages you publish can be a powerful first step towards being producing efficient, trim code that doesn't use more resources that it should and that is a no-brainer to adopt. This document compiles some suggestions on how to get the most out of packwatch for your project.
Got best practices questions or suggestions? Open an [issue](https://github.com/mcataford/packwatch/issues)!
### First-run
On the first-run, Packwatch will generate a manifest file that sets the package size and the limit to the same value. Following this, the run will return a non-zero status code. This is perfectly normal! This is meant to ensure that an initial run or a run without a manifest won't pass on CI. The next time you will run Packwatch, it will use the now-present manifest as a comparison point and proceed normally.
### Understanding `.packwatch.json`
The `.packwatch.json` file persisted in your project keeps track of the last reported package size that was commited to your version control system. At the moment, it consists of three keys:
- `packageSize`, representing the size of your package's archive as it is when packed using `npm pack`;
- `unpackedSize`, representing the _unpacked_ size of that package (i.e. once installed, what space do the published filed occupy on disk);
- `limit`, representing a threshold that will cause Packwatch to throw an error if crossed.
While the `packageSize` and `unpackedSize` are automatically populated when you update Packwatch's manifest, the `limit` value can be set manually so that you leave yourself some "head room" for growth.
#### Setting sensible thresholds
When Packwatch initializes its manifest, it will initialize the `limit` parameter to be equal to `packedSize` so that any increase in size will cause a failure. Once you determine what kind of growth you want to allow for, you can edit the manifest to increase the limit.__ Any automatic updates to the manifest will leave the `limit` value as-is__.
Setting a sensible limit is essential to avoiding "packwatch fatigue", a scenario in which packwatch fails every time your package size grows because the `limit` parameter is too close to the `packageSize` value. Usually, __having a limit that is more or less 5 kB above the `packageSize` will allow for growth while still preventing bloat to sneak in__. From there, you can readjust the `limit` value as your project grows so that the "head room" space between `packageSize` and `limit` stays adequate.
### Integrating Packwatch into your workflow
In order to monitor your project's growth accurately, it's preferrable to have Packwatch run both in your pre-commit hooks and CI pipeline. This way, you can catch bloat before commits are event pushed up and you can get another chance to catch undesired overgrowth before you merge in change bundles. This will also act as a reminder to keep the manifest up to date so that it doesn't go out sync.

View file

@ -7,6 +7,7 @@
[![codecov](https://codecov.io/gh/mcataford/packwatch/branch/master/graph/badge.svg)](https://codecov.io/gh/mcataford/packwatch)
![packwatch CI](https://github.com/mcataford/packwatch/workflows/packwatch%20CI/badge.svg)
[![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
## Overview
@ -45,8 +46,6 @@ packwatch --update-manifest
Just commit your `.packwatch.json` manifest and you're good to go!
Check out [Packwatch's best practices tips and tricks](https://github.com/mcataford/packwatch/blob/master/BEST_PRACTICES.md) for some advice on how to make the most of it!
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@ -57,7 +56,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<table>
<tr>
<td align="center"><a href="https://mcataford.github.io"><img src="https://avatars2.githubusercontent.com/u/6210361?v=4" width="100px;" alt=""/><br /><sub><b>Marc Cataford</b></sub></a><br /><a href="#ideas-mcataford" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/mcataford/packwatch/commits?author=mcataford" title="Code">💻</a> <a href="#infra-mcataford" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/mcataford/packwatch/commits?author=mcataford" title="Tests">⚠️</a> <a href="https://github.com/mcataford/packwatch/commits?author=mcataford" title="Documentation">📖</a></td>
<td align="center"><a href="http://msrose.github.io"><img src="https://avatars3.githubusercontent.com/u/3495264?v=4" width="100px;" alt=""/><br /><sub><b>Michael Rose</b></sub></a><br /><a href="#infra-msrose" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/mcataford/packwatch/commits?author=msrose" title="Documentation">📖</a> <a href="https://github.com/mcataford/packwatch/commits?author=msrose" title="Code">💻</a></td>
<td align="center"><a href="http://msrose.github.io"><img src="https://avatars3.githubusercontent.com/u/3495264?v=4" width="100px;" alt=""/><br /><sub><b>Michael Rose</b></sub></a><br /><a href="#infra-msrose" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/mcataford/packwatch/commits?author=msrose" title="Documentation">📖</a></td>
</tr>
</table>
@ -65,4 +64,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!

5
babel.config.json Normal file
View file

@ -0,0 +1,5 @@
{
"comments": false,
"ignore": ["**/*.test.js"],
"presets": [["@babel/preset-env", { "targets": { "node": 10 } }]]
}

View file

@ -1,5 +1,3 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
transformIgnorePatterns: ['.test.js'],
}

View file

@ -1,7 +1,7 @@
{
"name": "packwatch",
"version": "0.0.0",
"main": "dist/cli.js",
"main": "dist/index.js",
"description": "📦👀 Keep an eye on your packages' footprint",
"keywords": [
"npm",
@ -19,31 +19,41 @@
"author": "Marc Cataford <c.marcandre@gmail.com>",
"license": "MIT",
"files": [
"dist/*.js"
"dist/**/*.js"
],
"bin": "./dist/cli.js",
"bin": "./dist/index.js",
"repository": {
"type": "git",
"url": "https://github.com/mcataford/packwatch.git"
},
"pre-commit": [
"lint",
"test"
],
"scripts": {
"prepack": "yarn build",
"prebuild": "rm -rf dist",
"build": "tsc --project .",
"lint": "yarn rome format src tests && yarn rome check src tests",
"lint:fix": "yarn rome format src tests --write && yarn rome check src tests --apply",
"test": "jest tests",
"prebuild": "rimraf dist",
"build": "babel src -d dist",
"lint": "eslint src *.js",
"lint:fix": "yarn lint --fix",
"test": "jest src",
"test:watch": "yarn test --watchAll",
"test:coverage": "yarn test --coverage",
"types": "tsc --noEmit src/**/*.ts",
"types:watch": "yarn types --watch"
"test:coverage": "yarn test --coverage"
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^18.15.5",
"jest": "^29.5.0",
"rome": "^12.0.0",
"ts-jest": "^29.0.5",
"typescript": "^4.3.0"
"@babel/cli": "^7.8.4",
"@babel/core": "^7.8.6",
"@babel/preset-env": "^7.8.7",
"@tophat/eslint-config": "^0.6.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jest": "^23.8.0",
"eslint-plugin-prettier": "^3.1.2",
"jest": "^25.1.0",
"mock-fs": "^4.11.0",
"pre-commit": "^1.2.2",
"prettier": "^1.19.1",
"rimraf": "^3.0.2",
"semantic-release": "^17.0.4"
}
}

View file

@ -1,5 +0,0 @@
{
"extends": [
"config:base"
]
}

View file

@ -1,13 +0,0 @@
{
"formatter": {
"enabled": true,
"lineWidth": 120
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"quoteStyle": "single"
}
}
}

View file

@ -1,9 +0,0 @@
#!/usr/bin/env node
import packwatch from '.'
const isUpdatingManifest = process.argv.includes('--update-manifest')
const cwd = process.cwd()
packwatch({ cwd, isUpdatingManifest })
.catch(() => process.exit(1))
.then(() => process.exit(0))

73
src/helpers.js Normal file
View file

@ -0,0 +1,73 @@
const { spawnSync } = require('child_process')
const { readFileSync, writeFileSync } = require('fs')
const PACKAGE_SIZE_PATT = /package size:\s+([0-9]+\.?[0-9]*\s+[A-Za-z]+)/g
const UNPACKED_SIZE_PATT = /unpacked size:\s+([0-9]+\.?[0-9]*\s+[A-Za-z]+)/g
const SIZE_SUFFIX_PATT = /([A-Za-z]+)/
const SIZE_MAGNITUDE_PATT = /([0-9]+\.?[0-9]*)/
const MANIFEST_FILENAME = '.packwatch.json'
const FS_OPTIONS = { encoding: 'utf-8' }
function convertSizeToBytes(sizeString) {
const sizeSuffix = SIZE_SUFFIX_PATT.exec(sizeString)[1]
const sizeMagnitude = SIZE_MAGNITUDE_PATT.exec(sizeString)[1]
let multiplier = 1
if (sizeSuffix === 'kB') multiplier = 1000
else if (sizeSuffix === 'mB') {
multiplier = 1000000
}
return multiplier * parseFloat(sizeMagnitude)
}
function getCurrentPackageStats() {
const { stderr } = spawnSync('npm', ['pack', '--dry-run'], FS_OPTIONS)
const packageSize = PACKAGE_SIZE_PATT.exec(stderr)[1]
const unpackedSize = UNPACKED_SIZE_PATT.exec(stderr)[1]
return {
packageSize,
unpackedSize,
packageSizeBytes: convertSizeToBytes(packageSize),
unpackedSizeBytes: convertSizeToBytes(unpackedSize),
}
}
function getPreviousPackageStats() {
try {
const currentManifest = readFileSync(MANIFEST_FILENAME, FS_OPTIONS)
const parsedManifest = JSON.parse(currentManifest)
return {
...parsedManifest,
packageSizeBytes: convertSizeToBytes(parsedManifest.packageSize),
unpackedSizeBytes: convertSizeToBytes(parsedManifest.unpackedSize),
limitBytes: convertSizeToBytes(parsedManifest.limit),
}
} catch (e) {
return {}
}
}
function createOrUpdateManifest({ previous, current, updateLimit = false }) {
const { limit } = previous || {}
const { packageSize, unpackedSize } = current
const newManifest = {
limit: updateLimit ? packageSize : limit || packageSize,
packageSize: packageSize,
unpackedSize: unpackedSize,
}
writeFileSync(MANIFEST_FILENAME, JSON.stringify(newManifest))
}
module.exports = {
createOrUpdateManifest,
getPreviousPackageStats,
getCurrentPackageStats,
convertSizeToBytes,
MANIFEST_FILENAME,
}

100
src/index.js Normal file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env node
const { existsSync } = require('fs')
const {
MANIFEST_FILENAME,
getCurrentPackageStats,
getPreviousPackageStats,
createOrUpdateManifest,
} = require('./helpers')
if (!existsSync('package.json')) {
console.log(
'🤔 There is no package.json file here. Are you in the root directory of your project?',
)
process.exit(1)
}
const isUpdatingManifest = process.argv.includes('--update-manifest')
const currentStats = getCurrentPackageStats()
/*
* If there is no manifest file yet, we can use the current package stats as
* a base to build one. The current package size becomes the limit.
*/
if (!existsSync(MANIFEST_FILENAME)) {
createOrUpdateManifest({ current: currentStats })
console.log(
`📝 No Manifest to compare against! Current package stats written to ${MANIFEST_FILENAME}!`,
)
console.log(
`Package size (${currentStats.packageSize}) adopted as new limit.`,
)
// If the update flag wasn't specified, exit with a non-zero code so we
// don't "accidentally" pass CI builds if the manifest didn't exist
process.exit(isUpdatingManifest ? 0 : 1)
}
const previousStats = getPreviousPackageStats()
const { packageSizeBytes, packageSize } = currentStats
const {
packageSize: previousSize,
packageSizeBytes: previousSizeBytes,
limit,
limitBytes,
} = previousStats
const hasExceededLimit = packageSizeBytes > limitBytes
/*
* If we are updating the manifest, we can write right away and terminate.
*/
if (isUpdatingManifest) {
createOrUpdateManifest({
previous: previousStats,
current: currentStats,
updateLimit: true,
})
console.log(
`📝 Updated the manifest! Package size: ${packageSize}, Limit: ${packageSize}`,
)
process.exit(0)
}
/*
* If there is a manifest file and the current package busts its limit
* we signal it and terminate with an error.
*/
if (hasExceededLimit) {
console.log(
`🔥🔥📦🔥🔥 Your package exceeds the limit set in ${MANIFEST_FILENAME}! ${packageSize} > ${limit}`,
)
console.log(
'Either update the limit by using the --update-manifest flag or trim down your packed files!',
)
process.exit(1)
}
/*
* If there is a manifest file and the limit is not busted, we give
* the user some feedback on how the current package compares with
* the previous one.
*/
if (packageSizeBytes > previousSizeBytes) {
console.log(
`📦 👀 Your package grew! ${packageSize} > ${previousSize} (Limit: ${limit})`,
)
} else if (packageSizeBytes < previousSizeBytes) {
console.log(
`📦 💯 Your package shrank! ${packageSize} < ${previousSize} (Limit: ${limit})`,
)
} else {
console.log(
`📦 Nothing to report! Your package is the same size as the latest manifest reports! (Limit: ${limit})`,
)
}

177
src/index.test.js Normal file
View file

@ -0,0 +1,177 @@
const childProcess = require('child_process')
const { readFileSync } = require('fs')
const mockFS = require('mock-fs')
jest.mock('child_process')
childProcess.spawnSync = jest.fn(() => ({ stderr: mockPackOutput }))
const {
MANIFEST_FILENAME,
convertSizeToBytes,
getCurrentPackageStats,
getPreviousPackageStats,
createOrUpdateManifest,
} = require('./helpers')
const mockPackageSize = '1.1 kB'
const mockUnpackedSize = '9000 kB'
const mockPackOutput = `
npm notice
npm notice 📦 footprint@0.0.0
npm notice === Tarball Contents ===
npm notice 732B package.json
npm notice 1.8kB dist/helpers.js
npm notice 1.9kB dist/index.js
npm notice === Tarball Details ===
npm notice name: footprint
npm notice version: 0.0.0
npm notice filename: footprint-0.0.0.tgz
npm notice package size: ${mockPackageSize}
npm notice unpacked size: ${mockUnpackedSize}
npm notice shasum: bdf33d471543cd8126338a82a27b16a9010b8dbd
npm notice integrity: sha512-ZZvTg9GVcJw8J[...]bkE0xlqQhlt4Q==
npm notice total files: 3
npm notice
`
describe('Helpers', () => {
beforeEach(() => {
mockFS.restore()
jest.restoreAllMocks()
})
afterAll(mockFS.restore)
describe('Size string conversion', () => {
it.each`
sizeString | expectedValue
${'1 B'} | ${1}
${'1.1 B'} | ${1.1}
${'1 kB'} | ${1000}
${'1.1kB'} | ${1100}
${'1 mB'} | ${1000000}
${'1.1 mB'} | ${1100000}
`(
'converts $sizeString properly to $expectedValue bytes',
({ sizeString, expectedValue }) => {
expect(convertSizeToBytes(sizeString)).toEqual(expectedValue)
},
)
})
describe('Current package statistics', () => {
it('constructs the current package report properly', () => {
const packageSizeBytes = 1100
const unpackedSizeBytes = 9000000
expect(getCurrentPackageStats()).toEqual({
packageSize: mockPackageSize,
packageSizeBytes,
unpackedSize: mockUnpackedSize,
unpackedSizeBytes,
})
})
})
describe('Previous package statistics', () => {
it('constructs the previous package report properly', () => {
const packageSize = '0.9 kB'
const packageSizeBytes = 900
const unpackedSize = '90 kB'
const unpackedSizeBytes = 90000
const limit = '1 kB'
const limitBytes = 1000
const mockReport = { packageSize, unpackedSize, limit }
mockFS({ [MANIFEST_FILENAME]: JSON.stringify(mockReport) })
expect(getPreviousPackageStats()).toEqual({
packageSize,
packageSizeBytes,
unpackedSize,
unpackedSizeBytes,
limit,
limitBytes,
})
})
it('returns an empty manifest if it fails to reads the manifest file', () => {
mockFS({
[MANIFEST_FILENAME]: 'not valid JSON',
})
expect(getPreviousPackageStats()).toEqual({})
})
})
describe('Creating or updating the manifest', () => {
const currentStats = {
packageSize: '1 kB',
unpackedSize: '10 kB',
}
const previousManifest = {
limit: '2 kB',
packageSize: '1.5 kB',
}
it('creates a anifest from the current data if no previous data is provided', () => {
mockFS({})
createOrUpdateManifest({ current: currentStats })
const writtenManifest = readFileSync(MANIFEST_FILENAME, {
encoding: 'utf-8',
})
expect(JSON.parse(writtenManifest)).toEqual({
packageSize: currentStats.packageSize,
unpackedSize: currentStats.unpackedSize,
limit: currentStats.packageSize,
})
})
it('updates the previous manifest sizes if previous data exists', () => {
mockFS({
[MANIFEST_FILENAME]: JSON.stringify(previousManifest),
})
createOrUpdateManifest({
current: currentStats,
previous: previousManifest,
updateLimit: false,
})
const writtenManifest = readFileSync(MANIFEST_FILENAME, {
encoding: 'utf-8',
})
expect(JSON.parse(writtenManifest)).toEqual({
packageSize: currentStats.packageSize,
unpackedSize: currentStats.unpackedSize,
limit: previousManifest.limit,
})
})
it('updates the previous manifest sizes and limit if previous data exists and updateLimit is set', () => {
mockFS({
[MANIFEST_FILENAME]: JSON.stringify(previousManifest),
})
createOrUpdateManifest({
current: currentStats,
previous: previousManifest,
updateLimit: true,
})
const writtenManifest = readFileSync(MANIFEST_FILENAME, {
encoding: 'utf-8',
})
expect(JSON.parse(writtenManifest)).toEqual({
packageSize: currentStats.packageSize,
unpackedSize: currentStats.unpackedSize,
limit: currentStats.packageSize,
})
})
})
})

View file

@ -1,87 +0,0 @@
import { existsSync } from 'fs'
import { join, resolve } from 'path'
import { assertInPackageRoot } from './invariants'
import logger from './logger'
import { createOrUpdateManifest, getCurrentPackageStats, getPreviousPackageStats, mergeDefaultArguments } from './utils'
import type { PackwatchArguments } from './types'
const MANIFEST_FILENAME = '.packwatch.json'
export default async function packwatch(args: Partial<PackwatchArguments>): Promise<void> {
const { cwd, isUpdatingManifest } = mergeDefaultArguments(args)
const manifestPath = resolve(join(cwd, MANIFEST_FILENAME))
assertInPackageRoot(cwd)
const currentStats = getCurrentPackageStats(cwd)
/*
* If there is no manifest file yet, we can use the current package stats as
* a base to build one. The current package size becomes the limit.
*/
if (!existsSync(manifestPath)) {
createOrUpdateManifest({ manifestPath, current: currentStats })
logger.warn(
`📝 No Manifest to compare against! Current package stats written to ${MANIFEST_FILENAME}!\nPackage size (${currentStats.packageSize}) adopted as new limit.`,
)
if (!isUpdatingManifest) {
logger.error(
'❗ It looks like you ran PackWatch without a manifest. To prevent accidental passes in CI or hooks, packwatch will terminate with an error. If you are running packwatch for the first time in your project, this is expected!',
)
throw new Error('NO_MANIFEST_NO_UPDATE')
}
return
}
const previousStats = getPreviousPackageStats(cwd)
const { packageSizeBytes, packageSize } = currentStats
const { packageSize: previousSize, packageSizeBytes: previousSizeBytes, limit, limitBytes } = previousStats
const hasExceededLimit = limitBytes && packageSizeBytes > limitBytes
/*
* If we are updating the manifest, we can write right away and terminate.
*/
if (isUpdatingManifest) {
createOrUpdateManifest({
previous: previousStats,
current: currentStats,
updateLimit: true,
manifestPath,
})
logger.log(`📝 Updated the manifest! Package size: ${packageSize}, Limit: ${packageSize}`)
return
}
/*
* If there is a manifest file and the current package busts its limit
* we signal it and terminate with an error.
*/
if (hasExceededLimit) {
logger.error(
`🔥🔥📦🔥🔥 Your package exceeds the limit set in ${MANIFEST_FILENAME}! ${packageSize} > ${limit}\nEither update the limit by using the --update-manifest flag or trim down your packed files!`,
)
throw new Error('PACKAGE_EXCEEDS_LIMIT')
}
/*
* If there is a manifest file and the limit is not busted, we give
* the user some feedback on how the current package compares with
* the previous one.
*/
if (packageSizeBytes > previousSizeBytes) {
logger.log(`📦 👀 Your package grew! ${packageSize} > ${previousSize} (Limit: ${limit})`)
} else if (packageSizeBytes < previousSizeBytes) {
logger.log(`📦 💯 Your package shrank! ${packageSize} < ${previousSize} (Limit: ${limit})`)
} else {
logger.log(`📦 Nothing to report! Your package is the same size as the latest manifest reports! (Limit: ${limit})`)
}
return
}

View file

@ -1,14 +0,0 @@
import { existsSync } from 'fs'
import { join, resolve } from 'path'
import logger from './logger'
export function assertInPackageRoot(cwd: string): void {
const packagePath = resolve(join(cwd, 'package.json'))
const packageJsonExists = existsSync(packagePath)
if (!packageJsonExists) {
logger.log('🤔 There is no package.json file here. Are you in the root directory of your project?')
throw new Error('NOT_IN_PACKAGE_ROOT')
}
}

View file

@ -1,11 +0,0 @@
export default {
log: (...args: unknown[]): void => {
console.log(...args)
},
warn: (...args: unknown[]): void => {
console.warn(...args)
},
error: (...args: unknown[]): void => {
console.error(...args)
},
}

View file

@ -1,13 +0,0 @@
export type PackwatchArguments = {
cwd: string
isUpdatingManifest: boolean
}
export type Report = {
packageSize: string
unpackedSize: string
packageSizeBytes: number
unpackedSizeBytes: number
limit?: string
limitBytes?: number
}

View file

@ -1,98 +0,0 @@
import { spawnSync } from 'child_process'
import { readFileSync, writeFileSync } from 'fs'
import { join, resolve } from 'path'
import { type PackwatchArguments, type Report } from './types'
const PACKAGE_SIZE_PATT = /package size:\s*([0-9]+\.?[0-9]*\s+[A-Za-z]{1,2})/
const UNPACKED_SIZE_PATT = /unpacked size:\s*([0-9]+\.?[0-9]*\s+[A-Za-z]{1,2})/
const SIZE_SUFFIX_PATT = /([A-Za-z]+)/
const SIZE_MAGNITUDE_PATT = /([0-9]+\.?[0-9]*)/
const MANIFEST_FILENAME = '.packwatch.json'
export function mergeDefaultArguments(args: Partial<PackwatchArguments>): PackwatchArguments {
return {
cwd: args.cwd ?? '.',
isUpdatingManifest: args.isUpdatingManifest ?? false,
}
}
export function convertSizeToBytes(sizeString: string): number {
const sizeSuffix = SIZE_SUFFIX_PATT.exec(sizeString)?.[1] ?? ''
const sizeMagnitude = SIZE_MAGNITUDE_PATT.exec(sizeString)?.[1] ?? '0.0'
let multiplier = 1
if (sizeSuffix === 'kB') multiplier = 1000
else if (sizeSuffix === 'mB') {
multiplier = 1000000
}
return multiplier * parseFloat(sizeMagnitude)
}
export function getCurrentPackageStats(cwd: string): Report {
const { stderr } = spawnSync('npm', ['pack', '--dry-run'], {
encoding: 'utf-8',
cwd,
})
const stderrString = String(stderr)
const packageSize = PACKAGE_SIZE_PATT.exec(stderrString)?.[1] ?? '0'
const unpackedSize = UNPACKED_SIZE_PATT.exec(stderrString)?.[1] ?? '0'
return {
packageSize,
unpackedSize,
packageSizeBytes: convertSizeToBytes(packageSize),
unpackedSizeBytes: convertSizeToBytes(unpackedSize),
}
}
export function getPreviousPackageStats(cwd: string): Report {
const manifestPath = resolve(join(cwd, MANIFEST_FILENAME))
try {
const currentManifest = readFileSync(manifestPath, {
encoding: 'utf-8',
})
const parsedManifest = JSON.parse(currentManifest)
return {
...parsedManifest,
packageSizeBytes: convertSizeToBytes(parsedManifest.packageSize),
unpackedSizeBytes: convertSizeToBytes(parsedManifest.unpackedSize),
limitBytes: convertSizeToBytes(parsedManifest.limit),
}
} catch {
/* No manifest */
return {
packageSize: '0',
packageSizeBytes: 0,
unpackedSizeBytes: 0,
unpackedSize: '0',
limitBytes: 0,
}
}
}
export function createOrUpdateManifest({
previous,
current,
manifestPath,
updateLimit = false,
}: {
previous?: Report
current: Report
manifestPath: string
updateLimit?: boolean
}): void {
const { limit } = previous || {}
const { packageSize, unpackedSize } = current
const newManifest = {
limit: updateLimit ? packageSize : limit || packageSize,
packageSize: packageSize,
unpackedSize: unpackedSize,
}
writeFileSync(manifestPath, JSON.stringify(newManifest))
}

View file

@ -1,234 +0,0 @@
import { promises as fs } from 'fs'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import packwatch from '../src'
import type { Report } from '../src/types'
let workspace: string | null
function getActualPackageSizeByNodeVersion(nodeVersion: string): string {
if (nodeVersion.startsWith('v14')) return '160'
else if (nodeVersion.startsWith('v16')) return '157'
else if (nodeVersion.startsWith('v18')) return '157'
return 'unknown'
}
async function prepareWorkspace(): Promise<string> {
const workspacePath = await fs.mkdtemp(`${tmpdir()}/`)
workspace = workspacePath
return workspacePath
}
async function cleanUpWorkspace(paths: string[]): Promise<void> {
await Promise.all(paths.map(async (path) => fs.rmdir(path, { recursive: true })))
}
async function createFile(path: string, content: string): Promise<void> {
await fs.writeFile(path, content)
}
async function createPackageJson(cwd: string): Promise<void> {
const path = resolve(join(cwd, 'package.json'))
await createFile(path, '{ "name": "wow", "version": "0.0.0", "files": ["!.packwatch.json"] }')
}
async function createManifest(cwd: string, configuration: Report): Promise<void> {
const path = resolve(join(cwd, '.packwatch.json'))
await createFile(path, JSON.stringify(configuration))
}
describe('Packwatch', () => {
const actualSize = getActualPackageSizeByNodeVersion(process.version)
afterEach(async () => {
jest.restoreAllMocks()
if (workspace) {
await cleanUpWorkspace([workspace])
workspace = null
}
})
it('warns the user and errors if run away from package.json', async () => {
const workspacePath = await prepareWorkspace()
const mockLogger = jest.spyOn(console, 'log')
await expect(async () => packwatch({ cwd: workspacePath })).rejects.toThrow('NOT_IN_PACKAGE_ROOT')
expect(mockLogger.mock.calls).toHaveLength(1)
expect(mockLogger.mock.calls[0][0]).toEqual(
expect.stringMatching('There is no package.json file here. Are you in the root directory of your project?'),
)
})
describe('without manifest', () => {
it('generates the initial manifest properly', async () => {
const workspacePath = await prepareWorkspace()
await createPackageJson(workspacePath)
await expect(async () => packwatch({ cwd: workspacePath })).rejects.toThrow('NO_MANIFEST_NO_UPDATE')
const generatedManifest = await fs.readFile(resolve(join(workspacePath, '.packwatch.json')), { encoding: 'utf8' })
expect(generatedManifest).toBe(
`{"limit":"${actualSize} B","packageSize":"${actualSize} B","unpackedSize":"68 B"}`,
)
})
it('outputs expected messaging', async () => {
const workspacePath = await prepareWorkspace()
const mockWarn = jest.spyOn(console, 'warn')
const mockError = jest.spyOn(console, 'error')
await createPackageJson(workspacePath)
await expect(async () => packwatch({ cwd: workspacePath })).rejects.toThrow()
expect(mockWarn.mock.calls).toHaveLength(1)
expect(mockWarn.mock.calls[0][0]).toEqual(
expect.stringMatching(
/No Manifest to compare against! Current package stats written to \.packwatch\.json!\nPackage size \(\d+ B\) adopted as new limit\./,
),
)
expect(mockError.mock.calls).toHaveLength(1)
expect(mockError.mock.calls[0][0]).toEqual(
expect.stringMatching(
'It looks like you ran PackWatch without a manifest. To prevent accidental passes in CI or hooks, packwatch will terminate with an error. If you are running packwatch for the first time in your project, this is expected!',
),
)
})
it('outputs expected messaging when not updating the manifest', async () => {
const mockWarn = jest.spyOn(console, 'warn')
const workspacePath = await prepareWorkspace()
await createPackageJson(workspacePath)
await packwatch({ cwd: workspacePath, isUpdatingManifest: true })
expect(mockWarn.mock.calls).toHaveLength(1)
expect(mockWarn.mock.calls[0][0]).toEqual(
expect.stringMatching(
/No Manifest to compare against! Current package stats written to \.packwatch\.json!\nPackage size \(\d+ B\) adopted as new limit\./,
),
)
})
})
describe('with manifest', () => {
it('messages when the size is equal to the limit', async () => {
const workspacePath = await prepareWorkspace()
const mockLogger = jest.spyOn(console, 'log')
await createPackageJson(workspacePath)
await createManifest(workspacePath, {
limit: `${actualSize}B`,
packageSize: `${actualSize}B`,
packageSizeBytes: Number(actualSize),
unpackedSize: '150B',
unpackedSizeBytes: 150,
})
await packwatch({ cwd: workspacePath })
expect(mockLogger.mock.calls).toHaveLength(1)
expect(mockLogger.mock.calls[0][0]).toEqual(
expect.stringMatching(/Nothing to report! Your package is the same size as the latest manifest reports!/),
)
})
it('messages when the size is lower than the limit (no growth)', async () => {
const workspacePath = await prepareWorkspace()
const mockLogger = jest.spyOn(console, 'log')
await createPackageJson(workspacePath)
await createManifest(workspacePath, {
limit: '170B',
packageSize: `${actualSize}B`,
packageSizeBytes: Number(actualSize),
unpackedSize: '150B',
unpackedSizeBytes: 150,
})
await packwatch({ cwd: workspacePath })
expect(mockLogger.mock.calls).toHaveLength(1)
expect(mockLogger.mock.calls[0][0]).toEqual(
expect.stringMatching(
/Nothing to report! Your package is the same size as the latest manifest reports! \(Limit: 170B\)/,
),
)
})
it('messages when the size is lower than the limit (growth)', async () => {
const workspacePath = await prepareWorkspace()
const mockLogger = jest.spyOn(console, 'log')
await createPackageJson(workspacePath)
await createManifest(workspacePath, {
limit: '180B',
packageSize: '150B',
packageSizeBytes: 150,
unpackedSize: '140B',
unpackedSizeBytes: 140,
})
await packwatch({ cwd: workspacePath })
expect(mockLogger.mock.calls).toHaveLength(1)
expect(mockLogger.mock.calls[0][0]).toEqual(
expect.stringMatching(/Your package grew! \d+ B > 150B \(Limit: 180B\)/),
)
})
it('messages when the size is lower than the limit (shrinkage)', async () => {
const workspacePath = await prepareWorkspace()
const mockLogger = jest.spyOn(console, 'log')
await createPackageJson(workspacePath)
await createManifest(workspacePath, {
limit: '180B',
packageSize: '170B',
packageSizeBytes: 170,
unpackedSize: '140B',
unpackedSizeBytes: 140,
})
await packwatch({ cwd: workspacePath })
expect(mockLogger.mock.calls).toHaveLength(1)
expect(mockLogger.mock.calls[0][0]).toEqual(
expect.stringMatching(/Your package shrank! \d+ B < 170B \(Limit: 180B\)/),
)
})
it('messages when the size exceeds the limit', async () => {
const workspacePath = await prepareWorkspace()
const mockError = jest.spyOn(console, 'error')
await createPackageJson(workspacePath)
await createManifest(workspacePath, {
limit: '10B',
packageSize: '170B',
packageSizeBytes: 170,
unpackedSize: '140B',
unpackedSizeBytes: 140,
})
await expect(async () => packwatch({ cwd: workspacePath })).rejects.toThrow('PACKAGE_EXCEEDS_LIMIT')
expect(mockError.mock.calls).toHaveLength(1)
expect(mockError.mock.calls[0][0]).toEqual(
expect.stringMatching(
/Your package exceeds the limit set in \.packwatch\.json! \d+ B > 10B\nEither update the limit by using the --update-manifest flag or trim down your packed files!/,
),
)
})
it('messages when updating the manifest', async () => {
const workspacePath = await prepareWorkspace()
const mockLogger = jest.spyOn(console, 'log')
await createPackageJson(workspacePath)
await createManifest(workspacePath, {
limit: '10B',
packageSize: '170B',
packageSizeBytes: 170,
unpackedSize: '140B',
unpackedSizeBytes: 140,
})
await packwatch({ cwd: workspacePath, isUpdatingManifest: true })
expect(mockLogger.mock.calls).toHaveLength(1)
expect(mockLogger.mock.calls[0][0]).toEqual(
expect.stringMatching(/Updated the manifest! Package size: \d+ B, Limit: \d+ B/),
)
})
})
})

View file

@ -1,12 +0,0 @@
import { convertSizeToBytes } from '../src/utils'
describe('utils', () => {
it.each`
initialSize | expectedSize
${'1 B'} | ${1}
${'1 kB'} | ${1000}
${'1 mB'} | ${1000000}
`('converts sizes properly ($initialSize -> $expectedSize)', ({ initialSize, expectedSize }) => {
expect(convertSizeToBytes(initialSize)).toEqual(expectedSize)
})
})

View file

@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"allowJs": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./",
"removeComments": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": [
"dist/**/*",
"tests/**/*"
]
}

12168
yarn.lock

File diff suppressed because it is too large Load diff