@cdxgen/cdxgen 13.2.0 → 13.4.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 (311) hide show
  1. package/README.md +33 -14
  2. package/bin/cdxgen.js +310 -138
  3. package/bin/evinse.js +22 -0
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +119 -15
  6. package/bin/tracebom.js +69 -5
  7. package/bin/validate.js +107 -19
  8. package/bin/verify.js +172 -63
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +85 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/bomAssembly.js +105 -15
  15. package/lib/cli/cliOptions.js +93 -1
  16. package/lib/cli/index.js +156 -58
  17. package/lib/cli/jsBom.js +99 -53
  18. package/lib/cli/jvmBom.js +708 -147
  19. package/lib/cli/managedBom.js +415 -224
  20. package/lib/cli/nativeBom.js +264 -44
  21. package/lib/core/activity.js +12 -0
  22. package/lib/core/env.js +22 -15
  23. package/lib/core/fs.js +176 -7
  24. package/lib/core/httpClient.js +159 -24
  25. package/lib/core/metadataProperties.js +97 -0
  26. package/lib/core/pluginRun.js +184 -0
  27. package/lib/core/processTree.js +100 -0
  28. package/lib/core/projectConfig.js +31 -4
  29. package/lib/core/runState.js +39 -0
  30. package/lib/core/toml.js +72 -0
  31. package/lib/ecosystems/asarutils.js +4 -2
  32. package/lib/ecosystems/bunutils.js +274 -108
  33. package/lib/ecosystems/cargoCache.js +231 -8
  34. package/lib/ecosystems/caxa.js +434 -2
  35. package/lib/ecosystems/cmakeResolver.js +76 -21
  36. package/lib/ecosystems/cppBuildContext.js +631 -0
  37. package/lib/ecosystems/cppEvidence.js +399 -51
  38. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  39. package/lib/ecosystems/denoutils.js +93 -66
  40. package/lib/ecosystems/ecosystems.js +1983 -793
  41. package/lib/ecosystems/golem.js +28 -12
  42. package/lib/ecosystems/gradleutils.js +356 -37
  43. package/lib/ecosystems/jsEvidence.js +104 -44
  44. package/lib/ecosystems/kosi.js +31 -7
  45. package/lib/ecosystems/npmutils.js +174 -9
  46. package/lib/ecosystems/parsers-dotnet.js +616 -159
  47. package/lib/ecosystems/parsers-elm.js +46 -28
  48. package/lib/ecosystems/parsers-gleam.js +3 -4
  49. package/lib/ecosystems/parsers-go.js +371 -70
  50. package/lib/ecosystems/parsers-js.js +263 -76
  51. package/lib/ecosystems/parsers-julia.js +2 -3
  52. package/lib/ecosystems/parsers-jvm.js +204 -12
  53. package/lib/ecosystems/parsers-misc.js +929 -216
  54. package/lib/ecosystems/parsers-python.js +167 -24
  55. package/lib/ecosystems/parsers-rust.js +28 -22
  56. package/lib/ecosystems/parsers-terraform.js +1573 -19
  57. package/lib/ecosystems/pythonutils.js +102 -6
  58. package/lib/ecosystems/remote/huggingface.js +3 -0
  59. package/lib/ecosystems/rubyutils.js +1492 -182
  60. package/lib/ecosystems/rusi.js +54 -10
  61. package/lib/ecosystems/sbtutils.js +243 -187
  62. package/lib/ecosystems/scalacliutils.js +342 -0
  63. package/lib/ecosystems/scalasem.js +2618 -0
  64. package/lib/ecosystems/terraformRegistry.js +288 -0
  65. package/lib/ecosystems/utils.js +2 -0
  66. package/lib/ecosystems/vendoredCode.js +156 -0
  67. package/lib/evinser/evinser.js +1008 -191
  68. package/lib/evinser/swiftsem.js +1914 -302
  69. package/lib/helpers/agenticToolutils.js +2 -2
  70. package/lib/helpers/bomSigner.js +1101 -222
  71. package/lib/inventory/aiCollector.js +4 -1
  72. package/lib/inventory/analyzer.js +110 -127
  73. package/lib/inventory/atomUtils.js +703 -43
  74. package/lib/inventory/auditCategories.js +7 -0
  75. package/lib/inventory/buildToolRateLimit.js +144 -0
  76. package/lib/inventory/cacheCommands.js +2 -1
  77. package/lib/inventory/cdxrs.js +103 -12
  78. package/lib/inventory/cmakeBuildDirs.js +254 -0
  79. package/lib/inventory/deps.js +501 -124
  80. package/lib/inventory/depsUtils.js +183 -11
  81. package/lib/inventory/display.js +4 -5
  82. package/lib/inventory/dosai.js +798 -57
  83. package/lib/inventory/dosaiParsers.js +310 -15
  84. package/lib/inventory/dynamic.js +90 -6
  85. package/lib/inventory/evidenceUtils.js +166 -113
  86. package/lib/inventory/fetchBatch.js +690 -55
  87. package/lib/inventory/fetchDiskCache.js +597 -0
  88. package/lib/inventory/forgeEnricher.js +14 -5
  89. package/lib/inventory/jvmLocalRepos.js +917 -0
  90. package/lib/inventory/mcpConfigParser.js +2 -3
  91. package/lib/inventory/parentSelection.js +146 -0
  92. package/lib/inventory/protobom.js +8 -1
  93. package/lib/inventory/purl.js +152 -3
  94. package/lib/inventory/rustFormulationParser.js +3 -4
  95. package/lib/inventory/sbomDocument.js +2 -70
  96. package/lib/inventory/scalaCoords.js +153 -0
  97. package/lib/inventory/source.js +45 -27
  98. package/lib/inventory/spdx.js +101 -0
  99. package/lib/inventory/traceRunner.js +295 -25
  100. package/lib/inventory/tracedCrypto.js +294 -0
  101. package/lib/managers/binary.js +115 -24
  102. package/lib/managers/docker.js +467 -46
  103. package/lib/managers/oci.js +63 -3
  104. package/lib/parsers/cmakeCache.js +69 -0
  105. package/lib/parsers/cmakeLists.js +466 -0
  106. package/lib/parsers/cmakePresets.js +400 -0
  107. package/lib/parsers/compileCommands.js +557 -0
  108. package/lib/parsers/largeJson.js +454 -0
  109. package/lib/parsers/npmrc.js +19 -1
  110. package/lib/parsers/strictJson.js +322 -0
  111. package/lib/parsers/terraformConfig.js +1073 -0
  112. package/lib/parsers/terraformSource.js +652 -0
  113. package/lib/server/server.js +23 -0
  114. package/lib/stages/postgen/introspection/markers.js +1 -1
  115. package/lib/stages/postgen/postgen.js +83 -16
  116. package/lib/stages/postgen/specVersionCompat.js +32 -0
  117. package/lib/validator/bomValidator.js +6 -16
  118. package/lib/validator/index.js +64 -11
  119. package/lib/validator/reporters/console.js +3 -0
  120. package/lib/validator/reporters/json.js +2 -0
  121. package/lib/validator/reporters/sarif.js +7 -0
  122. package/package.json +23 -23
  123. package/types/lib/audit/index.d.ts.map +1 -1
  124. package/types/lib/cli/bomAssembly.d.ts +2 -1
  125. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  126. package/types/lib/cli/cliOptions.d.ts +33 -1
  127. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  128. package/types/lib/cli/index.d.ts.map +1 -1
  129. package/types/lib/cli/jsBom.d.ts.map +1 -1
  130. package/types/lib/cli/jvmBom.d.ts +24 -1
  131. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  132. package/types/lib/cli/managedBom.d.ts +7 -4
  133. package/types/lib/cli/managedBom.d.ts.map +1 -1
  134. package/types/lib/cli/nativeBom.d.ts +3 -1
  135. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  136. package/types/lib/core/activity.d.ts.map +1 -1
  137. package/types/lib/core/env.d.ts +9 -0
  138. package/types/lib/core/env.d.ts.map +1 -1
  139. package/types/lib/core/fs.d.ts +55 -0
  140. package/types/lib/core/fs.d.ts.map +1 -1
  141. package/types/lib/core/httpClient.d.ts +13 -8
  142. package/types/lib/core/httpClient.d.ts.map +1 -1
  143. package/types/lib/core/metadataProperties.d.ts +39 -0
  144. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  145. package/types/lib/core/pluginRun.d.ts +97 -0
  146. package/types/lib/core/pluginRun.d.ts.map +1 -0
  147. package/types/lib/core/processTree.d.ts +36 -0
  148. package/types/lib/core/processTree.d.ts.map +1 -0
  149. package/types/lib/core/projectConfig.d.ts +6 -2
  150. package/types/lib/core/projectConfig.d.ts.map +1 -1
  151. package/types/lib/core/runState.d.ts +29 -0
  152. package/types/lib/core/runState.d.ts.map +1 -0
  153. package/types/lib/core/toml.d.ts +20 -0
  154. package/types/lib/core/toml.d.ts.map +1 -0
  155. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  156. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  157. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  159. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  160. package/types/lib/ecosystems/caxa.d.ts +14 -0
  161. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  162. package/types/lib/ecosystems/cmakeResolver.d.ts +28 -3
  163. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  164. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  165. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  166. package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
  167. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  168. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  169. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  170. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  171. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  172. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  173. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  174. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  175. package/types/lib/ecosystems/gradleutils.d.ts +70 -4
  176. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  177. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  178. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  179. package/types/lib/ecosystems/kosi.d.ts +13 -0
  180. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  181. package/types/lib/ecosystems/npmutils.d.ts +57 -0
  182. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  183. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  184. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-go.d.ts +54 -2
  188. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  189. package/types/lib/ecosystems/parsers-js.d.ts +16 -5
  190. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  191. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  199. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  200. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  201. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rubyutils.d.ts +164 -3
  205. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  206. package/types/lib/ecosystems/rusi.d.ts +13 -0
  207. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  208. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  209. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  210. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  211. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  212. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  213. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  214. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  215. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  216. package/types/lib/ecosystems/utils.d.ts +2 -2
  217. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  218. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  219. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  220. package/types/lib/evinser/evinser.d.ts +132 -15
  221. package/types/lib/evinser/evinser.d.ts.map +1 -1
  222. package/types/lib/evinser/swiftsem.d.ts +280 -15
  223. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  224. package/types/lib/helpers/bomSigner.d.ts +182 -13
  225. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  226. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  227. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  228. package/types/lib/inventory/atomUtils.d.ts +219 -5
  229. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  230. package/types/lib/inventory/auditCategories.d.ts +3 -0
  231. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  232. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  233. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  234. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  235. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  236. package/types/lib/inventory/cdxrs.d.ts +8 -3
  237. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  238. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  239. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  240. package/types/lib/inventory/deps.d.ts +59 -1
  241. package/types/lib/inventory/deps.d.ts.map +1 -1
  242. package/types/lib/inventory/depsUtils.d.ts +15 -0
  243. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  244. package/types/lib/inventory/display.d.ts.map +1 -1
  245. package/types/lib/inventory/dosai.d.ts +121 -9
  246. package/types/lib/inventory/dosai.d.ts.map +1 -1
  247. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  248. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  249. package/types/lib/inventory/dynamic.d.ts +10 -0
  250. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  251. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  252. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  253. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  254. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  255. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  256. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  257. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  258. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  259. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  260. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  261. package/types/lib/inventory/parentSelection.d.ts +34 -0
  262. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  263. package/types/lib/inventory/plugins.d.ts.map +1 -1
  264. package/types/lib/inventory/protobom.d.ts.map +1 -1
  265. package/types/lib/inventory/purl.d.ts +71 -2
  266. package/types/lib/inventory/purl.d.ts.map +1 -1
  267. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  268. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  269. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  270. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  271. package/types/lib/inventory/source.d.ts.map +1 -1
  272. package/types/lib/inventory/spdx.d.ts +9 -0
  273. package/types/lib/inventory/spdx.d.ts.map +1 -1
  274. package/types/lib/inventory/traceRunner.d.ts +59 -1
  275. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  276. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  277. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  278. package/types/lib/managers/binary.d.ts +30 -1
  279. package/types/lib/managers/binary.d.ts.map +1 -1
  280. package/types/lib/managers/docker.d.ts +29 -0
  281. package/types/lib/managers/docker.d.ts.map +1 -1
  282. package/types/lib/managers/oci.d.ts +17 -1
  283. package/types/lib/managers/oci.d.ts.map +1 -1
  284. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  285. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  286. package/types/lib/parsers/cmakeLists.d.ts +77 -0
  287. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  288. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  289. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  290. package/types/lib/parsers/compileCommands.d.ts +115 -0
  291. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  292. package/types/lib/parsers/largeJson.d.ts +52 -0
  293. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  294. package/types/lib/parsers/npmrc.d.ts +5 -0
  295. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  296. package/types/lib/parsers/strictJson.d.ts +46 -0
  297. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  298. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  299. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  300. package/types/lib/parsers/terraformSource.d.ts +66 -0
  301. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  302. package/types/lib/server/server.d.ts.map +1 -1
  303. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  304. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  306. package/types/lib/validator/index.d.ts +18 -1
  307. package/types/lib/validator/index.d.ts.map +1 -1
  308. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  309. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  310. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
  311. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -10,11 +10,13 @@
