sourcemonkey 0.0.0-stage → 0.5.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 (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. package/skills/SourceMonkey-friction/references/template.md +59 -0
@@ -0,0 +1,878 @@
1
+ // Prefetched external files referenced by a userscript: `@require`
2
+ // libraries and `@resource` data blobs. Both flow through the same
3
+ // resolution + fetch + dedup pipeline; the diff is the typed shape
4
+ // each produces and how they're consumed downstream.
5
+ //
6
+ // `@require`:
7
+ // - JS text prepended before the script body at injection time
8
+ // (the registered userScript carries the concatenated source so
9
+ // no runtime fetch is needed at injection time — Chrome's
10
+ // userScripts API can't fetch on its own).
11
+ // - Any URL, with or without a `.js` suffix (CDN shorthands like
12
+ // `cdn.jsdelivr.net/npm/sweetalert2@11` have none, and TM and VM
13
+ // don't gate on it). Strict UTF-8 decode on the fetched bytes —
14
+ // non-UTF-8 fails the script outright. We never inject binary as
15
+ // JS.
16
+ //
17
+ // `@resource`:
18
+ // - Arbitrary bytes (any media type). Stored as base64 so the
19
+ // chrome.storage.local JSON layer can carry them.
20
+ // - Exposed to userscripts at runtime via GM_getResourceText
21
+ // (UTF-8 decode on demand, mojibake-tolerant) and
22
+ // GM_getResourceURL (blob: URL minted in the page with the
23
+ // fetched content-type).
24
+ //
25
+ // Shared semantics:
26
+ // - resolveScriptResource rules from file-loader: absolute
27
+ // http(s):// or relative-against-script. file:// only reachable
28
+ // via relative resolution, and a local script's relative ref may
29
+ // not climb out of its own folder. data: rejected.
30
+ // - Same byte fetcher with size cap.
31
+ // - Same per-refresh cross-script fetch cache: two scripts (and
32
+ // two refs of either kind) pointing at the same URL share one
33
+ // fetch. The typed assembly happens after the cache hit.
34
+ // - Any failure (resolve-time or fetch-time) returns an error
35
+ // message; the caller flips the script to status:'invalid' with
36
+ // the message and preserves prior-known-good state.
37
+ import { fetchResourceBytes, isNotModified, resolveScriptResourceDetailed, } from './file-loader.js';
38
+ import { localCandidateMayExist, localFileCandidatesDetailed, } from './local-file-overrides.js';
39
+ import { splitResourceValue } from './script-links.js';
40
+ import { sha256Hex, bytesToBase64, base64ToBytes } from './bytes.js';
41
+ import { timePhase } from './phase-timing.js';
42
+ import { getValues } from './userscript-header.js';
43
+ import { iconDeclFromHeader, isIconContentType, MAX_ICON_BYTES } from './script-icon.js';
44
+ import { isMarkupContentType, looksLikeMarkup, markupResponseMessage, } from './markup-response.js';
45
+ // Per-file limit. Big enough for jQuery (~90 KB) and the 1-2 MB
46
+ // libraries scripts pin (MathJax, video.js, babel-standalone, the
47
+ // stockfish engines); small enough to keep a runaway from draining the
48
+ // 10 MB chrome.storage.local quota in one row. Applies to both
49
+ // @require and @resource.
50
+ export const MAX_FILE_BYTES = 2 * 1024 * 1024;
51
+ // Rendered in whole MB when the cap divides evenly, so messages say
52
+ // "1 MB" rather than "1024 KB".
53
+ function byteLimitText(maxBytes) {
54
+ return maxBytes % (1024 * 1024) === 0
55
+ ? `${maxBytes / (1024 * 1024)} MB`
56
+ : `${Math.round(maxBytes / 1024)} KB`;
57
+ }
58
+ // Why a `data:` URL dependency won't load. Exported so the corpus
59
+ // validator can recognize it without matching the wording.
60
+ export const DATA_URL_NOT_SUPPORTED = 'URLs with the data: scheme are not supported';
61
+ // Split out so `isFetchFailureMessage` can recognize the one message
62
+ // whose text isn't fixed at module load, without either side spelling
63
+ // the prefix twice.
64
+ const HTTP_STATUS_PREFIX = 'The server returned HTTP ';
65
+ // Why a dependency wouldn't load, as a sentence of its own that lands
66
+ // after the subject: `@require ./lib.js: ` + one of these.
67
+ //
68
+ // These used to be bare predicates (`is empty`, `could not be fetched:
69
+ // HTTP 404`) that only parsed as English once the subject was glued on.
70
+ // Each now carries its own subject so it reads as a sentence wherever
71
+ // it lands, and takes the capital every message takes — see
72
+ // [docs/diagnostics.md](../docs/diagnostics.md#wording). Dropping the
73
+ // `could not be fetched: ` half also drops a second colon from a string
74
+ // that already arrives after one.
75
+ //
76
+ // These read as facts rather than as a list of possibilities because
77
+ // the fetcher now reports which case it hit. It used to return a bare
78
+ // `null`, so a single message had to cover "no response", "an HTTP
79
+ // error", and "over the limit" at once — and the corpus validator
80
+ // reconstructed the real cause from a fetch log to make its report
81
+ // useful.
82
+ //
83
+ // `maxBytes` is the limit the fetch was made with, for the oversize
84
+ // message: `@require` / `@resource` by default, smaller for an `@icon`.
85
+ export function describeFetchFailure(failure, maxBytes = MAX_FILE_BYTES) {
86
+ switch (failure.reason) {
87
+ case 'unsupported-scheme':
88
+ // Unreachable for a dependency — resolution rejects anything
89
+ // that isn't http(s) or a contained relative path long before
90
+ // the fetch. Kept for totality.
91
+ return 'That URL scheme cannot be fetched';
92
+ case 'no-response':
93
+ return 'No response from the server';
94
+ case 'timeout':
95
+ return 'The request timed out';
96
+ case 'unreadable-file':
97
+ // The one fetch failure that still names two causes, because a
98
+ // file:// read has no HTTP layer: "isn't there" and "can't be
99
+ // read" arrive identically. Not a network problem either way,
100
+ // which is what this case used to be reported as.
101
+ return 'The file may not exist, or may not be readable';
102
+ case 'http-error':
103
+ return `${HTTP_STATUS_PREFIX}${failure.status}`;
104
+ case 'wrong-content-type':
105
+ // Only the icon fetches ask for a content type.
106
+ return 'The file is not an image';
107
+ case 'empty':
108
+ return 'The file is empty';
109
+ case 'oversize':
110
+ return `The file is larger than the ${byteLimitText(maxBytes)} limit`;
111
+ }
112
+ }
113
+ // Every `FetchFailReason`, so the predicate below can't silently stop
114
+ // covering one. `FetchFailure` is a discriminated union, not an enum,
115
+ // so there is no runtime list to derive this from.
116
+ //
117
+ // A `Record` keyed by the union rather than an array: a `Record` is
118
+ // missing-key-checked, so adding a `FetchFailReason` without a line
119
+ // here fails the build. An array — even `as const satisfies
120
+ // readonly FetchFailure['reason'][]` — only checks that each entry IS a
121
+ // reason, never that none is missing, which is the direction that
122
+ // matters. A missed reason wouldn't throw; it would quietly stop being
123
+ // recognized, and the validator would mislabel that bucket.
124
+ const FETCH_FAILURE_REASONS = {
125
+ 'unsupported-scheme': true,
126
+ 'no-response': true,
127
+ 'timeout': true,
128
+ 'unreadable-file': true,
129
+ 'http-error': true,
130
+ 'wrong-content-type': true,
131
+ 'empty': true,
132
+ 'oversize': true,
133
+ };
134
+ // Did this text come out of `describeFetchFailure`?
135
+ //
136
+ // Exists for the corpus validator
137
+ // (`tests/greasyfork-examples/validate-corpus.mjs`), which substitutes
138
+ // its own wording when the offline mirror's 404 is an artifact of the
139
+ // mirror rather than a real server answer — it has to recognize a
140
+ // fetch-layer failure to know when that substitution applies.
141
+ //
142
+ // A predicate rather than a regex over the wording: these strings are
143
+ // user-facing and get reworded, and a pattern living in the validator
144
+ // would go stale silently (it did — it matched a `could not be
145
+ // fetched: ` prefix that no longer exists).
146
+ export function isFetchFailureMessage(text) {
147
+ // `http-error` first and separately: its text carries the status, so
148
+ // it's the one case that can't be compared against a fixed string.
149
+ if (text.startsWith(HTTP_STATUS_PREFIX))
150
+ return true;
151
+ const reasons = Object.keys(FETCH_FAILURE_REASONS);
152
+ return reasons.some((reason) => {
153
+ if (reason === 'http-error')
154
+ return false;
155
+ return describeFetchFailure({ failed: true, reason }) === text;
156
+ });
157
+ }
158
+ // Why a ref couldn't be resolved to something fetchable. Same shape:
159
+ // a sentence that follows the subject, not a predicate glued to it.
160
+ export function describeResolveFailure(failure) {
161
+ switch (failure.reason) {
162
+ case 'empty':
163
+ // Unreachable — `distillRequires` / `distillResources` drop empty
164
+ // refs before a loader sees one. Kept for totality.
165
+ return 'The tag has no reference after it';
166
+ case 'malformed-url':
167
+ return 'The reference is not a valid URL';
168
+ case 'unsupported-scheme':
169
+ // Names what the author actually wrote. `file:` reaches this on
170
+ // purpose — an absolute local path is not accepted even from a
171
+ // local script (see file-loader.ts's module header). The scheme
172
+ // is parenthesized rather than leading, so the sentence can open
173
+ // with a capital of ours instead of a verbatim value.
174
+ return `That URL scheme (${failure.scheme}) is not supported; ` +
175
+ 'use an http(s) URL, or a relative path resolved against the script\'s own location';
176
+ case 'outside-script-folder':
177
+ // Only reachable for a script loaded from `file://`: a relative
178
+ // ref in a script served over http(s) may climb, the way it does
179
+ // in Violentmonkey. So the message names the case it applies to
180
+ // rather than stating a rule that isn't universal.
181
+ return 'That path points outside the script\'s own folder; ' +
182
+ 'a relative path in a local script has to stay inside it';
183
+ case 'relative-in-scratch':
184
+ // A scratch script is text alone, without the files beside it.
185
+ return 'A scratch script can use a relative path only to a file pushed along with its text. ' +
186
+ 'Use an http(s) URL, or push the file with the script.';
187
+ }
188
+ }
189
+ function isFailure(v) {
190
+ return v.failed === true;
191
+ }
192
+ // ---------- Header distillation ----------
193
+ // Unique `@require` refs in declaration order. Localized variants
194
+ // (`@require:de`) are ignored. Empty / whitespace-only refs dropped.
195
+ // Duplicate refs collapse to first occurrence — TM does the same.
196
+ export function distillRequires(header) {
197
+ if (!header)
198
+ return [];
199
+ const out = [];
200
+ const seen = new Set();
201
+ for (const raw of getValues(header, 'require')) {
202
+ const trimmed = raw.trim();
203
+ if (!trimmed)
204
+ continue;
205
+ if (seen.has(trimmed))
206
+ continue;
207
+ seen.add(trimmed);
208
+ out.push(trimmed);
209
+ }
210
+ return out;
211
+ }
212
+ // Unique `@resource` declarations in declaration order. Each line is
213
+ // `<name> <url>`; we split via the same helper the viewer uses so
214
+ // linkification and load semantics stay aligned. Lines with no URL
215
+ // portion (no whitespace gap) are dropped silently. Duplicate names
216
+ // keep the first declaration — TM/VM raise an error; being lenient
217
+ // means a typo only loses one resource rather than failing the
218
+ // whole script.
219
+ export function distillResources(header) {
220
+ if (!header)
221
+ return [];
222
+ const out = [];
223
+ const seenName = new Set();
224
+ for (const raw of getValues(header, 'resource')) {
225
+ const trimmed = raw.trim();
226
+ if (!trimmed)
227
+ continue;
228
+ const parts = splitResourceValue(trimmed);
229
+ if (!parts)
230
+ continue;
231
+ const name = parts.prefix.trim();
232
+ const ref = parts.url.trim();
233
+ if (!name || !ref)
234
+ continue;
235
+ if (seenName.has(name))
236
+ continue;
237
+ seenName.add(name);
238
+ out.push({ name, ref });
239
+ }
240
+ return out;
241
+ }
242
+ // Find the header line that declared a file, so a load failure can
243
+ // point at it. Matching is on the declaration's own text rather than
244
+ // on the resolved URL: resolution is exactly what may have failed, and
245
+ // the line the user has to edit is the one they wrote.
246
+ //
247
+ // Localized forms (`@require:de`) are skipped for the same reason
248
+ // `getValues` skips them — they are not the declarations that were
249
+ // loaded.
250
+ function declarationLine(header, tag, matches) {
251
+ if (!header)
252
+ return undefined;
253
+ for (const t of header.tags) {
254
+ if (t.tag !== tag || t.locale)
255
+ continue;
256
+ if (matches(t.value.trim()))
257
+ return t.line;
258
+ }
259
+ return undefined;
260
+ }
261
+ export async function attachFiles(args) {
262
+ const requireRefs = distillRequires(args.header);
263
+ const resourceDecls = distillResources(args.header);
264
+ if (requireRefs.length === 0 && resourceDecls.length === 0) {
265
+ return { requires: [], resources: [] };
266
+ }
267
+ const requires = [];
268
+ const resources = [];
269
+ // @require dedup is at URL granularity (TM/VM also do this).
270
+ // @resource dedup is at NAME granularity, handled in distillResources —
271
+ // two names pointing at the same URL produce two distinct entries.
272
+ const seenRequireUrls = new Set();
273
+ const localRoots = args.localRoots ?? [];
274
+ const localLookup = args.localLookup ?? 'search';
275
+ // All files load at once, rather than one after another: each is a
276
+ // round trip of its own (a stand-in read, a network fetch), and on a
277
+ // page-load check they decide whether the reload's window is met.
278
+ // Started in declaration order, and `loadRequire` claims its URL in
279
+ // `seenUrls` before its first await, so the first of two refs to one
280
+ // URL is still the one that loads. Failures are reported for the
281
+ // first failing declaration, as the serial loop did.
282
+ const requireLoads = requireRefs.map((ref) => loadRequire({
283
+ ref,
284
+ scriptFileUrl: args.scriptFileUrl,
285
+ prior: args.priorRequires,
286
+ recheck: args.recheck,
287
+ fetchCache: args.fetchCache,
288
+ now: args.now,
289
+ seenUrls: seenRequireUrls,
290
+ localRoots,
291
+ localLookup,
292
+ pushed: args.pushedFiles?.[ref],
293
+ }));
294
+ const resourceLoads = resourceDecls.map((decl) => loadResource({
295
+ decl,
296
+ scriptFileUrl: args.scriptFileUrl,
297
+ prior: args.priorResources,
298
+ recheck: args.recheck,
299
+ fetchCache: args.fetchCache,
300
+ now: args.now,
301
+ localRoots,
302
+ localLookup,
303
+ pushed: args.pushedFiles?.[decl.ref],
304
+ }));
305
+ const [requireResults, resourceResults] = await Promise.all([
306
+ Promise.all(requireLoads), Promise.all(resourceLoads),
307
+ ]);
308
+ for (const [i, res] of requireResults.entries()) {
309
+ if (res.errorMessage) {
310
+ const ref = requireRefs[i];
311
+ return {
312
+ requires: [],
313
+ resources: [],
314
+ errorMessage: res.errorMessage,
315
+ errorLine: declarationLine(args.header, 'require', (v) => v === ref),
316
+ };
317
+ }
318
+ if (res.entry)
319
+ requires.push(res.entry);
320
+ }
321
+ for (const [i, res] of resourceResults.entries()) {
322
+ if (res.errorMessage) {
323
+ const decl = resourceDecls[i];
324
+ return {
325
+ requires: [],
326
+ resources: [],
327
+ errorMessage: res.errorMessage,
328
+ // Matched on NAME, which is what `distillResources` dedups on,
329
+ // so the line found is the declaration that was actually
330
+ // loaded rather than a later one the loader ignored.
331
+ errorLine: declarationLine(args.header, 'resource', (v) => splitResourceValue(v)?.prefix.trim() === decl.name),
332
+ };
333
+ }
334
+ if (res.entry)
335
+ resources.push(res.entry);
336
+ }
337
+ return { requires, resources };
338
+ }
339
+ // Check that the script's `@icon` can be shown: that it resolves,
340
+ // downloads, is an image, and is within `MAX_ICON_BYTES`. The pages
341
+ // that paint icons apply the same rules and just show a globe when one
342
+ // fails, so this is the only place the reason is reported.
343
+ //
344
+ // Never makes the script invalid: the icon is cosmetic. Downloads on
345
+ // the same terms as `@require`:
346
+ // - `recheck: 'preserve'` keeps the prior answer for an unchanged
347
+ // reference, so a background refresh doesn't download it again.
348
+ // Not one that may pass on its own (no response, a timeout).
349
+ // - A local stand-in is re-read every pass, like a `@require`'s.
350
+ //
351
+ // Returns null when there is nothing to check: no `@icon`, or an inline
352
+ // `data:` icon, which has nothing to download.
353
+ export async function checkScriptIcon(args) {
354
+ const decl = iconDeclFromHeader(args.header);
355
+ if (!decl || decl.ref.startsWith('data:'))
356
+ return null;
357
+ const { ref } = decl;
358
+ const problem = (reason) => ({ ref, message: `@icon ${ref}: ${reason}` });
359
+ const resolved = resolveScriptResourceDetailed(ref, args.scriptFileUrl);
360
+ if ('failed' in resolved)
361
+ return problem(describeResolveFailure(resolved));
362
+ const { url } = resolved;
363
+ // A stand-in the page could show wins. One it couldn't (too big, not
364
+ // an image) is skipped, as the page skips it for the declared URL.
365
+ // Read through the shared fetch, which has no icon rules, so they
366
+ // are applied to the bytes here.
367
+ const prior = args.prior;
368
+ // A 'known' lookup goes by what the last check of this same reference
369
+ // found. A row that recorded a stand-in without its URL is searched.
370
+ const last = args.localLookup === 'known' && prior?.ref === ref && prior.url === url &&
371
+ (!prior.local || prior.localUrl)
372
+ ? { localUrl: prior.localUrl } : undefined;
373
+ const local = await readLocalStandIn(url, args.localRoots, args.fetchCache, last);
374
+ if (local?.localUrl && !isFailure(local.got) &&
375
+ local.got.bytes.byteLength <= MAX_ICON_BYTES &&
376
+ (!local.got.contentType || isIconContentType(local.got.contentType))) {
377
+ return { ref, url, local: true, localUrl: local.localUrl };
378
+ }
379
+ if (args.recheck === 'preserve' && prior?.ref === ref && prior.url === url &&
380
+ !prior.local && !prior.retry) {
381
+ return prior;
382
+ }
383
+ // Not through the shared fetch cache: that one fetches up to the
384
+ // `@require` limit, and an icon has its own limit and type rule.
385
+ const res = await fetchResourceBytes(url, {
386
+ maxBytes: MAX_ICON_BYTES,
387
+ acceptContentType: isIconContentType,
388
+ });
389
+ if ('failed' in res) {
390
+ const transient = res.reason === 'no-response' || res.reason === 'timeout';
391
+ return {
392
+ ...problem(describeFetchFailure(res, MAX_ICON_BYTES)),
393
+ url,
394
+ failure: res.reason,
395
+ ...(transient ? { retry: true } : {}),
396
+ };
397
+ }
398
+ return { ref, url };
399
+ }
400
+ // Fetch a single `@require` the way `attachFiles` would, for one
401
+ // preview TAB: the tabs are built from the header and render one body
402
+ // each, so a tab can't use the batch orchestrator (which returns all
403
+ // files or an error). The preview's parse check does use `attachFiles`
404
+ // — this is the per-tab path only, and the shared `fetchCache` means
405
+ // whichever runs first pays for the fetch. Routing through the same
406
+ // `loadRequire` keeps the validation and the error wording identical
407
+ // to what installing the script would produce.
408
+ //
409
+ // Throws (rather than returning an error field) because the caller is
410
+ // a lazy tab body, and a rejected promise is what the tab view renders
411
+ // as a load failure.
412
+ export async function fetchRequireForPreview(ref, scriptFileUrl, fetchCache, now, localRoots = []) {
413
+ const res = await loadRequire({
414
+ ref,
415
+ scriptFileUrl,
416
+ prior: new Map(),
417
+ recheck: 'all',
418
+ fetchCache,
419
+ now,
420
+ localRoots,
421
+ localLookup: 'search',
422
+ // Fresh per call: dedup across refs is the batch loader's job, and
423
+ // the preview shows one tab per declared ref.
424
+ seenUrls: new Set(),
425
+ });
426
+ if (res.errorMessage)
427
+ throw new Error(res.errorMessage);
428
+ // Defensive: `loadRequire` returns neither an entry nor an error only
429
+ // when it skipped the fetch (a URL already seen this pass, or a
430
+ // preserved prior), and this call can do neither — fresh `seenUrls`,
431
+ // empty `prior`, `recheck: 'all'`. So it says nothing about the file.
432
+ if (!res.entry)
433
+ throw new Error(`@require ${ref}: Internal error: no content and no reason`);
434
+ return res.entry;
435
+ }
436
+ // The stored fields that say a stand-in was read, and its stat. Empty
437
+ // on the ordinary path, so nothing is written for a declared-URL fetch.
438
+ function standInFields(loaded) {
439
+ if (!loaded.localUrl)
440
+ return {};
441
+ return {
442
+ localUrl: loaded.localUrl,
443
+ ...(loaded.fsMtime !== undefined ? { fsMtime: loaded.fsMtime } : {}),
444
+ };
445
+ }
446
+ // A pushed body as what was read for its ref. A relative ref's body is
447
+ // the declared file itself, so only a stand-in for a remote URL counts
448
+ // as one.
449
+ function pushedBytes(pushed) {
450
+ return {
451
+ ...(pushed.localUrl ? { localUrl: pushed.localUrl } : {}),
452
+ got: { bytes: base64ToBytes(pushed.bytesBase64), contentType: pushed.contentType },
453
+ };
454
+ }
455
+ // Read a declared file, preferring a local stand-in when one exists.
456
+ //
457
+ // Candidates are tried in order and the first that READS wins — the
458
+ // probe and the real read are the same fetch, so a hit costs nothing
459
+ // extra. A root is listed only when the search reaches it, to rule out
460
+ // candidates under it that can't exist (see `localCandidateMayExist`),
461
+ // so a hit in an early root never lists the later ones. `ensureFetch`
462
+ // caches the reads for the rest of the pass, misses included.
463
+ //
464
+ // `last` is what the last check found, passed by the page-load checks
465
+ // (a listing per root costs more than the whole rest of such a check):
466
+ // - its stand-in, when still a candidate under these roots, is read
467
+ // first and no root is listed. A path that no longer reads falls
468
+ // through to the search.
469
+ // - no stand-in last time means none is looked for: one appearing is
470
+ // rare, and searching would list every root on every page load.
471
+ // Either can miss a copy that appeared since, nearer or at all; the
472
+ // searching checks (focus, Refresh) find it.
473
+ //
474
+ // Returns `null` when no stand-in was found, so the caller can take its
475
+ // `recheck: 'preserve'` shortcut on the declared URL. That ordering is
476
+ // deliberate: a stand-in is checked on EVERY refresh, including
477
+ // opportunistic ones, so editing a local library shows up on the next
478
+ // navigation — while a remote dependency still gets the cached-and-left-
479
+ // alone treatment that keeps per-navigation cost bounded.
480
+ //
481
+ // A `null` here does NOT mean the caller can preserve any prior: a prior
482
+ // that itself carries a `localUrl` describes a file we just failed to
483
+ // find, so keeping it would serve a deleted stand-in indefinitely. Each
484
+ // caller's preserve shortcut checks for that.
485
+ async function readLocalStandIn(declaredUrl, localRoots, fetchCache, last) {
486
+ const candidates = localFileCandidatesDetailed(declaredUrl, localRoots);
487
+ if (last && !last.localUrl)
488
+ return null;
489
+ const known = last?.localUrl;
490
+ if (known && candidates.some((c) => c.url === known)) {
491
+ const got = await ensureFetch(fetchCache, known);
492
+ if (!isFailure(got))
493
+ return standInRead(known, got);
494
+ }
495
+ const listings = listingsFor(fetchCache);
496
+ for (const candidate of candidates) {
497
+ if (!await localCandidateMayExist(candidate, listings))
498
+ continue;
499
+ const got = await ensureFetch(fetchCache, candidate.url);
500
+ // A candidate that doesn't read isn't a failure — it just isn't
501
+ // there. Only an actual hit ends the search.
502
+ if (!isFailure(got))
503
+ return standInRead(candidate.url, got);
504
+ }
505
+ return null;
506
+ }
507
+ // A stand-in that read, with its modified time for the "modified" chip.
508
+ // The time comes from the read itself (`Last-Modified`), in the units a
509
+ // listing of its folder would give.
510
+ function standInRead(localUrl, got) {
511
+ return { localUrl, got, ...(got.fsMtime !== undefined ? { fsMtime: got.fsMtime } : {}) };
512
+ }
513
+ // The root-directory listings the stand-in search gates on,
514
+ // given the same lifetime as the fetch cache they sit beside: one
515
+ // refresh pass, or the page for the install preview's page-lifetime
516
+ // cache, which memoizes misses for that long anyway. Keyed off the fetch cache
517
+ // rather than threaded as another parameter because every entry point
518
+ // that builds a `FileFetchCache` would otherwise have to build this one
519
+ // too, and the two must never outlive each other — a listing that
520
+ // outlived its pass would hide a stand-in created after it was taken.
521
+ const listingCaches = new WeakMap();
522
+ // A pass that already keeps directory listings (the navigation
523
+ // `RefreshBatch`) registers them here, so the stat it took of the
524
+ // script's own folder and the gate's read of that same folder are one
525
+ // listing rather than two.
526
+ export function bindListingCache(fetchCache, listings) {
527
+ listingCaches.set(fetchCache, listings);
528
+ }
529
+ function listingsFor(fetchCache) {
530
+ let listings = listingCaches.get(fetchCache);
531
+ if (!listings) {
532
+ listings = new Map();
533
+ listingCaches.set(fetchCache, listings);
534
+ }
535
+ return listings;
536
+ }
537
+ // What the last check found for this file, under a 'known' lookup. A
538
+ // file the last check didn't load (newly declared) has nothing known,
539
+ // so it is searched for.
540
+ function lastLookup(lookup, prior) {
541
+ return lookup === 'known' ? prior : undefined;
542
+ }
543
+ // Start reading the stand-ins a script's last check found, into the
544
+ // pass's fetch cache, so a check can overlap them with reading the
545
+ // script itself. The loaders then take them from the cache. Only for a
546
+ // 'known' lookup, which reads these paths first anyway.
547
+ export function prefetchKnownStandIns(fetchCache, prior) {
548
+ for (const entry of [...(prior.requires ?? []), ...(prior.resources ?? [])]) {
549
+ if (entry.localUrl)
550
+ void ensureFetch(fetchCache, entry.localUrl);
551
+ }
552
+ }
553
+ // Where a ref resolves: the pushed body's URL when there is one, which
554
+ // the pusher already resolved and checked, else the ref resolved
555
+ // against the script.
556
+ function resolveRef(ref, scriptFileUrl, pushed) {
557
+ if (pushed)
558
+ return { url: pushed.url };
559
+ const resolved = resolveScriptResourceDetailed(ref, scriptFileUrl);
560
+ if ('failed' in resolved)
561
+ return { failure: describeResolveFailure(resolved) };
562
+ if (resolved.kind === 'data')
563
+ return { failure: DATA_URL_NOT_SUPPORTED };
564
+ return { url: resolved.url };
565
+ }
566
+ async function loadRequire(args) {
567
+ const resolved = resolveRef(args.ref, args.scriptFileUrl, args.pushed);
568
+ if ('failure' in resolved) {
569
+ return { errorMessage: `@require ${args.ref}: ${resolved.failure}` };
570
+ }
571
+ if (args.seenUrls.has(resolved.url))
572
+ return {};
573
+ args.seenUrls.add(resolved.url);
574
+ const prior = args.prior.get(resolved.url);
575
+ // Local stand-in first, and before the preserve shortcut — see
576
+ // `readLocalStandIn` for why an overridden file is never cached past
577
+ // a refresh. A pushed body is read every time for the same reason.
578
+ const local = args.pushed ? pushedBytes(args.pushed)
579
+ : await timePhase('local-standin', () => readLocalStandIn(resolved.url, args.localRoots, args.fetchCache, lastLookup(args.localLookup, prior)));
580
+ if (!local && !args.pushed && args.recheck === 'preserve' && prior && !prior.localUrl) {
581
+ return { entry: prior };
582
+ }
583
+ const loaded = local ?? {
584
+ got: await ensureFetch(args.fetchCache, resolved.url, priorCopyOf(prior, priorRequireBytes(prior))),
585
+ };
586
+ const got = loaded.got;
587
+ if (isFailure(got)) {
588
+ return { errorMessage: `@require ${args.ref}: ${got.errorMessage}` };
589
+ }
590
+ // The server answered 304 against OUR validator, so the copy we hold
591
+ // IS the current one — reuse it whole.
592
+ //
593
+ // Not merely an optimisation (though it does drop a SHA-256 per
594
+ // dependency per manual refresh). Rebuilding from the reconstructed
595
+ // bytes would be wrong: `source` was decoded with a BOM-stripping
596
+ // decoder, so re-encoding it does not reproduce the original bytes
597
+ // for a BOM-prefixed file. That would hash differently, bump
598
+ // `loadedAt`, shrink the stored `size` by three, and report a content
599
+ // change the server just told us did not happen.
600
+ //
601
+ // Matched on the hash rather than on "did we send a validator",
602
+ // because the shared cache may have resolved this URL from ANOTHER
603
+ // script's prior. Equal hashes mean equal content, so reusing this
604
+ // caller's own row is right; unequal means these are someone else's
605
+ // bytes and the normal path below has to run.
606
+ if (prior && got.priorContentHash !== undefined && got.priorContentHash === prior.contentHash) {
607
+ return { entry: { ...prior, ref: args.ref, url: resolved.url, ...validatorFields(got) } };
608
+ }
609
+ // @require-specific markup gate. Applied here (not in the shared
610
+ // fetch) so a @require + @resource pair pointing at the same URL
611
+ // share one fetch without the require's stricter rule poisoning the
612
+ // resource's cache entry — a `@resource` may legitimately BE an HTML
613
+ // file.
614
+ //
615
+ // Only markup is rejected. `application/octet-stream` and other
616
+ // vague types are accepted on purpose — they are common and
617
+ // legitimate for JavaScript (GitHub serves every release asset that
618
+ // way, and release URLs are a normal thing to pin a library to), and
619
+ // the strict-UTF-8 decode below is what actually closes the
620
+ // "anything binary gets through" hole. Tampermonkey does no
621
+ // content-type gating on `@require` at all.
622
+ if (isMarkupContentType(got.contentType)) {
623
+ return {
624
+ errorMessage: `@require ${args.ref}: ${markupResponseMessage({
625
+ ...(loaded.localUrl ? { localUrl: loaded.localUrl } : {}),
626
+ contentType: got.contentType,
627
+ })}`,
628
+ };
629
+ }
630
+ // Strict UTF-8 decode: catches binary regardless of what the server
631
+ // claimed the type was (a misconfigured CDN serving a PNG, or the
632
+ // generic octet-stream the markup gate deliberately lets
633
+ // through). Previously we used fatal:false and the bytes were
634
+ // mojibake'd into JS source, where they'd produce a confusing
635
+ // SyntaxError at eval. This is the gate that closes that hole.
636
+ let source;
637
+ try {
638
+ source = new TextDecoder('utf-8', { fatal: true }).decode(got.bytes);
639
+ }
640
+ catch {
641
+ return { errorMessage: `@require ${args.ref}: The response is not valid UTF-8 (binary or wrong-encoding file)` };
642
+ }
643
+ // Second half of the same gate, on the bytes rather than the header.
644
+ // The Content-Type test above can't fire when there is no
645
+ // Content-Type — the common `file://` case, which is exactly where a
646
+ // directory-listing HTML page comes from — nor when a server labels
647
+ // its login page `text/plain`.
648
+ if (looksLikeMarkup(source)) {
649
+ return {
650
+ errorMessage: `@require ${args.ref}: ${markupResponseMessage({
651
+ ...(loaded.localUrl ? { localUrl: loaded.localUrl } : {}),
652
+ })}`,
653
+ };
654
+ }
655
+ const u8 = new Uint8Array(got.bytes);
656
+ const contentHash = await sha256Hex(got.bytes);
657
+ const unchanged = prior?.contentHash === contentHash;
658
+ const loadedAt = unchanged && prior ? prior.loadedAt : args.now;
659
+ return {
660
+ entry: {
661
+ ref: args.ref,
662
+ url: resolved.url,
663
+ source,
664
+ size: u8.byteLength,
665
+ contentHash,
666
+ contentType: got.contentType,
667
+ loadedAt,
668
+ // Spread rather than assigned so the field is ABSENT (not
669
+ // `undefined`) on the ordinary path — stored rows are compared
670
+ // and JSON-round-tripped, and an explicit `undefined` would
671
+ // survive as a key in some of those comparisons.
672
+ ...standInFields(loaded),
673
+ ...validatorFields(got),
674
+ },
675
+ };
676
+ }
677
+ // The prior copy's bytes, for a conditional request. `source` was
678
+ // decoded from those bytes with a strict UTF-8 decoder, so re-encoding
679
+ // reproduces them exactly.
680
+ function priorRequireBytes(prior) {
681
+ if (!prior)
682
+ return null;
683
+ return new TextEncoder().encode(prior.source).buffer;
684
+ }
685
+ // The prior copy's bytes, decoded back from storage.
686
+ function priorResourceBytes(prior) {
687
+ if (!prior)
688
+ return null;
689
+ return base64ToBytes(prior.bytesBase64);
690
+ }
691
+ // Assemble the conditional-request input, or nothing when this entry
692
+ // can't support one.
693
+ //
694
+ // Withheld for a local stand-in (`localUrl`): the bytes we hold came
695
+ // off disk, and the validators — if any survived — describe the remote
696
+ // copy, so pairing them would ask the server about one file and answer
697
+ // with another.
698
+ function priorCopyOf(prior, bytes) {
699
+ if (!prior || !bytes)
700
+ return undefined;
701
+ if (prior.localUrl)
702
+ return undefined;
703
+ if (!prior.httpEtag && !prior.httpLastModified)
704
+ return undefined;
705
+ // Without a Content-Type the prior can't fully stand in for a
706
+ // response. `StoredRequire.contentType` is optional — rows written
707
+ // before it existed don't carry one — and a 304 resolved from such a
708
+ // row would hand `''` to every sharer of that URL through the fetch
709
+ // cache, including a `@resource` whose blob URL needs a real MIME.
710
+ // Refetching unconditionally costs one body and keeps that honest.
711
+ if (!prior.contentType)
712
+ return undefined;
713
+ return {
714
+ bytes,
715
+ contentType: prior.contentType,
716
+ contentHash: prior.contentHash,
717
+ ...(prior.httpEtag ? { etag: prior.httpEtag } : {}),
718
+ ...(prior.httpLastModified ? { lastModified: prior.httpLastModified } : {}),
719
+ };
720
+ }
721
+ // Validators onto the stored entry, absent rather than `undefined` for
722
+ // the same reason `localUrl` is spread above.
723
+ function validatorFields(got) {
724
+ return {
725
+ ...(got.etag ? { httpEtag: got.etag } : {}),
726
+ ...(got.lastModified ? { httpLastModified: got.lastModified } : {}),
727
+ };
728
+ }
729
+ async function loadResource(args) {
730
+ // A `@resource` line carries a name AND a ref, and the name alone
731
+ // doesn't say what failed to load. `@require` names its ref because
732
+ // that is all it has; this names both.
733
+ const subject = `@resource ${args.decl.name} (${args.decl.ref})`;
734
+ const resolved = resolveRef(args.decl.ref, args.scriptFileUrl, args.pushed);
735
+ if ('failure' in resolved) {
736
+ return { errorMessage: `${subject}: ${resolved.failure}` };
737
+ }
738
+ // Resources can be any media type.
739
+ // No URL-level dedup: two `@resource` lines with different
740
+ // names pointing at the same URL are intentional — we want two
741
+ // distinct StoredResource entries so each name resolves to its own
742
+ // GM_getResource* lookup. The shared FileFetchCache still ensures
743
+ // we only fetch the URL once.
744
+ // Prior lookup by URL.
745
+ const prior = args.prior.get(resolved.url);
746
+ // Local stand-in first, and before the preserve shortcut — same rule
747
+ // and same reasoning as `@require`; see `readLocalStandIn`.
748
+ const local = args.pushed ? pushedBytes(args.pushed)
749
+ : await timePhase('local-standin', () => readLocalStandIn(resolved.url, args.localRoots, args.fetchCache, lastLookup(args.localLookup, prior)));
750
+ if (!local && args.recheck === 'preserve' &&
751
+ prior && prior.name === args.decl.name && !prior.localUrl) {
752
+ return { entry: prior };
753
+ }
754
+ // Resources accept anything — including HTML. A script that wants
755
+ // to embed a fragment of HTML as a string via GM_getResourceText
756
+ // shouldn't be blocked.
757
+ const loaded = local ?? {
758
+ got: await ensureFetch(args.fetchCache, resolved.url, priorCopyOf(prior, priorResourceBytes(prior))),
759
+ };
760
+ const got = loaded.got;
761
+ if (isFailure(got)) {
762
+ return { errorMessage: `${subject}: ${got.errorMessage}` };
763
+ }
764
+ // Same 304 reuse as `loadRequire`. The name has to match too: a
765
+ // `@resource` renamed to the same URL is a changed declaration even
766
+ // though the body is identical, and the normal path below is what
767
+ // stamps the new name and bumps `loadedAt`.
768
+ if (prior && prior.name === args.decl.name &&
769
+ got.priorContentHash !== undefined && got.priorContentHash === prior.contentHash) {
770
+ return {
771
+ entry: { ...prior, ref: args.decl.ref, url: resolved.url, ...validatorFields(got) },
772
+ };
773
+ }
774
+ const u8 = new Uint8Array(got.bytes);
775
+ const contentHash = await sha256Hex(got.bytes);
776
+ const unchanged = prior && prior.contentHash === contentHash && prior.name === args.decl.name;
777
+ const loadedAt = unchanged && prior ? prior.loadedAt : args.now;
778
+ return {
779
+ entry: {
780
+ ref: args.decl.ref,
781
+ url: resolved.url,
782
+ name: args.decl.name,
783
+ bytesBase64: bytesToBase64(u8),
784
+ size: u8.byteLength,
785
+ contentHash,
786
+ contentType: got.contentType,
787
+ loadedAt,
788
+ ...standInFields(loaded),
789
+ ...validatorFields(got),
790
+ },
791
+ };
792
+ }
793
+ // ---------- Shared byte fetcher ----------
794
+ // `prior` is used only by the FIRST caller to ask for a given URL —
795
+ // whoever populates the cache entry. A later caller sharing the URL
796
+ // takes that entry as-is.
797
+ //
798
+ // That is safe even when the two hold different copies, because a 304
799
+ // resolves to the prior's BYTES rather than to a "nothing changed"
800
+ // marker. The cache entry always means "the current bytes for this
801
+ // URL", however they were obtained, so a script with no prior of its
802
+ // own still gets a body it can build from.
803
+ function ensureFetch(cache, url, prior) {
804
+ // The cache stores raw bytes + Content-Type, no kind-specific
805
+ // filtering applied. Per-kind acceptance (the @require Content-Type
806
+ // gate, the @require UTF-8 fatal decode, etc.) happens at the
807
+ // typed call site after the cache hit. This way a @require + a
808
+ // @resource pointing at the same URL share one fetch without
809
+ // either's rules biasing the other's outcome.
810
+ const cached = cache.get(url);
811
+ if (cached)
812
+ return cached;
813
+ const promise = timePhase('dep-fetch', () => fetchFileBytes(url, prior));
814
+ cache.set(url, promise);
815
+ return promise;
816
+ }
817
+ async function fetchFileBytes(url, prior) {
818
+ try {
819
+ const res = await fetchResourceBytes(url, {
820
+ maxBytes: MAX_FILE_BYTES,
821
+ // Only conditional when we hold a validator to offer. A bare
822
+ // prior with neither would send no header and just refetch.
823
+ ...(prior && (prior.etag ?? prior.lastModified)
824
+ ? { conditional: { etag: prior.etag, lastModified: prior.lastModified } }
825
+ : {}),
826
+ });
827
+ if ('failed' in res) {
828
+ return { failed: true, errorMessage: describeFetchFailure(res) };
829
+ }
830
+ if (isNotModified(res)) {
831
+ // Unreachable without a prior — `conditional` is only set above
832
+ // when one exists — but the type doesn't know that, and resolving
833
+ // to the prior's bytes is what makes the shared cache entry mean
834
+ // the same thing on both paths.
835
+ if (!prior) {
836
+ return {
837
+ failed: true,
838
+ errorMessage: 'Internal error: the server reported no change for a file we do not hold',
839
+ };
840
+ }
841
+ // Validators carried forward, not re-read: a 304 need not repeat
842
+ // them, and the ones we sent are by definition still current.
843
+ const out = {
844
+ bytes: prior.bytes,
845
+ contentType: prior.contentType,
846
+ priorContentHash: prior.contentHash,
847
+ };
848
+ if (prior.etag)
849
+ out.etag = prior.etag;
850
+ if (prior.lastModified)
851
+ out.lastModified = prior.lastModified;
852
+ return out;
853
+ }
854
+ const out = { bytes: res.body, contentType: res.contentType };
855
+ if (res.etag)
856
+ out.etag = res.etag;
857
+ if (res.lastModified)
858
+ out.lastModified = res.lastModified;
859
+ if (res.fsMtime !== undefined)
860
+ out.fsMtime = res.fsMtime;
861
+ return out;
862
+ }
863
+ catch (err) {
864
+ return {
865
+ failed: true,
866
+ // NOT a `describeFetchFailure` string, deliberately.
867
+ // `fetchResourceBytes` reports every fetch outcome as a
868
+ // `FetchFailure` return and does not throw, so reaching here means
869
+ // our own code broke its contract — which is what the reader is
870
+ // being told, and why `isFetchFailureMessage` correctly declines
871
+ // it. Phrasing it as one of the fetch failures would both invite
872
+ // the corpus validator to substitute mirror wording for a bug of
873
+ // ours, and put a second `could not be fetched: ` colon into a
874
+ // string that already lands after `@require <ref>: `.
875
+ errorMessage: `Internal error: the file fetcher failed unexpectedly (${err instanceof Error ? err.message : String(err)})`,
876
+ };
877
+ }
878
+ }