Initial upload #1
10 changed files with 4763 additions and 0 deletions
6
.eslintrc.js
Normal file
6
.eslintrc.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
extends: [
|
||||
"@tophat/eslint-config/base",
|
||||
"@tophat/eslint-config/jest",
|
||||
]
|
||||
}
|
1
.packwatch.json
Normal file
1
.packwatch.json
Normal file
|
@ -0,0 +1 @@
|
|||
{"limit":"3.5 kB","packageSize":"3.5 kB","unpackedSize":"8.6 kB"}
|
40
README.md
Normal file
40
README.md
Normal file
|
@ -0,0 +1,40 @@
|
|||
# PackWatch
|
||||
|
||||
> It ain't easy being tiny.
|
||||
|
||||
## Overview
|
||||
|
||||
`packwatch` is inspired by what projects like [`bundlewatch`](https://github.com/bundlewatch/bundlewatch) do for webpack bundle size monitoring and applies the same general idea to monitor your node packages' tarball sizes across time and help avoid incremental bloat. Keeping your applications as trim as possible is important to provide better experiences to users and to avoid wasting system resources, and being cognizant of the footprint of the packages you put out there is paramount.
|
||||
|
||||
Using `packwatch`, you can track your package's expected size, packed and unpacked, via a manifest comitted along with your code. You can use it to define an upper limit for your package's size and validate that increases in package footprint are warranted and not accidental.
|
||||
|
||||
## Installation
|
||||
|
||||
Installing `packwatch` is easy as pie:
|
||||
|
||||
```
|
||||
yarn add packwatch -D
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
npm install packwatch --dev
|
||||
```
|
||||
|
||||
While you can install `packwatch` as a global package, it's better to include it as a devDependency in your project.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
`packwatch` tracks your packages' size via its `.packwatch.json` manifest. To get started, call `packwatch` at the root of your project: a fresh manifest will be generated for you using your current package's size as the initial upper limit for package size.
|
||||
|
||||
Once a manifest file exists, calling `packwatch` again will compare its data to the current state of your package. Every time `packwatch` compares your code to the manifest, it will update the last reported package size statistics it contains, but not the limit you have set.
|
||||
|
||||
At any time, you can update the limit specified in your manifest by using the `--update-manifest` flag:
|
||||
|
||||
```
|
||||
packwatch --update-manifest
|
||||
```
|
||||
|
||||
Just commit your `.packwatch.json` manifest and you're good to go!
|
4
babel.config.json
Normal file
4
babel.config.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"comments": false,
|
||||
"ignore": ["**/*.test.js"]
|
||||
}
|
3
jest.config.js
Normal file
3
jest.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
transformIgnorePatterns: ['.test.js'],
|
||||
}
|
57
package.json
Normal file
57
package.json
Normal file
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "packwatch",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"description": "📦👀 Keep an eye on your packages' footprint",
|
||||
"keywords": [
|
||||
"npm",
|
||||
"footprint",
|
||||
"package size",
|
||||
"package",
|
||||
"publish",
|
||||
"dependencies"
|
||||
],
|
||||
"homepage": "https://github.com/mcataford/packwatch#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/mcataford/packwatch/issues",
|
||||
"email": "c.marcandre@gmail.com"
|
||||
},
|
||||
"author": "Marc Cataford <c.marcandre@gmail.com>",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist/**/*.js"
|
||||
],
|
||||
"bin": "./dist/index.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mcataford/packwatch.git"
|
||||
},
|
||||
"pre-commit": [
|
||||
"lint",
|
||||
"test"
|
||||
],
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.8.4",
|
||||
"@babel/core": "^7.8.6",
|
||||
"@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"
|
||||
}
|
||||
}
|
73
src/helpers.js
Normal file
73
src/helpers.js
Normal 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 DIGEST_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 currentDigest = readFileSync(DIGEST_FILENAME, FS_OPTIONS)
|
||||
const parsedDigest = JSON.parse(currentDigest)
|
||||
return {
|
||||
...parsedDigest,
|
||||
packageSizeBytes: convertSizeToBytes(parsedDigest.packageSize),
|
||||
unpackedSizeBytes: convertSizeToBytes(parsedDigest.unpackedSize),
|
||||
limitBytes: convertSizeToBytes(parsedDigest.limit),
|
||||
}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function createOrUpdateDigest({ previous, current, updateLimit = false }) {
|
||||
const { limit } = previous || {}
|
||||
const { packageSize, unpackedSize } = current
|
||||
|
||||
const newDigest = {
|
||||
limit: updateLimit ? packageSize : limit || packageSize,
|
||||
packageSize: packageSize,
|
||||
unpackedSize: unpackedSize,
|
||||
}
|
||||
|
||||
writeFileSync(DIGEST_FILENAME, JSON.stringify(newDigest))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOrUpdateDigest,
|
||||
getPreviousPackageStats,
|
||||
getCurrentPackageStats,
|
||||
convertSizeToBytes,
|
||||
DIGEST_FILENAME,
|
||||
}
|
98
src/index.js
Normal file
98
src/index.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const { existsSync } = require('fs')
|
||||
|
||||
const {
|
||||
DIGEST_FILENAME,
|
||||
getCurrentPackageStats,
|
||||
getPreviousPackageStats,
|
||||
createOrUpdateDigest,
|
||||
} = 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 isUpdatingDigest = process.argv.includes('--update-digest')
|
||||
|
||||
const currentStats = getCurrentPackageStats()
|
||||
|
||||
/*
|
||||
* If there is no digest file yet, we can use the current package stats as
|
||||
* a base to build one. The current package size becomes the limit.
|
||||
*/
|
||||
|
||||
if (!existsSync(DIGEST_FILENAME)) {
|
||||
createOrUpdateDigest({ current: currentStats })
|
||||
console.log(
|
||||
`📝 No digest to compare against! Current package stats written to ${DIGEST_FILENAME}!`,
|
||||
)
|
||||
console.log(
|
||||
`Package size (${currentStats.packageSize}) adopted as new limit.`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const previousStats = getPreviousPackageStats()
|
||||
const { packageSizeBytes, packageSize } = currentStats
|
||||
const {
|
||||
packageSize: previousSize,
|
||||
packageSizeBytes: previousSizeBytes,
|
||||
limit,
|
||||
limitBytes,
|
||||
} = previousStats
|
||||
const hasExceededLimit = packageSizeBytes > limitBytes
|
||||
|
||||
/*
|
||||
* If we are updating the digest, we can write right away and terminate.
|
||||
*/
|
||||
|
||||
if (isUpdatingDigest) {
|
||||
createOrUpdateDigest({
|
||||
previous: previousStats,
|
||||
current: currentStats,
|
||||
updateLimit: true,
|
||||
})
|
||||
console.log(
|
||||
`📝 Updated the digest! Package size: ${packageSize}, Limit: ${packageSize}`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
/*
|
||||
* If there is a digest 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 ${DIGEST_FILENAME}! ${packageSize} > ${limit}`,
|
||||
)
|
||||
console.log(
|
||||
'Either update the limit by using the --update-digest flag or trim down your packed files!',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/*
|
||||
* If there is a digest 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
177
src/index.test.js
Normal 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 {
|
||||
DIGEST_FILENAME,
|
||||
convertSizeToBytes,
|
||||
getCurrentPackageStats,
|
||||
getPreviousPackageStats,
|
||||
createOrUpdateDigest,
|
||||
} = 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({ [DIGEST_FILENAME]: JSON.stringify(mockReport) })
|
||||
|
||||
expect(getPreviousPackageStats()).toEqual({
|
||||
packageSize,
|
||||
packageSizeBytes,
|
||||
unpackedSize,
|
||||
unpackedSizeBytes,
|
||||
limit,
|
||||
limitBytes,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an empty digest if it fails to reads the digest file', () => {
|
||||
mockFS({
|
||||
[DIGEST_FILENAME]: 'not valid JSON',
|
||||
})
|
||||
|
||||
expect(getPreviousPackageStats()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Creating or updating the digest', () => {
|
||||
const currentStats = {
|
||||
packageSize: '1 kB',
|
||||
unpackedSize: '10 kB',
|
||||
}
|
||||
|
||||
const previousDigest = {
|
||||
limit: '2 kB',
|
||||
packageSize: '1.5 kB',
|
||||
}
|
||||
it('creates a digest from the current data if no previous data is provided', () => {
|
||||
mockFS({})
|
||||
|
||||
createOrUpdateDigest({ current: currentStats })
|
||||
|
||||
const writtenDigest = readFileSync(DIGEST_FILENAME, {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
expect(JSON.parse(writtenDigest)).toEqual({
|
||||
packageSize: currentStats.packageSize,
|
||||
unpackedSize: currentStats.unpackedSize,
|
||||
limit: currentStats.packageSize,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the previous digest sizes if previous data exists', () => {
|
||||
mockFS({
|
||||
[DIGEST_FILENAME]: JSON.stringify(previousDigest),
|
||||
})
|
||||
|
||||
createOrUpdateDigest({
|
||||
current: currentStats,
|
||||
previous: previousDigest,
|
||||
updateLimit: false,
|
||||
})
|
||||
|
||||
const writtenDigest = readFileSync(DIGEST_FILENAME, {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
expect(JSON.parse(writtenDigest)).toEqual({
|
||||
packageSize: currentStats.packageSize,
|
||||
unpackedSize: currentStats.unpackedSize,
|
||||
limit: previousDigest.limit,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the previous digest sizes and limit if previous data exists and updateLimit is set', () => {
|
||||
mockFS({
|
||||
[DIGEST_FILENAME]: JSON.stringify(previousDigest),
|
||||
})
|
||||
|
||||
createOrUpdateDigest({
|
||||
current: currentStats,
|
||||
previous: previousDigest,
|
||||
updateLimit: true,
|
||||
})
|
||||
|
||||
const writtenDigest = readFileSync(DIGEST_FILENAME, {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
|
||||
expect(JSON.parse(writtenDigest)).toEqual({
|
||||
packageSize: currentStats.packageSize,
|
||||
unpackedSize: currentStats.unpackedSize,
|
||||
limit: currentStats.packageSize,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
Reference in a new issue