10
10
  * read instead of the network.
11
11
  *
12
12
  * There is one policy layer (`fetchRate.js`, mirrored by the Rust `rate.rs`)
13
- * and two transports. When the `cdxrs` binary is available it is preferred,
14
- * because it brings an on-disk conditional cache (D26) the JS pool does not
15
- * have. When it is absent, disabled, or unusable, the JS pool runs every
16
- * request through `cdxgenAgent` so the secure-mode host allowlist, the
17
- * activity recorder and the test cassette interceptor all still apply.
13
+ * and two transports. When the `cdxrs` binary is available it is preferred for
14
+ * the JSON it can carry. When it is absent, disabled, or unusable, and whenever
15
+ * a dry run, secure mode or a host allowlist is in force, the JS pool runs
16
+ * every request through `cdxgenAgent` so those policies, the activity recorder
17
+ * and the test cassette interceptor all still apply. The JS pool keeps the same
18
+ * on-disk conditional cache as cdxrs (`fetchDiskCache.js`), in the same
19
+ * directory and layout.
18
20
  *
19
21
  * Two properties are deliberate and load-bearing:
20
22
  *
@@ -27,22 +29,35 @@
27
29
  * JS on all three registries it covered.
28
30
  *
29
31
  * 2. **Fallback is per URL, not per run.** A URL that the batch could not
