shakapacker 8.4.0 → 9.7.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 (265) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/address-review.md +206 -0
  3. data/.claude/commands/update-changelog.md +354 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +6 -9
  5. data/.github/ISSUE_TEMPLATE/feature_request.md +6 -8
  6. data/.github/STATUS.md +1 -0
  7. data/.github/actionlint-matcher.json +17 -0
  8. data/.github/workflows/claude-code-review.yml +45 -0
  9. data/.github/workflows/claude.yml +55 -0
  10. data/.github/workflows/dummy.yml +18 -5
  11. data/.github/workflows/eslint-validation.yml +46 -0
  12. data/.github/workflows/generator.yml +38 -22
  13. data/.github/workflows/node.yml +116 -2
  14. data/.github/workflows/ruby.yml +57 -15
  15. data/.github/workflows/test-bundlers.yml +180 -0
  16. data/.gitignore +27 -0
  17. data/.husky/pre-commit +2 -0
  18. data/.npmignore +56 -0
  19. data/.prettierignore +7 -0
  20. data/.rubocop.yml +2 -0
  21. data/.yalcignore +26 -0
  22. data/CHANGELOG.md +487 -19
  23. data/CLAUDE.md +63 -0
  24. data/CONTRIBUTING.md +268 -21
  25. data/ESLINT_TECHNICAL_DEBT.md +165 -0
  26. data/README.md +497 -137
  27. data/Rakefile +44 -4
  28. data/TODO.md +58 -0
  29. data/TODO_v9.md +97 -0
  30. data/bin/conductor-exec +24 -0
  31. data/bin/shakapacker-config +11 -0
  32. data/conductor-setup.sh +147 -0
  33. data/conductor.json +9 -0
  34. data/docs/api-reference.md +519 -0
  35. data/docs/cdn_setup.md +384 -0
  36. data/docs/common-upgrades.md +695 -0
  37. data/docs/configuration.md +845 -0
  38. data/docs/css-modules-export-mode.md +566 -0
  39. data/docs/customizing_babel_config.md +16 -16
  40. data/docs/deployment.md +78 -7
  41. data/docs/developing_shakapacker.md +6 -0
  42. data/docs/early_hints.md +433 -0
  43. data/docs/early_hints_manual_api.md +454 -0
  44. data/docs/feature_testing.md +492 -0
  45. data/docs/node_package_api.md +70 -0
  46. data/docs/optional-peer-dependencies.md +203 -0
  47. data/docs/peer-dependencies.md +71 -0
  48. data/docs/precompile_hook.md +486 -0
  49. data/docs/preventing_fouc.md +132 -0
  50. data/docs/react.md +58 -48
  51. data/docs/releasing.md +288 -0
  52. data/docs/rspack.md +218 -0
  53. data/docs/rspack_migration_guide.md +862 -0
  54. data/docs/sprockets.md +1 -0
  55. data/docs/style_loader_vs_mini_css.md +12 -12
  56. data/docs/subresource_integrity.md +13 -7
  57. data/docs/transpiler-migration.md +212 -0
  58. data/docs/transpiler-performance.md +200 -0
  59. data/docs/troubleshooting.md +272 -24
  60. data/docs/typescript-migration.md +388 -0
  61. data/docs/typescript.md +103 -0
  62. data/docs/using_esbuild_loader.md +12 -12
  63. data/docs/using_swc_loader.md +121 -16
  64. data/docs/v6_upgrade.md +42 -19
  65. data/docs/v7_upgrade.md +8 -6
  66. data/docs/v8_upgrade.md +13 -12
  67. data/docs/v9_upgrade.md +616 -0
  68. data/eslint.config.fast.js +254 -0
  69. data/eslint.config.js +309 -0
  70. data/jest.config.js +8 -1
  71. data/knip.ts +61 -0
  72. data/lib/install/bin/shakapacker +4 -6
  73. data/lib/install/bin/shakapacker-config +11 -0
  74. data/lib/install/bin/shakapacker-dev-server +1 -1
  75. data/lib/install/binstubs.rb +6 -2
  76. data/lib/install/config/rspack/rspack.config.js +6 -0
  77. data/lib/install/config/rspack/rspack.config.ts +7 -0
  78. data/lib/install/config/shakapacker.yml +75 -12
  79. data/lib/install/config/webpack/webpack.config.ts +7 -0
  80. data/lib/install/package.json +38 -0
  81. data/lib/install/template.rb +207 -45
  82. data/lib/shakapacker/build_config_loader.rb +147 -0
  83. data/lib/shakapacker/bundler_switcher.rb +415 -0
  84. data/lib/shakapacker/compiler.rb +87 -0
  85. data/lib/shakapacker/configuration.rb +475 -6
  86. data/lib/shakapacker/dev_server.rb +88 -1
  87. data/lib/shakapacker/dev_server_runner.rb +240 -6
  88. data/lib/shakapacker/doctor.rb +1191 -0
  89. data/lib/shakapacker/env.rb +19 -3
  90. data/lib/shakapacker/helper.rb +411 -14
  91. data/lib/shakapacker/install/env.rb +33 -0
  92. data/lib/shakapacker/instance.rb +93 -4
  93. data/lib/shakapacker/manifest.rb +167 -30
  94. data/lib/shakapacker/railtie.rb +4 -0
  95. data/lib/shakapacker/rspack_runner.rb +19 -0
  96. data/lib/shakapacker/runner.rb +668 -9
  97. data/lib/shakapacker/swc_migrator.rb +384 -0
  98. data/lib/shakapacker/utils/manager.rb +2 -0
  99. data/lib/shakapacker/utils/version_syntax_converter.rb +1 -1
  100. data/lib/shakapacker/version.rb +1 -1
  101. data/lib/shakapacker/version_checker.rb +1 -1
  102. data/lib/shakapacker/webpack_runner.rb +4 -42
  103. data/lib/shakapacker.rb +159 -1
  104. data/lib/tasks/shakapacker/binstubs.rake +4 -2
  105. data/lib/tasks/shakapacker/check_binstubs.rake +2 -2
  106. data/lib/tasks/shakapacker/doctor.rake +48 -0
  107. data/lib/tasks/shakapacker/export_bundler_config.rake +68 -0
  108. data/lib/tasks/shakapacker/install.rake +16 -4
  109. data/lib/tasks/shakapacker/migrate_to_swc.rake +13 -0
  110. data/lib/tasks/shakapacker/switch_bundler.rake +72 -0
  111. data/lib/tasks/shakapacker.rake +2 -0
  112. data/package/.npmignore +4 -0
  113. data/package/babel/preset.ts +59 -0
  114. data/package/config.ts +189 -0
  115. data/package/configExporter/buildValidator.ts +906 -0
  116. data/package/configExporter/cli.ts +1748 -0
  117. data/package/configExporter/configDocs.ts +102 -0
  118. data/package/configExporter/configFile.ts +663 -0
  119. data/package/configExporter/fileWriter.ts +112 -0
  120. data/package/configExporter/index.ts +15 -0
  121. data/package/configExporter/types.ts +159 -0
  122. data/package/configExporter/yamlSerializer.ts +391 -0
  123. data/package/dev_server.ts +27 -0
  124. data/package/env.ts +92 -0
  125. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +36 -0
  126. data/package/environments/base.ts +147 -0
  127. data/package/environments/development.ts +88 -0
  128. data/package/environments/production.ts +82 -0
  129. data/package/environments/test.ts +55 -0
  130. data/package/environments/types.ts +98 -0
  131. data/package/esbuild/index.ts +40 -0
  132. data/package/index.d.ts +68 -93
  133. data/package/index.d.ts.template +72 -0
  134. data/package/index.ts +104 -0
  135. data/package/loaders.d.ts +28 -0
  136. data/package/optimization/rspack.ts +36 -0
  137. data/package/optimization/webpack.ts +55 -0
  138. data/package/plugins/envFilter.ts +82 -0
  139. data/package/plugins/rspack.ts +119 -0
  140. data/package/plugins/webpack.ts +82 -0
  141. data/package/rspack/index.ts +91 -0
  142. data/package/rules/{babel.js → babel.ts} +2 -2
  143. data/package/rules/{coffee.js → coffee.ts} +1 -1
  144. data/package/rules/css.ts +3 -0
  145. data/package/rules/{erb.js → erb.ts} +1 -1
  146. data/package/rules/esbuild.ts +10 -0
  147. data/package/rules/file.ts +41 -0
  148. data/package/rules/{jscommon.js → jscommon.ts} +5 -4
  149. data/package/rules/{less.js → less.ts} +4 -4
  150. data/package/rules/raw.ts +28 -0
  151. data/package/rules/rspack.ts +174 -0
  152. data/package/rules/sass.ts +21 -0
  153. data/package/rules/{stylus.js → stylus.ts} +4 -8
  154. data/package/rules/swc.ts +10 -0
  155. data/package/rules/{index.js → webpack.ts} +1 -2
  156. data/package/swc/index.ts +54 -0
  157. data/package/types/README.md +90 -0
  158. data/package/types/index.ts +69 -0
  159. data/package/types.ts +105 -0
  160. data/package/utils/bundlerUtils.ts +232 -0
  161. data/package/utils/configPath.ts +6 -0
  162. data/package/utils/debug.ts +45 -0
  163. data/package/utils/defaultConfigPath.ts +7 -0
  164. data/package/utils/ensureManifestExists.ts +17 -0
  165. data/package/utils/errorCodes.ts +249 -0
  166. data/package/utils/errorHelpers.ts +152 -0
  167. data/package/utils/getStyleRule.ts +75 -0
  168. data/package/utils/helpers.ts +99 -0
  169. data/package/utils/{inliningCss.js → inliningCss.ts} +3 -3
  170. data/package/utils/pathValidation.ts +207 -0
  171. data/package/utils/requireOrError.ts +24 -0
  172. data/package/utils/snakeToCamelCase.ts +5 -0
  173. data/package/utils/typeGuards.ts +388 -0
  174. data/package/utils/validateDependencies.ts +61 -0
  175. data/package/webpack-types.d.ts +33 -0
  176. data/package/webpackDevServerConfig.ts +130 -0
  177. data/package.json +157 -18
  178. data/scripts/remove-use-strict.js +44 -0
  179. data/scripts/type-check-no-emit.js +27 -0
  180. data/shakapacker.gemspec +4 -2
  181. data/sig/shakapacker/commands.rbs +35 -0
  182. data/sig/shakapacker/compiler.rbs +65 -0
  183. data/sig/shakapacker/compiler_strategy.rbs +41 -0
  184. data/sig/shakapacker/configuration.rbs +140 -0
  185. data/sig/shakapacker/dev_server.rbs +56 -0
  186. data/sig/shakapacker/env.rbs +25 -0
  187. data/sig/shakapacker/helper.rbs +98 -0
  188. data/sig/shakapacker/instance.rbs +46 -0
  189. data/sig/shakapacker/manifest.rbs +69 -0
  190. data/sig/shakapacker/version.rbs +4 -0
  191. data/sig/shakapacker.rbs +66 -0
  192. data/test/configExporter/buildValidator.test.js +1295 -0
  193. data/test/configExporter/configFile.test.js +393 -0
  194. data/test/configExporter/integration.test.js +262 -0
  195. data/test/helpers.js +1 -1
  196. data/test/package/bundlerUtils.rspack.test.js +145 -0
  197. data/test/package/bundlerUtils.test.js +97 -0
  198. data/test/package/config.test.js +14 -0
  199. data/test/package/configExporter/cli.test.js +440 -0
  200. data/test/package/configExporter/types.test.js +163 -0
  201. data/test/package/configExporter.test.js +491 -0
  202. data/test/package/env.test.js +42 -7
  203. data/test/package/environments/base.test.js +14 -4
  204. data/test/package/helpers.test.js +2 -2
  205. data/test/package/plugins/envFiltering.test.js +453 -0
  206. data/test/package/plugins/webpackSubresourceIntegrity.test.js +89 -0
  207. data/test/package/rspack/index.test.js +293 -0
  208. data/test/package/rspack/optimization.test.js +86 -0
  209. data/test/package/rspack/plugins.test.js +185 -0
  210. data/test/package/rspack/rules.test.js +229 -0
  211. data/test/package/rules/babel.test.js +65 -38
  212. data/test/package/rules/esbuild.test.js +13 -4
  213. data/test/package/rules/file.test.js +7 -1
  214. data/test/package/rules/raw.test.js +40 -7
  215. data/test/package/rules/sass-version-parsing.test.js +71 -0
  216. data/test/package/rules/sass.test.js +11 -6
  217. data/test/package/rules/sass1.test.js +8 -5
  218. data/test/package/rules/sass16.test.js +24 -0
  219. data/test/package/rules/swc.test.js +50 -39
  220. data/test/package/rules/webpack.test.js +35 -0
  221. data/test/package/staging.test.js +4 -3
  222. data/test/package/transpiler-defaults.test.js +169 -0
  223. data/test/package/utils/ensureManifestExists.test.js +51 -0
  224. data/test/package/yamlSerializer.test.js +204 -0
  225. data/test/peer-dependencies.sh +85 -0
  226. data/test/resolver.js +34 -3
  227. data/test/scripts/remove-use-strict.test.js +125 -0
  228. data/test/typescript/build.test.js +118 -0
  229. data/test/typescript/environments.test.js +107 -0
  230. data/test/typescript/pathValidation.test.js +186 -0
  231. data/test/typescript/requireOrError.test.js +49 -0
  232. data/test/typescript/securityValidation.test.js +182 -0
  233. data/tools/README.md +134 -0
  234. data/tools/css-modules-v9-codemod.js +179 -0
  235. data/tsconfig.eslint.json +9 -0
  236. data/tsconfig.json +38 -0
  237. data/yarn.lock +3202 -1097
  238. metadata +212 -44
  239. data/.eslintignore +0 -4
  240. data/.eslintrc.js +0 -36
  241. data/Gemfile.lock +0 -251
  242. data/package/babel/preset.js +0 -48
  243. data/package/config.js +0 -56
  244. data/package/dev_server.js +0 -23
  245. data/package/env.js +0 -48
  246. data/package/environments/base.js +0 -171
  247. data/package/environments/development.js +0 -13
  248. data/package/environments/production.js +0 -88
  249. data/package/environments/test.js +0 -3
  250. data/package/esbuild/index.js +0 -40
  251. data/package/index.js +0 -40
  252. data/package/rules/css.js +0 -3
  253. data/package/rules/esbuild.js +0 -10
  254. data/package/rules/file.js +0 -29
  255. data/package/rules/raw.js +0 -5
  256. data/package/rules/sass.js +0 -18
  257. data/package/rules/swc.js +0 -10
  258. data/package/swc/index.js +0 -50
  259. data/package/utils/configPath.js +0 -4
  260. data/package/utils/defaultConfigPath.js +0 -2
  261. data/package/utils/getStyleRule.js +0 -40
  262. data/package/utils/helpers.js +0 -62
  263. data/package/utils/snakeToCamelCase.js +0 -5
  264. data/package/webpackDevServerConfig.js +0 -71
  265. data/test/package/rules/index.test.js +0 -16
