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,991 @@
1
+ // Single entry point for every userscript refresh path.
2
+ //
3
+ // Two trigger modes share the same underlying work (refreshScript /
4
+ // refreshCollection / refreshAllCollections in script-store.ts):
5
+ //
6
+ // - 'manual' — the user clicked a Refresh button. Always runs.
7
+ // Awaited by the caller so the UI can show a
8
+ // spinner and react to the result.
9
+ // - 'opportunistic' — the SW noticed a page that uses some scripts
10
+ // and wants to check whether their source files
11
+ // changed on disk. Fire-and-forget. Rate-limited
12
+ // to no sooner than UPDATE_MIN_INTERVAL_MS after
13
+ // the previous completion, and never concurrent
14
+ // for the same resource.
15
+ //
16
+ // In both modes a check that's currently in flight for a resource is
17
+ // shared — a manual click that lands while an opportunistic check is
18
+ // already running awaits that existing promise instead of starting a
19
+ // second pass. This holds PER gate key: the script gate
20
+ // (`updateScript`), the collection gate (`updateCollection`), and the
21
+ // all-collections gate (`updateAllCollections`) are independent maps,
22
+ // so an opportunistic `updateScript(X)` can still run alongside a
23
+ // manual `updateCollection(S⊇X)` or `updateAllCollections` that also
24
+ // rebuilds X. They fetch the same bytes, and their storage writes are
25
+ // serialized and re-read user-owned state by `script-store.ts`'s
26
+ // `mutate()` lock (see docs/storage.md "Write serialization"), so the
27
+ // overlap is idempotent rather than corrupting — but it is NOT deduped
28
+ // to a single fetch.
29
+ //
30
+ // We deliberately don't try to mark tabs stale from here. The actual
31
+ // stale propagation happens via chrome.storage.onChanged in
32
+ // background.ts so that ANY write that changes a script's content
33
+ // (this module, external tools poking storage, future paths we haven't
34
+ // imagined) triggers the same propagation.
35
+ import { getAllScriptMetas, getScriptMeta, getCollections, getCollectionWithScriptMeta, loadStateLogEntry, localFileLogMessages, startupLogEntry, refreshAllCollections, refreshScript, refreshCollection, createRefreshBatch, setVersionProbes, } from './script-store.js';
36
+ import { outdatedProbe, probeLabel, probeTargets, probesAreFresh, } from '../version-probe.js';
37
+ import { isContainerCollection } from './directories.js';
38
+ import { probeVersions } from './version-probe-fetch.js';
39
+ import { mapWithConcurrency, PROBE_SCRIPTS_CONCURRENCY } from './concurrency.js';
40
+ import { describeRefreshError, readRefreshErrors } from './refresh-error.js';
41
+ import { getValue } from '../userscript-header.js';
42
+ import { formatBytes, scriptOutdatedProbe } from '../script-display.js';
43
+ import { dlog } from '../debug-log.js';
44
+ import { startPhaseTimer, resetPhaseTotals, reportPhaseTotals } from '../phase-timing.js';
45
+ import { githubTokenLogSuffix } from '../github-auth.js';
46
+ import { getScriptLogLabel } from './script-log-id.js';
47
+ import { appendScriptLog, logScriptEvent } from './script-log.js';
48
+ import { describeDiagnostic } from '../diagnostic-location.js';
49
+ import { retryRejectedRegistrations } from './injector.js';
50
+ // Minimum gap between opportunistic checks for the same resource. The
51
+ // 10s figure is a balance: short enough that a user editing a script
52
+ // and switching tabs sees the update within a click or two, long
53
+ // enough that the background work doesn't measurably affect navigation.
54
+ export const UPDATE_MIN_INTERVAL_MS = 10_000;
55
+ // Allow tests to swap the clock. Production uses Date.now.
56
+ let nowMsImpl = () => Date.now();
57
+ export function _setNowForTests(fn) {
58
+ nowMsImpl = fn;
59
+ }
60
+ // Pure-ish core: run `task` if and only if there isn't one already in
61
+ // flight for this resource, and (for opportunistic mode) the cooldown
62
+ // has elapsed. Returns the active promise either way. The state Map is
63
+ // mutated in place; callers pass in a stable per-resource-type map so
64
+ // independent resources can run concurrently.
65
+ //
66
+ // Exported so tests can exercise the concurrency/rate-limit logic
67
+ // without touching chrome.storage.
68
+ export function gate(state, key, mode, task, skipResult,
69
+ // How the rate-limit skip below names this resource. Only the caller
70
+ // knows whether the key is a script, a collection, or the
71
+ // all-collections sentinel, and only a script has a readable label —
72
+ // so the naming is passed in rather than guessed from the key.
73
+ label = String(key),
74
+ // A caller that needs its own run's result (a Refresh click: forced
75
+ // probe, awaited parse check) rather than a running check's. It waits
76
+ // for that check, then runs, and later callers join it instead.
77
+ own = false) {
78
+ let slot = state.get(key);
79
+ if (!slot) {
80
+ slot = {};
81
+ state.set(key, slot);
82
+ }
83
+ // Coalesce onto any in-flight work for this resource regardless of
84
+ // mode, unless the caller needs its own run (`own`). A manual check
85
+ // during a background one shouldn't start a second fetch; it should
86
+ // wait for the running one.
87
+ const running = slot.inFlight?.promise;
88
+ if (running && !own)
89
+ return running;
90
+ if (mode === 'opportunistic') {
91
+ const last = slot.lastCompletedAt ?? 0;
92
+ const since = nowMsImpl() - last;
93
+ if (since < UPDATE_MIN_INTERVAL_MS) {
94
+ // Logged because a rate-limited check does nothing else: no
95
+ // fetch, no storage write, no other log line. Without this it
96
+ // looks identical to a check that never started.
97
+ dlog(`[SourceMonkey] update check skipped for ${label}: ` +
98
+ `checked ${Math.round(since / 1000)}s ago, interval is ${UPDATE_MIN_INTERVAL_MS / 1000}s`);
99
+ return Promise.resolve(skipResult);
100
+ }
101
+ }
102
+ // The slot entry first: its cleanup compares the slot against it.
103
+ const entry = {};
104
+ entry.promise = (async () => {
105
+ if (running)
106
+ await running.catch(() => undefined);
107
+ try {
108
+ return await task();
109
+ }
110
+ finally {
111
+ const s = state.get(key);
112
+ if (s) {
113
+ // Only its own entry: an `own` run may have taken the slot since.
114
+ if (s.inFlight === entry)
115
+ s.inFlight = undefined;
116
+ s.lastCompletedAt = nowMsImpl();
117
+ }
118
+ }
119
+ })();
120
+ slot.inFlight = entry;
121
+ return entry.promise;
122
+ }
123
+ // Separate maps per result type so type inference stays clean.
124
+ const scriptState = new Map();
125
+ const collectionState = new Map();
126
+ const allCollectionsState = new Map();
127
+ // "all collections" is one global slot, but reuse the same shape so the
128
+ // concurrency/rate-limit logic stays uniform.
129
+ const ALL_COLLECTIONS_KEY = '*';
130
+ // Public API ---------------------------------------------------------
131
+ // `probe` is separate from `mode` on purpose. `manual` means "not
132
+ // rate-limited as a refresh", and the reload fast-path uses it for
133
+ // every matched script on every explicit tab reload — so treating
134
+ // `manual` as "the user asked, check upstream now" would fire a
135
+ // third-party request per script per reload. Only a genuine Refresh
136
+ // click passes `'force'`.
137
+ //
138
+ // `parseCheck` follows one rule, and it is deliberately not the same
139
+ // axis as `mode`: **a navigation-triggered check never waits for a
140
+ // parse verdict; a user-initiated one does.** Both of this module's
141
+ // navigation callers therefore pass 'defer' — the reload fast path,
142
+ // which has a wrapper in the page blocked on the answer, and the
143
+ // opportunistic sweep, which has nobody waiting at all.
144
+ //
145
+ // The rule has to be uniform because `gate()` coalesces regardless of
146
+ // mode, and both of those callers are queued off the same
147
+ // `onCommitted`. If one of them waited for the verdict, whichever
148
+ // reached the gate first would decide for both — so the fast path
149
+ // would silently pay the check on exactly the first reload after an
150
+ // idle gap, which is the ordinary edit-save-reload loop.
151
+ //
152
+ // The same coalescing would let a user-initiated check inherit a
153
+ // verdict-less row, and miss its forced probe. So a check that forces
154
+ // the probe (a Refresh click, from a page or from sm-dev) doesn't join a
155
+ // running one: it waits for it, then runs its own (`own` in `gate`).
156
+ // `batch` shares the reads a group of checks started together would
157
+ // otherwise each do (see `RefreshBatch`). Every navigation-triggered
158
+ // fan-out passes one — the reload fast path and the opportunistic
159
+ // sweep share the batch their common `onCommitted` created. The
160
+ // user-initiated single-script callers (the Refresh buttons) don't:
161
+ // they check one script, so there is nothing to share.
162
+ //
163
+ // **A coalesced call inherits the running check's batch, and that is
164
+ // safe.** `gate()` hands a second caller the FIRST call's promise, so it
165
+ // has always received that call's whole result — rows, verdicts and all.
166
+ // Which cache produced those rows changes nothing: the coalescing caller
167
+ // never had a refresh of its own to be given stale data from.
168
+ export function updateScript(scriptId, mode, probe = 'if-stale', parseCheck = 'await', batch) {
169
+ return gate(scriptState, scriptId, mode, () => runScriptCheck(scriptId, mode, probe, parseCheck, batch), null, getScriptLogLabel(scriptId), probe === 'force');
170
+ }
171
+ // Only ever reached from a Refresh button, so probes force by default.
172
+ export function updateCollection(collectionId, mode, probe = 'force') {
173
+ return gate(collectionState, collectionId, mode, () => runCollectionCheck(collectionId, mode, probe), null, `collection ${collectionId}`);
174
+ }
175
+ // Only ever reached from Refresh All, so probes force by default.
176
+ export function updateAllCollections(mode, probe = 'force') {
177
+ // We have to do the cast dance because gate is generic over a single
178
+ // result type and the skip result for "no work done" is just an empty
179
+ // map, not null.
180
+ return gate(allCollectionsState, ALL_COLLECTIONS_KEY, mode, () => runAllCollectionsCheck(mode, probe), new Map(), 'all collections');
181
+ }
182
+ // ---------- Task wrappers that log lifecycle to the SW console -------
183
+ //
184
+ // Each user-visible check goes through these so the SW console tells
185
+ // the story: "checking X", then either "no change", "loaded new
186
+ // content", or "failed: <err>". Without this it's hard to tell
187
+ // whether the opportunistic refresh is even running.
188
+ //
189
+ // We do a getScript / getCollectionWithScripts read BEFORE the refresh
190
+ // so we have the prior loadedAt to compare against the post-refresh
191
+ // row. One extra storage read per check — cheap.
192
+ // `@require` + `@resource` policy by trigger mode. Manual clicks
193
+ // rebuild deps from scratch so users can pick up remote updates
194
+ // without touching the main script. Opportunistic background checks
195
+ // preserve the cached entries verbatim and only fetch newly-declared
196
+ // refs — keeping page-load cost bounded.
197
+ function fileRecheckMode(mode) {
198
+ return mode === 'manual' ? 'all' : 'preserve';
199
+ }
200
+ async function runScriptCheck(scriptId, mode, probe, parseCheck = 'await', batch) {
201
+ const prior = await getScriptMeta(scriptId);
202
+ const label = getScriptLogLabel(scriptId);
203
+ // Manual checks fire on a deliberate user click and are infrequent;
204
+ // their start + "no change" outcome stays at console.log so the user
205
+ // gets unconditional confirmation that the click did something.
206
+ // Opportunistic checks fire on every navigation and are gated by the
207
+ // debug flag.
208
+ const checkLog = mode === 'manual' ? console.log : dlog;
209
+ const tokenSuffix = prior?.fileUrl ? githubTokenLogSuffix(prior.fileUrl) : '';
210
+ checkLog(`[SourceMonkey] update check (${mode}): ${label}${tokenSuffix}`);
211
+ // No Log-tab row before the work: `emitScriptOutcomeLog` writes the
212
+ // whole story afterwards, as one line when there is nothing to report
213
+ // and a `Checking for updates` preamble when there is. The SW console
214
+ // above still brackets the work for anyone watching it live.
215
+ let next;
216
+ try {
217
+ next = await refreshScript(scriptId, {
218
+ recheckFiles: fileRecheckMode(mode),
219
+ parseCheck,
220
+ ...(batch ? { batch } : {}),
221
+ });
222
+ }
223
+ catch (err) {
224
+ const msg = describe(err);
225
+ console.info(`[SourceMonkey] update check failed: ${label}: ${msg}`);
226
+ // Stands alone — it names the check and its failure in one line,
227
+ // so it needs no preamble.
228
+ //
229
+ // `state: 'refresh-failed'` even though the row carries no
230
+ // `refreshError` (the throw came from before the row was written):
231
+ // the meaning is the same — this check didn't complete and the
232
+ // cached copy keeps running — and a bare `error` would bump the
233
+ // `loglevel:` sentinel and leave the script badged ERROR for the
234
+ // rest of the session over a failure the next check may not repeat.
235
+ logScriptEvent(scriptId, {
236
+ level: 'error',
237
+ state: 'refresh-failed',
238
+ source: refreshLogSource(mode),
239
+ message: `Could not check for updates: ${msg}`,
240
+ });
241
+ throw err;
242
+ }
243
+ logScriptOutcome(label, prior, next, mode);
244
+ emitScriptOutcomeLog(scriptId, prior, next, mode);
245
+ // Deliberately NOT awaited. This promise is what the reload
246
+ // fast-path races against a 500 ms window (see
247
+ // docs/injection.md#reload-fast-path) — folding a third-party fetch
248
+ // into it would blow that budget and drop the reload back to running
249
+ // the old body. The probe's own storage write repaints any open
250
+ // viewer via storage.onChanged when it lands.
251
+ if (next)
252
+ void runVersionProbes([next], probe);
253
+ // Not awaited, same as the probe above: it must not sit in the
254
+ // fast-path window. No-ops unless this script is currently rejected,
255
+ // in which case a refresh that changed nothing would otherwise never
256
+ // re-offer it to Chrome. See `retryRejectedRegistrations`.
257
+ //
258
+ // `probe === 'force'` is the only signal we have for "the user
259
+ // clicked Refresh" — `mode: 'manual'` means "not rate-limited" and
260
+ // the reload fast-path uses it for every matched script on every
261
+ // reload. A click retries immediately; a navigation-driven check
262
+ // takes the rate limit, so one page load can't produce a burst of
263
+ // identical rows.
264
+ void retryRejectedRegistrations([scriptId], { userInitiated: probe === 'force' });
265
+ return next;
266
+ }
267
+ async function runCollectionCheck(collectionId, mode, probe) {
268
+ const priorData = await getCollectionWithScriptMeta(collectionId);
269
+ const prior = priorData
270
+ ? { collection: priorData.collection, scripts: new Map(priorData.scripts.map((s) => [s.id, s])) }
271
+ : undefined;
272
+ const label = priorData?.collection.path ?? collectionId;
273
+ // Suffix the start line with "(with GitHub token)" when the
274
+ // collection URL would carry the GitHub auth header on fetch. Lets
275
+ // the user confirm from the SW console that their Options-page token
276
+ // is actually in play for this collection (otherwise a silent "still
277
+ // hitting unauthenticated rate limits" is hard to diagnose).
278
+ const tokenSuffix = prior?.collection.path ? githubTokenLogSuffix(prior.collection.path) : '';
279
+ console.log(`[SourceMonkey] collection refresh (${mode}): ${label}${tokenSuffix}`);
280
+ // Sits OUTSIDE refreshCollection's own timer rather than extending it:
281
+ // the probes and the rejection retry are this layer's work, and the
282
+ // question "is the refresh slow or is the annotation pass slow?" is
283
+ // exactly what the split answers.
284
+ const timer = startPhaseTimer(`collection check (${mode})`);
285
+ resetPhaseTotals();
286
+ let result;
287
+ try {
288
+ result = await refreshCollection(collectionId, { recheckFiles: fileRecheckMode(mode) });
289
+ }
290
+ catch (err) {
291
+ const msg = describe(err);
292
+ console.info(`[SourceMonkey] collection refresh failed: ${label}: ${msg}`);
293
+ // A throw out of refreshCollection leaves every script under this
294
+ // collection unrefreshed. Say so in each one's Log rather than
295
+ // letting the failure live only in the SW console.
296
+ logCollectionEventToScripts([...(prior?.scripts.values() ?? [])], mode, await collectionFailureMessage(collectionId, msg));
297
+ throw err;
298
+ }
299
+ timer.mark('refresh');
300
+ if (result) {
301
+ summarizeCollectionOutcome(label, prior, result, mode);
302
+ await runVersionProbes(result.scripts, probe);
303
+ timer.mark('probes');
304
+ timer.note(() => `${probedCount(result.scripts)} of ${result.scripts.length} scripts have a probe target`);
305
+ // Retry any script under this collection that Chrome is refusing —
306
+ // refreshing a collection is one of the places a user goes to say
307
+ // "try again". See `retryRejectedRegistrations`.
308
+ await retryRejectedRegistrations(result.scripts.map((s) => s.id), { userInitiated: probe === 'force' });
309
+ timer.mark('retry-rejected');
310
+ }
311
+ timer.done();
312
+ reportPhaseTotals('collection check');
313
+ return result;
314
+ }
315
+ // How many of these scripts a probe pass would actually fetch for.
316
+ // Reported alongside the probe phase because a `0ms` probe leg means
317
+ // two very different things — nothing to check, or everything already
318
+ // fresh — and only the count separates them.
319
+ function probedCount(scripts) {
320
+ return scripts.filter((s) => s.status === 'ok' && probeTargets(s.header, s.fileUrl).length > 0).length;
321
+ }
322
+ // The one sentence a collection-level refresh failure gets in every
323
+ // affected script's Log, whichever entry point caught it. Both callers
324
+ // route through here so the same failure can't read two ways.
325
+ //
326
+ // Which sentence depends on WHERE it failed, and the recorded map is the
327
+ // evidence: `refreshCollection` records only when its write throws, so
328
+ // an entry means the collection was read fine and couldn't be stored.
329
+ // A throw with nothing recorded came from earlier in the pipeline — a
330
+ // storage read, a hash, a rebuild — and must not claim the read
331
+ // succeeded, so it gets the generic wording.
332
+ async function collectionFailureMessage(collectionId, fallbackReason) {
333
+ const recorded = (await readRefreshErrors())[collectionId];
334
+ return recorded
335
+ ? describeRefreshError(recorded)
336
+ : `Could not refresh this collection: ${fallbackReason}`;
337
+ }
338
+ async function runAllCollectionsCheck(mode, probe) {
339
+ console.log(`[SourceMonkey] refreshing all collections (${mode})`);
340
+ // Same split as runCollectionCheck: each collection times its own
341
+ // refresh, so what this adds is the work Refresh All does AROUND
342
+ // them — the prior snapshot, and the probe + retry pass that runs
343
+ // once over every script in every collection rather than per
344
+ // collection.
345
+ const timer = startPhaseTimer(`all-collections check (${mode})`);
346
+ resetPhaseTotals();
347
+ // Snapshot priors BEFORE the refresh; reading after would just give
348
+ // back the post-refresh state and the summary would always say 0
349
+ // updated / 0 added.
350
+ const priorByCollectionId = await snapshotAllCollectionScripts();
351
+ timer.mark('snapshot');
352
+ let result;
353
+ try {
354
+ result = await refreshAllCollections({ recheckFiles: fileRecheckMode(mode) });
355
+ }
356
+ catch (err) {
357
+ const msg = describe(err);
358
+ console.info(`[SourceMonkey] refresh all failed: ${msg}`);
359
+ // Reached only when the PASS itself broke — a per-collection failure
360
+ // is recorded and skipped inside `refreshAllCollections`, and handled
361
+ // below. Nothing was summarized here, so every script hears the same
362
+ // reason. Same fan-out as runCollectionCheck so the two paths can't
363
+ // drift.
364
+ for (const snapshot of priorByCollectionId.values()) {
365
+ logCollectionEventToScripts([...snapshot.scripts.values()], mode, `Could not refresh this collection: ${msg}`);
366
+ }
367
+ throw err;
368
+ }
369
+ // Collections that were refreshed but couldn't be saved are absent
370
+ // from `result` without the pass having failed. They still owe their
371
+ // scripts a Log entry — a Refresh All that silently skipped this would
372
+ // say less than refreshing the same collection on its own, which does
373
+ // log (see runCollectionCheck).
374
+ //
375
+ // Keyed off the recorded map so each collection gets ITS reason, and so
376
+ // a collection absent for the other reason — removed mid-refresh, which
377
+ // clears its entry — stays quiet.
378
+ timer.mark('refresh');
379
+ const refreshErrors = await readRefreshErrors();
380
+ for (const [id, snapshot] of priorByCollectionId) {
381
+ if (result.has(id))
382
+ continue;
383
+ const reason = refreshErrors[id];
384
+ if (!reason)
385
+ continue;
386
+ logCollectionEventToScripts([...snapshot.scripts.values()], mode, describeRefreshError(reason));
387
+ }
388
+ for (const [, refreshed] of result) {
389
+ summarizeCollectionOutcome(refreshed.collection.path, priorByCollectionId.get(refreshed.collection.id), refreshed, mode);
390
+ }
391
+ const allScripts = [...result.values()].flatMap((r) => r.scripts);
392
+ timer.mark('summarize');
393
+ await runVersionProbes(allScripts, probe);
394
+ timer.mark('probes');
395
+ // Counted, not just timed: each probe result is its own storage write,
396
+ // and a write is what schedules a registration re-sync. The number of
397
+ // probed scripts is therefore the number of re-syncs this pass can
398
+ // provoke, which is not something the elapsed time shows.
399
+ timer.note(() => `${probedCount(allScripts)} of ${allScripts.length} scripts have a probe target`);
400
+ // Refresh All is the other "try again" button, so it retries every
401
+ // refused registration it just re-read. See
402
+ // `retryRejectedRegistrations`.
403
+ await retryRejectedRegistrations(allScripts.map((s) => s.id), { userInitiated: probe === 'force' });
404
+ timer.mark('retry-rejected');
405
+ timer.done();
406
+ reportPhaseTotals('all-collections check');
407
+ return result;
408
+ }
409
+ // ---------- Update probes ----------
410
+ //
411
+ // Runs after the refresh, against the rows it produced. Deliberately
412
+ // downstream of everything above: a probe can't change a status, can't
413
+ // set a refreshError, and a probe host that's down or slow must leave
414
+ // the refresh's own outcome exactly as it was. See src/version-probe.ts
415
+ // for what "probe" means here and [docs/updates.md] for why we don't
416
+ // simply update from the probed URL.
417
+ //
418
+ // Awaited by the collection-level entry points (a Refresh button click,
419
+ // where the user is watching a spinner and expects the row to be
420
+ // current when it stops) but not by the per-script one, which sits on
421
+ // the navigation hot path — see runScriptCheck.
422
+ async function runVersionProbes(scripts, probe) {
423
+ // Bounded for the same reason the refresh fan-out is (see
424
+ // src/extension/concurrency.ts): "Refresh All" hands this every script in every
425
+ // collection, and each one expands into its own pool of probe fetches.
426
+ // Unbounded here would put the product in flight at once.
427
+ await mapWithConcurrency(scripts, PROBE_SCRIPTS_CONCURRENCY, (s) => probeOneScript(s, probe).catch(() => {
428
+ // A probe is an annotation. Nothing downstream depends on it, so a
429
+ // failure that escaped runProbe's own handling dies here rather
430
+ // than propagating into the refresh the user actually asked for.
431
+ }));
432
+ }
433
+ // After a single-script install: check for a newer version at once, so
434
+ // an install of an already-outdated copy says so without waiting for a
435
+ // Refresh. Not awaited by the install, like the per-script refresh's:
436
+ // the result lands on the row, and the pages repaint from it. A whole
437
+ // collection added at once isn't probed here; its first Refresh does.
438
+ export function probeInstalledScript(result) {
439
+ const id = result?.kind === 'existingContainer' ? result.scriptId
440
+ : result && isContainerCollection(result.collection.path) ? result.collection.scriptIds[0]
441
+ : undefined;
442
+ if (!id)
443
+ return;
444
+ void (async () => {
445
+ const script = await getScriptMeta(id);
446
+ if (script)
447
+ await runVersionProbes([script], 'force');
448
+ })().catch(() => {
449
+ // An annotation, as in `runVersionProbes`: the install stands.
450
+ });
451
+ }
452
+ async function probeOneScript(script, policy) {
453
+ // Only rows that are actually running get probed. An `invalid` row's
454
+ // header may not have parsed at all. Its stored probes are left alone
455
+ // rather than cleared: INVALID outranks OUTDATED so nothing stale is
456
+ // on screen, and a row that recovers gets its annotation back without
457
+ // a refetch.
458
+ if (script.status !== 'ok')
459
+ return;
460
+ const loadedVersion = script.header ? getValue(script.header, 'version') : undefined;
461
+ const targets = probeTargets(script.header, script.fileUrl);
462
+ if (targets.length === 0) {
463
+ // The script no longer names an upstream we don't already fetch
464
+ // from (or never did). Clear any annotation left from when it did,
465
+ // so a removed `@updateURL` doesn't strand an OUTDATED badge.
466
+ if (script.versionProbes) {
467
+ const wasOutdated = outdatedProbe(loadedVersion, script.versionProbes);
468
+ await setVersionProbes(script.id, []);
469
+ if (wasOutdated)
470
+ logUpToDateAgain(script.id, loadedVersion);
471
+ }
472
+ return;
473
+ }
474
+ const now = nowMsImpl();
475
+ // Everything but an explicit Refresh click respects the interval —
476
+ // including the reload fast-path, which runs in `manual` mode on
477
+ // every tab reload.
478
+ if (policy === 'if-stale' && probesAreFresh(script.versionProbes, targets, now))
479
+ return;
480
+ const before = outdatedProbe(loadedVersion, script.versionProbes);
481
+ const probes = await probeVersions(targets, now, script.versionProbes);
482
+ await setVersionProbes(script.id, probes);
483
+ // Report the transition, not the state: an upstream that has been
484
+ // ahead of us for a month shouldn't add a Log entry every interval.
485
+ // Both directions carry the `state` they leave the badge in, so the
486
+ // Log's status column reads OUTDATED / OK exactly where the badge
487
+ // changed.
488
+ //
489
+ // `before` is computed against the row this probe was handed, which
490
+ // is the freshly refreshed one — so "our copy caught up" is already
491
+ // not outdated here. That transition is reported by
492
+ // `emitScriptOutcomeLog`, which has the prior row; this one only sees
493
+ // the upstream side moving (a version rolled back).
494
+ const after = outdatedProbe(loadedVersion, probes);
495
+ if (!after) {
496
+ if (before)
497
+ logUpToDateAgain(script.id, loadedVersion);
498
+ return;
499
+ }
500
+ if (after.version === before?.version)
501
+ return;
502
+ const entry = outdatedLogEntry(after, loadedVersion);
503
+ console.info(`[SourceMonkey] ${getScriptLogLabel(script.id)}: ${entry.message}`);
504
+ // Not split by trigger mode like the refresh labels: this entry
505
+ // reports a transition ("an upstream copy just moved ahead of us"),
506
+ // and whether a click or a navigation noticed it tells the user
507
+ // nothing they'd act on.
508
+ logScriptEvent(script.id, { ...entry, source: 'version-probe' });
509
+ }
510
+ // The OUTDATED row, minus its source: written by the probe when the
511
+ // upstream moves ahead, and restated at boot for a probe that persisted
512
+ // across a restart. One formatter so both read alike.
513
+ function outdatedLogEntry(probe, loadedVersion) {
514
+ return {
515
+ level: 'warning',
516
+ state: 'outdated',
517
+ message: `Newer version ${probe.version} at ${probeLabel(probe.kinds)} ` +
518
+ `(running ${loadedVersion}): ${probe.url}`,
519
+ };
520
+ }
521
+ // The OUTDATED → OK row. One message for every way the badge can go:
522
+ // our copy caught up (seen by the refresh outcome), the upstream rolled
523
+ // back (seen by the probe), or the header stopped naming an upstream
524
+ // (the probe's clear path).
525
+ function logUpToDateAgain(scriptId, loadedVersion) {
526
+ logScriptEvent(scriptId, {
527
+ level: 'info', state: 'ok', source: 'version-probe',
528
+ message: `Up to date again (running ${loadedVersion ?? '<none>'})`,
529
+ });
530
+ }
531
+ // Pre-refresh state per collection: the collection row (for its
532
+ // `scanError`, so a still-down collection doesn't re-log) plus its
533
+ // scripts by ID.
534
+ async function snapshotAllCollectionScripts() {
535
+ const collections = await getCollections();
536
+ const entries = await Promise.all(collections.map(async (s) => {
537
+ const data = await getCollectionWithScriptMeta(s.id);
538
+ const scripts = new Map((data?.scripts ?? []).map((sc) => [sc.id, sc]));
539
+ return [s.id, { collection: s, scripts }];
540
+ }));
541
+ return new Map(entries);
542
+ }
543
+ // ---------- Logging helpers ----------
544
+ function logScriptOutcome(label, prior, next, mode) {
545
+ if (!next) {
546
+ console.info(`[SourceMonkey] update check: ${label}: row vanished`);
547
+ return;
548
+ }
549
+ if (next.status === 'invalid') {
550
+ console.info(`[SourceMonkey] update check: ${label}: invalid: ` +
551
+ `${describeDiagnostic(next.errorMessage ?? 'unknown', next.errorLocation)}`);
552
+ return;
553
+ }
554
+ // Refresh failed but the cached good copy keeps running. Log once on
555
+ // the transition (or when the reason changes) so a collection that
556
+ // stays down doesn't spam the console on every opportunistic check.
557
+ if (next.refreshError && prior?.refreshError !== next.refreshError) {
558
+ console.info(`[SourceMonkey] refresh failed: ${label}: ${next.refreshError}`);
559
+ return;
560
+ }
561
+ if (!prior || prior.loadedAt !== next.loadedAt) {
562
+ // loadedAt bumps iff the load pipeline actually built from fresh
563
+ // content. Same loadedAt means the size+mtime skip caught it.
564
+ if (dependenciesOnlyChange(prior, next)) {
565
+ console.log(`[SourceMonkey] loaded new content for its @require or @resource files: ${label}`);
566
+ return;
567
+ }
568
+ const size = next.size != null ? ` (${formatBytes(next.size)})` : '';
569
+ console.log(`[SourceMonkey] loaded new content: ${label}${size}`);
570
+ }
571
+ else {
572
+ // "no change" pairs with the entry log: visible on manual, gated
573
+ // on opportunistic. Otherwise a Refresh click could log "update
574
+ // check" with nothing following.
575
+ const outcomeLog = mode === 'manual' ? console.log : dlog;
576
+ outcomeLog(`[SourceMonkey] no change: ${label}`);
577
+ }
578
+ }
579
+ // A new `loadedAt` that came from a `@require` / `@resource` alone: the
580
+ // script's own content hash is the same (see `dependenciesChanged` in
581
+ // src/script-build.ts).
582
+ function dependenciesOnlyChange(prior, next) {
583
+ return prior?.contentHash !== undefined && prior.contentHash === next.contentHash;
584
+ }
585
+ // Write a check's whole story into the per-script Log, mirroring the
586
+ // SW-console outcome line. The uneventful outcome is manual-only, so
587
+ // background polling doesn't fill the log with rows saying a healthy
588
+ // script is still healthy — those still get debug-gated console output
589
+ // via dlog.
590
+ //
591
+ // Every failure the UI surfaces on a script row also lands here, so
592
+ // clicking a red/amber badge always leads to an explanation: the entry
593
+ // carries the `state` the badge shows (INVALID / REFRESH FAILED, and
594
+ // OK for the recovery) — see `loadStateLogEntry` for the
595
+ // phrasing.
596
+ //
597
+ // **One row per uneventful check, two when there's news.** A check
598
+ // that finds nothing writes a single `Checked for updates and found no change`
599
+ // line. A check with something to report writes `Checking for updates`
600
+ // and then the outcome, which carries its own severity and is what the
601
+ // status badge links to.
602
+ //
603
+ // "Still broken" is a freshly computed verdict, not an echo: an
604
+ // `invalid` row can't take the size+mtime skip (`canSkip` requires
605
+ // `status === 'ok'`), so every check refetches and re-runs split +
606
+ // parse + validate.
607
+ //
608
+ // **Opportunistic checks log too.** They report the same facts a manual
609
+ // check would, and the whole point of a background check is to notice a
610
+ // failure the user hasn't gone looking for yet — a badge that turns
611
+ // amber on its own should have a matching Log line. (Trigger mode still
612
+ // gates the *non*-events: the uneventful row is manual-only.)
613
+ //
614
+ // Exported for unit tests — the public entry points would need the
615
+ // whole chrome.storage pipeline stubbed to reach it.
616
+ export function emitScriptOutcomeLog(scriptId, prior, next, mode) {
617
+ const source = refreshLogSource(mode);
618
+ // A check that found nothing to say is ONE line naming both the
619
+ // check and its result — see `NO_CHANGE_MESSAGE`. Only a check with
620
+ // something to report gets a preamble, because there the result is
621
+ // its own line (and its own severity) and needs the check named
622
+ // separately.
623
+ //
624
+ // Written after the work, so both halves land together. The SW
625
+ // console is where a check is bracketed live; the Log tab is a
626
+ // record of what happened.
627
+ // A row that went away during the check — deleted by the user, or
628
+ // dropped by this very check because its file was no longer listed
629
+ // — has no Log any more: `removeScript` cleared it. Writing here
630
+ // would leave an orphan `log:` row for a script nothing displays.
631
+ // The SW console line in `logScriptOutcome` is the record.
632
+ if (!next)
633
+ return;
634
+ if (hasReportableOutcome(prior, next)) {
635
+ logScriptEvent(scriptId, { level: 'info', source, message: 'Checking for updates' });
636
+ }
637
+ // Before the state verdict, and not gated by it: a script can be
638
+ // `invalid` for an unrelated reason and still have picked up a local
639
+ // stand-in this pass, and that substitution is exactly what the user
640
+ // needs to know about while debugging the failure.
641
+ for (const entry of localFileLogMessages(prior, next)) {
642
+ logScriptEvent(scriptId, { ...entry, source });
643
+ }
644
+ const nextState = loadStateLogEntry(next);
645
+ const priorState = prior ? loadStateLogEntry(prior) : null;
646
+ if (nextState) {
647
+ // Written even when the failure is byte-identical to the one
648
+ // already in the log: every check re-runs the full fetch + parse +
649
+ // validate — an `invalid` row can't take the size+mtime skip, which
650
+ // requires `status === 'ok'` — so this is a fresh verdict on a
651
+ // fresh read, and the check that produced it has to end somewhere.
652
+ logScriptEvent(scriptId, { ...nextState, source });
653
+ return;
654
+ }
655
+ if (prior && priorState) {
656
+ // The row is healthy again. Log the recovery so the Log explains
657
+ // why the badge went back to OK — without this the last word on the
658
+ // script would be a failure that is no longer true.
659
+ logScriptEvent(scriptId, { level: 'info', state: 'ok', source, message: recoveryMessage(prior) });
660
+ // Recovering IS the outcome of this check. Falling through to the
661
+ // uneventful line below would read as a contradiction when the
662
+ // recovery didn't come with new bytes (a dependency host that
663
+ // starts answering again clears the failure without touching the
664
+ // script's own content, so `loadedAt` is preserved unless the
665
+ // dependency's bytes changed meanwhile).
666
+ if (prior.loadedAt === next.loadedAt)
667
+ return;
668
+ }
669
+ if (!prior || prior.loadedAt !== next.loadedAt) {
670
+ // The script's own file is the same, so its size is too. The changed
671
+ // files have their own lines when they are local copies
672
+ // (`localFileLogMessages`).
673
+ const dependenciesOnly = dependenciesOnlyChange(prior, next);
674
+ const size = next.size != null && !dependenciesOnly ? ` (${formatBytes(next.size)})` : '';
675
+ // A new version is a clean slate for the WARNING / ERROR badge: the
676
+ // old version's runtime errors say nothing about this one, and a
677
+ // developer who just fixed one needs the badge to reflect only what
678
+ // happens next. Shown as OK in the Log's status column for the same
679
+ // reason. See `LogEntry.resetsLogLevel`.
680
+ logScriptEvent(scriptId, {
681
+ level: 'info',
682
+ state: 'ok',
683
+ resetsLogLevel: true,
684
+ source,
685
+ message: dependenciesOnly ? 'Loaded new content for its @require or @resource files' : `Loaded new content${size}`,
686
+ });
687
+ // The new content's `@version` caught up with a probe that had been
688
+ // ahead of us. The probes ride along unchanged on the refreshed row
689
+ // (see `preserveConcurrentEdits` in script-store.ts), so the comparison is
690
+ // between the old and new header against the same stored answer.
691
+ if (prior && scriptOutdatedProbe(prior) && !scriptOutdatedProbe(next)) {
692
+ logUpToDateAgain(scriptId, next.header ? getValue(next.header, 'version') : undefined);
693
+ }
694
+ // Replay parse-time diagnostics whenever a new version lands. They
695
+ // appear AFTER the "Loaded new content" line so it's clear which
696
+ // version the warning belongs to. A dependency-only change too: the
697
+ // line above starts the badge over, and the header's warnings still
698
+ // hold (a row that was invalid on a failed `@require` didn't log
699
+ // them on any refresh while it stayed so).
700
+ for (const entry of next.startupLogs ?? []) {
701
+ logScriptEvent(scriptId, startupLogEntry(entry));
702
+ }
703
+ return;
704
+ }
705
+ if (mode === 'manual') {
706
+ logScriptEvent(scriptId, { level: 'info', source, message: NO_CHANGE_MESSAGE });
707
+ // A manual refresh is the user asking what state this script is in,
708
+ // so a parse-time problem that is STILL TRUE gets restated — the
709
+ // same reason `replayScriptLogsAtBoot` restates load state and
710
+ // OUTDATED rather than leaving a badge over a silent Log. Without
711
+ // it the only line a permanently misconfigured script ever gets is
712
+ // the one from the refresh that first loaded it: it scrolls away,
713
+ // Clear log wipes it, and nothing brings it back until the next
714
+ // Chrome restart. Manual only, like the line above — a background
715
+ // check nobody asked for stays silent.
716
+ for (const entry of next.startupLogs ?? []) {
717
+ logScriptEvent(scriptId, startupLogEntry(entry));
718
+ }
719
+ }
720
+ }
721
+ // Fan a COLLECTION-level fact out into the Log of every script under
722
+ // that collection. Used for the failures that stop us from checking any
723
+ // of them (an unreadable listing, a throw out of the refresh) and for
724
+ // the matching recovery.
725
+ //
726
+ // **Always `info`, never warning/error.** These entries exist so a user
727
+ // reading one script's Log can see why it wasn't checked — the badge
728
+ // for a collection-level problem belongs on the collection row, which
729
+ // already renders `scanError` (see `renderCollectionSection` in
730
+ // collections.ts). Logging them higher would badge every script under
731
+ // the collection WARNING via the `loglevel:` sentinel, which is for
732
+ // things the script itself did.
733
+ //
734
+ // Plain append. A collection that stays unreadable re-logs on each pass,
735
+ // which is honest — every one of those passes really did try to read
736
+ // it. Only reachable from a Refresh / Refresh All click (see the
737
+ // `updateCollection` / `updateAllCollections` callers in
738
+ // collections.ts), so the user asked for each repeat.
739
+ function logCollectionEventToScripts(scripts, mode, message) {
740
+ for (const s of scripts) {
741
+ logScriptEvent(s.id, { level: 'info', source: refreshLogSource(mode), message });
742
+ }
743
+ }
744
+ // The Log tab's Source label for a refresh pass. Spelled out per mode
745
+ // rather than composed from the mode name: these strings are read by
746
+ // users, and "opportunistic" describes our scheduling policy rather
747
+ // than anything they did or would recognise.
748
+ function refreshLogSource(mode) {
749
+ return mode === 'manual' ? 'manual-refresh' : 'background-refresh';
750
+ }
751
+ // The single line a check writes when it has nothing to report: names
752
+ // the check AND its result, so a routine poll costs one row rather than
753
+ // a start/outcome pair. Manual-only (see `emitScriptOutcomeLog`).
754
+ const NO_CHANGE_MESSAGE = 'Checked for updates and found no change';
755
+ // Whether this check has anything to say beyond that one line — i.e.
756
+ // whether there was something to check for this script in the first
757
+ // place.
758
+ //
759
+ // Mirrors the branches of `emitScriptOutcomeLog` below, in the same
760
+ // order, and is only ever read by it. Kept as its own predicate so the
761
+ // "should we announce the check?" decision and the "what do we report?"
762
+ // decision are written once each rather than twice.
763
+ //
764
+ // True when: it's failing now, it was failing before (so this pass
765
+ // genuinely re-fetched and re-validated — an `invalid` row can't take
766
+ // the size+mtime skip), or the content changed. False only for the
767
+ // healthy-and-unchanged case, whose whole outcome is the one
768
+ // self-describing `NO_CHANGE_MESSAGE` row.
769
+ function hasReportableOutcome(prior, next) {
770
+ if (loadStateLogEntry(next))
771
+ return true;
772
+ if (prior && loadStateLogEntry(prior))
773
+ return true;
774
+ // A local stand-in that appeared, vanished, or changed on disk is an
775
+ // outcome even when the script's own bytes didn't move — that's the
776
+ // common shape while editing a required library.
777
+ if (localFileLogMessages(prior, next).length > 0)
778
+ return true;
779
+ return !prior || prior.loadedAt !== next.loadedAt;
780
+ }
781
+ // How to phrase "the failure we last logged for this row is over".
782
+ // Keyed off the prior row's state so the line names what recovered —
783
+ // a bare "ok again" wouldn't tell the user whether the file came back,
784
+ // the parse started succeeding, or the server did.
785
+ function recoveryMessage(prior) {
786
+ if (prior.status === 'invalid')
787
+ return 'Loads cleanly again (was invalid)';
788
+ return 'Refresh succeeded again (collection is reachable)';
789
+ }
790
+ // Sentinel key in chrome.storage.session that records "we've already
791
+ // replayed the persisted per-script state for THIS Chrome session."
792
+ // session storage is cleared on Chrome shutdown, so a fresh Chrome
793
+ // session always misses the sentinel; SW restarts within a session
794
+ // always hit it.
795
+ //
796
+ // The key name predates the load-state leg and now under-describes what
797
+ // it gates. Kept as-is deliberately: renaming it would make one Chrome
798
+ // session miss its existing sentinel and replay a second time, which
799
+ // buys nothing.
800
+ const BOOT_REPLAY_SENTINEL_KEY = 'sm:startup-logs-replayed-at';
801
+ // Refill the Log tab from persisted state, once per Chrome session, at
802
+ // SW startup. Everything replayed here is so for the same reason: the
803
+ // facts live in `chrome.storage.local` and outlive the Chrome shutdown
804
+ // that wipes the session-scoped log, so without this a fresh session
805
+ // shows a red or amber badge over an empty Log until the user manually
806
+ // refreshes.
807
+ //
808
+ // - **Load state** — a row that is currently `invalid` / `missing` /
809
+ // carrying a `refreshError`. Nothing re-checks a script until it is
810
+ // refreshed or its page is visited, so without this a script broken
811
+ // since before the restart shows a badge over an empty Log.
812
+ // - **Outdated** — a row whose persisted `versionProbes` put an
813
+ // upstream ahead of it. The probe only logs the *transition*, and a
814
+ // re-probe after the restart finds the same answer, so this is the
815
+ // only line the OUTDATED badge would ever get.
816
+ // - **Parse-time diagnostics** — `script.startupLogs`.
817
+ //
818
+ // The sentinel makes it idempotent: SW restarts within the same Chrome
819
+ // session bail out immediately. Every leg reuses the exact message
820
+ // text the refresh paths use, so the Log tab is visually uniform.
821
+ //
822
+ // The sentinel is written AFTER the walk, not before: MV3 can evict the
823
+ // worker mid-loop, and claiming it up front would mark the session
824
+ // replayed while most scripts still had empty logs — the exact state
825
+ // this exists to prevent. A torn-down replay simply runs again on the
826
+ // next SW wake-up, duplicating a row for the scripts the first pass
827
+ // reached. Rare and bounded to one Chrome session.
828
+ export async function replayScriptLogsAtBoot() {
829
+ const got = await chrome.storage.session.get(BOOT_REPLAY_SENTINEL_KEY);
830
+ if (got[BOOT_REPLAY_SENTINEL_KEY])
831
+ return;
832
+ // One read of the script rows (no bodies) via getAllScriptMetas —
833
+ // cheaper than per-collection reads when most scripts won't have
834
+ // anything to replay anyway. Per-script work runs concurrently;
835
+ // `appendScriptLog`'s own queue keeps each script's appends ordered.
836
+ const scripts = await getAllScriptMetas();
837
+ await Promise.all(scripts.map(async (script) => {
838
+ await ensureLoadStateInLogTab(script);
839
+ await ensureOutdatedInLogTab(script);
840
+ await ensureStartupLogsInLogTab(script);
841
+ }));
842
+ await chrome.storage.session.set({ [BOOT_REPLAY_SENTINEL_KEY]: Date.now() });
843
+ }
844
+ // Restate a script's current load failure, if it has one.
845
+ //
846
+ // `source: 'extension-startup'` rather than a `*-refresh` label so the
847
+ // Log reads honestly: nothing was re-checked, we're just carrying the
848
+ // last known verdict forward across a browser restart. Not plain
849
+ // 'startup' either — that would read as the *script's* startup, which
850
+ // is what the `parser` source already covers.
851
+ //
852
+ // Plain append. The session sentinel already makes this run once per
853
+ // Chrome session, which is the only thing keeping the volume down that
854
+ // needs to.
855
+ async function ensureLoadStateInLogTab(script) {
856
+ const entry = loadStateLogEntry(script);
857
+ if (!entry)
858
+ return;
859
+ await appendScriptLog(script.id, { ...entry, source: 'extension-startup' });
860
+ }
861
+ // Restate OUTDATED for a row whose stored probe is still ahead of it.
862
+ // Same source and reasoning as `ensureLoadStateInLogTab`; same wording
863
+ // as the probe's own transition row.
864
+ //
865
+ // `ok` rows only, mirroring `probeOneScript`: an `invalid` / `missing`
866
+ // row keeps its stale probes but isn't running, so "(running <v>)"
867
+ // would be false and the line would land after — and appear to
868
+ // override — the load-state line the row actually deserves.
869
+ async function ensureOutdatedInLogTab(script) {
870
+ if (script.status !== 'ok')
871
+ return;
872
+ const probe = scriptOutdatedProbe(script);
873
+ if (!probe)
874
+ return;
875
+ const loadedVersion = script.header ? getValue(script.header, 'version') : undefined;
876
+ await appendScriptLog(script.id, { ...outdatedLogEntry(probe, loadedVersion), source: 'extension-startup' });
877
+ }
878
+ // Replay `script.startupLogs` into the Log tab as `parser`-source rows.
879
+ //
880
+ // Plain append, like everything else here. A refresh that races this
881
+ // replay can leave a duplicate pair of rows — bounded to once per
882
+ // script per Chrome session, and a visible duplicate is a cheaper
883
+ // problem than a rule that has to explain when a line is dropped.
884
+ async function ensureStartupLogsInLogTab(script) {
885
+ for (const sl of script.startupLogs ?? []) {
886
+ await appendScriptLog(script.id, startupLogEntry(sl));
887
+ }
888
+ }
889
+ // Roll a collection-level refresh up into one SW-console line, and fan
890
+ // the per-script outcomes out into the Log tab.
891
+ //
892
+ // The scan-failure case is handled separately from the per-script loop.
893
+ // `refreshCollection` deliberately leaves every script row untouched
894
+ // when the listing can't be read (see [docs/storage.md]) — so the
895
+ // per-script loop would report no change for every one of them, which
896
+ // is a lie: nothing was checked. Instead the reason is fanned out to
897
+ // each script (see `logCollectionEventToScripts` for why that's
898
+ // `info`).
899
+ //
900
+ // Exported for unit tests.
901
+ export function summarizeCollectionOutcome(label, prior, next, mode) {
902
+ const nextScripts = next.scripts;
903
+ const scanError = next.collection.scanError;
904
+ if (scanError) {
905
+ logCollectionEventToScripts(nextScripts, mode,
906
+ // Same no-echo phrasing as the collections page's banner, since
907
+ // `scanError` usually starts with its own `Could not read …`.
908
+ // A message that already ends its sentence (a GitHub hint) gets no
909
+ // second period.
910
+ `This collection did not load: ${/[.!?]$/.test(scanError) ? scanError : `${scanError}.`} Still running the cached copy.`);
911
+ console.log(`[SourceMonkey] collection refresh: ${label}: scan failed: ${scanError}`);
912
+ return;
913
+ }
914
+ if (prior?.collection.scanError) {
915
+ logCollectionEventToScripts(nextScripts, mode, 'This collection is readable again');
916
+ }
917
+ const priorBy = prior?.scripts ?? new Map();
918
+ let changed = 0;
919
+ let added = 0;
920
+ let unchanged = 0;
921
+ let errored = 0;
922
+ let refreshFailed = 0;
923
+ for (const n of nextScripts) {
924
+ const prior = priorBy.get(n.id);
925
+ emitScriptOutcomeLog(n.id, prior, n, mode);
926
+ if (n.status === 'invalid') {
927
+ errored++;
928
+ continue;
929
+ }
930
+ // A still-running row whose latest refresh failed counts as its own
931
+ // bucket, not "unchanged" — the content is unchanged but the outcome
932
+ // wasn't a clean check.
933
+ if (n.refreshError) {
934
+ refreshFailed++;
935
+ continue;
936
+ }
937
+ if (!prior) {
938
+ added++;
939
+ continue;
940
+ }
941
+ if (prior.loadedAt !== n.loadedAt)
942
+ changed++;
943
+ else
944
+ unchanged++;
945
+ }
946
+ const parts = [`${nextScripts.length} script(s)`];
947
+ if (added)
948
+ parts.push(`${added} new`);
949
+ if (changed)
950
+ parts.push(`${changed} updated`);
951
+ if (unchanged)
952
+ parts.push(`${unchanged} unchanged`);
953
+ if (refreshFailed)
954
+ parts.push(`${refreshFailed} refresh-failed`);
955
+ if (errored)
956
+ parts.push(`${errored} error`);
957
+ console.log(`[SourceMonkey] collection refresh: ${label}: ${parts.join(', ')}`);
958
+ }
959
+ function describe(err) {
960
+ return err instanceof Error ? err.message : String(err);
961
+ }
962
+ // Convenience: kick off opportunistic checks for a batch of script IDs
963
+ // without awaiting. Each id gates independently; ids whose check is
964
+ // rate-limited or already in flight are dropped silently. Used by the
965
+ // background SW when a navigation matches one or more scripts.
966
+ //
967
+ // **This is a fan-out over one page's script set, so it always gets a
968
+ // batch** — the caller's when they have one (the same navigation's
969
+ // reload fast path, so the two halves of one commit share a single
970
+ // collection read and their file reads rather than one each), otherwise
971
+ // its own. The rule is uniform on purpose: making it depend on which
972
+ // call reached `gate()` first is exactly the ordering-sensitive shape
973
+ // `parseCheck` had to be fixed out of. See `updateScript`.
974
+ export function requestOpportunisticScriptChecks(scriptIds, batch = createRefreshBatch()) {
975
+ for (const id of scriptIds) {
976
+ // Swallow rejections — opportunistic work shouldn't ever surface a
977
+ // user-visible error; the failure was already logged by the fetch.
978
+ //
979
+ // `'defer'`: navigation-triggered, so it doesn't wait for the parse
980
+ // verdict — and this call and the reload fast path's coalesce onto
981
+ // whichever of them reaches the gate first. See `updateScript`.
982
+ updateScript(id, 'opportunistic', 'if-stale', 'defer', batch).catch(() => { });
983
+ }
984
+ }
985
+ // Test/debug hook so tests can reset the rate-limiter without
986
+ // timing-mock gymnastics.
987
+ export function _resetUpdaterStateForTests() {
988
+ scriptState.clear();
989
+ collectionState.clear();
990
+ allCollectionsState.clear();
991
+ }