shakapacker 9.3.0.beta.0 ā 9.3.0.beta.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/Gemfile.lock +1 -1
- data/docs/troubleshooting.md +141 -1
- data/jest.config.js +8 -1
- data/lib/shakapacker/version.rb +1 -1
- data/package/configExporter/buildValidator.ts +883 -0
- data/package/configExporter/cli.ts +183 -12
- data/package/configExporter/index.ts +3 -1
- data/package/configExporter/types.ts +18 -0
- data/package-lock.json +2 -2
- data/package.json +17 -16
- data/test/configExporter/buildValidator.test.js +1292 -0
- data/test/package/environments/base.test.js +6 -3
- data/test/package/rules/babel.test.js +61 -51
- data/test/package/rules/esbuild.test.js +12 -3
- data/test/package/rules/file.test.js +3 -1
- data/test/package/rules/sass.test.js +9 -2
- data/test/package/rules/sass1.test.js +3 -2
- data/test/package/rules/sass16.test.js +3 -2
- data/test/package/rules/swc.test.js +48 -38
- data/yarn.lock +62 -3
- metadata +5 -2
@@ -0,0 +1,883 @@
|
|
1
|
+
import { spawn } from "child_process"
|
2
|
+
import { existsSync } from "fs"
|
3
|
+
import { resolve, relative, sep } from "path"
|
4
|
+
import { ResolvedBuildConfig, BuildValidationResult } from "./types"
|
5
|
+
|
6
|
+
export interface ValidatorOptions {
|
7
|
+
verbose: boolean
|
8
|
+
timeout?: number // milliseconds
|
9
|
+
strictBinaryResolution?: boolean // If true, fail if binaries not found locally (recommended for CI)
|
10
|
+
maxConcurrentBuilds?: number // Maximum number of builds to validate concurrently
|
11
|
+
}
|
12
|
+
|
13
|
+
/**
|
14
|
+
* Maximum buffer size for stdout/stderr to prevent memory exhaustion
|
15
|
+
*/
|
16
|
+
const MAX_BUFFER_SIZE = 10 * 1024 * 1024 // 10MB
|
17
|
+
|
18
|
+
/**
|
19
|
+
* Default timeout for build validation in milliseconds
|
20
|
+
*/
|
21
|
+
const DEFAULT_TIMEOUT_MS = 120000 // 2 minutes
|
22
|
+
|
23
|
+
/**
|
24
|
+
* Safety timeout after SIGTERM before forcing resolution (milliseconds)
|
25
|
+
*/
|
26
|
+
const KILL_SAFETY_TIMEOUT_MS = 5000 // 5 seconds
|
27
|
+
|
28
|
+
/**
|
29
|
+
* Exit code for SIGTERM signal
|
30
|
+
*/
|
31
|
+
const SIGTERM_EXIT_CODE = 143
|
32
|
+
|
33
|
+
/**
|
34
|
+
* TypeScript interface for webpack/rspack JSON output structure
|
35
|
+
*/
|
36
|
+
interface WebpackJsonOutput {
|
37
|
+
errors?: Array<string | { message: string }>
|
38
|
+
warnings?: Array<string | { message: string }>
|
39
|
+
hash?: string
|
40
|
+
time?: number
|
41
|
+
builtAt?: number
|
42
|
+
outputPath?: string
|
43
|
+
}
|
44
|
+
|
45
|
+
/**
|
46
|
+
* Whitelisted environment variables that are safe to pass to build processes.
|
47
|
+
* This prevents arbitrary environment variable injection from config files.
|
48
|
+
*
|
49
|
+
* Note: PATH is essential for webpack/rspack to find node and other binaries.
|
50
|
+
* HOME is needed for tools that read user config (e.g., .npmrc, .yarnrc).
|
51
|
+
*/
|
52
|
+
const SAFE_ENV_VARS = [
|
53
|
+
"PATH",
|
54
|
+
"HOME",
|
55
|
+
"NODE_ENV",
|
56
|
+
"RAILS_ENV",
|
57
|
+
"NODE_OPTIONS",
|
58
|
+
"BABEL_ENV",
|
59
|
+
"WEBPACK_SERVE",
|
60
|
+
"HMR",
|
61
|
+
"CLIENT_BUNDLE_ONLY",
|
62
|
+
"SERVER_BUNDLE_ONLY",
|
63
|
+
"PUBLIC_URL",
|
64
|
+
"ASSET_HOST",
|
65
|
+
"CDN_HOST",
|
66
|
+
"TMPDIR",
|
67
|
+
"TEMP",
|
68
|
+
"TMP"
|
69
|
+
] as const
|
70
|
+
|
71
|
+
/**
|
72
|
+
* Success patterns for detecting successful compilation in webpack/rspack output.
|
73
|
+
* These patterns are used to determine when webpack-dev-server has successfully
|
74
|
+
* compiled and is ready to serve, or when a static build has completed.
|
75
|
+
*
|
76
|
+
* Note: Patterns use substring matching, not exact matching, to support version variations.
|
77
|
+
* For example, "webpack 5." matches "webpack 5.95.0 compiled successfully"
|
78
|
+
*
|
79
|
+
* Patterns are checked after excluding lines starting with ERROR: or WARNING:
|
80
|
+
* to prevent false positives in error messages.
|
81
|
+
*/
|
82
|
+
const SUCCESS_PATTERNS = [
|
83
|
+
"webpack compiled",
|
84
|
+
"Compiled successfully",
|
85
|
+
"rspack compiled successfully",
|
86
|
+
"webpack: Compiled successfully",
|
87
|
+
"Compilation completed",
|
88
|
+
"wds: Compiled successfully", // webpack-dev-server 4.x
|
89
|
+
"webpack-dev-server: Compiled", // webpack-dev-server 5.x
|
90
|
+
"[webpack-dev-server] Compiled successfully", // webpack-dev-server 5.x alternative format
|
91
|
+
"webpack 5.", // matches "webpack 5.95.0 compiled successfully" (any 5.x.x version)
|
92
|
+
"rspack 0.", // matches "rspack 0.7.5 compiled successfully" (any 0.x.x version)
|
93
|
+
"rspack-dev-server: Compiled" // rspack-dev-server output
|
94
|
+
]
|
95
|
+
|
96
|
+
/**
|
97
|
+
* Error patterns for detecting compilation errors in webpack/rspack output
|
98
|
+
*/
|
99
|
+
const ERROR_PATTERNS = ["ERROR", "Error:", "Failed to compile"]
|
100
|
+
|
101
|
+
/**
|
102
|
+
* Warning patterns for detecting compilation warnings in webpack/rspack output
|
103
|
+
*/
|
104
|
+
const WARNING_PATTERNS = ["WARNING", "Warning:"]
|
105
|
+
|
106
|
+
/**
|
107
|
+
* Pattern to detect suspicious characters in environment variable values
|
108
|
+
* that could indicate command injection attempts
|
109
|
+
*/
|
110
|
+
const SUSPICIOUS_ENV_PATTERN = /[;&|`$()]/
|
111
|
+
|
112
|
+
/**
|
113
|
+
* Validates webpack/rspack builds by running them and checking for errors
|
114
|
+
* For HMR builds, starts webpack-dev-server and shuts down after successful start
|
115
|
+
*/
|
116
|
+
export class BuildValidator {
|
117
|
+
private options: ValidatorOptions
|
118
|
+
|
119
|
+
constructor(options: ValidatorOptions) {
|
120
|
+
this.options = {
|
121
|
+
verbose: options.verbose,
|
122
|
+
timeout: options.timeout || DEFAULT_TIMEOUT_MS,
|
123
|
+
strictBinaryResolution:
|
124
|
+
options.strictBinaryResolution ||
|
125
|
+
process.env.CI === "true" ||
|
126
|
+
process.env.GITHUB_ACTIONS === "true",
|
127
|
+
maxConcurrentBuilds: options.maxConcurrentBuilds || 3
|
128
|
+
}
|
129
|
+
}
|
130
|
+
|
131
|
+
/**
|
132
|
+
* Filters environment variables to only include whitelisted safe variables.
|
133
|
+
* This prevents command injection and limits exposure of sensitive data.
|
134
|
+
* Also validates environment variable values for suspicious patterns.
|
135
|
+
*/
|
136
|
+
private filterEnvironment(
|
137
|
+
buildEnv: Record<string, string>
|
138
|
+
): Record<string, string> {
|
139
|
+
const filtered: Record<string, string> = {}
|
140
|
+
|
141
|
+
// Start with current process.env but only whitelisted vars
|
142
|
+
SAFE_ENV_VARS.forEach((key) => {
|
143
|
+
if (process.env[key]) {
|
144
|
+
filtered[key] = process.env[key]!
|
145
|
+
}
|
146
|
+
})
|
147
|
+
|
148
|
+
// Override with build-specific env vars (also filtered)
|
149
|
+
Object.entries(buildEnv).forEach(([key, value]) => {
|
150
|
+
if ((SAFE_ENV_VARS as readonly string[]).includes(key)) {
|
151
|
+
// Validate for suspicious patterns that could indicate command injection
|
152
|
+
if (SUSPICIOUS_ENV_PATTERN.test(value)) {
|
153
|
+
if (this.options.verbose) {
|
154
|
+
console.warn(
|
155
|
+
` [Security Warning] Suspicious pattern detected in environment variable ${key}: ${value}`
|
156
|
+
)
|
157
|
+
}
|
158
|
+
}
|
159
|
+
filtered[key] = value
|
160
|
+
}
|
161
|
+
})
|
162
|
+
|
163
|
+
return filtered
|
164
|
+
}
|
165
|
+
|
166
|
+
/**
|
167
|
+
* Validates that a config file exists and returns the resolved path.
|
168
|
+
* Throws an error if the config file is not found or attempts path traversal.
|
169
|
+
*
|
170
|
+
* @param configFile - The config file path from the build configuration
|
171
|
+
* @param appRoot - The application root directory
|
172
|
+
* @param buildName - The name of the build (for error messages)
|
173
|
+
* @returns The resolved absolute path to the config file
|
174
|
+
* @throws Error if the config file does not exist or is outside appRoot
|
175
|
+
*/
|
176
|
+
private validateConfigPath(
|
177
|
+
configFile: string,
|
178
|
+
appRoot: string,
|
179
|
+
buildName: string
|
180
|
+
): string {
|
181
|
+
const configPath = resolve(appRoot, configFile)
|
182
|
+
|
183
|
+
// Security: Ensure resolved path is within appRoot using path.relative
|
184
|
+
// This works cross-platform (Windows/Unix) and prevents path traversal attacks
|
185
|
+
const rel = relative(appRoot, configPath)
|
186
|
+
|
187
|
+
// Path is valid if:
|
188
|
+
// 1. rel === "" (same as appRoot) OR
|
189
|
+
// 2. rel doesn't start with ".." (not outside appRoot)
|
190
|
+
// Note: On Windows, ".." will be used for parent dir regardless of path separator
|
191
|
+
if (rel !== "" && rel.startsWith("..")) {
|
192
|
+
throw new Error(
|
193
|
+
`Invalid config file path for build '${buildName}': Path must be within project directory. ` +
|
194
|
+
`Config file: ${configFile}, Resolved path: ${configPath}, Project root: ${appRoot}`
|
195
|
+
)
|
196
|
+
}
|
197
|
+
|
198
|
+
if (!existsSync(configPath)) {
|
199
|
+
throw new Error(
|
200
|
+
`Config file not found for build '${buildName}': ${configPath}. ` +
|
201
|
+
`Check the 'config' setting in your build configuration.`
|
202
|
+
)
|
203
|
+
}
|
204
|
+
|
205
|
+
return configPath
|
206
|
+
}
|
207
|
+
|
208
|
+
/**
|
209
|
+
* Validates a single build configuration by running the appropriate bundler command.
|
210
|
+
* For HMR builds, starts webpack-dev-server and validates successful compilation.
|
211
|
+
* For static builds, runs a full build and validates the output.
|
212
|
+
*
|
213
|
+
* @param build - The resolved build configuration to validate
|
214
|
+
* @param appRoot - The application root directory
|
215
|
+
* @returns A promise that resolves to the build validation result
|
216
|
+
*/
|
217
|
+
async validateBuild(
|
218
|
+
build: ResolvedBuildConfig,
|
219
|
+
appRoot: string
|
220
|
+
): Promise<BuildValidationResult> {
|
221
|
+
// Detect HMR builds by checking for WEBPACK_SERVE or HMR environment variables
|
222
|
+
const isHMR =
|
223
|
+
build.environment.WEBPACK_SERVE === "true" ||
|
224
|
+
build.environment.HMR === "true"
|
225
|
+
const bundler = build.bundler
|
226
|
+
|
227
|
+
if (isHMR) {
|
228
|
+
return this.validateHMRBuild(build, appRoot, bundler)
|
229
|
+
} else {
|
230
|
+
return this.validateStaticBuild(build, appRoot, bundler)
|
231
|
+
}
|
232
|
+
}
|
233
|
+
|
234
|
+
/**
|
235
|
+
* Validates an HMR build by starting webpack-dev-server
|
236
|
+
* Waits for successful compilation, then shuts down
|
237
|
+
*/
|
238
|
+
private async validateHMRBuild(
|
239
|
+
build: ResolvedBuildConfig,
|
240
|
+
appRoot: string,
|
241
|
+
bundler: "webpack" | "rspack"
|
242
|
+
): Promise<BuildValidationResult> {
|
243
|
+
const startTime = Date.now()
|
244
|
+
const result: BuildValidationResult = {
|
245
|
+
buildName: build.name,
|
246
|
+
success: false,
|
247
|
+
errors: [],
|
248
|
+
warnings: [],
|
249
|
+
output: [],
|
250
|
+
outputs: build.outputs,
|
251
|
+
configFile: build.configFile,
|
252
|
+
startTime
|
253
|
+
}
|
254
|
+
|
255
|
+
// Determine the dev server command
|
256
|
+
const devServerCmd =
|
257
|
+
bundler === "rspack" ? "rspack-dev-server" : "webpack-dev-server"
|
258
|
+
const devServerBin = this.findBinary(devServerCmd, appRoot)
|
259
|
+
|
260
|
+
if (!devServerBin) {
|
261
|
+
const packageManager = existsSync(resolve(appRoot, "yarn.lock"))
|
262
|
+
? "yarn add"
|
263
|
+
: "npm install"
|
264
|
+
result.errors.push(
|
265
|
+
`Could not find ${devServerCmd} binary. Please install it:\n` +
|
266
|
+
` ${packageManager} -D ${bundler}-dev-server`
|
267
|
+
)
|
268
|
+
return result
|
269
|
+
}
|
270
|
+
|
271
|
+
// Build arguments
|
272
|
+
const args: string[] = []
|
273
|
+
|
274
|
+
// Add config file if specified
|
275
|
+
if (build.configFile) {
|
276
|
+
try {
|
277
|
+
const configPath = this.validateConfigPath(
|
278
|
+
build.configFile,
|
279
|
+
appRoot,
|
280
|
+
build.name
|
281
|
+
)
|
282
|
+
args.push("--config", configPath)
|
283
|
+
} catch (error) {
|
284
|
+
const errorMessage =
|
285
|
+
error instanceof Error ? error.message : String(error)
|
286
|
+
result.errors.push(errorMessage)
|
287
|
+
return result
|
288
|
+
}
|
289
|
+
} else {
|
290
|
+
// Use default config path
|
291
|
+
const defaultConfig = resolve(
|
292
|
+
appRoot,
|
293
|
+
`config/${bundler}/${bundler}.config.js`
|
294
|
+
)
|
295
|
+
if (existsSync(defaultConfig)) {
|
296
|
+
args.push("--config", defaultConfig)
|
297
|
+
}
|
298
|
+
}
|
299
|
+
|
300
|
+
// Add bundler env args (--env flags)
|
301
|
+
if (build.bundlerEnvArgs && build.bundlerEnvArgs.length > 0) {
|
302
|
+
args.push(...build.bundlerEnvArgs)
|
303
|
+
}
|
304
|
+
|
305
|
+
return new Promise((resolve) => {
|
306
|
+
const child = spawn(devServerBin, args, {
|
307
|
+
cwd: appRoot,
|
308
|
+
env: this.filterEnvironment(build.environment),
|
309
|
+
stdio: ["ignore", "pipe", "pipe"]
|
310
|
+
})
|
311
|
+
|
312
|
+
let hasCompiled = false
|
313
|
+
let hasError = false
|
314
|
+
let resolved = false
|
315
|
+
let processKilled = false
|
316
|
+
|
317
|
+
const resolveOnce = (res: BuildValidationResult) => {
|
318
|
+
if (!resolved) {
|
319
|
+
resolved = true
|
320
|
+
resolve(res)
|
321
|
+
}
|
322
|
+
}
|
323
|
+
|
324
|
+
const timeoutId = setTimeout(() => {
|
325
|
+
if (!hasCompiled && !resolved && !processKilled) {
|
326
|
+
result.errors.push(
|
327
|
+
`Timeout: webpack-dev-server did not compile within ${this.options.timeout}ms.`
|
328
|
+
)
|
329
|
+
processKilled = true
|
330
|
+
child.kill("SIGTERM")
|
331
|
+
// Remove listeners to prevent further callbacks
|
332
|
+
child.stdout?.removeAllListeners()
|
333
|
+
child.stderr?.removeAllListeners()
|
334
|
+
child.removeAllListeners()
|
335
|
+
resolveOnce(result)
|
336
|
+
}
|
337
|
+
}, this.options.timeout)
|
338
|
+
|
339
|
+
const processOutput = (data: Buffer) => {
|
340
|
+
const lines = data.toString().split("\n")
|
341
|
+
lines.forEach((line) => {
|
342
|
+
if (!line.trim()) return
|
343
|
+
|
344
|
+
// Always output in real-time in verbose mode so user sees progress
|
345
|
+
if (this.options.verbose) {
|
346
|
+
console.log(` ${line}`)
|
347
|
+
}
|
348
|
+
|
349
|
+
// Store all output
|
350
|
+
result.output.push(line)
|
351
|
+
|
352
|
+
// Check for successful compilation
|
353
|
+
// Only match success patterns if the line doesn't start with ERROR: or WARNING:
|
354
|
+
const isErrorOrWarning =
|
355
|
+
line.trim().startsWith("ERROR") || line.trim().startsWith("WARNING")
|
356
|
+
if (
|
357
|
+
!processKilled &&
|
358
|
+
!isErrorOrWarning &&
|
359
|
+
SUCCESS_PATTERNS.some((pattern) => line.includes(pattern))
|
360
|
+
) {
|
361
|
+
hasCompiled = true
|
362
|
+
result.success = true
|
363
|
+
// Set processKilled BEFORE clearing timeout to prevent race condition
|
364
|
+
// where timeout could fire between clearTimeout and setting the flag
|
365
|
+
processKilled = true
|
366
|
+
clearTimeout(timeoutId)
|
367
|
+
child.kill("SIGTERM")
|
368
|
+
// Don't call resolveOnce here - let the exit handler do it
|
369
|
+
// This ensures proper cleanup order and avoids race conditions
|
370
|
+
|
371
|
+
// Safety timeout: if process doesn't exit within 5 seconds, force resolve
|
372
|
+
// This prevents hanging if kill() fails or process is unresponsive
|
373
|
+
setTimeout(() => {
|
374
|
+
if (!resolved) {
|
375
|
+
if (this.options.verbose) {
|
376
|
+
console.warn(
|
377
|
+
` [Warning] Process did not exit after SIGTERM, forcing resolution.`
|
378
|
+
)
|
379
|
+
}
|
380
|
+
child.stdout?.removeAllListeners()
|
381
|
+
child.stderr?.removeAllListeners()
|
382
|
+
child.removeAllListeners()
|
383
|
+
resolveOnce(result)
|
384
|
+
}
|
385
|
+
}, KILL_SAFETY_TIMEOUT_MS)
|
386
|
+
}
|
387
|
+
|
388
|
+
// Check for errors
|
389
|
+
if (ERROR_PATTERNS.some((pattern) => line.includes(pattern))) {
|
390
|
+
hasError = true
|
391
|
+
result.errors.push(line)
|
392
|
+
}
|
393
|
+
|
394
|
+
// Check for warnings
|
395
|
+
if (WARNING_PATTERNS.some((pattern) => line.includes(pattern))) {
|
396
|
+
result.warnings.push(line)
|
397
|
+
}
|
398
|
+
})
|
399
|
+
}
|
400
|
+
|
401
|
+
child.stdout?.on("data", (data) => processOutput(data))
|
402
|
+
child.stderr?.on("data", (data) => processOutput(data))
|
403
|
+
|
404
|
+
child.on("exit", (code) => {
|
405
|
+
clearTimeout(timeoutId)
|
406
|
+
// Clean up listeners after exit
|
407
|
+
child.stdout?.removeAllListeners()
|
408
|
+
child.stderr?.removeAllListeners()
|
409
|
+
child.removeAllListeners()
|
410
|
+
|
411
|
+
// Record timing
|
412
|
+
result.endTime = Date.now()
|
413
|
+
result.duration = result.endTime - (result.startTime || result.endTime)
|
414
|
+
|
415
|
+
if (!hasCompiled && !hasError && !resolved) {
|
416
|
+
if (code !== 0 && code !== null && code !== SIGTERM_EXIT_CODE) {
|
417
|
+
result.errors.push(
|
418
|
+
`${devServerCmd} exited with code ${code} before compilation completed.`
|
419
|
+
)
|
420
|
+
}
|
421
|
+
}
|
422
|
+
resolveOnce(result)
|
423
|
+
})
|
424
|
+
|
425
|
+
child.on("error", (err) => {
|
426
|
+
clearTimeout(timeoutId)
|
427
|
+
// Provide more helpful error messages for common spawn failures
|
428
|
+
let errorMessage = `Failed to start ${devServerCmd}: ${err.message}`
|
429
|
+
|
430
|
+
// Check for specific error codes and provide actionable guidance
|
431
|
+
if ("code" in err) {
|
432
|
+
const code = (err as NodeJS.ErrnoException).code
|
433
|
+
if (code === "ENOENT") {
|
434
|
+
errorMessage += `. Binary not found. Install with: npm install -D ${devServerCmd}`
|
435
|
+
} else if (code === "EMFILE" || code === "ENFILE") {
|
436
|
+
errorMessage += `. Too many open files. Increase system file descriptor limit or reduce concurrent builds`
|
437
|
+
} else if (code === "EACCES") {
|
438
|
+
errorMessage += `. Permission denied. Check file permissions for the binary`
|
439
|
+
}
|
440
|
+
}
|
441
|
+
|
442
|
+
result.errors.push(errorMessage)
|
443
|
+
resolveOnce(result)
|
444
|
+
})
|
445
|
+
})
|
446
|
+
}
|
447
|
+
|
448
|
+
/**
|
449
|
+
* Validates a static build by running webpack/rspack in production mode
|
450
|
+
* Uses --json flag to get structured output
|
451
|
+
*/
|
452
|
+
private async validateStaticBuild(
|
453
|
+
build: ResolvedBuildConfig,
|
454
|
+
appRoot: string,
|
455
|
+
bundler: "webpack" | "rspack"
|
456
|
+
): Promise<BuildValidationResult> {
|
457
|
+
const startTime = Date.now()
|
458
|
+
const result: BuildValidationResult = {
|
459
|
+
buildName: build.name,
|
460
|
+
success: false,
|
461
|
+
errors: [],
|
462
|
+
warnings: [],
|
463
|
+
output: [],
|
464
|
+
outputs: build.outputs,
|
465
|
+
configFile: build.configFile,
|
466
|
+
startTime
|
467
|
+
}
|
468
|
+
|
469
|
+
const bundlerBin = this.findBinary(bundler, appRoot)
|
470
|
+
|
471
|
+
if (!bundlerBin) {
|
472
|
+
const packageManager = existsSync(resolve(appRoot, "yarn.lock"))
|
473
|
+
? "yarn add"
|
474
|
+
: "npm install"
|
475
|
+
result.errors.push(
|
476
|
+
`Could not find ${bundler} binary. Please install it:\n` +
|
477
|
+
` ${packageManager} -D ${bundler}`
|
478
|
+
)
|
479
|
+
return result
|
480
|
+
}
|
481
|
+
|
482
|
+
// Build arguments - use --dry-run if available, otherwise just build
|
483
|
+
const args: string[] = []
|
484
|
+
|
485
|
+
// Add config file if specified
|
486
|
+
if (build.configFile) {
|
487
|
+
try {
|
488
|
+
const configPath = this.validateConfigPath(
|
489
|
+
build.configFile,
|
490
|
+
appRoot,
|
491
|
+
build.name
|
492
|
+
)
|
493
|
+
args.push("--config", configPath)
|
494
|
+
} catch (error) {
|
495
|
+
const errorMessage =
|
496
|
+
error instanceof Error ? error.message : String(error)
|
497
|
+
result.errors.push(errorMessage)
|
498
|
+
return result
|
499
|
+
}
|
500
|
+
} else {
|
501
|
+
// Use default config path
|
502
|
+
const defaultConfig = resolve(
|
503
|
+
appRoot,
|
504
|
+
`config/${bundler}/${bundler}.config.js`
|
505
|
+
)
|
506
|
+
if (existsSync(defaultConfig)) {
|
507
|
+
args.push("--config", defaultConfig)
|
508
|
+
}
|
509
|
+
}
|
510
|
+
|
511
|
+
// Add bundler env args (--env flags)
|
512
|
+
if (build.bundlerEnvArgs && build.bundlerEnvArgs.length > 0) {
|
513
|
+
args.push(...build.bundlerEnvArgs)
|
514
|
+
}
|
515
|
+
|
516
|
+
// Add --json for structured output (helps parse errors)
|
517
|
+
args.push("--json")
|
518
|
+
|
519
|
+
return new Promise((resolve) => {
|
520
|
+
const child = spawn(bundlerBin, args, {
|
521
|
+
cwd: appRoot,
|
522
|
+
env: this.filterEnvironment(build.environment),
|
523
|
+
stdio: ["ignore", "pipe", "pipe"]
|
524
|
+
})
|
525
|
+
|
526
|
+
const stdoutChunks: Buffer[] = []
|
527
|
+
const stderrChunks: Buffer[] = []
|
528
|
+
|
529
|
+
let stdoutSize = 0
|
530
|
+
let stderrSize = 0
|
531
|
+
let bufferOverflow = false
|
532
|
+
|
533
|
+
const timeoutId = setTimeout(() => {
|
534
|
+
result.errors.push(
|
535
|
+
`Timeout: ${bundler} did not complete within ${this.options.timeout}ms.`
|
536
|
+
)
|
537
|
+
child.kill("SIGTERM")
|
538
|
+
resolve(result)
|
539
|
+
}, this.options.timeout)
|
540
|
+
|
541
|
+
child.stdout?.on("data", (data: Buffer) => {
|
542
|
+
// Check buffer size to prevent memory issues
|
543
|
+
if (stdoutSize + data.length > MAX_BUFFER_SIZE) {
|
544
|
+
if (!bufferOverflow) {
|
545
|
+
bufferOverflow = true
|
546
|
+
const warning = `Output buffer limit exceeded (${MAX_BUFFER_SIZE / 1024 / 1024}MB). Build output is too large - data will be truncated.`
|
547
|
+
result.warnings.push(warning)
|
548
|
+
if (this.options.verbose) {
|
549
|
+
console.warn(` [Warning] ${warning}`)
|
550
|
+
}
|
551
|
+
}
|
552
|
+
// Explicitly skip this chunk - don't silently drop
|
553
|
+
return
|
554
|
+
}
|
555
|
+
|
556
|
+
stdoutChunks.push(data)
|
557
|
+
stdoutSize += data.length
|
558
|
+
|
559
|
+
// Don't output JSON in verbose mode - it's too large and not useful
|
560
|
+
// JSON is for parsing errors, not for human consumption
|
561
|
+
})
|
562
|
+
|
563
|
+
child.stderr?.on("data", (data: Buffer) => {
|
564
|
+
// Check buffer size
|
565
|
+
if (stderrSize + data.length > MAX_BUFFER_SIZE) {
|
566
|
+
if (!bufferOverflow) {
|
567
|
+
bufferOverflow = true
|
568
|
+
const warning = `Error output buffer limit exceeded (${MAX_BUFFER_SIZE / 1024 / 1024}MB). Build errors are too large - data will be truncated.`
|
569
|
+
result.warnings.push(warning)
|
570
|
+
if (this.options.verbose) {
|
571
|
+
console.warn(` [Warning] ${warning}`)
|
572
|
+
}
|
573
|
+
}
|
574
|
+
// Explicitly skip this chunk - don't silently drop
|
575
|
+
return
|
576
|
+
}
|
577
|
+
|
578
|
+
stderrChunks.push(data)
|
579
|
+
stderrSize += data.length
|
580
|
+
|
581
|
+
// In verbose mode, show useful stderr output (warnings, progress, etc.)
|
582
|
+
if (this.options.verbose) {
|
583
|
+
const output = data.toString()
|
584
|
+
// Only show meaningful output, not just noise
|
585
|
+
const lines = output.split("\n")
|
586
|
+
lines.forEach((line) => {
|
587
|
+
if (line.trim()) {
|
588
|
+
console.log(` ${line}`)
|
589
|
+
}
|
590
|
+
})
|
591
|
+
}
|
592
|
+
})
|
593
|
+
|
594
|
+
child.on("exit", (code) => {
|
595
|
+
clearTimeout(timeoutId)
|
596
|
+
|
597
|
+
// Record timing
|
598
|
+
result.endTime = Date.now()
|
599
|
+
result.duration = result.endTime - (result.startTime || result.endTime)
|
600
|
+
|
601
|
+
// Combine chunks into strings
|
602
|
+
const stdoutData = Buffer.concat(stdoutChunks).toString()
|
603
|
+
const stderrData = Buffer.concat(stderrChunks).toString()
|
604
|
+
|
605
|
+
// Parse JSON output
|
606
|
+
try {
|
607
|
+
const jsonOutput: WebpackJsonOutput = JSON.parse(stdoutData)
|
608
|
+
|
609
|
+
// Extract output path if available
|
610
|
+
if (jsonOutput.outputPath) {
|
611
|
+
result.outputPath = jsonOutput.outputPath
|
612
|
+
}
|
613
|
+
|
614
|
+
// Check for errors in webpack/rspack JSON output
|
615
|
+
if (jsonOutput.errors && jsonOutput.errors.length > 0) {
|
616
|
+
jsonOutput.errors.forEach((error) => {
|
617
|
+
const errorMsg =
|
618
|
+
typeof error === "string"
|
619
|
+
? error
|
620
|
+
: error.message || String(error)
|
621
|
+
result.errors.push(errorMsg)
|
622
|
+
// Also add to output for visibility
|
623
|
+
if (!this.options.verbose) {
|
624
|
+
result.output.push(errorMsg)
|
625
|
+
}
|
626
|
+
})
|
627
|
+
}
|
628
|
+
|
629
|
+
// Check for warnings
|
630
|
+
if (jsonOutput.warnings && jsonOutput.warnings.length > 0) {
|
631
|
+
jsonOutput.warnings.forEach((warning) => {
|
632
|
+
const warningMsg =
|
633
|
+
typeof warning === "string"
|
634
|
+
? warning
|
635
|
+
: warning.message || String(warning)
|
636
|
+
result.warnings.push(warningMsg)
|
637
|
+
})
|
638
|
+
}
|
639
|
+
|
640
|
+
result.success =
|
641
|
+
code === 0 && (!jsonOutput.errors || jsonOutput.errors.length === 0)
|
642
|
+
|
643
|
+
// If build failed but no errors were captured, add helpful message
|
644
|
+
if (code !== 0 && result.errors.length === 0) {
|
645
|
+
result.errors.push(
|
646
|
+
`${bundler} exited with code ${code} but no errors were captured. ` +
|
647
|
+
`This may indicate a configuration issue. Run with --verbose for full output.`
|
648
|
+
)
|
649
|
+
}
|
650
|
+
} catch (err) {
|
651
|
+
// If JSON parsing fails, log the parsing error in verbose mode
|
652
|
+
if (this.options.verbose) {
|
653
|
+
const parseError = err instanceof Error ? err.message : String(err)
|
654
|
+
console.log(` [Debug] Failed to parse JSON output: ${parseError}`)
|
655
|
+
}
|
656
|
+
|
657
|
+
// Fall back to stderr analysis
|
658
|
+
if (stderrData && stderrData.length > 0) {
|
659
|
+
const lines = stderrData.split("\n")
|
660
|
+
lines.forEach((line) => {
|
661
|
+
if (ERROR_PATTERNS.some((pattern) => line.includes(pattern))) {
|
662
|
+
result.errors.push(line)
|
663
|
+
}
|
664
|
+
if (WARNING_PATTERNS.some((pattern) => line.includes(pattern))) {
|
665
|
+
result.warnings.push(line)
|
666
|
+
}
|
667
|
+
})
|
668
|
+
}
|
669
|
+
|
670
|
+
if (code !== 0) {
|
671
|
+
result.errors.push(`${bundler} exited with code ${code}.`)
|
672
|
+
}
|
673
|
+
|
674
|
+
result.success = code === 0 && result.errors.length === 0
|
675
|
+
}
|
676
|
+
|
677
|
+
// Add stderr to output if there were errors and not verbose
|
678
|
+
if (
|
679
|
+
!this.options.verbose &&
|
680
|
+
result.errors.length > 0 &&
|
681
|
+
stderrData &&
|
682
|
+
stderrData.length > 0
|
683
|
+
) {
|
684
|
+
result.output.push(stderrData)
|
685
|
+
}
|
686
|
+
|
687
|
+
resolve(result)
|
688
|
+
})
|
689
|
+
|
690
|
+
child.on("error", (err) => {
|
691
|
+
clearTimeout(timeoutId)
|
692
|
+
// Provide more helpful error messages for common spawn failures
|
693
|
+
let errorMessage = `Failed to start ${bundler}: ${err.message}`
|
694
|
+
|
695
|
+
// Check for specific error codes and provide actionable guidance
|
696
|
+
if ("code" in err) {
|
697
|
+
const code = (err as NodeJS.ErrnoException).code
|
698
|
+
if (code === "ENOENT") {
|
699
|
+
errorMessage += `. Binary not found. Install with: npm install -D ${bundler}`
|
700
|
+
} else if (code === "EMFILE" || code === "ENFILE") {
|
701
|
+
errorMessage += `. Too many open files. Increase system file descriptor limit or reduce concurrent builds`
|
702
|
+
} else if (code === "EACCES") {
|
703
|
+
errorMessage += `. Permission denied. Check file permissions for the binary`
|
704
|
+
}
|
705
|
+
}
|
706
|
+
|
707
|
+
result.errors.push(errorMessage)
|
708
|
+
resolve(result)
|
709
|
+
})
|
710
|
+
})
|
711
|
+
}
|
712
|
+
|
713
|
+
/**
|
714
|
+
* Finds the binary for webpack, rspack, or dev servers.
|
715
|
+
* Prefers local node_modules/.bin installation for security.
|
716
|
+
* Falls back to global installation and PATH resolution with a warning in verbose mode.
|
717
|
+
*
|
718
|
+
* SECURITY NOTE: The PATH fallback allows resolving binaries from the system PATH,
|
719
|
+
* which could be a security risk in untrusted environments where an attacker could
|
720
|
+
* manipulate the PATH environment variable. This fallback is included for flexibility
|
721
|
+
* and backward compatibility with systems that use npx or have binaries installed in
|
722
|
+
* non-standard locations. In production CI/CD environments, ensure binaries are
|
723
|
+
* installed locally in node_modules to avoid PATH resolution.
|
724
|
+
*
|
725
|
+
* @param name - The binary name to find (e.g., "webpack", "webpack-dev-server")
|
726
|
+
* @param appRoot - The application root directory
|
727
|
+
* @returns The path to the binary, or the bare name for PATH resolution
|
728
|
+
*/
|
729
|
+
private findBinary(name: string, appRoot: string): string | null {
|
730
|
+
// Try node_modules/.bin (preferred for security)
|
731
|
+
const nodeModulesBin = resolve(appRoot, "node_modules", ".bin", name)
|
732
|
+
if (existsSync(nodeModulesBin)) {
|
733
|
+
return nodeModulesBin
|
734
|
+
}
|
735
|
+
|
736
|
+
// Try global installation
|
737
|
+
const globalBin = resolve("/usr/local/bin", name)
|
738
|
+
if (existsSync(globalBin)) {
|
739
|
+
if (this.options.verbose) {
|
740
|
+
console.log(
|
741
|
+
` [Security Warning] Using global ${name} from /usr/local/bin. ` +
|
742
|
+
`Consider installing locally: npm install -D ${name}`
|
743
|
+
)
|
744
|
+
}
|
745
|
+
return globalBin
|
746
|
+
}
|
747
|
+
|
748
|
+
// Fall back to PATH resolution (least secure but most flexible)
|
749
|
+
// SECURITY: This allows the binary to be found via PATH, which could be
|
750
|
+
// exploited if an attacker controls the PATH environment variable.
|
751
|
+
|
752
|
+
// In strict mode (CI environments), fail instead of falling back to PATH
|
753
|
+
if (this.options.strictBinaryResolution) {
|
754
|
+
return null // Caller will handle the error
|
755
|
+
}
|
756
|
+
|
757
|
+
if (this.options.verbose) {
|
758
|
+
console.log(
|
759
|
+
` [Security Warning] Binary '${name}' not found locally. ` +
|
760
|
+
`Falling back to PATH resolution. In production, install locally: npm install -D ${name}`
|
761
|
+
)
|
762
|
+
}
|
763
|
+
|
764
|
+
// Return the bare binary name to use PATH resolution
|
765
|
+
// This maintains backward compatibility with npx and non-standard installations
|
766
|
+
return name
|
767
|
+
}
|
768
|
+
|
769
|
+
/**
|
770
|
+
* Formats validation results for display in the terminal.
|
771
|
+
* Shows a summary of all builds with success/failure status,
|
772
|
+
* error messages, warnings, and optional output logs.
|
773
|
+
*
|
774
|
+
* @param results - Array of validation results from all builds
|
775
|
+
* @returns Formatted string ready for console output
|
776
|
+
*/
|
777
|
+
formatResults(results: BuildValidationResult[]): string {
|
778
|
+
const lines: string[] = []
|
779
|
+
|
780
|
+
lines.push("\n" + "=".repeat(80))
|
781
|
+
lines.push("š Build Validation Results")
|
782
|
+
lines.push("=".repeat(80) + "\n")
|
783
|
+
|
784
|
+
let totalBuilds = results.length
|
785
|
+
let successCount = 0
|
786
|
+
let failureCount = 0
|
787
|
+
|
788
|
+
results.forEach((result) => {
|
789
|
+
if (result.success) {
|
790
|
+
successCount++
|
791
|
+
} else {
|
792
|
+
failureCount++
|
793
|
+
}
|
794
|
+
|
795
|
+
const icon = result.success ? "ā
" : "ā"
|
796
|
+
|
797
|
+
// Format timing information
|
798
|
+
let timingInfo = ""
|
799
|
+
if (result.duration !== undefined) {
|
800
|
+
const seconds = (result.duration / 1000).toFixed(2)
|
801
|
+
timingInfo = ` (${seconds}s)`
|
802
|
+
}
|
803
|
+
|
804
|
+
lines.push(`${icon} Build: ${result.buildName}${timingInfo}`)
|
805
|
+
|
806
|
+
// Show outputs (client/server bundles)
|
807
|
+
if (result.outputs && result.outputs.length > 0) {
|
808
|
+
lines.push(` š¦ Outputs: ${result.outputs.join(", ")}`)
|
809
|
+
}
|
810
|
+
|
811
|
+
// Show config file if specified
|
812
|
+
if (result.configFile) {
|
813
|
+
lines.push(` āļø Config: ${result.configFile}`)
|
814
|
+
}
|
815
|
+
|
816
|
+
// Show output directory if available
|
817
|
+
if (result.outputPath) {
|
818
|
+
lines.push(` š Output: ${result.outputPath}`)
|
819
|
+
}
|
820
|
+
|
821
|
+
if (result.warnings.length > 0) {
|
822
|
+
lines.push(` ā ļø ${result.warnings.length} warning(s)`)
|
823
|
+
}
|
824
|
+
|
825
|
+
if (result.errors.length > 0) {
|
826
|
+
lines.push(` ā ${result.errors.length} error(s)`)
|
827
|
+
result.errors.forEach((error) => {
|
828
|
+
lines.push(` ${error}`)
|
829
|
+
})
|
830
|
+
}
|
831
|
+
|
832
|
+
// Always show output if there are errors (unless verbose already showing it)
|
833
|
+
if (
|
834
|
+
result.output.length > 0 &&
|
835
|
+
(this.options.verbose || result.errors.length > 0)
|
836
|
+
) {
|
837
|
+
lines.push("\n Full Output:")
|
838
|
+
result.output.forEach((line) => {
|
839
|
+
lines.push(` ${line}`)
|
840
|
+
})
|
841
|
+
}
|
842
|
+
|
843
|
+
lines.push("")
|
844
|
+
})
|
845
|
+
|
846
|
+
lines.push("=".repeat(80))
|
847
|
+
|
848
|
+
// Calculate total time
|
849
|
+
const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0)
|
850
|
+
const totalSeconds = (totalDuration / 1000).toFixed(2)
|
851
|
+
|
852
|
+
lines.push(
|
853
|
+
`Summary: ${successCount}/${totalBuilds} builds passed, ${failureCount} failed (Total: ${totalSeconds}s)`
|
854
|
+
)
|
855
|
+
lines.push("=".repeat(80))
|
856
|
+
|
857
|
+
// Add debugging guidance if there are failures
|
858
|
+
if (failureCount > 0) {
|
859
|
+
lines.push("\nš” Debugging Tips:")
|
860
|
+
lines.push(
|
861
|
+
" To get more details, run individual builds with --verbose:"
|
862
|
+
)
|
863
|
+
lines.push("")
|
864
|
+
|
865
|
+
const failedBuilds = results.filter((r) => !r.success)
|
866
|
+
failedBuilds.forEach((result) => {
|
867
|
+
lines.push(
|
868
|
+
` bin/export-bundler-config --validate-build ${result.buildName} --verbose`
|
869
|
+
)
|
870
|
+
})
|
871
|
+
|
872
|
+
lines.push("")
|
873
|
+
lines.push(
|
874
|
+
" Or validate all builds with full output: bin/export-bundler-config --validate --verbose"
|
875
|
+
)
|
876
|
+
lines.push("=".repeat(80))
|
877
|
+
}
|
878
|
+
|
879
|
+
lines.push("")
|
880
|
+
|
881
|
+
return lines.join("\n")
|
882
|
+
}
|
883
|
+
}
|