prod-files 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -20
  2. package/index.mjs +228 -62
  3. package/package.json +4 -6
package/README.md CHANGED
@@ -11,7 +11,7 @@ projects, or you’re just obsessed with small deployments.
11
11
 
12
12
  It's relatively fast, prunes
13
13
  [Sentry's `node_modules`](https://github.com/getsentry/sentry/blob/master/package.json)
14
- in 2.1s (M2 MacBook). Prod deps only though, installed with `pnpm i --prod`, but
14
+ in 1.8s (M2 MacBook). Prod deps only though, installed with `pnpm i --prod`, but
15
15
  that's the common use-case anyway.
16
16
 
17
17
  ## Install
@@ -62,7 +62,9 @@ Flags:
62
62
 
63
63
  -g, --globs Prints out the default globs.
64
64
 
65
- -n, --noSize Skips the size calc at the end, saves about 200-1000ms.
65
+ -n, --noSize Skips the size calculation.
66
+
67
+ -q, --quiet Quiet output, suppresses stdout.
66
68
  ```
67
69
 
68
70
  With a package manager:
@@ -122,42 +124,41 @@ RUN node pf.js my-app/foo/node_modules/.pnpm
122
124
 
123
125
  ## Development
124
126
 
125
- Unit tests are written with node's test utils.
126
-
127
127
  ```sh
128
- pnpm test
128
+ pnpm i
129
129
  ```
130
130
 
131
- There's also a `test-project` directory with dummy `package.json` with some
132
- random deps. You can run the script against it to see how it fairs in real usage
133
- and get some timing data.
131
+ ### Unit tests
134
132
 
135
- Set it up:
133
+ Unit tests are written with node's test utils.
136
134
 
137
135
  ```sh
138
- pnpm test:setup
136
+ pnpm test
139
137
  ```
140
138
 
141
- Run `prod-files` on it:
139
+ ### End to end tests
140
+
141
+ In `test-project` directory has Sentry's `package.json`. You can run the script
142
+ against it to see how it does in real-world use and get some timing data.
142
143
 
143
144
  ```sh
144
- pnpm test:prune
145
+ # Re-installs the packages and runs the script on it
146
+ pnpm test:e2e
147
+ # Disable size reportings since it adds 200-300ms
148
+ pnpm test:e2e --noSize
145
149
  ```
146
150
 
147
- It uses the real file system, so you need to reset it with `pnpm test:setup`
148
- before running another test.
149
-
150
- Or chain it for ease of use (with timing):
151
+ The nuke command removes `node_modules` and prunes the store:
151
152
 
152
153
  ```sh
153
- pnpm test:setup && time pnpm test:prune
154
+ pnpm test:e2e:nuke
154
155
  ```
155
156
 
156
- There's also a simple script to print the size of `test-project/node_modules/`
157
- using `du`:
157
+ There's also a simple script to print the weight of `test-project/node_modules/`
158
+ using `du`. You can run it before and after to see more detailed results:
158
159
 
159
160
  ```sh
160
- pnpm test:size
161
+ pnpm test:e2e:weight
161
162
  ```
162
163
 
163
164
  ## Prior art
package/index.mjs CHANGED
@@ -1,14 +1,17 @@
1
- import childProcess from 'node:child_process'
1
+ // oxlint-disable prefer-spread
2
+ import cp from 'node:child_process'
2
3
  import fs from 'node:fs/promises'
3
4
  import { matchesGlob, join, isAbsolute, resolve } from 'node:path'
4
- import { parseArgs, styleText } from 'node:util'
5
+ import { parseArgs, promisify, styleText } from 'node:util'
6
+
7
+ const exec = promisify(cp.exec)
5
8
 
6
9
  /**
7
10
  * A list of glob patterns for files/dirs to be deleted. The globs are matched
8
11
  * with node's `matchesGlob()`. With one special rule: globs which end in `/`
9
12
  * are marked as directories.
10
13
  *
11
- * Ordered by popularity (educated guess).
14
+ * Roughly ordered by popularity (educated guess).
12
15
  *
13
16
  * Partially based on
14
17
  * @see {@link https://github.com/duniul/clean-modules/blob/main/.cleanmodules-default}
@@ -21,16 +24,21 @@ const defaultGlobs = [
21
24
  '**/*.tsx',
22
25
  '**/doc{,s}/',
23
26
 
24
- // TypeScript
27
+ // Types
25
28
  '**/*tsconfig*.json',
26
29
  '**/*.tsbuildinfo',
30
+ '**/flow-typed/',
31
+
32
+ // Sensitive
33
+ '**/.env*',
27
34
 
28
35
  // Package mangers
29
36
  '**/.npm*',
30
- '**/pnpm-*.y{,a}ml',
37
+ '**/pnpm-{lock,workspace}.yaml',
31
38
  '**/.yarn*',
32
39
  '**/yarn.lock',
33
40
  '**/bun.lock',
41
+ '**/bunfig.toml',
34
42
 
35
43
  // IDE
36
44
  '**/.idea/',
@@ -50,6 +58,13 @@ const defaultGlobs = [
50
58
  // CI/CD
51
59
  '**/.github/',
52
60
  '**/.circleci/',
61
+ '**/.vercel',
62
+ '**/now.json',
63
+ '**/.travis.yml',
64
+
65
+ // Docker
66
+ '**/Dockerfile*',
67
+ '**/.dockerignore',
53
68
 
54
69
  // Tests
55
70
  '**/test{,s}/',
@@ -60,31 +75,42 @@ const defaultGlobs = [
60
75
  '**/karma.conf.{js,ts}',
61
76
  '**/wallaby.conf.{js,ts}',
62
77
  '**/wallaby.{js,ts}',
63
-
64
- // Build tools
65
- '**/gemfile',
78
+ '**/playwright.config.{js,ts}',
79
+ '**/.mocharc*',
80
+
81
+ // Build/bundle config
82
+ '**/{rollup,rolldown,vite}.config.{js,ts,mjs}',
83
+ '**/webpack.config.{js,mjs,cjs,ts}',
84
+ '**/babel.config.{js,mjs,cjs,json}',
85
+ '**/parcel.config.{js,ts,json}',
86
+ '**/rspack.config.{js,mjs,cjs,ts}',
87
+ '**/.babelrc*',
88
+ '**/turbo.json',
89
+ '**/.browserslist*',
90
+ '**/metro.config.{js,json}',
66
91
  '**/{G,g}runtfile.{js,ts}',
67
92
  '**/{G,g}ulpfile.{js,ts}',
68
93
  '**/{M,m}akefile',
94
+ '**/gemfile',
69
95
 
70
96
  // Images
71
97
  '**/*.jp{,e}g',
72
- '**/*.png',
73
- '**/*.gif',
74
98
  '**/*.svg',
99
+ '**/*.gif',
100
+ '**/*.png',
75
101
 
76
102
  // Linters and formatters
77
- '**/.jshintrc',
78
- '**/.lint',
79
- '**/.prettier*',
80
- '**/prettier.config*',
81
- '**/biome.json{,c}',
82
- '**/tslint.json',
83
- '**/.eslintrc',
84
103
  '**/eslint*.{json,jsonc,ts}',
104
+ '**/.eslintrc',
105
+ '**/prettier.config*',
106
+ '**/.prettier*',
85
107
  '**/.ox{lint,fmt}rc.json{,c}',
86
108
  '**/ox{lint,fmt}*.{json,jsonc,ts}',
109
+ '**/biome.json{,c}',
87
110
  '**/.dprint.json{,c}',
111
+ '**/.jshintrc',
112
+ '**/.lint',
113
+ '**/tslint.json',
88
114
 
89
115
  // Git
90
116
  '**/.git/',
@@ -93,7 +119,7 @@ const defaultGlobs = [
93
119
 
94
120
  // Code coverage
95
121
  '**/.nyc_output/',
96
- '**/.nycrc',
122
+ '**/.nycrc*',
97
123
  '**/.codecov.y{,a}ml',
98
124
  '**/coverage/',
99
125
 
@@ -123,6 +149,7 @@ const defaultGlobs = [
123
149
  '**/*.coffee',
124
150
 
125
151
  // Misc
152
+ '**/.jscpd',
126
153
  '**/*.jst',
127
154
  '**/*.log',
128
155
  '**/*.mkd',
@@ -188,10 +215,12 @@ function usage() {
188
215
 
189
216
  -g, --globs Prints out the default globs.
190
217
 
191
- -n, --noSize Skips the size calc at the end, saves about 200-1000ms.
218
+ -n, --noSize Skips the size calculation.
219
+
220
+ -q, --quiet Quiet output, suppresses stdout.
192
221
  `
193
222
 
194
- console.log(usageText)
223
+ log.log(usageText)
195
224
  }