30
- * resolve for a transport reason is simply absent from the map, and the
31
- * caller's own `cdxgenAgent.get` runs for it exactly as before. A URL that
32
- * resolved to a definite HTTP error is recorded as such, so the caller does
33
- * not re-request it only to get the same 404.
32
+ * resolve for a transport reason is left to the caller, whose own request
33
+ * goes through {@link gatedGet} and the same per-host gate. A URL the server
34
+ * answered with an error status, after the batch's retries, is recorded as
35
+ * such, so the caller does not ask again only to get the same answer, and a
36
+ * 404 or 410 is remembered for the rest of the run.
34
37
  */
35
38
 
39
+ import { createHash } from "node:crypto";
40
+
36
41
  import {
37
42
  BLOCKED_HOST_ERROR_CODE,
38
43
  cdxgenAgent,
39
44
  DEBUG_MODE,
40
45
  DRY_RUN_ERROR_CODE,
46
+ isDryRun,
47
+ isSecureMode,
41
48
  readEnvironmentVariable,
42
49
  } from "../core/activity.js";
43
50
  import { isLedgerEnabled, recordDegradation } from "../core/buildLedger.js";
51
+ import { registerRunReset } from "../core/runState.js";
44
52
  import { resolveCacheDir } from "./cacheDir.js";
45
53
  import { cdxrsAvailable, runCdxrs } from "./cdxrs.js";