@@ -0,0 +1,906 @@
1
+ import { spawn } from "child_process"
2
+ import { existsSync } from "fs"
3
+ import { resolve, relative } 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 static 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
226
+
227
+ if (isHMR) {
228
+ return this.validateHMRBuild(build, appRoot, bundler)
229
+ }
230
+ return this.validateStaticBuild(build, appRoot, bundler)
231
+ }
232
+
233
+ /**
234
+ * Validates an HMR build by starting webpack-dev-server
235
+ * Waits for successful compilation, then shuts down
236
+ */
237
+ private async validateHMRBuild(
238
+ build: ResolvedBuildConfig,
239
+ appRoot: string,
240
+ bundler: "webpack" | "rspack"
241
+ ): Promise<BuildValidationResult> {
242
+ const startTime = Date.now()
243
+ const result: BuildValidationResult = {
244
+ buildName: build.name,
245
+ success: false,
246
+ errors: [],
247
+ warnings: [],
248
+ output: [],
249
+ outputs: build.outputs,
250
+ configFile: build.configFile,
251
+ startTime
252
+ }
253
+
254
+ // Determine the dev server command
255
+ const devServerCmd =
256
+ bundler === "rspack" ? "rspack-dev-server" : "webpack-dev-server"
257
+ const devServerBin = this.findBinary(devServerCmd, appRoot)
258
+
259
+ if (!devServerBin) {
260
+ const packageManager = existsSync(resolve(appRoot, "yarn.lock"))
261
+ ? "yarn add"
262
+ : "npm install"
263
+ result.errors.push(
264
+ `Could not find ${devServerCmd} binary. Please install it:\n` +
265
+ ` ${packageManager} -D ${bundler}-dev-server`
266
+ )
267
+ return result
268
+ }
269
+
270
+ // Build arguments
271
+ const args: string[] = []
272
+
273
+ // Add config file if specified
274
+ if (build.configFile) {
275
+ try {
276
+ const configPath = BuildValidator.validateConfigPath(
277
+ build.configFile,
278
+ appRoot,
279
+ build.name
280
+ )
281
+ args.push("--config", configPath)
282
+ } catch (error) {
283
+ const errorMessage =
284
+ error instanceof Error ? error.message : String(error)
285
+ result.errors.push(errorMessage)
286
+ return result
287
+ }
288
+ } else {
289
+ // Use default config path
290
+ const defaultConfig = resolve(
291
+ appRoot,
292
+ `config/${bundler}/${bundler}.config.js`
293
+ )
294
+ if (existsSync(defaultConfig)) {
295
+ args.push("--config", defaultConfig)
296
+ }
297
+ }
298
+
299
+ // Add bundler env args (--env flags)
300
+ if (build.bundlerEnvArgs && build.bundlerEnvArgs.length > 0) {
301
+ args.push(...build.bundlerEnvArgs)
302
+ }
303
+
304
+ return new Promise((resolvePromise) => {
305
+ const child = spawn(devServerBin, args, {
306
+ cwd: appRoot,
307
+ env: this.filterEnvironment(build.environment),
308
+ stdio: ["ignore", "pipe", "pipe"]
309
+ })
310
+
311
+ let hasCompiled = false
312
+ let hasError = false
313
+ let resolved = false
314
+ let processKilled = false
315
+
316
+ const resolveOnce = (res: BuildValidationResult) => {
317
+ if (!resolved) {
318
+ resolved = true
319
+ resolvePromise(res)
320
+ }
321
+ }
322
+
323
+ const timeoutId = setTimeout(() => {
324
+ if (!hasCompiled && !resolved && !processKilled) {
325
+ result.errors.push(
326
+ `Timeout: webpack-dev-server did not compile within ${this.options.timeout}ms.`
327
+ )
328
+ processKilled = true
329
+ child.kill("SIGTERM")
330
+ // Remove listeners to prevent further callbacks
331
+ child.stdout?.removeAllListeners()
332
+ child.stderr?.removeAllListeners()
333
+ child.removeAllListeners()
334
+ resolveOnce(result)
335
+ }
336
+ }, this.options.timeout)
337
+
338
+ const processOutput = (data: Buffer) => {
339
+ const lines = data.toString().split("\n")
340
+ lines.forEach((line) => {
341
+ if (!line.trim()) return
342
+
343
+ // Always output in real-time in verbose mode so user sees progress
344
+ if (this.options.verbose) {
345
+ console.log(` ${line}`)
346
+ }
347
+
348
+ // Store all output
349
+ result.output.push(line)
350
+
351
+ // Check for successful compilation
352
+ // Only match success patterns if the line doesn't start with ERROR: or WARNING:
353
+ const isErrorOrWarning =
354
+ line.trim().startsWith("ERROR") || line.trim().startsWith("WARNING")
355
+ if (
356
+ !processKilled &&
357
+ !isErrorOrWarning &&
358
+ SUCCESS_PATTERNS.some((pattern) => line.includes(pattern))
359
+ ) {
360
+ hasCompiled = true
361
+ result.success = true
362
+ // Set processKilled BEFORE clearing timeout to prevent race condition
363
+ // where timeout could fire between clearTimeout and setting the flag
364
+ processKilled = true
365
+ clearTimeout(timeoutId)
366
+ child.kill("SIGTERM")
367
+ // Don't call resolveOnce here - let the exit handler do it
368
+ // This ensures proper cleanup order and avoids race conditions
369
+
370
+ // Safety timeout: if process doesn't exit within 5 seconds, force resolve
371
+ // This prevents hanging if kill() fails or process is unresponsive
372
+ setTimeout(() => {
373
+ if (!resolved) {
374
+ if (this.options.verbose) {
375
+ console.warn(
376
+ ` [Warning] Process did not exit after SIGTERM, forcing resolution.`
377
+ )
378
+ }
379
+ child.stdout?.removeAllListeners()
380
+ child.stderr?.removeAllListeners()
381
+ child.removeAllListeners()
382
+ resolveOnce(result)
383
+ }
384
+ }, KILL_SAFETY_TIMEOUT_MS)
385
+ }
386
+
387
+ // Check for errors
388
+ if (ERROR_PATTERNS.some((pattern) => line.includes(pattern))) {
389
+ hasError = true
390
+ result.errors.push(line)
391
+ }
392
+
393
+ // Check for warnings
394
+ if (WARNING_PATTERNS.some((pattern) => line.includes(pattern))) {
395
+ result.warnings.push(line)
396
+ }
397
+ })
398
+ }
399
+
400
+ child.stdout?.on("data", (data: Buffer) => processOutput(data))
401
+ child.stderr?.on("data", (data: Buffer) => processOutput(data))
402
+
403
+ child.on("exit", (code) => {
404
+ clearTimeout(timeoutId)
405
+ // Clean up listeners after exit
406
+ child.stdout?.removeAllListeners()
407
+ child.stderr?.removeAllListeners()
408
+ child.removeAllListeners()
409
+
410
+ // Record timing
411
+ result.endTime = Date.now()
412
+ result.duration = result.endTime - (result.startTime || result.endTime)
413
+
414
+ if (!hasCompiled && !hasError && !resolved) {
415
+ if (code !== 0 && code !== null && code !== SIGTERM_EXIT_CODE) {
416
+ result.errors.push(
417
+ `${devServerCmd} exited with code ${code} before compilation completed.`
418
+ )
419
+ }
420
+ }
421
+ resolveOnce(result)
422
+ })
423
+
424
+ child.on("error", (err) => {
425
+ clearTimeout(timeoutId)
426
+ // Provide more helpful error messages for common spawn failures
427
+ let errorMessage = `Failed to start ${devServerCmd}: ${err.message}`
428
+
429
+ // Check for specific error codes and provide actionable guidance
430
+ if ("code" in err) {
431
+ const { code } = err as NodeJS.ErrnoException
432
+ if (code === "ENOENT") {
433
+ errorMessage += `. Binary not found. Install with: npm install -D ${devServerCmd}`
434
+ } else if (code === "EMFILE" || code === "ENFILE") {
435
+ errorMessage += `. Too many open files. Increase system file descriptor limit or reduce concurrent builds`
436
+ } else if (code === "EACCES") {
437
+ errorMessage += `. Permission denied. Check file permissions for the binary`
438
+ }
439
+ }
440
+
441
+ result.errors.push(errorMessage)
442
+ resolveOnce(result)
443
+ })
444
+ })
445
+ }
446
+
447
+ /**
448
+ * Validates a static build by running webpack/rspack in production mode
449
+ * Uses --json flag to get structured output
450
+ */
451
+ private async validateStaticBuild(
452
+ build: ResolvedBuildConfig,
453
+ appRoot: string,
454
+ bundler: "webpack" | "rspack"
455
+ ): Promise<BuildValidationResult> {
456
+ const startTime = Date.now()
457
+ const result: BuildValidationResult = {
458
+ buildName: build.name,
459
+ success: false,
460
+ errors: [],
461
+ warnings: [],
462
+ output: [],
463
+ outputs: build.outputs,
464
+ configFile: build.configFile,
465
+ startTime
466
+ }
467
+
468
+ const bundlerBin = this.findBinary(bundler, appRoot)
469
+
470
+ if (!bundlerBin) {
471
+ const packageManager = existsSync(resolve(appRoot, "yarn.lock"))
472
+ ? "yarn add"
473
+ : "npm install"
474
+ result.errors.push(
475
+ `Could not find ${bundler} binary. Please install it:\n` +
476
+ ` ${packageManager} -D ${bundler}`
477
+ )
478
+ return result
479
+ }
480
+
481
+ // Build arguments - use --dry-run if available, otherwise just build
482
+ const args: string[] = []
483
+
484
+ // Add config file if specified
485
+ if (build.configFile) {
486
+ try {
487
+ const configPath = BuildValidator.validateConfigPath(
488
+ build.configFile,
489
+ appRoot,
490
+ build.name
491
+ )
492
+ args.push("--config", configPath)
493
+ } catch (error) {
494
+ const errorMessage =
495
+ error instanceof Error ? error.message : String(error)
496
+ result.errors.push(errorMessage)
497
+ return result
498
+ }
499
+ } else {
500
+ // Use default config path
501
+ const defaultConfig = resolve(
502
+ appRoot,
503
+ `config/${bundler}/${bundler}.config.js`
504
+ )
505
+ if (existsSync(defaultConfig)) {
506
+ args.push("--config", defaultConfig)
507
+ }
508
+ }
509
+
510
+ // Add bundler env args (--env flags)
511
+ if (build.bundlerEnvArgs && build.bundlerEnvArgs.length > 0) {
512
+ args.push(...build.bundlerEnvArgs)
513
+ }
514
+
515
+ // Add --json for structured output (helps parse errors)
516
+ args.push("--json")
517
+
518
+ return new Promise((resolvePromise) => {
519
+ const child = spawn(bundlerBin, args, {
520
+ cwd: appRoot,
521
+ env: this.filterEnvironment(build.environment),
522
+ stdio: ["ignore", "pipe", "pipe"]
523
+ })
524
+
525
+ const stdoutChunks: Buffer[] = []
526
+ const stderrChunks: Buffer[] = []
527
+
528
+ let stdoutSize = 0
529
+ let stderrSize = 0
530
+ let bufferOverflow = false
531
+
532
+ const timeoutId = setTimeout(() => {
533
+ result.errors.push(
534
+ `Timeout: ${bundler} did not complete within ${this.options.timeout}ms.`
535
+ )
536
+ child.kill("SIGTERM")
537
+ resolvePromise(result)
538
+ }, this.options.timeout)
539
+
540
+ child.stdout?.on("data", (data: Buffer) => {
541
+ // Check buffer size to prevent memory issues
542
+ if (stdoutSize + data.length > MAX_BUFFER_SIZE) {
543
+ if (!bufferOverflow) {
544
+ bufferOverflow = true
545
+ const warning = `Output buffer limit exceeded (${MAX_BUFFER_SIZE / 1024 / 1024}MB). Build output is too large - data will be truncated.`
546
+ result.warnings.push(warning)
547
+ if (this.options.verbose) {
548
+ console.warn(` [Warning] ${warning}`)
549
+ }
550
+ }
551
+ // Explicitly skip this chunk - don't silently drop
552
+ return
553
+ }
554
+
555
+ stdoutChunks.push(data)
556
+ stdoutSize += data.length
557
+
558
+ // Don't output JSON in verbose mode - it's too large and not useful
559
+ // JSON is for parsing errors, not for human consumption
560
+ })
561
+
562
+ child.stderr?.on("data", (data: Buffer) => {
563
+ // Check buffer size
564
+ if (stderrSize + data.length > MAX_BUFFER_SIZE) {
565
+ if (!bufferOverflow) {
566
+ bufferOverflow = true
567
+ const warning = `Error output buffer limit exceeded (${MAX_BUFFER_SIZE / 1024 / 1024}MB). Build errors are too large - data will be truncated.`
568
+ result.warnings.push(warning)
569
+ if (this.options.verbose) {
570
+ console.warn(` [Warning] ${warning}`)
571
+ }
572
+ }
573
+ // Explicitly skip this chunk - don't silently drop
574
+ return
575
+ }
576
+
577
+ stderrChunks.push(data)
578
+ stderrSize += data.length
579
+
580
+ // In verbose mode, show useful stderr output (warnings, progress, etc.)
581
+ if (this.options.verbose) {
582
+ const output = data.toString()
583
+ // Only show meaningful output, not just noise
584
+ const lines = output.split("\n")
585
+ lines.forEach((line) => {
586
+ if (line.trim()) {
587
+ console.log(` ${line}`)
588
+ }
589
+ })
590
+ }
591
+ })
592
+
593
+ child.on("exit", (code) => {
594
+ clearTimeout(timeoutId)
595
+
596
+ // Record timing
597
+ result.endTime = Date.now()
598
+ result.duration = result.endTime - (result.startTime || result.endTime)
599
+
600
+ // Combine chunks into strings
601
+ const stdoutData = Buffer.concat(stdoutChunks).toString()
602
+ const stderrData = Buffer.concat(stderrChunks).toString()
603
+
604
+ // Parse JSON output
605
+ try {
606
+ const jsonOutput = JSON.parse(stdoutData) as WebpackJsonOutput
607
+
608
+ // Extract output path if available
609
+ if (jsonOutput.outputPath) {
610
+ result.outputPath = jsonOutput.outputPath
611
+ }
612
+
613
+ // Check for errors in webpack/rspack JSON output
614
+ if (jsonOutput.errors && jsonOutput.errors.length > 0) {
615
+ jsonOutput.errors.forEach((error) => {
616
+ let errorMsg: string
617
+ if (typeof error === "string") {
618
+ errorMsg = error
619
+ } else if (error.message) {
620
+ errorMsg = error.message
621
+ } else {
622
+ // Attempt to extract useful info from malformed error using all enumerable props
623
+ try {
624
+ errorMsg = JSON.stringify(
625
+ error,
626
+ Object.getOwnPropertyNames(error)
627
+ )
628
+ } catch {
629
+ errorMsg = "[Error object with no message]"
630
+ }
631
+ }
632
+ result.errors.push(errorMsg)
633
+ // Also add to output for visibility
634
+ if (!this.options.verbose) {
635
+ result.output.push(errorMsg)
636
+ }
637
+ })
638
+ }
639
+
640
+ // Check for warnings
641
+ if (jsonOutput.warnings && jsonOutput.warnings.length > 0) {
642
+ jsonOutput.warnings.forEach((warning) => {
643
+ let warningMsg: string
644
+ if (typeof warning === "string") {
645
+ warningMsg = warning
646
+ } else if (warning.message) {
647
+ warningMsg = warning.message
648
+ } else {
649
+ // Attempt to extract useful info from malformed warning using all enumerable props
650
+ try {
651
+ warningMsg = JSON.stringify(
652
+ warning,
653
+ Object.getOwnPropertyNames(warning)
654
+ )
655
+ } catch {
656
+ warningMsg = "[Warning object with no message]"
657
+ }
658
+ }
659
+ result.warnings.push(warningMsg)
660
+ })
661
+ }
662
+
663
+ result.success =
664
+ code === 0 && (!jsonOutput.errors || jsonOutput.errors.length === 0)
665
+
666
+ // If build failed but no errors were captured, add helpful message
667
+ if (code !== 0 && result.errors.length === 0) {
668
+ result.errors.push(
669
+ `${bundler} exited with code ${code} but no errors were captured. ` +
670
+ `This may indicate a configuration issue. Run with --verbose for full output.`
671
+ )
672
+ }
673
+ } catch (err) {
674
+ // If JSON parsing fails, log the parsing error in verbose mode
675
+ if (this.options.verbose) {
676
+ const parseError = err instanceof Error ? err.message : String(err)
677
+ console.log(` [Debug] Failed to parse JSON output: ${parseError}`)
678
+ }
679
+
680
+ // Fall back to stderr analysis
681
+ if (stderrData && stderrData.length > 0) {
682
+ const lines = stderrData.split("\n")
683
+ lines.forEach((line) => {
684
+ if (ERROR_PATTERNS.some((pattern) => line.includes(pattern))) {
685
+ result.errors.push(line)
686
+ }
687
+ if (WARNING_PATTERNS.some((pattern) => line.includes(pattern))) {
688
+ result.warnings.push(line)
689
+ }
690
+ })
691
+ }
692
+
693
+ if (code !== 0) {
694
+ result.errors.push(`${bundler} exited with code ${code}.`)
695
+ }
696
+
697
+ result.success = code === 0 && result.errors.length === 0
698
+ }
699
+
700
+ // Add stderr to output if there were errors and not verbose
701
+ if (
702
+ !this.options.verbose &&
703
+ result.errors.length > 0 &&
704
+ stderrData &&
705
+ stderrData.length > 0
706
+ ) {
707
+ result.output.push(stderrData)
708
+ }
709
+
710
+ resolvePromise(result)
711
+ })
712
+
713
+ child.on("error", (err) => {
714
+ clearTimeout(timeoutId)
715
+ // Provide more helpful error messages for common spawn failures
716
+ let errorMessage = `Failed to start ${bundler}: ${err.message}`
717
+
718
+ // Check for specific error codes and provide actionable guidance
719
+ if ("code" in err) {
720
+ const { code } = err as NodeJS.ErrnoException
721
+ if (code === "ENOENT") {
722
+ errorMessage += `. Binary not found. Install with: npm install -D ${bundler}`
723
+ } else if (code === "EMFILE" || code === "ENFILE") {
724
+ errorMessage += `. Too many open files. Increase system file descriptor limit or reduce concurrent builds`
725
+ } else if (code === "EACCES") {
726
+ errorMessage += `. Permission denied. Check file permissions for the binary`
727
+ }
728
+ }
729
+
730
+ result.errors.push(errorMessage)
731
+ resolvePromise(result)
732
+ })
733
+ })
734
+ }
735
+
736
+ /**
737
+ * Finds the binary for webpack, rspack, or dev servers.
738
+ * Prefers local node_modules/.bin installation for security.
739
+ * Falls back to global installation and PATH resolution with a warning in verbose mode.
740
+ *
741
+ * SECURITY NOTE: The PATH fallback allows resolving binaries from the system PATH,
742
+ * which could be a security risk in untrusted environments where an attacker could
743
+ * manipulate the PATH environment variable. This fallback is included for flexibility
744
+ * and backward compatibility with systems that use npx or have binaries installed in
745
+ * non-standard locations. In production CI/CD environments, ensure binaries are
746
+ * installed locally in node_modules to avoid PATH resolution.
747
+ *
748
+ * @param name - The binary name to find (e.g., "webpack", "webpack-dev-server")
749
+ * @param appRoot - The application root directory
750
+ * @returns The path to the binary, or the bare name for PATH resolution
751
+ */
752
+ private findBinary(name: string, appRoot: string): string | null {
753
+ // Try node_modules/.bin (preferred for security)
754
+ const nodeModulesBin = resolve(appRoot, "node_modules", ".bin", name)
755
+ if (existsSync(nodeModulesBin)) {
756
+ return nodeModulesBin
757
+ }
758
+
759
+ // Try global installation
760
+ const globalBin = resolve("/usr/local/bin", name)
761
+ if (existsSync(globalBin)) {
762
+ if (this.options.verbose) {
763
+ console.log(
764
+ ` [Security Warning] Using global ${name} from /usr/local/bin. ` +
765
+ `Consider installing locally: npm install -D ${name}`
766
+ )
767
+ }
768
+ return globalBin
769
+ }
770
+
771
+ // Fall back to PATH resolution (least secure but most flexible)
772
+ // SECURITY: This allows the binary to be found via PATH, which could be
773
+ // exploited if an attacker controls the PATH environment variable.
774
+
775
+ // In strict mode (CI environments), fail instead of falling back to PATH
776
+ if (this.options.strictBinaryResolution) {
777
+ return null // Caller will handle the error
778
+ }
779
+
780
+ if (this.options.verbose) {
781
+ console.log(
782
+ ` [Security Warning] Binary '${name}' not found locally. ` +
783
+ `Falling back to PATH resolution. In production, install locally: npm install -D ${name}`
784
+ )
785
+ }
786
+
787
+ // Return the bare binary name to use PATH resolution
788
+ // This maintains backward compatibility with npx and non-standard installations
789
+ return name
790
+ }
791
+
792
+ /**
793
+ * Formats validation results for display in the terminal.
794
+ * Shows a summary of all builds with success/failure status,
795
+ * error messages, warnings, and optional output logs.
796
+ *
797
+ * @param results - Array of validation results from all builds
798
+ * @returns Formatted string ready for console output
799
+ */
800
+ formatResults(results: BuildValidationResult[]): string {
801
+ const lines: string[] = []
802
+
803
+ lines.push(`\n${"=".repeat(80)}`)
804
+ lines.push("šŸ” Build Validation Results")
805
+ lines.push(`${"=".repeat(80)}\n`)
806
+
807
+ const totalBuilds = results.length
808
+ let successCount = 0
809
+ let failureCount = 0
810
+
811
+ results.forEach((result) => {
812
+ if (result.success) {
813
+ successCount += 1
814
+ } else {
815
+ failureCount += 1
816
+ }
817
+
818
+ const icon = result.success ? "āœ…" : "āŒ"
819
+
820
+ // Format timing information
821
+ let timingInfo = ""
822
+ if (result.duration !== undefined) {
823
+ const seconds = (result.duration / 1000).toFixed(2)
824
+ timingInfo = ` (${seconds}s)`
825
+ }
826
+
827
+ lines.push(`${icon} Build: ${result.buildName}${timingInfo}`)
828
+
829
+ // Show outputs (client/server bundles)
830
+ if (result.outputs && result.outputs.length > 0) {
831
+ lines.push(` šŸ“¦ Outputs: ${result.outputs.join(", ")}`)
832
+ }
833
+
834
+ // Show config file if specified
835
+ if (result.configFile) {
836
+ lines.push(` āš™ļø Config: ${result.configFile}`)
837
+ }
838
+
839
+ // Show output directory if available
840
+ if (result.outputPath) {
841
+ lines.push(` šŸ“ Output: ${result.outputPath}`)
842
+ }
843
+
844
+ if (result.warnings.length > 0) {
845
+ lines.push(` āš ļø ${result.warnings.length} warning(s)`)
846
+ }
847
+
848
+ if (result.errors.length > 0) {
849
+ lines.push(` āŒ ${result.errors.length} error(s)`)
850
+ result.errors.forEach((error) => {
851
+ lines.push(` ${error}`)
852
+ })
853
+ }
854
+
855
+ // Always show output if there are errors (unless verbose already showing it)
856
+ if (
857
+ result.output.length > 0 &&
858
+ (this.options.verbose || result.errors.length > 0)
859
+ ) {
860
+ lines.push("\n Full Output:")
861
+ result.output.forEach((line) => {
862
+ lines.push(` ${line}`)
863
+ })
864
+ }
865
+
866
+ lines.push("")
867
+ })
868
+
869
+ lines.push("=".repeat(80))
870
+
871
+ // Calculate total time
872
+ const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0)
873
+ const totalSeconds = (totalDuration / 1000).toFixed(2)
874
+
875
+ lines.push(
876
+ `Summary: ${successCount}/${totalBuilds} builds passed, ${failureCount} failed (Total: ${totalSeconds}s)`
877
+ )
878
+ lines.push("=".repeat(80))
879
+
880
+ // Add debugging guidance if there are failures
881
+ if (failureCount > 0) {
882
+ lines.push("\nšŸ’” Debugging Tips:")
883
+ lines.push(
884
+ " To get more details, run individual builds with --verbose:"
885
+ )
886
+ lines.push("")
887
+
888
+ const failedBuilds = results.filter((r) => !r.success)
889
+ failedBuilds.forEach((result) => {
890
+ lines.push(
891
+ ` bin/shakapacker-config --validate-build ${result.buildName} --verbose`
892
+ )
893
+ })
894
+
895
+ lines.push("")
896
+ lines.push(
897
+ " Or validate all builds with full output: bin/shakapacker-config --validate --verbose"
898
+ )
899
+ lines.push("=".repeat(80))
900
+ }
901
+
902
+ lines.push("")
903
+
904
+ return lines.join("\n")
905
+ }
906
+ }