prod-files 0.1.0
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.
- package/LICENCE +21 -0
- package/README.md +167 -0
- package/index.mjs +704 -0
- package/package.json +49 -0
package/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# prod-files
|
|
2
|
+
|
|
3
|
+
Keep only production related files in `node_modules`, remove files which are not
|
|
4
|
+
needed to run the app in production, so your final Docker images is smaller and
|
|
5
|
+
you spend less time and resources zooting ballast over internet.
|
|
6
|
+
|
|
7
|
+
Cuts anything from 10 to 70+ percent of weight, largely depending on how many
|
|
8
|
+
source map files you have, which is usually the bulk of the weight. Comes handy
|
|
9
|
+
if you’re dealing with limited resources or work at a scale of thousands of
|
|
10
|
+
projects, or you’re just obsessed with small deployments.
|
|
11
|
+
|
|
12
|
+
It's relatively fast, prunes
|
|
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
|
|
15
|
+
that's the common use-case anyway.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add prod-files
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
It’s a single JavaScript file with no deps, so you can easily copy it to your
|
|
24
|
+
project if you don’t want to install it.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Examples:
|
|
30
|
+
Basic usage:
|
|
31
|
+
$ prod-files node_modules/.pnpm
|
|
32
|
+
Short:
|
|
33
|
+
$ pf node_modules/.pnpm
|
|
34
|
+
|
|
35
|
+
Since we’re just raw-dogging parseArgs, the short args don’t support inline
|
|
36
|
+
arguments, so don't use equals signs:
|
|
37
|
+
$ pf -i "**/foo" -e "**/*tsconfig*.json" node_modules/.pnpm
|
|
38
|
+
|
|
39
|
+
Also with short-hand args the space between the key and the value can be
|
|
40
|
+
omitted:
|
|
41
|
+
$ pf -i"**/foo" node_modules/.pnpm
|
|
42
|
+
|
|
43
|
+
Usage:
|
|
44
|
+
prod-files [flags] path
|
|
45
|
+
pf [flags] path
|
|
46
|
+
|
|
47
|
+
Arguments:
|
|
48
|
+
path Relative or absolute path to node_modules directory:
|
|
49
|
+
- pnpm: 'node_modules/.pnpm'
|
|
50
|
+
- npm: 'node_modules'
|
|
51
|
+
- yarn: 'node_modules' or 'node_modules/.store'
|
|
52
|
+
|
|
53
|
+
Flags:
|
|
54
|
+
-i, --include Glob patterns of extra files to be removed. Uses node's
|
|
55
|
+
path.matchesGlob(), with one exception: patterns ending with
|
|
56
|
+
slash '**/foo/' are marked as directories.
|
|
57
|
+
|
|
58
|
+
-e, --exclude Exclude existing glob patterns if the script is too
|
|
59
|
+
aggressive. Must be exact match.
|
|
60
|
+
|
|
61
|
+
-h, --help Prints out the help.
|
|
62
|
+
|
|
63
|
+
-g, --globs Prints out the default globs.
|
|
64
|
+
|
|
65
|
+
-n, --noSize Skips the size calc at the end, saves about 200-1000ms.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
With a package manager:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pnpm prod-files node_modules/.pnpm
|
|
72
|
+
# Short
|
|
73
|
+
pnpm pf node_modules/.pnpm
|
|
74
|
+
# pnpx/npx
|
|
75
|
+
pnpx prod-files node_modules/.pnpm
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Different package manager `node_modules` paths:
|
|
79
|
+
|
|
80
|
+
| Manager | Linker | Path | Description |
|
|
81
|
+
| ------- | ------------ | --------------------- | --------------- |
|
|
82
|
+
| pnpm | - | `node_modules/.pnpm` | hard-linked |
|
|
83
|
+
| npm | - | `node_modules` | the good old |
|
|
84
|
+
| yarn v1 | - | `node_modules` | the good old |
|
|
85
|
+
| yarn | node-modules | `node_modules` | the good old |
|
|
86
|
+
| yarn | pnpm | `node_modules/.store` | same as pnpm |
|
|
87
|
+
| yarn | pnp | no-op | no node_modules |
|
|
88
|
+
|
|
89
|
+
### Dockerfile example
|
|
90
|
+
|
|
91
|
+
Simple yet somewhat realistic example usage in Dockerfile for an app named `foo`
|
|
92
|
+
using pnpm:
|
|
93
|
+
|
|
94
|
+
```dockerfile
|
|
95
|
+
FROM node:lts-alpine3.19 AS base
|
|
96
|
+
WORKDIR /usr/src/app
|
|
97
|
+
COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
98
|
+
RUN pnpm fetch
|
|
99
|
+
COPY . ./
|
|
100
|
+
RUN pnpm i --offline --frozen-lockfile
|
|
101
|
+
RUN pnpm build
|
|
102
|
+
RUN pnpm -F=foo --prod deploy /my-app/foo
|
|
103
|
+
# Run it as the last command of the build step. NOTE: if you installed with
|
|
104
|
+
# --prod flag, prod-files needs to be a prod dep. Or use pnpx/npx/yarn dlx
|
|
105
|
+
RUN pnpm prod-files my-app/foo/node_modules/.pnpm
|
|
106
|
+
|
|
107
|
+
# Enjoy your new slimmer image
|
|
108
|
+
FROM node:lts-alpine3.19 AS foo
|
|
109
|
+
COPY --from=base /my-app/foo /my-app/foo
|
|
110
|
+
WORKDIR /myapp/foo
|
|
111
|
+
CMD node build/server.js
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Or use wget in if you don't have a package manager in your env (there are
|
|
115
|
+
certain risks involved when you execute files downloaded from the net, if I get
|
|
116
|
+
comprised that file can have anything):
|
|
117
|
+
|
|
118
|
+
```dockerfile
|
|
119
|
+
RUN wget -O pf.js https://raw.githubusercontent.com/hilja/prod-files/refs/heads/main/index.mjs
|
|
120
|
+
RUN node pf.js my-app/foo/node_modules/.pnpm
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
Unit tests are written with node's test utils.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
pnpm test
|
|
129
|
+
```
|
|
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.
|
|
134
|
+
|
|
135
|
+
Set it up:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
pnpm test:setup
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Run `prod-files` on it:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
pnpm test:prune
|
|
145
|
+
```
|
|
146
|
+
|
|
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
|
+
|
|
152
|
+
```sh
|
|
153
|
+
pnpm test:setup && time pnpm test:prune
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
There's also a simple script to print the size of `test-project/node_modules/`
|
|
157
|
+
using `du`:
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
pnpm test:size
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Prior art
|
|
164
|
+
|
|
165
|
+
- [npmprune](https://github.com/xthezealot/npmprune) (bash)
|
|
166
|
+
- [node-prune](https://github.com/tuananh/node-prune) (go)
|
|
167
|
+
- [clean-modules](https://github.com/duniul/clean-modules) (node)
|
package/index.mjs
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import childProcess from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import { matchesGlob, join, isAbsolute, resolve } from 'node:path'
|
|
4
|
+
import { parseArgs, styleText } from 'node:util'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A list of glob patterns for files/dirs to be deleted. The globs are matched
|
|
8
|
+
* with node's `matchesGlob()`. With one special rule: globs which end in `/`
|
|
9
|
+
* are marked as directories.
|
|
10
|
+
*
|
|
11
|
+
* Ordered by popularity (educated guess).
|
|
12
|
+
*
|
|
13
|
+
* Partially based on
|
|
14
|
+
* @see {@link https://github.com/duniul/clean-modules/blob/main/.cleanmodules-default}
|
|
15
|
+
*/
|
|
16
|
+
const defaultGlobs = [
|
|
17
|
+
// Common ones first
|
|
18
|
+
'**/*.md',
|
|
19
|
+
'**/*.map',
|
|
20
|
+
'**/*.{,m,c}ts',
|
|
21
|
+
'**/*.tsx',
|
|
22
|
+
'**/doc{,s}/',
|
|
23
|
+
|
|
24
|
+
// TypeScript
|
|
25
|
+
'**/*tsconfig*.json',
|
|
26
|
+
'**/*.tsbuildinfo',
|
|
27
|
+
|
|
28
|
+
// Package mangers
|
|
29
|
+
'**/.npm*',
|
|
30
|
+
'**/pnpm-*.y{,a}ml',
|
|
31
|
+
'**/.yarn*',
|
|
32
|
+
'**/yarn.lock',
|
|
33
|
+
'**/bun.lock',
|
|
34
|
+
|
|
35
|
+
// IDE
|
|
36
|
+
'**/.idea/',
|
|
37
|
+
'**/.vscode/',
|
|
38
|
+
'**/.zed/',
|
|
39
|
+
|
|
40
|
+
// Docs
|
|
41
|
+
'**/*.markdown',
|
|
42
|
+
'**/example{,s}/',
|
|
43
|
+
'**/website/',
|
|
44
|
+
'**/*.txt',
|
|
45
|
+
'**/AUTHORS',
|
|
46
|
+
'**/contributing',
|
|
47
|
+
'**/CONTRIBUTORS',
|
|
48
|
+
'**/contributors',
|
|
49
|
+
|
|
50
|
+
// CI/CD
|
|
51
|
+
'**/.github/',
|
|
52
|
+
'**/.circleci/',
|
|
53
|
+
|
|
54
|
+
// Tests
|
|
55
|
+
'**/test{,s}/',
|
|
56
|
+
'**/spec{,s}/',
|
|
57
|
+
'**/__{mocks,tests}__/',
|
|
58
|
+
'**/jest.*.{js,ts}',
|
|
59
|
+
'**/vitest.*.ts',
|
|
60
|
+
'**/karma.conf.{js,ts}',
|
|
61
|
+
'**/wallaby.conf.{js,ts}',
|
|
62
|
+
'**/wallaby.{js,ts}',
|
|
63
|
+
|
|
64
|
+
// Build tools
|
|
65
|
+
'**/gemfile',
|
|
66
|
+
'**/{G,g}runtfile.{js,ts}',
|
|
67
|
+
'**/{G,g}ulpfile.{js,ts}',
|
|
68
|
+
'**/{M,m}akefile',
|
|
69
|
+
|
|
70
|
+
// Images
|
|
71
|
+
'**/*.jp{,e}g',
|
|
72
|
+
'**/*.png',
|
|
73
|
+
'**/*.gif',
|
|
74
|
+
'**/*.svg',
|
|
75
|
+
|
|
76
|
+
// Linters and formatters
|
|
77
|
+
'**/.jshintrc',
|
|
78
|
+
'**/.lint',
|
|
79
|
+
'**/.prettier*',
|
|
80
|
+
'**/prettier.config*',
|
|
81
|
+
'**/biome.json{,c}',
|
|
82
|
+
'**/tslint.json',
|
|
83
|
+
'**/.eslintrc',
|
|
84
|
+
'**/eslint*.{json,jsonc,ts}',
|
|
85
|
+
'**/.ox{lint,fmt}rc.json{,c}',
|
|
86
|
+
'**/ox{lint,fmt}*.{json,jsonc,ts}',
|
|
87
|
+
'**/.dprint.json{,c}',
|
|
88
|
+
|
|
89
|
+
// Git
|
|
90
|
+
'**/.git/',
|
|
91
|
+
'**/.gitattributes',
|
|
92
|
+
'**/.gitmodules',
|
|
93
|
+
|
|
94
|
+
// Code coverage
|
|
95
|
+
'**/.nyc_output/',
|
|
96
|
+
'**/.nycrc',
|
|
97
|
+
'**/.codecov.y{,a}ml',
|
|
98
|
+
'**/coverage/',
|
|
99
|
+
|
|
100
|
+
// Licenses
|
|
101
|
+
'**/LICEN{C,S}E*',
|
|
102
|
+
'**/licen{s,c}e*',
|
|
103
|
+
'**/{CHANGELOG,changelog}',
|
|
104
|
+
'**/README',
|
|
105
|
+
'**/NOTICE',
|
|
106
|
+
'**/OSSMETADATA',
|
|
107
|
+
|
|
108
|
+
// Compiled
|
|
109
|
+
'**/*.h',
|
|
110
|
+
'**/*.c',
|
|
111
|
+
'**/*.hpp',
|
|
112
|
+
'**/*.cpp',
|
|
113
|
+
'**/*.o',
|
|
114
|
+
'**/*.mk',
|
|
115
|
+
|
|
116
|
+
// Compressed
|
|
117
|
+
'**/*.{,g}zip',
|
|
118
|
+
'**/*.{r,t}ar',
|
|
119
|
+
'**/*.{,t}gz',
|
|
120
|
+
'**/*.7z',
|
|
121
|
+
|
|
122
|
+
// CoffeeScript
|
|
123
|
+
'**/*.coffee',
|
|
124
|
+
|
|
125
|
+
// Misc
|
|
126
|
+
'**/*.jst',
|
|
127
|
+
'**/*.log',
|
|
128
|
+
'**/*.mkd',
|
|
129
|
+
'**/*.orig',
|
|
130
|
+
'**/*.patch',
|
|
131
|
+
'**/*.pdb',
|
|
132
|
+
'**/*.rej',
|
|
133
|
+
'**/*.sln',
|
|
134
|
+
'**/*.swp',
|
|
135
|
+
'**/*.tlog',
|
|
136
|
+
'**/.dir-locals.el',
|
|
137
|
+
'**/.DS_Store',
|
|
138
|
+
'**/.iml',
|
|
139
|
+
'**/.jamignore',
|
|
140
|
+
'**/binding.gyp',
|
|
141
|
+
'**/cakefile',
|
|
142
|
+
'**/node-gyp',
|
|
143
|
+
'**/pom.xml',
|
|
144
|
+
'**/thumbs.db',
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Prints out instructions
|
|
149
|
+
* @returns {void}
|
|
150
|
+
*/
|
|
151
|
+
function usage() {
|
|
152
|
+
const usageText = `
|
|
153
|
+
Removes non-prod files from node_modules, config files, readmes, types, etc.
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
Basic usage:
|
|
157
|
+
$ prod-files node_modules/.pnpm
|
|
158
|
+
Short:
|
|
159
|
+
$ pf node_modules/.pnpm
|
|
160
|
+
|
|
161
|
+
Since we’re just raw-dogging parseArgs, the short args don’t support inline
|
|
162
|
+
arguments, so don't use equals signs:
|
|
163
|
+
$ pf -i "**/foo" -e "**/*tsconfig*.json" node_modules/.pnpm
|
|
164
|
+
|
|
165
|
+
Also with short-hand args the space between the key and the value can be
|
|
166
|
+
omitted:
|
|
167
|
+
$ pf -i"**/foo" node_modules/.pnpm
|
|
168
|
+
|
|
169
|
+
Usage:
|
|
170
|
+
prod-files [flags] path
|
|
171
|
+
pf [flags] path
|
|
172
|
+
|
|
173
|
+
Arguments:
|
|
174
|
+
path Relative or absolute path to node_modules directory:
|
|
175
|
+
- pnpm: 'node_modules/.pnpm'
|
|
176
|
+
- npm: 'node_modules'
|
|
177
|
+
- yarn: 'node_modules' or 'node_modules/.store'
|
|
178
|
+
|
|
179
|
+
Flags:
|
|
180
|
+
-i, --include Glob patterns of extra files to be removed. Uses node's
|
|
181
|
+
path.matchesGlob(), with one exception: patterns ending with
|
|
182
|
+
slash '**/foo/' are marked as directories.
|
|
183
|
+
|
|
184
|
+
-e, --exclude Exclude existing glob patterns if the script is too
|
|
185
|
+
aggressive. Must be exact match.
|
|
186
|
+
|
|
187
|
+
-h, --help Prints out the help.
|
|
188
|
+
|
|
189
|
+
-g, --globs Prints out the default globs.
|
|
190
|
+
|
|
191
|
+
-n, --noSize Skips the size calc at the end, saves about 200-1000ms.
|
|
192
|
+
`
|
|
193
|
+
|
|
194
|
+
console.log(usageText)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Logs error, and usage if defined, then exits with 1
|
|
199
|
+
* @param {string} [message]
|
|
200
|
+
* @param {unknown} [error]
|
|
201
|
+
* @param {boolean} [withUsage]
|
|
202
|
+
* @returns {void}
|
|
203
|
+
*/
|
|
204
|
+
function bail(message, error, withUsage = false) {
|
|
205
|
+
if (error) {
|
|
206
|
+
log.error(error)
|
|
207
|
+
process.exit(1)
|
|
208
|
+
}
|
|
209
|
+
log.info(message)
|
|
210
|
+
if (withUsage) usage()
|
|
211
|
+
process.exit(0)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @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
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A utility for styled console logs
|
|
223
|
+
* @type {Logger}
|
|
224
|
+
*/
|
|
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(' '))),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get size of node_modules
|
|
233
|
+
* @param {string} dirPath - Path to node_modules
|
|
234
|
+
* @returns {number}
|
|
235
|
+
*/
|
|
236
|
+
function getSize(dirPath) {
|
|
237
|
+
const stdout = childProcess.execSync(`du -s ${dirPath} | awk '{print $1}'`)
|
|
238
|
+
return Number(stdout)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {number} originalSize
|
|
243
|
+
* @param {number} prunedSize
|
|
244
|
+
*/
|
|
245
|
+
function calcSize(originalSize, prunedSize) {
|
|
246
|
+
const diff = originalSize - prunedSize
|
|
247
|
+
const diffMb = `${(diff / 1024).toFixed(1)} MB`
|
|
248
|
+
const diffPercent = `${((diff / prunedSize) * 100).toFixed(1)}%`
|
|
249
|
+
|
|
250
|
+
return `${diffPercent} (${diffMb})`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Prints a nice diff table
|
|
255
|
+
* @param {object} opts
|
|
256
|
+
* @param {number | undefined} opts.prunedSize
|
|
257
|
+
* @param {number} opts.startTime
|
|
258
|
+
* @param {number} opts.itemCount
|
|
259
|
+
* @param {number | undefined} opts.originalSize
|
|
260
|
+
*/
|
|
261
|
+
export function printDiff({ prunedSize, startTime, itemCount, originalSize }) {
|
|
262
|
+
console.table([
|
|
263
|
+
{
|
|
264
|
+
Pruned:
|
|
265
|
+
originalSize && prunedSize ? calcSize(originalSize, prunedSize) : 'n/a',
|
|
266
|
+
Time: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
|
|
267
|
+
Items: itemCount,
|
|
268
|
+
},
|
|
269
|
+
])
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @typedef Args
|
|
274
|
+
* @type {object}
|
|
275
|
+
* @property {string} path - Path to node_modules
|
|
276
|
+
* @property {string[]} include - New glob pattern
|
|
277
|
+
* @property {string[]} exclude - Existing glob pattern
|
|
278
|
+
* @property {boolean} help - Prints help
|
|
279
|
+
* @property {boolean} noSize - Don't show size savings
|
|
280
|
+
* @property {boolean} globs - Prints globs
|
|
281
|
+
*/
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Parse the command-line arguments into an object
|
|
285
|
+
* @returns {Args}
|
|
286
|
+
*/
|
|
287
|
+
function handleArgs() {
|
|
288
|
+
try {
|
|
289
|
+
const {
|
|
290
|
+
values,
|
|
291
|
+
positionals: [path],
|
|
292
|
+
} = parseArgs({
|
|
293
|
+
allowPositionals: true,
|
|
294
|
+
options: {
|
|
295
|
+
include: { type: 'string', short: 'i', default: [], multiple: true },
|
|
296
|
+
exclude: { type: 'string', short: 'e', default: [], multiple: true },
|
|
297
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
298
|
+
globs: { type: 'boolean', short: 'g', default: false },
|
|
299
|
+
noSize: { type: 'boolean', short: 'n', default: false },
|
|
300
|
+
},
|
|
301
|
+
})
|
|
302
|
+
if (!path) throw bail('Path not defined', undefined, true)
|
|
303
|
+
|
|
304
|
+
return { ...values, path }
|
|
305
|
+
} catch (err) {
|
|
306
|
+
throw bail(undefined, err, true)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check if the given path exists on disc
|
|
312
|
+
* @param {string|undefined} nodeModulesPath
|
|
313
|
+
* @returns {Promise<string>}
|
|
314
|
+
*/
|
|
315
|
+
export async function validateNodeModulesPath(nodeModulesPath) {
|
|
316
|
+
if (!nodeModulesPath) throw bail(undefined, 'path arg is required', true)
|
|
317
|
+
|
|
318
|
+
const absolutePath = isAbsolute(nodeModulesPath)
|
|
319
|
+
? nodeModulesPath
|
|
320
|
+
: resolve(process.cwd(), nodeModulesPath)
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
await fs.access(absolutePath)
|
|
324
|
+
return absolutePath
|
|
325
|
+
} catch (err) {
|
|
326
|
+
throw bail(undefined, err)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Removes a directory or a file
|
|
332
|
+
* @param {string} file - the file or dir to remove
|
|
333
|
+
*/
|
|
334
|
+
async function rimraf(file) {
|
|
335
|
+
await fs.rm(file, { recursive: true, force: true })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `file.matchesGlob()` does not match dotfiles, this util replaces leading dots
|
|
340
|
+
* with an underscore
|
|
341
|
+
* @param {string} pathOrPattern
|
|
342
|
+
*/
|
|
343
|
+
function escapeLeadingDots(pathOrPattern) {
|
|
344
|
+
return pathOrPattern.replace(/(^|\/)\./g, '$1_')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* @typedef {object} CompiledSet
|
|
349
|
+
* @property {Set<string>} exact
|
|
350
|
+
* @property {string[]} prefix
|
|
351
|
+
* @property {string[]} ext
|
|
352
|
+
* @property {RegExp[]} pats
|
|
353
|
+
* @property {string[]} globs
|
|
354
|
+
*/
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* @typedef {object} CompiledGlobs
|
|
358
|
+
* @property {CompiledSet} any
|
|
359
|
+
* @property {CompiledSet} dir
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* @returns {CompiledSet}
|
|
364
|
+
*/
|
|
365
|
+
function makeSet() {
|
|
366
|
+
return {
|
|
367
|
+
exact: new Set(),
|
|
368
|
+
prefix: [],
|
|
369
|
+
ext: [],
|
|
370
|
+
pats: [],
|
|
371
|
+
globs: [],
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* @param {string} value
|
|
377
|
+
* @returns {boolean}
|
|
378
|
+
*/
|
|
379
|
+
function hasGlobChars(value) {
|
|
380
|
+
return (
|
|
381
|
+
value.includes('*') ||
|
|
382
|
+
value.includes('?') ||
|
|
383
|
+
value.includes('[') ||
|
|
384
|
+
value.includes(']') ||
|
|
385
|
+
value.includes('{') ||
|
|
386
|
+
value.includes('}')
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {string} value
|
|
392
|
+
* @returns {string}
|
|
393
|
+
*/
|
|
394
|
+
function escapeRegExp(value) {
|
|
395
|
+
return value.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Splits a brace expression on top-level commas while preserving nested groups
|
|
400
|
+
* @param {string} value - Brace contents without the outer `{}` characters
|
|
401
|
+
* @returns {string[]} The individual brace options in their original order
|
|
402
|
+
*/
|
|
403
|
+
function splitBraceOptions(value) {
|
|
404
|
+
/** @type {string[]} */
|
|
405
|
+
const options = []
|
|
406
|
+
let current = ''
|
|
407
|
+
let depth = 0
|
|
408
|
+
|
|
409
|
+
for (const char of value) {
|
|
410
|
+
// Only split on commas that are not nested inside another brace pair
|
|
411
|
+
if (char === ',' && depth === 0) {
|
|
412
|
+
options.push(current)
|
|
413
|
+
current = ''
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (char === '{') depth += 1
|
|
418
|
+
if (char === '}') depth -= 1
|
|
419
|
+
current += char
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
options.push(current)
|
|
423
|
+
|
|
424
|
+
return options
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Converts a basename-style glob fragment into a regular expression source.
|
|
429
|
+
* Supports `*`, `?`, and nested brace expansions such as `{js,ts}`.
|
|
430
|
+
* @param {string} glob - The glob fragment to translate
|
|
431
|
+
* @returns {string} A regex source string that preserves path-segment boundaries
|
|
432
|
+
*/
|
|
433
|
+
function globFragmentToRegExpSource(glob) {
|
|
434
|
+
let source = ''
|
|
435
|
+
|
|
436
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
437
|
+
const char = glob[index]
|
|
438
|
+
if (char === undefined) continue
|
|
439
|
+
|
|
440
|
+
if (char === '*') {
|
|
441
|
+
// `*` matches any characters except path separators
|
|
442
|
+
source += '[^/]*'
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (char === '?') {
|
|
447
|
+
source += '[^/]'
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (char === '{') {
|
|
452
|
+
let depth = 1
|
|
453
|
+
let endIndex = index + 1
|
|
454
|
+
|
|
455
|
+
// Find the matching closing brace so nested brace groups stay intact
|
|
456
|
+
while (endIndex < glob.length && depth > 0) {
|
|
457
|
+
if (glob[endIndex] === '{') depth += 1
|
|
458
|
+
if (glob[endIndex] === '}') depth -= 1
|
|
459
|
+
endIndex += 1
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (depth > 0) {
|
|
463
|
+
source += '\\{'
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const braceContents = glob.slice(index + 1, endIndex - 1)
|
|
468
|
+
const options = splitBraceOptions(braceContents)
|
|
469
|
+
const optionSource = options
|
|
470
|
+
.map(option => globFragmentToRegExpSource(option))
|
|
471
|
+
.join('|')
|
|
472
|
+
|
|
473
|
+
source += `(?:${optionSource})`
|
|
474
|
+
index = endIndex - 1
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
source += escapeRegExp(char)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return source
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Compiles a basename glob into an anchored regular expression
|
|
486
|
+
* @param {string} basenameGlob - A glob that is expected to match a single path segment
|
|
487
|
+
* @returns {RegExp} A regular expression that must match the whole basename
|
|
488
|
+
*/
|
|
489
|
+
function basenameGlobToRegExp(basenameGlob) {
|
|
490
|
+
return new RegExp(`^${globFragmentToRegExpSource(basenameGlob)}$`)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Fast paths for globs: adds a glob to the most efficient matcher bucket
|
|
495
|
+
* available
|
|
496
|
+
* @param {string} glob - The glob pattern to classify
|
|
497
|
+
* @param {CompiledSet} set - The compiled matcher set being populated
|
|
498
|
+
*/
|
|
499
|
+
function addGlob(glob, set) {
|
|
500
|
+
if (glob.startsWith('**/*.')) {
|
|
501
|
+
const ext = glob.slice('**/*.'.length)
|
|
502
|
+
if (ext && !ext.includes('/') && !hasGlobChars(ext)) {
|
|
503
|
+
// Fast path for recursive extension globs like `**/*.log`
|
|
504
|
+
set.ext.push(`.${ext}`)
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (glob.startsWith('**/') && glob.endsWith('*')) {
|
|
510
|
+
const prefix = glob.slice('**/'.length, -1)
|
|
511
|
+
if (prefix && !prefix.includes('/') && !hasGlobChars(prefix)) {
|
|
512
|
+
// Fast path for basename prefix globs like `**/npm-debug*`
|
|
513
|
+
set.prefix.push(prefix)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (glob.startsWith('**/')) {
|
|
519
|
+
const base = glob.slice('**/'.length)
|
|
520
|
+
if (base && !base.includes('/')) {
|
|
521
|
+
if (hasGlobChars(base)) set.pats.push(basenameGlobToRegExp(base))
|
|
522
|
+
else set.exact.add(base)
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Anything more complex falls back to full path glob matching
|
|
528
|
+
set.globs.push(escapeLeadingDots(glob))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Compiles raw glob strings into optimized matcher sets for files and
|
|
533
|
+
* directories.
|
|
534
|
+
* @param {string[]} globs - User-provided glob patterns
|
|
535
|
+
* @returns {CompiledGlobs} The compiled matcher structure used during scanning
|
|
536
|
+
*/
|
|
537
|
+
export function compileGlobs(globs) {
|
|
538
|
+
/** @type {CompiledGlobs} */
|
|
539
|
+
const compiledGlobs = { any: makeSet(), dir: makeSet() }
|
|
540
|
+
|
|
541
|
+
for (const glob of globs) {
|
|
542
|
+
const isDir = glob.endsWith('/')
|
|
543
|
+
const set = isDir ? compiledGlobs.dir : compiledGlobs.any
|
|
544
|
+
addGlob(isDir ? glob.slice(0, -1) : glob, set)
|
|
545
|
+
|
|
546
|
+
if (isDir && set.globs.length > 0) {
|
|
547
|
+
const last = set.globs.pop()
|
|
548
|
+
// Directory globs keep their trailing slash so they cannot match files
|
|
549
|
+
if (last !== undefined) set.globs.push(`${last}/`)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return compiledGlobs
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const defaultCompiledGlobs = compileGlobs(defaultGlobs)
|
|
557
|
+
|
|
558
|
+
/** @typedef {import('node:fs').Dirent} Dirent */
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Checks whether a basename matches any of the precompiled fast-match buckets
|
|
562
|
+
* @param {string} name - The basename to test
|
|
563
|
+
* @param {CompiledSet} set - The compiled matcher set to test against
|
|
564
|
+
* @returns {boolean} `true` when the basename matches any exact, prefix, extension, or regex rule
|
|
565
|
+
*/
|
|
566
|
+
function matchesSet(name, set) {
|
|
567
|
+
// Keep the cheapest checks first because this runs for every scanned entry
|
|
568
|
+
if (set.exact.has(name)) return true
|
|
569
|
+
for (const prefix of set.prefix) if (name.startsWith(prefix)) return true
|
|
570
|
+
for (const ext of set.ext) if (name.endsWith(ext)) return true
|
|
571
|
+
for (const pat of set.pats) if (pat.test(name)) return true
|
|
572
|
+
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Finds file system entries that match the compiled junk rules
|
|
578
|
+
* @param {Dirent[]} files - Directory entries collected during traversal
|
|
579
|
+
* @param {CompiledGlobs} [compiledGlobs=defaultCompiledGlobs] - Precompiled glob matchers to apply
|
|
580
|
+
* @returns {string[]} Full paths for entries considered junk
|
|
581
|
+
*/
|
|
582
|
+
export function findJunkFiles(files, compiledGlobs = defaultCompiledGlobs) {
|
|
583
|
+
/** @type {string[]} */
|
|
584
|
+
const junkFiles = []
|
|
585
|
+
const hasAnyGlobs = compiledGlobs.any.globs.length > 0
|
|
586
|
+
const hasDirGlobs = compiledGlobs.dir.globs.length > 0
|
|
587
|
+
|
|
588
|
+
for (const file of files) {
|
|
589
|
+
const { name, parentPath } = file
|
|
590
|
+
const isDir = file.isDirectory()
|
|
591
|
+
|
|
592
|
+
// Basename checks are cheaper than full path glob checks, so try them first
|
|
593
|
+
if (
|
|
594
|
+
matchesSet(name, compiledGlobs.any) ||
|
|
595
|
+
(isDir && matchesSet(name, compiledGlobs.dir))
|
|
596
|
+
) {
|
|
597
|
+
junkFiles.push(join(parentPath, name))
|
|
598
|
+
continue
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!hasAnyGlobs && !(isDir && hasDirGlobs)) continue
|
|
602
|
+
|
|
603
|
+
const path = join(parentPath, name)
|
|
604
|
+
const escapedPath = escapeLeadingDots(isDir ? `${path}/` : path)
|
|
605
|
+
const match =
|
|
606
|
+
compiledGlobs.any.globs.some(glob => matchesGlob(escapedPath, glob)) ||
|
|
607
|
+
(isDir &&
|
|
608
|
+
compiledGlobs.dir.globs.some(glob => matchesGlob(escapedPath, glob)))
|
|
609
|
+
|
|
610
|
+
// Don't add directories twice when both basename and path globs could match
|
|
611
|
+
if (match) junkFiles.push(path)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return junkFiles
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Removes descendant paths when an ancestor path is already present
|
|
619
|
+
* @param {string[]} paths - Paths to compact
|
|
620
|
+
* @returns {string[]} A sorted list without redundant child paths
|
|
621
|
+
*/
|
|
622
|
+
export function compactPaths(paths) {
|
|
623
|
+
/** @type {Set<string>} */
|
|
624
|
+
const seen = new Set()
|
|
625
|
+
/** @type {string[]} */
|
|
626
|
+
const compact = []
|
|
627
|
+
|
|
628
|
+
// Sorting guarantees parents are encountered before their nested children
|
|
629
|
+
for (const path of paths.toSorted()) {
|
|
630
|
+
let i = path.lastIndexOf('/')
|
|
631
|
+
|
|
632
|
+
while (i > 0) {
|
|
633
|
+
if (seen.has(path.slice(0, i))) break
|
|
634
|
+
i = path.lastIndexOf('/', i - 1)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Skip this path if one of its ancestors has already been kept
|
|
638
|
+
if (i > 0) continue
|
|
639
|
+
|
|
640
|
+
seen.add(path)
|
|
641
|
+
compact.push(path)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return compact
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Removes unneeded files from node_modules
|
|
649
|
+
* @param {Args} opts
|
|
650
|
+
*/
|
|
651
|
+
export async function prune(opts) {
|
|
652
|
+
const startTime = Date.now()
|
|
653
|
+
log.info('Pruning:', opts.path)
|
|
654
|
+
|
|
655
|
+
const originalSize = opts.noSize ? undefined : getSize(opts.path)
|
|
656
|
+
const excludedGlobs = new Set(opts.exclude)
|
|
657
|
+
const activeGlobs = [...defaultGlobs, ...opts.include].filter(
|
|
658
|
+
glob => !excludedGlobs.has(glob)
|
|
659
|
+
)
|
|
660
|
+
const compiledGlobs = compileGlobs(activeGlobs)
|
|
661
|
+
|
|
662
|
+
// This could be slightly faster with optimized walker
|
|
663
|
+
const allFiles = await fs.readdir(opts.path, {
|
|
664
|
+
recursive: true,
|
|
665
|
+
withFileTypes: true,
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
const junkFiles = findJunkFiles(allFiles, compiledGlobs)
|
|
669
|
+
const results = compactPaths(junkFiles)
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
await Promise.all(results.map(x => rimraf(x)))
|
|
673
|
+
} catch (err) {
|
|
674
|
+
throw bail(undefined, err)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
printDiff({
|
|
678
|
+
itemCount: results.length,
|
|
679
|
+
prunedSize: opts.noSize ? undefined : getSize(opts.path),
|
|
680
|
+
originalSize,
|
|
681
|
+
startTime,
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
return results
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const runAsScript = process.argv[1] === import.meta.filename
|
|
688
|
+
|
|
689
|
+
if (runAsScript) {
|
|
690
|
+
const args = handleArgs()
|
|
691
|
+
|
|
692
|
+
if (args.help) {
|
|
693
|
+
usage()
|
|
694
|
+
process.exit(0)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (args.globs) {
|
|
698
|
+
console.log(JSON.stringify(defaultGlobs, null, 2))
|
|
699
|
+
process.exit(0)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
await validateNodeModulesPath(args.path)
|
|
703
|
+
await prune(args)
|
|
704
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prod-files",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keep only prod files by pruning non-prod files from node_modules before deploying",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"clean",
|
|
7
|
+
"node_modules",
|
|
8
|
+
"prod",
|
|
9
|
+
"production",
|
|
10
|
+
"prune"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "hilja",
|
|
14
|
+
"repository": {
|
|
15
|
+
"url": "git+https://github.com/hilja/prod-files.git"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"pf": "./index.mjs",
|
|
19
|
+
"prod-files": "./index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.mjs"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "index.mjs",
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "25.5.2",
|
|
28
|
+
"memfs": "4.57.1",
|
|
29
|
+
"oxfmt": "0.44.0",
|
|
30
|
+
"oxlint": "1.59.0",
|
|
31
|
+
"oxlint-tsgolint": "0.20.0"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.17.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"checkUpdates": "pnpm update -L -i",
|
|
38
|
+
"format": "oxfmt",
|
|
39
|
+
"lint": "oxlint --type-check",
|
|
40
|
+
"pf": "node ./index.mjs",
|
|
41
|
+
"pub": "(npm whoami || npm login) && pnpm publish --access=public --tag=latest",
|
|
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"
|
|
48
|
+
}
|
|
49
|
+
}
|