54
+ import {
55
+ diskCacheable,
56
+ diskCacheGet,
57
+ diskCachePut,
58
+ diskCachePutMiss,
59
+ diskCacheRefresh,
60
+ } from "./fetchDiskCache.js";
46
61
  import {
47
62
  credentialsFor,
48
63
  DEFAULT_GLOBAL_CONCURRENCY,
@@ -86,7 +101,7 @@ let _available = false;
86
101
  // these guarantee one request per unique URL under concurrency, which the
87
102
  // serial path got for free from `await`-in-a-loop and which the in-memory
88
103
  // `responseCache` cannot provide because it is populated only after a response
89
- // arrives (and is disabled entirely under `CDXGEN_NO_CACHE`).
104
+ // arrives.
90
105
  // ---------------------------------------------------------------------------
91
106
 
92
107
  /** @type {Map<string, Promise<BatchEntry>>} Keyed by `${url}|${accept}|${authRealm}`. */
@@ -100,6 +115,299 @@ const _hostLimiters = new Map();
100
115
 
101
116
  const _globalSemaphore = makeSemaphore(DEFAULT_GLOBAL_CONCURRENCY);
102
117
 
118
+ /**
119
+ * Hosts that rate-limit by client IP and escalate the block when it keeps
120
+ * receiving requests. Maven Central's documented blocks grow to as long as 30
121
+ * days for sustained over-use, so a 429 from any of these pauses every further
122
+ * request to that host instead of being retried.
123
+ */
124
+ const CIRCUIT_BREAKER_HOSTS = new Map([
125
+ ["repo1.maven.org", "maven-central"],
126
+ ["repo.maven.apache.org", "maven-central"],
127
+ ["search.maven.org", "maven-search"],
128
+ ["central.sonatype.com", "maven-search"],
129
+ ]);
130
+
131
+ /** How long a paused host stays paused when the server sends no Retry-After. */
132
+ const DEFAULT_CIRCUIT_OPEN_MS = 10 * 60 * 1000;
133
+
134
+ /** Host to the time its pause ends. */
135
+ const _openCircuits = new Map();
136
+
137
+ /**
138
+ * Requests a host may answer with HTTP 429, after their retries, before it is
139
+ * paused. Maven Central and its search are paused on the first.
140
+ */
141
+ const RATE_LIMITED_REQUESTS_BEFORE_PAUSE = 3;
142
+
143
+ /** Host to the number of its requests that ended in HTTP 429 this run. */
144
+ const _rateLimitedRequests = new Map();
145
+
146
+ /**
147
+ * Requests that answered 404 or 410, so that no later pass of the same run asks
148
+ * again. Keyed by {@link missKey}, with the time the entry lapses, so that a
149
+ * process that never resets its run state still sees new releases.
150
+ */
151
+ const _definiteMisses = new Map();
152
+ const DEFINITE_MISS_TTL_MS = 60 * 60 * 1000;
153
+
154
+ registerRunReset("fetch:misses-and-rate-limits", () => {
155
+ _definiteMisses.clear();
156
+ _rateLimitedRequests.clear();
157
+ });
158
+
159
+ /**
160
+ * Whether requests to a host are paused.
161
+ *
162
+ * @param {string|null} host Hostname.
163
+ * @returns {boolean}
164
+ */
165
+ export function isHostCircuitOpen(host) {
166
+ if (!host) {
167
+ return false;
168
+ }
169
+ const until = _openCircuits.get(host.toLowerCase());
170
+ if (until === undefined) {
171
+ return false;
172
+ }
173
+ if (until > Date.now()) {
174
+ return true;
175
+ }
176
+ _openCircuits.delete(host.toLowerCase());
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * Pause requests to a host, for at least {@link DEFAULT_CIRCUIT_OPEN_MS} or for
182
+ * as long as the server asked. Warns once per pause.
183
+ *
184
+ * @param {string} host Hostname.
185
+ * @param {{delayMs?: number|null, cause?: string, quiet?: boolean, minimumMs?: number}} [details]
186
+ * Server-supplied delay, a short description of what happened, whether to
187
+ * pause without a warning or a degradation record (for failures that are not
188
+ * the host's doing, such as a dry run or an allowlist block), and the
189
+ * shortest pause, which a host that says exactly when it recovers sets to 0.
190
+ * @returns {void}
191
+ */
192
+ export function openHostCircuit(
193
+ host,
194
+ { delayMs, cause, quiet, minimumMs = DEFAULT_CIRCUIT_OPEN_MS } = {},
195
+ ) {
196
+ if (!host) {
197
+ return;
198
+ }
199
+ const key = host.toLowerCase();
200
+ const wasOpen = isHostCircuitOpen(key);
201
+ // A host that said how long to wait is paused that long, but not for less
202
+ // than `minimumMs`. One that did not is paused for the default.
203
+ const pauseMs = delayMs
204
+ ? Math.max(delayMs, minimumMs)
205
+ : DEFAULT_CIRCUIT_OPEN_MS;
206
+ const until = Date.now() + pauseMs;
207
+ _openCircuits.set(key, Math.max(until, _openCircuits.get(key) || 0));
208
+ if (wasOpen || quiet) {
209
+ return;
210
+ }
211
+ const minutes = Math.ceil((_openCircuits.get(key) - Date.now()) / 60000);
212
+ const what = cause || "rate limited this machine";
213
+ const breaker = CIRCUIT_BREAKER_HOSTS.get(key);
214
+ if (breaker === "maven-search") {
215
+ console.warn(
216
+ `${host} ${what}, so jar identification by hash skips it for the next ${minutes} minute(s). Set SEARCH_MAVEN_ORG=false to turn these lookups off.`,
217
+ );
218
+ } else if (isGitHubApiHost(key)) {
219
+ console.warn(
220
+ `${host} ${what}, so cdxgen will not call it for the next ${minutes} minute(s).${readEnvironmentVariable("GITHUB_TOKEN") ? "" : " Set GITHUB_TOKEN to raise the limit: https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api"}`,
221
+ );
222
+ } else if (breaker === "maven-central") {
223
+ console.warn(
224
+ `${host} ${what}, so cdxgen will not contact it for the next ${minutes} minute(s) and Maven metadata comes from local caches only. Set MAVEN_CENTRAL_URL to a mirror or repository manager, for example https://maven-central.storage-download.googleapis.com/maven2/, to keep license enrichment working.`,
225
+ );
226
+ } else {
227
+ console.warn(
228
+ `${host} ${what}, so cdxgen will not contact it for the next ${minutes} minute(s). The components it would describe keep the metadata found locally.`,
229
+ );
230
+ }
231
+ recordPolicyDegradationOnce("policy.rate-limited", {
232
+ ecosystem: "generic",
233
+ impact: "licenses",
234
+ detail: `${host} ${what}, so further requests to it were skipped.`,
235
+ });
236
+ }
237
+
238
+ /**
239
+ * Clear every paused host. Tests only.
240
+ *
241
+ * @returns {void}
242
+ */
243
+ export function resetHostCircuits() {
244
+ _openCircuits.clear();
245
+ _rateLimitedRequests.clear();
246
+ }
247
+
248
+ /**
249
+ * The error a request to a paused host fails with, shaped like an HTTP 429 so
250
+ * callers treat it the way they treat the response that opened the circuit.
251
+ *
252
+ * @param {string} host Hostname.
253
+ * @returns {Error}
254
+ */
255
+ function hostCircuitOpenError(host) {
256
+ const err = new Error(
257
+ `Response code 429 (requests to ${host} are paused after it rate limited this run)`,
258
+ );
259
+ err.name = "HTTPError";
260
+ err.code = "CDXGEN_HOST_CIRCUIT_OPEN";
261
+ err.statusCode = 429;
262
+ err.response = { statusCode: 429, headers: {} };
263
+ return err;
264
+ }
265
+
266
+ /**
267
+ * Open the circuit for a breaker host that answered 429.
268
+ *
269
+ * @param {string|null} host Hostname.
270
+ * @param {number|undefined} status HTTP status.
271
+ * @param {Object} [headers] Response headers.
272
+ * @returns {boolean} True when the circuit was opened.
273
+ */
274
+ function tripOnRateLimit(host, status, headers) {
275
+ if (
276
+ status !== 429 ||
277
+ !host ||
278
+ !CIRCUIT_BREAKER_HOSTS.has(host.toLowerCase())
279
+ ) {
280
+ return false;
281
+ }
282
+ openHostCircuit(host, {
283
+ delayMs: parseRetryAfter({ headers }),
284
+ cause: "answered HTTP 429 (rate limited)",
285
+ });
286
+ return true;
287
+ }
288
+
289
+ /**
290
+ * Whether a host is GitHub's REST API, or the GitHub Enterprise API that
291
+ * `GITHUB_API_URL` names. It reports its quota in response headers rather than
292
+ * with a 429.
293
+ *
294
+ * @param {string|null} host Hostname.
295
+ * @returns {boolean}
296
+ */
297
+ function isGitHubApiHost(host) {
298
+ const lower = host?.toLowerCase();
299
+ if (!lower) {
300
+ return false;
301
+ }
302
+ if (lower === "api.github.com") {
303
+ return true;
304
+ }
305
+ const configured = readEnvironmentVariable("GITHUB_API_URL");
306
+ return !!configured && extractHost(configured)?.toLowerCase() === lower;
307
+ }
308
+
309
+ /**
310
+ * Pause the GitHub API when it says the quota is used up: a 403 or 429 with no
311
+ * requests remaining, or a secondary limit that sends Retry-After. GitHub says
312
+ * when the quota resets, so the pause lasts exactly that long.
313
+ *
314
+ * @param {string|null} host Hostname.
315
+ * @param {number|undefined} status HTTP status.
316
+ * @param {Object} [headers] Response headers.
317
+ * @returns {boolean} True when the API was paused.
318
+ */
319
+ function tripOnGitHubQuota(host, status, headers) {
320
+ if (!isGitHubApiHost(host) || (status !== 403 && status !== 429)) {
321
+ return false;
322
+ }
323
+ const remaining = headers?.["x-ratelimit-remaining"];
324
+ if (String(remaining) !== "0" && headers?.["retry-after"] === undefined) {
325
+ return false;
326
+ }
327
+ openHostCircuit(host, {
328
+ delayMs: parseRetryAfter({ headers }),
329
+ cause: "reports that its API rate limit is used up",
330
+ minimumMs: 0,
331
+ });
332
+ return true;
333
+ }
334
+
335
+ /**
336
+ * Count a request to a host that ended in HTTP 429 once its retries ran out,
337
+ * and pause the host once it has refused several. Breaker hosts are paused on
338
+ * the first by {@link tripOnRateLimit}.
339
+ *
340
+ * @param {string|null} host Hostname.
341
+ * @param {Object} [headers] Response headers, for the server's back-off.
342
+ * @returns {void}
343
+ */
344
+ function noteRateLimited(host, headers) {
345
+ if (!host || CIRCUIT_BREAKER_HOSTS.has(host.toLowerCase())) {
346
+ return;
347
+ }
348
+ const key = host.toLowerCase();
349
+ const count = (_rateLimitedRequests.get(key) || 0) + 1;
350
+ _rateLimitedRequests.set(key, count);
351
+ if (count >= RATE_LIMITED_REQUESTS_BEFORE_PAUSE) {
352
+ openHostCircuit(host, {
353
+ delayMs: parseRetryAfter({ headers }),
354
+ cause: `answered HTTP 429 (rate limited) to ${count} requests`,
355
+ });
356
+ }
357
+ }
358
+
359
+ /**
360
+ * The key a definite miss is remembered under: the URL, and the credential it
361
+ * was asked with, since a private document can be missing to an anonymous
362
+ * request only. A credential enters the key as a digest, never as itself.
363
+ *
364
+ * @param {string} url Request URL.
365
+ * @param {{authRealm?: string, headers?: Object}} [request] Request details.
366
+ * @returns {string}
367
+ */
368
+ function missKey(url, { authRealm, headers } = {}) {
369
+ const authorization = headers?.Authorization || headers?.authorization;
370
+ const credential = authorization
371
+ ? createHash("sha256").update(String(authorization)).digest("hex")
372
+ : "";
373
+ return `${url}|${authRealm || ""}|${credential}`;
374
+ }
375
+
376
+ /**
377
+ * The status a request answered with when it was last asked this run, if that
378
+ * was 404 or 410.
379
+ *
380
+ * @param {string} key Key from {@link missKey}.
381
+ * @returns {number|undefined}
382
+ */
383
+ function rememberedMiss(key) {
384
+ const miss = _definiteMisses.get(key);
385
+ if (!miss) {
386
+ return undefined;
387
+ }
388
+ if (miss.until > Date.now()) {
389
+ return miss.status;
390
+ }
391
+ _definiteMisses.delete(key);
392
+ return undefined;
393
+ }
394
+
395
+ /**
396
+ * Remember a 404 or 410 for the rest of the run.
397
+ *
398
+ * @param {string} key Key from {@link missKey}.
399
+ * @param {number|undefined} status HTTP status.
400
+ * @returns {void}
401
+ */
402
+ function rememberIfMissing(key, status) {
403
+ if (status === 404 || status === 410) {
404
+ _definiteMisses.set(key, {
405
+ status,
406
+ until: Date.now() + DEFINITE_MISS_TTL_MS,
407
+ });
408
+ }
409
+ }
410
+
103
411
  /**
104
412
  * Statistics from the most recent batch, or null when no batch has run.
105
413
  *
@@ -156,6 +464,8 @@ export function resetBatchFetchAvailability() {
156
464
  * @property {number} [status] HTTP status, when the server produced one.
157
465
  * @property {boolean} [definite] True when the status is a final answer (a 4xx
158
466
  * other than 429) and re-requesting it in JS would be pointless.
467
+ * @property {Object} [headers] Response headers of a failed request, when the
468
+ * JS pool made it. cdxrs reports no headers.
159
469
  */
160
470
 
161
471
  /**
@@ -232,10 +542,57 @@ export async function prefetchJson(requests, options = {}) {
232
542
  return empty;
233
543
  }
234
544
 
235
- if (batchFetchAvailable() && unique.every(cdxrsCanServe)) {
236
- return await rustBatchFetch(unique, options);
545
+ // A paused host is not asked again, and neither is a document that answered
546
+ // 404 or 410 earlier in the run. Those URLs fail at once and definitely, so
547
+ // callers do not retry them on their own either.
548
+ const answered = new Map();
549
+ const runnable = [];
550
+ for (const request of unique) {
551
+ const missing = rememberedMiss(missKey(request.url, request));
552
+ if (missing) {
553
+ answered.set(request.url, { ok: false, status: missing, definite: true });
554
+ } else if (isHostCircuitOpen(extractHost(request.url))) {
555
+ answered.set(request.url, { ok: false, status: 429, definite: true });
556
+ } else {
557
+ runnable.push(request);
558
+ }
559
+ }
560
+ if (!runnable.length) {
561
+ return answered;
562
+ }
563
+ const results =
564
+ cdxrsMayFetch() && batchFetchAvailable() && runnable.every(cdxrsCanServe)
565
+ ? await rustBatchFetch(runnable, options)
566
+ : await jsBatchFetch(runnable);
567
+ for (const request of runnable) {
568
+ rememberIfMissing(
569
+ missKey(request.url, request),
570
+ results.get(request.url)?.status,
571
+ );
572
+ }
573
+ for (const [url, entry] of answered) {
574
+ results.set(url, entry);
237
575
  }
238
- return await jsBatchFetch(unique);
576
+ return results;
577
+ }
578
+
579
+ /**
580
+ * Whether cdxrs may make this run's requests at all.
581
+ *
582
+ * cdxrs opens its own connections, so the rules cdxgenAgent applies to every
583
+ * request would not reach them: a dry run makes no request, secure mode allows
584
+ * only HTTPS and follows no redirect, and `CDXGEN_ALLOWED_HOSTS` names the only
585
+ * hosts that may be contacted. While any of them is in force the JS pool makes
586
+ * every request, through cdxgenAgent.
587
+ *
588
+ * @returns {boolean}
589
+ */
590
+ function cdxrsMayFetch() {
591
+ return (
592
+ !isDryRun &&
593
+ !isSecureMode &&
594
+ !readEnvironmentVariable("CDXGEN_ALLOWED_HOSTS")
595
+ );
239
596
  }
240
597
 
241
598
  /**
@@ -249,6 +606,8 @@ export async function prefetchJson(requests, options = {}) {
249
606
  * - request-specific `headers`, since cdxrs derives its own Authorization from
250
607
  * `GITHUB_TOKEN` and would silently drop any other credential, turning an
251
608
  * authenticated lookup into an anonymous one;
609
+ * - the GitHub API, whose quota is reported in response headers that the
610
+ * envelope does not carry, so a spent quota could not pause it;
252
611
  * - anything addressed to a host whose rate policy only one transport knows.
253
612
  * crates.io's crawler policy allows one request per second with no
254
613
  * parallelism, and package.elm-lang.org has a JS-only allowance because
@@ -266,7 +625,8 @@ function cdxrsCanServe(request) {
266
625
  if (request.responseType && request.responseType !== "json") {
267
626
  return false;
268
627
  }
269
- if (isSingleGateHost(extractHost(request.url))) {
628
+ const host = extractHost(request.url);
629
+ if (isSingleGateHost(host) || isGitHubApiHost(host)) {
270
630
  return false;
271
631
  }
272
632
  return !request.headers || !Object.keys(request.headers).length;
@@ -300,18 +660,126 @@ export function isSingleGateHost(host) {
300
660
  return isCratesHost(host) || host.toLowerCase() === "package.elm-lang.org";
301
661
  }
302
662
 
663
+ /**
664
+ * Most URLs sent to one `cdxrs fetch` run. The envelope comes back as a single
665
+ * JSON string and V8 caps strings at ~512 MB, while a full npm packument is a
666
+ * few hundred KB on average and tens of MB for the largest packages. One run
667
+ * per workspace therefore outgrew the limit on large pnpm workspaces (issue
668
+ * 4393); runs of this size stay far below it for any realistic mix.
669
+ */
670
+ const RUST_FETCH_CHUNK_SIZE = 250;
671
+
303
672
  /**
304
673
  * Run the batch through the `cdxrs fetch` subprocess and translate its
305
674
  * envelope into the shared result shape.
306
675
  *
676
+ * The URLs go out in chunks of {@link RUST_FETCH_CHUNK_SIZE}, one run at a time
677
+ * so each host still sees a single rate gate. A chunk whose envelope is still
678
+ * too large to deliver is split in half and retried, which is cheap because
679
+ * the first attempt filled the cdxrs disk cache. A chunk that fails for any
680
+ * other reason falls back to the JS pool on its own, so one bad run never
681
+ * costs the rest of the batch its Rust fetch.
682
+ *
307
683
  * @param {Array<{url: string, accept?: string, authRealm?: string}>} unique
308
684
  * Already deduplicated by URL.
309
685
  * @param {Object} options Options with `timeoutMs`.
310
686
  * @returns {Promise<Map<string, BatchEntry>>}
311
687
  */
312
688
  async function rustBatchFetch(unique, options) {
689
+ const pending = [];
690
+ for (let i = 0; i < unique.length; i += RUST_FETCH_CHUNK_SIZE) {
691
+ pending.push(unique.slice(i, i + RUST_FETCH_CHUNK_SIZE));
692
+ }
693
+ const results = new Map();
694
+ const chunkStats = [];
695
+ while (pending.length) {
696
+ const chunk = pending.shift();
697
+ const outcome = await rustFetchChunk(chunk, options);
698
+ if (outcome.reason === "stdout-too-large" && chunk.length > 1) {
699
+ const middle = Math.ceil(chunk.length / 2);
700
+ pending.unshift(chunk.slice(0, middle), chunk.slice(middle));
701
+ continue;
702
+ }
703
+ if (!outcome.results) {
704
+ // Not "binary absent" (that never reaches here), so fall through to the
705
+ // JS pool rather than returning nothing: a transient cdxrs crash must
706
+ // not take this chunk's concurrency with it.
707
+ for (const [url, entry] of await jsBatchFetch(chunk)) {
708
+ results.set(url, entry);
709
+ }
710
+ chunkStats.push(_lastStats);
711
+ continue;
712
+ }
713
+ for (const [url, entry] of outcome.results) {
714
+ results.set(url, entry);
715
+ }
716
+ chunkStats.push(outcome.stats);
717
+ }
718
+ _lastStats = combineBatchStats(chunkStats);
719
+ if (DEBUG_MODE && _lastStats) {
720
+ const s = _lastStats;
721
+ console.log(
722
+ `cdxrs fetch: ${s.requests} url(s), ${s.unique} unique, ${s.ok} ok, ${s.failures} failed, ${s.cacheHits} cached, peak concurrency ${s.peakConcurrency}, ${s.elapsedMs} ms`,
723
+ );
724
+ }
725
+ return results;
726
+ }
727
+
728
+ /**
729
+ * Add up the stats of the runs that served one batch. Numeric fields sum
730
+ * because the runs are sequential, except peak concurrency, which is the
731
+ * widest run. Per-host entries under `hosts` are combined the same way.
732
+ *
733
+ * @param {Array<Object|null|undefined>} parts Stats from each run.
734
+ * @returns {Object|null} Combined stats, or null when no run reported any.
735
+ */
736
+ function combineBatchStats(parts) {
737
+ const reported = parts.filter(Boolean);
738
+ if (!reported.length) {
739
+ return null;
740
+ }
741
+ if (reported.length === 1) {
742
+ return reported[0];
743
+ }
744
+ const combined = addStats({}, reported);
745
+ const hosts = {};
746
+ for (const part of reported) {
747
+ for (const [host, entry] of Object.entries(part.hosts || {})) {
748
+ hosts[host] = addStats(hosts[host] || {}, [entry]);
749
+ }
750
+ }
751
+ if (Object.keys(hosts).length) {
752
+ combined.hosts = hosts;
753
+ }
754
+ return combined;
755
+ }
756
+
757
+ function addStats(target, parts) {
758
+ for (const part of parts) {
759
+ for (const [key, value] of Object.entries(part)) {
760
+ if (typeof value !== "number") {
761
+ continue;
762
+ }
763
+ target[key] =
764
+ key === "peakConcurrency"
765
+ ? Math.max(target[key] || 0, value)
766
+ : (target[key] || 0) + value;
767
+ }
768
+ }
769
+ return target;
770
+ }
771
+
772
+ /**
773
+ * Run one chunk through `cdxrs fetch`.
774
+ *
775
+ * @param {Array<{url: string, accept?: string, authRealm?: string}>} chunk
776
+ * @param {Object} options Options with `timeoutMs`.
777
+ * @returns {Promise<{results?: Map<string, BatchEntry>, stats?: Object, reason?: string}>}
778
+ * `results` when the run succeeded; otherwise the reason it did not.
779
+ */
780
+ async function rustFetchChunk(chunk, options) {
313
781
  const payload = { requests: [] };
314
- for (const request of unique) {
782
+ for (const request of chunk) {
315
783
  payload.requests.push({
316
784
  id: request.url,
317
785
  url: request.url,
@@ -328,20 +796,19 @@ async function rustBatchFetch(unique, options) {
328
796
  if (!ok) {
329
797
  if (DEBUG_MODE && reason !== "disabled" && reason !== "binary-not-found") {
330
798
  console.log(
331
- `cdxrs fetch unavailable (${reason}); falling back to the JS batch pool.`,
799
+ reason === "stdout-too-large" && chunk.length > 1
800
+ ? `cdxrs fetch output for ${chunk.length} url(s) is too large; retrying in two halves.`
801
+ : `cdxrs fetch unavailable (${reason}); falling back to the JS batch pool.`,
332
802
  );
333
803
  }
334
- // The Rust path failed in a way that is not "binary absent". Fall through
335
- // to the JS pool rather than returning an empty map, so a transient cdxrs
336
- // crash does not take the whole batch's concurrency with it.
337
- return await jsBatchFetch(unique);
804
+ return { reason };
338
805
  }
339
806
 
340
807
  let envelope;
341
808
  try {
342
809
  envelope = JSON.parse(stdout);
343
810
  } catch (_err) {
344
- return await jsBatchFetch(unique);
811
+ return { reason: "malformed-envelope" };
345
812
  }
346
813
  if (envelope?.schemaVersion !== BATCH_SCHEMA_VERSION) {
347
814
  // A binary from a different generation of this protocol is not something
@@ -351,10 +818,9 @@ async function rustBatchFetch(unique, options) {
351
818
  `cdxrs fetch envelope version ${envelope?.schemaVersion} != ${BATCH_SCHEMA_VERSION}; falling back to the JS batch pool.`,
352
819
  );
353
820
  }
354
- return await jsBatchFetch(unique);
821
+ return { reason: "version-mismatch" };
355
822
  }
356
823
 
357
- _lastStats = envelope.stats || null;
358
824
  const results = new Map();
359
825
  for (const result of envelope.results || []) {
360
826
  if (!result?.id) {
@@ -369,17 +835,17 @@ async function rustBatchFetch(unique, options) {
369
835
  // try through the JS agent, which has its own proxy and auth handling.
370
836
  const status =
371
837
  typeof result.status === "number" ? result.status : undefined;
372
- const definite = status >= 400 && status < 500 && status !== 429;
838
+ // cdxrs has already honoured Retry-After. A breaker host that still says
839
+ // 429 is paused, and the caller must not ask it again.
840
+ const tripped = tripOnRateLimit(extractHost(result.id), status, {});
841
+ if (status === 429 && !tripped) {
842
+ noteRateLimited(extractHost(result.id), {});
843
+ }
844
+ const definite =
845
+ tripped || (status >= 400 && status < 500 && status !== 429);
373
846
  results.set(result.id, { ok: false, status, definite });
374
847
  }
375
-
376
- if (DEBUG_MODE && envelope.stats) {
377
- const s = envelope.stats;
378
- console.log(
379
- `cdxrs fetch: ${s.requests} url(s), ${s.unique} unique, ${s.ok} ok, ${s.failures} failed, ${s.cacheHits} cached, peak concurrency ${s.peakConcurrency}, ${s.elapsedMs} ms`,
380
- );
381
- }
382
- return results;
848
+ return { results, stats: envelope.stats || null };
383
849
  }
384
850
 
385
851
  /**
@@ -548,16 +1014,42 @@ function exponentialBackoffMs(attempt) {
548
1014
  * @param {RateLimiter} limiter The per-host rate limiter.
549
1015
  * @returns {Promise<BatchEntry>}
550
1016
  */
551
- async function issueWithRetries(request, limiter) {
1017
+ async function issueWithRetries(request, limiter, disk = undefined) {
552
1018
  const host = extractHost(request.url);
553
- const options = buildAgentOptions(request, host);
1019
+ const options = {
1020
+ ...buildAgentOptions(request, host),
1021
+ // The limiter is consulted only when the request is about to reach the
1022
+ // network; a response already in the cache does not wait for a slot.
1023
+ hooks: { beforeNetwork: [() => limiter.wait()] },
1024
+ };
1025
+ // A stale disk entry with validators is revalidated, and a 304 keeps it.
1026
+ const stale = disk?.stale;
1027
+ if (stale) {
1028
+ options.headers = {
1029
+ ...options.headers,
1030
+ ...(stale.etag ? { "If-None-Match": stale.etag } : {}),
1031
+ ...(stale.last_modified
1032
+ ? { "If-Modified-Since": stale.last_modified }
1033
+ : {}),
1034
+ };
1035
+ options.cache = false;
1036
+ }
554
1037
  let attempt = 0;
555
1038
  while (true) {
556
1039
  attempt++;
557
- await limiter.wait();
1040
+ if (isHostCircuitOpen(host)) {
1041
+ return { ok: false, status: 429, definite: true };
1042
+ }
558
1043
  let result;
559
1044
  try {
560
1045
  const res = await cdxgenAgent.get(request.url, options);
1046
+ if (res.statusCode === 304 && stale) {
1047
+ diskCacheRefresh(disk.key, stale, res.headers);
1048
+ return { ok: true, body: stale.body, status: stale.status };
1049
+ }
1050
+ if (disk && res.statusCode === 200) {
1051
+ diskCachePut(disk.key, res);
1052
+ }
561
1053
  result = {
562
1054
  retryable: false,
563
1055
  delay: null,
@@ -565,6 +1057,14 @@ async function issueWithRetries(request, limiter) {
565
1057
  };
566
1058
  } catch (err) {
567
1059
  result = classifyAgentError(err);
1060
+ const status = err?.response?.statusCode;
1061
+ const headers = err?.response?.headers;
1062
+ if (tripOnRateLimit(host, status, headers)) {
1063
+ return { ok: false, status: 429, definite: true };
1064
+ }
1065
+ if (tripOnGitHubQuota(host, status, headers)) {
1066
+ return { ok: false, status, definite: true, headers };
1067
+ }
568
1068
  }
569
1069
  if (result.retryable && attempt <= MAX_RETRIES) {
570
1070
  const delay =
@@ -581,12 +1081,26 @@ async function issueWithRetries(request, limiter) {
581
1081
  }
582
1082
  continue;
583
1083
  }
1084
+ if (result.entry.status === 429) {
1085
+ noteRateLimited(host, result.headers);
1086
+ }
1087
+ const status = result.entry.status;
1088
+ if (disk && (status === 404 || status === 410)) {
1089
+ diskCachePutMiss(disk.key, status);
1090
+ } else if (stale && !result.entry.ok && (!status || status >= 500)) {
1091
+ // A stale copy is better than none while the server is failing, as in
1092
+ // cdxrs, but a 404 means the document is gone.
1093
+ return { ok: true, body: stale.body, status: stale.status };
1094
+ }
584
1095
  return result.entry;
585
1096
  }
586
1097
  }
587
1098
 
588
1099
  /** Policy and connectivity degradations already recorded this run; these describe the run, not one URL. */
589
1100
  const recordedPolicyDegradations = new Set();
1101
+ registerRunReset("fetch:policy-records", () =>
1102
+ recordedPolicyDegradations.clear(),
1103
+ );
590
1104
 
591
1105
  /**
592
1106
  * Record a run-level policy or connectivity degradation once per process. A
@@ -660,25 +1174,28 @@ function classifyAgentError(err) {
660
1174
  }
661
1175
  const status = err?.response?.statusCode;
662
1176
  const headers = err?.response?.headers;
1177
+ // The response headers travel with the entry, so a caller can read what
1178
+ // the server said about its limits, as it could from its own request.
663
1179
  if (status === 429) {
664
1180
  return {
665
1181
  retryable: true,
666
1182
  delay: parseRetryAfter({ headers }),
667
- entry: { ok: false, status, definite: false },
1183
+ headers,
1184
+ entry: { ok: false, status, definite: false, headers },
668
1185
  };
669
1186
  }
670
1187
  if (status >= 500 && status < 600) {
671
1188
  return {
672
1189
  retryable: true,
673
1190
  delay: null,
674
- entry: { ok: false, status, definite: false },
1191
+ entry: { ok: false, status, definite: false, headers },
675
1192
  };
676
1193
  }
677
1194
  if (status >= 400 && status < 500) {
678
1195
  return {
679
1196
  retryable: false,
680
1197
  delay: null,
681
- entry: { ok: false, status, definite: true },
1198
+ entry: { ok: false, status, definite: true, headers },
682
1199
  };
683
1200
  }
684
1201
  // Transport error (timeout, connection reset). The name check mirrors how
@@ -721,6 +1238,25 @@ async function fetchOne(request, gauge) {
721
1238
  if (!host) {
722
1239
  return { ok: false, status: undefined, definite: false };
723
1240
  }
1241
+ // The disk cache answers before any permit is taken, so a cached document
1242
+ // never waits for the host's gate.
1243
+ const diskKey = diskCacheable(request)
1244
+ ? {
1245
+ url: request.url,
1246
+ accept: request.accept,
1247
+ authRealm: request.authRealm,
1248
+ responseType: request.responseType || "json",
1249
+ }
1250
+ : undefined;
1251
+ const cached = diskKey ? diskCacheGet(diskKey) : undefined;
1252
+ if (cached?.state === "fresh") {
1253
+ gauge.cacheHits += 1;
1254
+ return { ok: true, body: cached.entry.body, status: cached.entry.status };
1255
+ }
1256
+ if (cached?.state === "miss") {
1257
+ gauge.cacheHits += 1;
1258
+ return { ok: false, status: cached.entry.status, definite: true };
1259
+ }
724
1260
  const controls = getHostControls(host);
725
1261
  const releaseGlobal = await _globalSemaphore.acquire();
726
1262
  const releaseHost = await controls.sem.acquire();
@@ -729,7 +1265,14 @@ async function fetchOne(request, gauge) {
729
1265
  gauge.peak = gauge.inFlight;
730
1266
  }
731
1267
  try {
732
- return await issueWithRetries(request, controls.limiter);
1268
+ return await issueWithRetries(
1269
+ request,
1270
+ controls.limiter,
1271
+ diskKey && {
1272
+ key: diskKey,
1273
+ stale: cached?.state === "stale" ? cached.entry : undefined,
1274
+ },
1275
+ );
733
1276
  } finally {
734
1277
  gauge.inFlight -= 1;
735
1278
  releaseHost();
@@ -750,28 +1293,100 @@ async function fetchOne(request, gauge) {
750
1293
  * The per-host semaphore and limiter are module state, so this shares one gate
751
1294
  * per host for the life of the process.
752
1295
  *
1296
+ * A host paused by its circuit breaker fails at once with a 429-shaped error,
1297
+ * and a 429 from a breaker host pauses it.
1298
+ *
1299
+ * With `deferGate`, `issue` receives request options carrying a
1300
+ * `beforeNetwork` hook and must pass them to `cdxgenAgent`. The rate limiter
1301
+ * then runs only when the request misses the response cache, so a cached
1302
+ * answer does not wait for a slot. Without it, the limiter runs before
1303
+ * `issue`, as before.
1304
+ *
753
1305
  * @template T
754
1306
  * @param {string} url The URL about to be requested.
755
- * @param {() => Promise<T>} issue Issues the request and resolves its result.
1307
+ * @param {(gate: Object) => Promise<T>} issue Issues the request and resolves its result.
1308
+ * @param {{deferGate?: boolean}} [opts]
756
1309
  * @returns {Promise<T>} Whatever `issue` resolves to.
757
1310
  */
758
- export async function withHostRateLimit(url, issue) {
1311
+ export async function withHostRateLimit(url, issue, { deferGate } = {}) {
759
1312
  const host = extractHost(url);
760
1313
  if (!host) {
761
- return await issue();
1314
+ return await issue({});
1315
+ }
1316
+ if (isHostCircuitOpen(host)) {
1317
+ throw hostCircuitOpenError(host);
762
1318
  }
763
1319
  const controls = getHostControls(host);
764
1320
  const releaseGlobal = await _globalSemaphore.acquire();
765
1321
  const releaseHost = await controls.sem.acquire();
766
1322
  try {
767
- await controls.limiter.wait();
768
- return await issue();
1323
+ let gate = {};
1324
+ if (deferGate) {
1325
+ gate = { hooks: { beforeNetwork: [() => controls.limiter.wait()] } };
1326
+ } else {
1327
+ await controls.limiter.wait();
1328
+ }
1329
+ return await issue(gate);
1330
+ } catch (err) {
1331
+ const status = err?.response?.statusCode;
1332
+ const headers = err?.response?.headers;
1333
+ if (
1334
+ !tripOnRateLimit(host, status, headers) &&
1335
+ !tripOnGitHubQuota(host, status, headers) &&
1336
+ status === 429
1337
+ ) {
1338
+ noteRateLimited(host, headers);
1339
+ }
1340
+ throw err;
769
1341
  } finally {
770
1342
  releaseHost();
771
1343
  releaseGlobal();
772
1344
  }
773
1345
  }
774
1346
 
1347
+ /**
1348
+ * Request a URL the batch did not answer, the way the pool would have: behind
1349
+ * the per-host gate, failing at once for a document that already answered 404
1350
+ * or 410 this run, and remembering such an answer for the next pass.
1351
+ *
1352
+ * This is the fallback every metadata function uses after a prefetch. Without
1353
+ * it, a URL the batch left open went straight to the network with no rate gate,
1354
+ * and the same miss was asked again on every pass.
1355
+ *
1356
+ * @param {string} url URL to request.
1357
+ * @param {Object} [options] `cdxgenAgent.get` options.
1358
+ * @returns {Promise<Object>} The response.
1359
+ * @throws {Error} The request's own error, or a 404-shaped error for a
1360
+ * remembered miss, so the caller's `catch` runs as it would for the network.
1361
+ */
1362
+ export async function gatedGet(url, options = {}) {
1363
+ const key = missKey(url, options);
1364
+ const missing = rememberedMiss(key);
1365
+ if (missing) {
1366
+ throw httpStatusError(url, missing, {}, "already answered this run");
1367
+ }
1368
+ try {
1369
+ return await withHostRateLimit(
1370
+ url,
1371
+ (gate) =>
1372
+ cdxgenAgent.get(url, {
1373
+ ...options,
1374
+ hooks: {
1375
+ ...options.hooks,
1376
+ beforeNetwork: [
1377
+ ...(options.hooks?.beforeNetwork || []),
1378
+ ...(gate.hooks?.beforeNetwork || []),
1379
+ ],
1380
+ },
1381
+ }),
1382
+ { deferGate: true },
1383
+ );
1384
+ } catch (err) {
1385
+ rememberIfMissing(key, err?.response?.statusCode ?? err?.statusCode);
1386
+ throw err;
1387
+ }
1388
+ }
1389
+
775
1390
  /**
776
1391
  * Dedupe key for an in-flight request: the URL, plus everything that changes
777
1392
  * which response the caller gets back. That is the headers which change what
@@ -808,14 +1423,14 @@ async function jsBatchFetch(unique) {
808
1423
  const results = new Map();
809
1424
  // Concurrency is counted per batch. A request served from another batch's
810
1425
  // in-flight promise is counted against that batch, not this one.
811
- const gauge = { inFlight: 0, peak: 0 };
1426
+ const gauge = { inFlight: 0, peak: 0, cacheHits: 0 };
812
1427
  const tasks = unique.map(async (request) => {
813
1428
  const key = inFlightKey(request);
814
1429
  // Cross-batch in-flight dedupe. If another batch already has this URL in
815
1430
  // flight, await its promise rather than issuing a second request. The
816
1431
  // entry is removed when the promise settles, so a later request for the
817
- // same URL (after completion) goes through `responseCache` or, under
818
- // `CDXGEN_NO_CACHE`, issues a fresh request — which is correct.
1432
+ // same URL (after completion) goes through `responseCache`, which is
1433
+ // correct.
819
1434
  let promise = _inFlight.get(key);
820
1435
  if (!promise) {
821
1436
  promise = fetchOne(request, gauge);
@@ -846,7 +1461,7 @@ async function jsBatchFetch(unique) {
846
1461
  unique: unique.length,
847
1462
  ok,
848
1463
  failures,
849
- cacheHits: 0,
1464
+ cacheHits: gauge.cacheHits,
850
1465
  elapsedMs: Date.now() - started,
851
1466
  peakConcurrency: gauge.peak,
852
1467
  };
@@ -859,6 +1474,26 @@ async function jsBatchFetch(unique) {
859
1474
  return results;
860
1475
  }
861
1476
 
1477
+ /**
1478
+ * An error shaped like the agent's `HTTPError`, for an HTTP answer the batch
1479
+ * or the miss memo already holds.
1480
+ *
1481
+ * @param {string} url Request URL.
1482
+ * @param {number} status HTTP status.
1483
+ * @param {Object} [headers] Response headers.
1484
+ * @param {string} [detail] Why no request was made.
1485
+ * @returns {Error}
1486
+ */
1487
+ function httpStatusError(url, status, headers = {}, detail = undefined) {
1488
+ const err = new Error(
1489
+ `Request failed with status code ${status}${detail ? ` (${detail})` : ""}`,
1490
+ );
1491
+ err.name = "HTTPError";
1492
+ err.statusCode = status;
1493
+ err.response = { statusCode: status, headers, url };
1494
+ return err;
1495
+ }
1496
+
862
1497
  /**
863
1498
  * Read a prefetched response, or signal that the caller should fetch it itself.
864
1499
  *
@@ -866,9 +1501,11 @@ async function jsBatchFetch(unique) {
866
1501
  * @param {string} url The URL the caller is about to request.
867
1502
  * @returns {{body: *}|undefined} A response-shaped object when the body is
868
1503
  * available, or `undefined` when the caller should issue its own request.
869
- * @throws {Error} When the batch established a definite HTTP error for this
870
- * URL, so that the caller's existing `catch` treats it exactly as it treats a
871
- * failed `cdxgenAgent.get`.
1504
+ * @throws {Error} When the server answered with an error status, so that the
1505
+ * caller's existing `catch` treats it exactly as it treats a failed
1506
+ * `cdxgenAgent.get`. A 5xx or a 429 is the answer after the batch's own
1507
+ * retries, and asking once more at once would only add to the load. Only a
1508
+ * transport failure, with no status, is left to the caller to retry.
872
1509
  */
873
1510
  export function prefetchedResponse(prefetched, url) {
874
1511
  const entry = prefetched?.get(url);
@@ -878,10 +1515,8 @@ export function prefetchedResponse(prefetched, url) {
878
1515
  if (entry.ok) {
879
1516
  return { body: entry.body };
880
1517
  }
881
- if (entry.definite) {
882
- const err = new Error(`Request failed with status code ${entry.status}`);
883
- err.statusCode = entry.status;
884
- throw err;
1518
+ if (entry.status !== undefined) {
1519
+ throw httpStatusError(url, entry.status, entry.headers);
885
1520
  }
886
1521
  return undefined;
887
1522
  }