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.
Files changed (4) hide show
  1. package/LICENCE +21 -0
  2. package/README.md +167 -0
  3. package/index.mjs +704 -0
  4. 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
+ }