196
225
 
197
226
  /**
@@ -213,29 +242,70 @@ function bail(message, error, withUsage = false) {
213
242
 
214
243
  /**
215
244
  * @typedef {Object} Logger
216
- * @property {( ...args: any[] ) => void} info - Logs information messages in blue
217
- * @property {( ...args: any[] ) => void} error - Logs error messages in red
218
- * @property {( ...args: any[] ) => void} success - Logs success messages in green
245
+ * @property {typeof console.error} error - Logs error messages in red
246
+ * @property {typeof console.info} info - Logs information messages in blue
247
+ * @property {typeof console.info} log - Logs with no color
248
+ * @property {typeof console.log} success - Logs success messages in green
249
+ * @property {typeof console.table} table - Logs as table
219
250
  */
220
251
 
252
+ // Quiet mode
253
+ let quiet = /** @type {boolean} */ (false)
254
+
255
+ /**
256
+ * @param {import('node:util').InspectColor} color
257
+ * @param {any[]} args
258
+ * @returns
259
+ */
260
+ const style = (color, args) => args.map(a => styleText(color, String(a)))
261
+
221
262
  /**
222
263
  * A utility for styled console logs
223
264
  * @type {Logger}
224
265
  */
225
- const log = {
226
- info: (...x) => console.info(styleText('blue', x.join(' '))),
227
- error: (...x) => console.error(styleText('red', x.join(' '))),
228
- success: (...x) => console.log(styleText('green', x.join(' '))),
266
+ export const log = {
267
+ error: (...a) =>
268
+ quiet ? undefined : console.error.apply(console, style('red', a)),
269
+ info: (...a) =>
270
+ quiet ? undefined : console.info.apply(console, style('blue', a)),
271
+ log: (...a) => (quiet ? undefined : console.log.apply(console, a)),
272
+ success: (...a) =>
273
+ quiet ? undefined : console.log.apply(console, style('green', a)),
274
+ table: (...a) => (quiet ? undefined : console.table.apply(console, a)),
275
+ }
276
+
277
+ /**
278
+ * Get disk usage via du (512-byte blocks)
279
+ * @param {string} dirPath
280
+ * @returns {Promise<number>}
281
+ */
282
+ async function getSize(dirPath) {
283
+ const { stdout, stderr } = await exec(`LC_ALL=C du -s ${dirPath}`)
284
+ if (stderr.length > 0) bail(stderr)
285
+ const size = stdout.split('\t')[0]
286
+ return size ? Number.parseInt(size, 10) : 0
229
287
  }
230
288
 
231
289
  /**
232
- * Get size of node_modules
233
- * @param {string} dirPath - Path to node_modules
234
- * @returns {number}
290
+ * Sums disk usage of a path using lstat blocks (512-byte blocks, same as du)
291
+ * @param {string} path
292
+ * @returns {Promise<number>} Total in 512-byte blocks
235
293
  */
236
- function getSize(dirPath) {
237
- const stdout = childProcess.execSync(`du -s ${dirPath} | awk '{print $1}'`)
238
- return Number(stdout)
294
+ async function treeSize(path) {
295
+ /** @type {import('node:fs').Stats} */
296
+ let stat
297
+ try {
298
+ stat = await fs.lstat(path)
299
+ } catch {
300
+ // Entry disappeared (concurrent pruning), count as 0
301
+ return 0
302
+ }
303
+ if (!stat.isDirectory()) return stat.blocks
304
+ const names = await fs.readdir(path).catch(() => [])
305
+ const sizes = await Promise.all(names.map(n => treeSize(join(path, n))))
306
+ let total = 0
307
+ for (const s of sizes) total += s
308
+ return total
239
309
  }
240
310
 
241
311
  /**
@@ -253,16 +323,23 @@ function calcSize(originalSize, prunedSize) {
253
323
  /**
254
324
  * Prints a nice diff table
255
325
  * @param {object} opts
256
- * @param {number | undefined} opts.prunedSize
326
+ * @param {number | undefined} opts.removedBytes
257
327
  * @param {number} opts.startTime
258
328
  * @param {number} opts.itemCount
259
329
  * @param {number | undefined} opts.originalSize
260
330
  */
261
- export function printDiff({ prunedSize, startTime, itemCount, originalSize }) {
262
- console.table([
331
+ export function printDiff({
332
+ removedBytes,
333
+ startTime,
334
+ itemCount,
335
+ originalSize,
336
+ }) {
337
+ log.table([
263
338
  {
264
- Pruned:
265
- originalSize && prunedSize ? calcSize(originalSize, prunedSize) : 'n/a',
339
+ ...(originalSize &&
340
+ removedBytes && {
341
+ Pruned: calcSize(originalSize, originalSize - removedBytes),
342
+ }),
266
343
  Time: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
267
344
  Items: itemCount,
268
345
  },
@@ -278,12 +355,14 @@ export function printDiff({ prunedSize, startTime, itemCount, originalSize }) {
278
355
  * @property {boolean} help - Prints help
279
356
  * @property {boolean} noSize - Don't show size savings
280
357
  * @property {boolean} globs - Prints globs
358
+ * @property {boolean} quiet - Suppress console.log output
281
359
  */
282
360
 
283
361
  /**
284
362
  * Parse the command-line arguments into an object
285
363
  * @returns {Args}
286
364
  */
365
+
287
366
  function handleArgs() {
288
367
  try {
289
368
  const {
@@ -297,6 +376,7 @@ function handleArgs() {
297
376
  help: { type: 'boolean', short: 'h', default: false },
298
377
  globs: { type: 'boolean', short: 'g', default: false },
299
378
  noSize: { type: 'boolean', short: 'n', default: false },
379
+ quiet: { type: 'boolean', short: 'q', default: false },
300
380
  },
301
381
  })
302
382
 
@@ -326,14 +406,6 @@ export async function validateNodeModulesPath(nodeModulesPath) {
326
406
  }
327
407
  }
328
408
 
329
- /**
330
- * Removes a directory or a file
331
- * @param {string} file - the file or dir to remove
332
- */
333
- async function rimraf(file) {
334
- await fs.rm(file, { recursive: true, force: true })
335
- }
336
-
337
409
  /**
338
410
  * `file.matchesGlob()` does not match dotfiles, this util replaces leading dots
339
411
  * with an underscore
@@ -643,6 +715,104 @@ export function compactPaths(paths) {
643
715
  return compact
644
716
  }
645
717
 
718
+ /**
719
+ * @typedef {object} WalkResult
720
+ * @property {string[]} removed - Compacted list of removed paths
721
+ * @property {number} removedBlocks - Removed disk usage in 512-byte blocks
722
+ */
723
+
724
+ /**
725
+ * Parallel walker that finds junk, removes it, and cleans empty dirs in one
726
+ * pass. Skips recursing into junk directories (implicit path compacting) and
727
+ * removes empty ancestors bottom-up as the recursion unwinds.
728
+ * @param {string} rootDir - The directory to walk
729
+ * @param {CompiledGlobs} compiledGlobs - Precompiled glob matchers
730
+ * @param {boolean} trackSize - Whether to collect byte sizes of removed items
731
+ * @returns {Promise<WalkResult>}
732
+ */
733
+ async function walkAndPrune(rootDir, compiledGlobs, trackSize) {
734
+ /** @type {string[]} */
735
+ const removed = []
736
+ let removedBlocks = 0
737
+ const hasAnyGlobs = compiledGlobs.any.globs.length > 0
738
+ const hasDirGlobs = compiledGlobs.dir.globs.length > 0
739
+
740
+ /**
741
+ * Walks a directory in parallel, removes junk, and reports whether the
742
+ * directory still has content so the caller can clean up empty parents
743
+ * @param {string} dir
744
+ * @returns {Promise<boolean>} true when the directory still has content
745
+ */
746
+ async function walkDir(dir) {
747
+ const entries = await fs.readdir(dir, { withFileTypes: true })
748
+
749
+ /** @type {string[]} */
750
+ const junkPaths = []
751
+ /** @type {string[]} */
752
+ const keptDirPaths = []
753
+ let keptFiles = 0
754
+
755
+ for (const entry of entries) {
756
+ const { name } = entry
757
+ const isDir = entry.isDirectory()
758
+ const path = join(dir, name)
759
+
760
+ // Basename checks are cheapest, try them first
761
+ if (
762
+ matchesSet(name, compiledGlobs.any) ||
763
+ (isDir && matchesSet(name, compiledGlobs.dir))
764
+ ) {
765
+ junkPaths.push(path)
766
+ continue
767
+ }
768
+
769
+ // Full path glob checks only when compiled globs exist
770
+ if (hasAnyGlobs || (isDir && hasDirGlobs)) {
771
+ const escapedPath = escapeLeadingDots(isDir ? `${path}/` : path)
772
+ if (
773
+ compiledGlobs.any.globs.some(g => matchesGlob(escapedPath, g)) ||
774
+ (isDir &&
775
+ compiledGlobs.dir.globs.some(g => matchesGlob(escapedPath, g)))
776
+ ) {
777
+ junkPaths.push(path)
778
+ continue
779
+ }
780
+ }
781
+
782
+ if (isDir) keptDirPaths.push(path)
783
+ else keptFiles += 1
784
+ }
785
+
786
+ // Collect removed paths before awaiting (no junk dir recursion = compacting)
787
+ for (const p of junkPaths) removed.push(p)
788
+
789
+ // Size (when tracking), remove junk, and recurse kept subdirs in parallel
790
+ const [junkSizes, walkResults] = await Promise.all([
791
+ Promise.all(
792
+ junkPaths.map(async p => {
793
+ const size = trackSize ? await treeSize(p) : 0
794
+ await fs.rm(p, { recursive: true, force: true })
795
+ return size
796
+ })
797
+ ),
798
+ Promise.all(keptDirPaths.map(walkDir)),
799
+ ])
800
+
801
+ for (const s of junkSizes) removedBlocks += s
802
+
803
+ // Subdirs that became empty after pruning their contents
804
+ const emptyDirs = keptDirPaths.filter((_, i) => !walkResults[i])
805
+ if (emptyDirs.length > 0) {
806
+ await Promise.all(emptyDirs.map(d => fs.rmdir(d)))
807
+ }
808
+
809
+ return keptFiles + keptDirPaths.length - emptyDirs.length > 0
810
+ }
811
+
812
+ await walkDir(rootDir)
813
+ return { removed, removedBlocks }
814
+ }
815
+
646
816
  /**
647
817
  * @typedef {Args & { path: string }} ArgsWithRequiredPath
648
818
  */
@@ -655,36 +825,30 @@ export async function prune(opts) {
655
825
  const startTime = Date.now()
656
826
  log.info('Pruning:', opts.path)
657
827
 
658
- const originalSize = opts.noSize ? undefined : getSize(opts.path)
828
+ // Fire early so du runs concurrently with the walk
829
+ const sizePromise = opts.noSize ? undefined : getSize(opts.path)
659
830
  const excludedGlobs = new Set(opts.exclude)
660
831
  const activeGlobs = [...defaultGlobs, ...opts.include].filter(
661
832
  glob => !excludedGlobs.has(glob)
662
833
  )
663
834
  const compiledGlobs = compileGlobs(activeGlobs)
664
835
 
665
- // This could be slightly faster with optimized walker
666
- const allFiles = await fs.readdir(opts.path, {
667
- recursive: true,
668
- withFileTypes: true,
669
- })
670
-
671
- const junkFiles = findJunkFiles(allFiles, compiledGlobs)
672
- const results = compactPaths(junkFiles)
673
-
836
+ /** @type {WalkResult} */
837
+ let result
674
838
  try {
675
- await Promise.all(results.map(x => rimraf(x)))
839
+ result = await walkAndPrune(opts.path, compiledGlobs, !opts.noSize)
676
840
  } catch (err) {
677
841
  throw bail(undefined, err)
678
842
  }
679
843
 
680
844
  printDiff({
681
- itemCount: results.length,
682
- prunedSize: opts.noSize ? undefined : getSize(opts.path),
683
- originalSize,
845
+ itemCount: result.removed.length,
846
+ removedBytes: opts.noSize ? undefined : result.removedBlocks,
847
+ originalSize: sizePromise ? await sizePromise : undefined,
684
848
  startTime,
685
849
  })
686
850
 
687
- return results
851
+ return result.removed
688
852
  }
689
853
 
690
854
  const entry = process.argv[1]
@@ -694,13 +858,15 @@ const runAsScript =
694
858
  if (runAsScript) {
695
859
  const args = handleArgs()
696
860
 
861
+ quiet = args.quiet
862
+
697
863
  if (args.help) {
698
864
  usage()
699
865
  process.exit(0)
700
866
  }
701
867
 
702
868
  if (args.globs) {
703
- console.log(JSON.stringify(defaultGlobs, null, 2))
869
+ log.log(JSON.stringify(defaultGlobs, null, 2))
704
870
  process.exit(0)
705
871
  }
706
872
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prod-files",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Keep only prod files by pruning non-prod files from node_modules before deploying",
5
5
  "keywords": [
6
6
  "clean",
@@ -40,10 +40,8 @@
40
40
  "pf": "node ./index.mjs",
41
41
  "pub": "(npm whoami || npm login) && pnpm publish --access=public --tag=latest",
42
42
  "test": "node --test",
43
- "test:nuke": "rm -rf ./test-project/node_modules ./test-project/pnpm-lock.yaml",
44
- "test:run": "cd test-project && time node ../index.mjs node_modules/.pnpm",
45
- "test:setup": "node --run test:nuke && pnpm -C=test-project i --prod",
46
- "test:size": "du -sh ./test-project/node_modules/.pnpm | sort -h",
47
- "test:watch": "node --test --watch"
43
+ "test:e2e": "cd test-project && bash prepare_test.sh && time node ../index.mjs node_modules/.pnpm",
44
+ "test:e2e:nuke": "trash ./test-project/node_modules ./test-project/pnpm-lock.yaml && pnpm store prune",
45
+ "test:e2e:weight": "du -sh ./test-project/node_modules/.pnpm | sort -h"
48
46
  }
49
47
  }