@chipmobilesdk/rn-ads 0.6.0 → 0.8.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 (340) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +4 -2
  3. package/lib/commonjs/config/resolveAdUnit.js +80 -0
  4. package/lib/commonjs/config/resolveAdUnit.js.map +1 -0
  5. package/lib/commonjs/config/types.js +138 -0
  6. package/lib/commonjs/config/types.js.map +1 -0
  7. package/lib/commonjs/config/validate.js +150 -0
  8. package/lib/commonjs/config/validate.js.map +1 -0
  9. package/lib/commonjs/core/adOriginatedReturn.js +119 -0
  10. package/lib/commonjs/core/adOriginatedReturn.js.map +1 -0
  11. package/lib/commonjs/core/adsInstance.js +129 -0
  12. package/lib/commonjs/core/adsInstance.js.map +1 -0
  13. package/lib/commonjs/core/consentFlow.js +154 -0
  14. package/lib/commonjs/core/consentFlow.js.map +1 -0
  15. package/lib/commonjs/core/enablement.js +64 -0
  16. package/lib/commonjs/core/enablement.js.map +1 -0
  17. package/lib/commonjs/core/fullScreenLock.js +48 -0
  18. package/lib/commonjs/core/fullScreenLock.js.map +1 -0
  19. package/lib/commonjs/core/gates.js +95 -0
  20. package/lib/commonjs/core/gates.js.map +1 -0
  21. package/lib/commonjs/core/initMachine.js +159 -0
  22. package/lib/commonjs/core/initMachine.js.map +1 -0
  23. package/lib/commonjs/core/instance/consent.js +155 -0
  24. package/lib/commonjs/core/instance/consent.js.map +1 -0
  25. package/lib/commonjs/core/instance/context.js +217 -0
  26. package/lib/commonjs/core/instance/context.js.map +1 -0
  27. package/lib/commonjs/core/instance/gate.js +194 -0
  28. package/lib/commonjs/core/instance/gate.js.map +1 -0
  29. package/lib/commonjs/core/instance/init.js +326 -0
  30. package/lib/commonjs/core/instance/init.js.map +1 -0
  31. package/lib/commonjs/core/instance/internals.js +115 -0
  32. package/lib/commonjs/core/instance/internals.js.map +1 -0
  33. package/lib/commonjs/core/instance/load.js +458 -0
  34. package/lib/commonjs/core/instance/load.js.map +1 -0
  35. package/lib/commonjs/core/instance/show.js +243 -0
  36. package/lib/commonjs/core/instance/show.js.map +1 -0
  37. package/lib/commonjs/core/instance/types.js +6 -0
  38. package/lib/commonjs/core/instance/types.js.map +1 -0
  39. package/lib/commonjs/core/placementMachine.js +116 -0
  40. package/lib/commonjs/core/placementMachine.js.map +1 -0
  41. package/lib/commonjs/core/privacyGate.js +93 -0
  42. package/lib/commonjs/core/privacyGate.js.map +1 -0
  43. package/lib/commonjs/core/requestThrottle.js +152 -0
  44. package/lib/commonjs/core/requestThrottle.js.map +1 -0
  45. package/lib/commonjs/core/retryPolicy.js +120 -0
  46. package/lib/commonjs/core/retryPolicy.js.map +1 -0
  47. package/lib/commonjs/core/rewardedSession.js +65 -0
  48. package/lib/commonjs/core/rewardedSession.js.map +1 -0
  49. package/lib/commonjs/core/timers.js +162 -0
  50. package/lib/commonjs/core/timers.js.map +1 -0
  51. package/lib/commonjs/diagnostics/correlation.js +33 -0
  52. package/lib/commonjs/diagnostics/correlation.js.map +1 -0
  53. package/lib/commonjs/diagnostics/errors.js +152 -0
  54. package/lib/commonjs/diagnostics/errors.js.map +1 -0
  55. package/lib/commonjs/diagnostics/events.js +132 -0
  56. package/lib/commonjs/diagnostics/events.js.map +1 -0
  57. package/lib/commonjs/engine/admobBanner.js +58 -0
  58. package/lib/commonjs/engine/admobBanner.js.map +1 -0
  59. package/lib/commonjs/engine/admobEngine.js +343 -0
  60. package/lib/commonjs/engine/admobEngine.js.map +1 -0
  61. package/lib/commonjs/engine/admobErrors.js +45 -0
  62. package/lib/commonjs/engine/admobErrors.js.map +1 -0
  63. package/lib/commonjs/engine/port.js +6 -0
  64. package/lib/commonjs/engine/port.js.map +1 -0
  65. package/lib/commonjs/engine/testAdUnits.js +61 -0
  66. package/lib/commonjs/engine/testAdUnits.js.map +1 -0
  67. package/lib/commonjs/index.js +218 -0
  68. package/lib/commonjs/index.js.map +1 -0
  69. package/lib/commonjs/package.json +1 -0
  70. package/lib/commonjs/react/AdsBanner.js +317 -0
  71. package/lib/commonjs/react/AdsBanner.js.map +1 -0
  72. package/lib/commonjs/react/AdsProvider.js +54 -0
  73. package/lib/commonjs/react/AdsProvider.js.map +1 -0
  74. package/lib/commonjs/react/hooks.js +63 -0
  75. package/lib/commonjs/react/hooks.js.map +1 -0
  76. package/lib/commonjs/testing/clock.js +76 -0
  77. package/lib/commonjs/testing/clock.js.map +1 -0
  78. package/lib/commonjs/testing/conformance.js +99 -0
  79. package/lib/commonjs/testing/conformance.js.map +1 -0
  80. package/lib/commonjs/testing/index.js +389 -0
  81. package/lib/commonjs/testing/index.js.map +1 -0
  82. package/lib/module/config/resolveAdUnit.js +75 -0
  83. package/lib/module/config/resolveAdUnit.js.map +1 -0
  84. package/lib/module/config/types.js +134 -0
  85. package/lib/module/config/types.js.map +1 -0
  86. package/lib/module/config/validate.js +146 -0
  87. package/lib/module/config/validate.js.map +1 -0
  88. package/lib/module/core/adOriginatedReturn.js +115 -0
  89. package/lib/module/core/adOriginatedReturn.js.map +1 -0
  90. package/lib/module/core/adsInstance.js +121 -0
  91. package/lib/module/core/adsInstance.js.map +1 -0
  92. package/lib/module/core/consentFlow.js +143 -0
  93. package/lib/module/core/consentFlow.js.map +1 -0
  94. package/lib/module/core/enablement.js +58 -0
  95. package/lib/module/core/enablement.js.map +1 -0
  96. package/lib/module/core/fullScreenLock.js +44 -0
  97. package/lib/module/core/fullScreenLock.js.map +1 -0
  98. package/lib/module/core/gates.js +91 -0
  99. package/lib/module/core/gates.js.map +1 -0
  100. package/lib/module/core/initMachine.js +152 -0
  101. package/lib/module/core/initMachine.js.map +1 -0
  102. package/lib/module/core/instance/consent.js +151 -0
  103. package/lib/module/core/instance/consent.js.map +1 -0
  104. package/lib/module/core/instance/context.js +212 -0
  105. package/lib/module/core/instance/context.js.map +1 -0
  106. package/lib/module/core/instance/gate.js +183 -0
  107. package/lib/module/core/instance/gate.js.map +1 -0
  108. package/lib/module/core/instance/init.js +321 -0
  109. package/lib/module/core/instance/init.js.map +1 -0
  110. package/lib/module/core/instance/internals.js +109 -0
  111. package/lib/module/core/instance/internals.js.map +1 -0
  112. package/lib/module/core/instance/load.js +452 -0
  113. package/lib/module/core/instance/load.js.map +1 -0
  114. package/lib/module/core/instance/show.js +238 -0
  115. package/lib/module/core/instance/show.js.map +1 -0
  116. package/lib/module/core/instance/types.js +4 -0
  117. package/lib/module/core/instance/types.js.map +1 -0
  118. package/lib/module/core/placementMachine.js +109 -0
  119. package/lib/module/core/placementMachine.js.map +1 -0
  120. package/lib/module/core/privacyGate.js +86 -0
  121. package/lib/module/core/privacyGate.js.map +1 -0
  122. package/lib/module/core/requestThrottle.js +146 -0
  123. package/lib/module/core/requestThrottle.js.map +1 -0
  124. package/lib/module/core/retryPolicy.js +113 -0
  125. package/lib/module/core/retryPolicy.js.map +1 -0
  126. package/lib/module/core/rewardedSession.js +61 -0
  127. package/lib/module/core/rewardedSession.js.map +1 -0
  128. package/lib/module/core/timers.js +150 -0
  129. package/lib/module/core/timers.js.map +1 -0
  130. package/lib/module/diagnostics/correlation.js +29 -0
  131. package/lib/module/diagnostics/correlation.js.map +1 -0
  132. package/lib/module/diagnostics/errors.js +143 -0
  133. package/lib/module/diagnostics/errors.js.map +1 -0
  134. package/lib/module/diagnostics/events.js +128 -0
  135. package/lib/module/diagnostics/events.js.map +1 -0
  136. package/lib/module/engine/admobBanner.js +52 -0
  137. package/lib/module/engine/admobBanner.js.map +1 -0
  138. package/lib/module/engine/admobEngine.js +334 -0
  139. package/lib/module/engine/admobEngine.js.map +1 -0
  140. package/lib/module/engine/admobErrors.js +41 -0
  141. package/lib/module/engine/admobErrors.js.map +1 -0
  142. package/lib/module/engine/port.js +4 -0
  143. package/lib/module/engine/port.js.map +1 -0
  144. package/lib/module/engine/testAdUnits.js +56 -0
  145. package/lib/module/engine/testAdUnits.js.map +1 -0
  146. package/lib/module/index.js +59 -0
  147. package/lib/module/index.js.map +1 -0
  148. package/lib/module/package.json +1 -0
  149. package/lib/module/react/AdsBanner.js +312 -0
  150. package/lib/module/react/AdsBanner.js.map +1 -0
  151. package/lib/module/react/AdsProvider.js +48 -0
  152. package/lib/module/react/AdsProvider.js.map +1 -0
  153. package/lib/module/react/hooks.js +58 -0
  154. package/lib/module/react/hooks.js.map +1 -0
  155. package/lib/module/testing/clock.js +72 -0
  156. package/lib/module/testing/clock.js.map +1 -0
  157. package/lib/module/testing/conformance.js +93 -0
  158. package/lib/module/testing/conformance.js.map +1 -0
  159. package/lib/module/testing/index.js +366 -0
  160. package/lib/module/testing/index.js.map +1 -0
  161. package/lib/typescript/commonjs/config/resolveAdUnit.d.ts +43 -0
  162. package/lib/typescript/commonjs/config/resolveAdUnit.d.ts.map +1 -0
  163. package/lib/typescript/commonjs/config/types.d.ts +280 -0
  164. package/lib/typescript/commonjs/config/types.d.ts.map +1 -0
  165. package/lib/typescript/commonjs/config/validate.d.ts +49 -0
  166. package/lib/typescript/commonjs/config/validate.d.ts.map +1 -0
  167. package/lib/typescript/commonjs/core/adOriginatedReturn.d.ts +61 -0
  168. package/lib/typescript/commonjs/core/adOriginatedReturn.d.ts.map +1 -0
  169. package/lib/typescript/commonjs/core/adsInstance.d.ts +33 -0
  170. package/lib/typescript/commonjs/core/adsInstance.d.ts.map +1 -0
  171. package/lib/typescript/commonjs/core/consentFlow.d.ts +84 -0
  172. package/lib/typescript/commonjs/core/consentFlow.d.ts.map +1 -0
  173. package/lib/typescript/commonjs/core/enablement.d.ts +74 -0
  174. package/lib/typescript/commonjs/core/enablement.d.ts.map +1 -0
  175. package/lib/typescript/commonjs/core/fullScreenLock.d.ts +25 -0
  176. package/lib/typescript/commonjs/core/fullScreenLock.d.ts.map +1 -0
  177. package/lib/typescript/commonjs/core/gates.d.ts +76 -0
  178. package/lib/typescript/commonjs/core/gates.d.ts.map +1 -0
  179. package/lib/typescript/commonjs/core/initMachine.d.ts +66 -0
  180. package/lib/typescript/commonjs/core/initMachine.d.ts.map +1 -0
  181. package/lib/typescript/commonjs/core/instance/consent.d.ts +4 -0
  182. package/lib/typescript/commonjs/core/instance/consent.d.ts.map +1 -0
  183. package/lib/typescript/commonjs/core/instance/context.d.ts +118 -0
  184. package/lib/typescript/commonjs/core/instance/context.d.ts.map +1 -0
  185. package/lib/typescript/commonjs/core/instance/gate.d.ts +38 -0
  186. package/lib/typescript/commonjs/core/instance/gate.d.ts.map +1 -0
  187. package/lib/typescript/commonjs/core/instance/init.d.ts +13 -0
  188. package/lib/typescript/commonjs/core/instance/init.d.ts.map +1 -0
  189. package/lib/typescript/commonjs/core/instance/internals.d.ts +60 -0
  190. package/lib/typescript/commonjs/core/instance/internals.d.ts.map +1 -0
  191. package/lib/typescript/commonjs/core/instance/load.d.ts +9 -0
  192. package/lib/typescript/commonjs/core/instance/load.d.ts.map +1 -0
  193. package/lib/typescript/commonjs/core/instance/show.d.ts +7 -0
  194. package/lib/typescript/commonjs/core/instance/show.d.ts.map +1 -0
  195. package/lib/typescript/commonjs/core/instance/types.d.ts +240 -0
  196. package/lib/typescript/commonjs/core/instance/types.d.ts.map +1 -0
  197. package/lib/typescript/commonjs/core/placementMachine.d.ts +55 -0
  198. package/lib/typescript/commonjs/core/placementMachine.d.ts.map +1 -0
  199. package/lib/typescript/commonjs/core/privacyGate.d.ts +40 -0
  200. package/lib/typescript/commonjs/core/privacyGate.d.ts.map +1 -0
  201. package/lib/typescript/commonjs/core/requestThrottle.d.ts +99 -0
  202. package/lib/typescript/commonjs/core/requestThrottle.d.ts.map +1 -0
  203. package/lib/typescript/commonjs/core/retryPolicy.d.ts +66 -0
  204. package/lib/typescript/commonjs/core/retryPolicy.d.ts.map +1 -0
  205. package/lib/typescript/commonjs/core/rewardedSession.d.ts +37 -0
  206. package/lib/typescript/commonjs/core/rewardedSession.d.ts.map +1 -0
  207. package/lib/typescript/commonjs/core/timers.d.ts +135 -0
  208. package/lib/typescript/commonjs/core/timers.d.ts.map +1 -0
  209. package/lib/typescript/commonjs/diagnostics/correlation.d.ts +18 -0
  210. package/lib/typescript/commonjs/diagnostics/correlation.d.ts.map +1 -0
  211. package/lib/typescript/commonjs/diagnostics/errors.d.ts +59 -0
  212. package/lib/typescript/commonjs/diagnostics/errors.d.ts.map +1 -0
  213. package/lib/typescript/commonjs/diagnostics/events.d.ts +126 -0
  214. package/lib/typescript/commonjs/diagnostics/events.d.ts.map +1 -0
  215. package/lib/typescript/commonjs/engine/admobBanner.d.ts +10 -0
  216. package/lib/typescript/commonjs/engine/admobBanner.d.ts.map +1 -0
  217. package/lib/typescript/commonjs/engine/admobEngine.d.ts +71 -0
  218. package/lib/typescript/commonjs/engine/admobEngine.d.ts.map +1 -0
  219. package/lib/typescript/commonjs/engine/admobErrors.d.ts +9 -0
  220. package/lib/typescript/commonjs/engine/admobErrors.d.ts.map +1 -0
  221. package/lib/typescript/commonjs/engine/port.d.ts +171 -0
  222. package/lib/typescript/commonjs/engine/port.d.ts.map +1 -0
  223. package/lib/typescript/commonjs/engine/testAdUnits.d.ts +33 -0
  224. package/lib/typescript/commonjs/engine/testAdUnits.d.ts.map +1 -0
  225. package/lib/typescript/commonjs/index.d.ts +42 -0
  226. package/lib/typescript/commonjs/index.d.ts.map +1 -0
  227. package/lib/typescript/commonjs/package.json +1 -0
  228. package/lib/typescript/commonjs/react/AdsBanner.d.ts +29 -0
  229. package/lib/typescript/commonjs/react/AdsBanner.d.ts.map +1 -0
  230. package/lib/typescript/commonjs/react/AdsProvider.d.ts +36 -0
  231. package/lib/typescript/commonjs/react/AdsProvider.d.ts.map +1 -0
  232. package/lib/typescript/commonjs/react/hooks.d.ts +27 -0
  233. package/lib/typescript/commonjs/react/hooks.d.ts.map +1 -0
  234. package/lib/typescript/commonjs/testing/clock.d.ts +29 -0
  235. package/lib/typescript/commonjs/testing/clock.d.ts.map +1 -0
  236. package/lib/typescript/commonjs/testing/conformance.d.ts +26 -0
  237. package/lib/typescript/commonjs/testing/conformance.d.ts.map +1 -0
  238. package/lib/typescript/commonjs/testing/index.d.ts +133 -0
  239. package/lib/typescript/commonjs/testing/index.d.ts.map +1 -0
  240. package/lib/typescript/module/config/resolveAdUnit.d.ts +43 -0
  241. package/lib/typescript/module/config/resolveAdUnit.d.ts.map +1 -0
  242. package/lib/typescript/module/config/types.d.ts +280 -0
  243. package/lib/typescript/module/config/types.d.ts.map +1 -0
  244. package/lib/typescript/module/config/validate.d.ts +49 -0
  245. package/lib/typescript/module/config/validate.d.ts.map +1 -0
  246. package/lib/typescript/module/core/adOriginatedReturn.d.ts +61 -0
  247. package/lib/typescript/module/core/adOriginatedReturn.d.ts.map +1 -0
  248. package/lib/typescript/module/core/adsInstance.d.ts +33 -0
  249. package/lib/typescript/module/core/adsInstance.d.ts.map +1 -0
  250. package/lib/typescript/module/core/consentFlow.d.ts +84 -0
  251. package/lib/typescript/module/core/consentFlow.d.ts.map +1 -0
  252. package/lib/typescript/module/core/enablement.d.ts +74 -0
  253. package/lib/typescript/module/core/enablement.d.ts.map +1 -0
  254. package/lib/typescript/module/core/fullScreenLock.d.ts +25 -0
  255. package/lib/typescript/module/core/fullScreenLock.d.ts.map +1 -0
  256. package/lib/typescript/module/core/gates.d.ts +76 -0
  257. package/lib/typescript/module/core/gates.d.ts.map +1 -0
  258. package/lib/typescript/module/core/initMachine.d.ts +66 -0
  259. package/lib/typescript/module/core/initMachine.d.ts.map +1 -0
  260. package/lib/typescript/module/core/instance/consent.d.ts +4 -0
  261. package/lib/typescript/module/core/instance/consent.d.ts.map +1 -0
  262. package/lib/typescript/module/core/instance/context.d.ts +118 -0
  263. package/lib/typescript/module/core/instance/context.d.ts.map +1 -0
  264. package/lib/typescript/module/core/instance/gate.d.ts +38 -0
  265. package/lib/typescript/module/core/instance/gate.d.ts.map +1 -0
  266. package/lib/typescript/module/core/instance/init.d.ts +13 -0
  267. package/lib/typescript/module/core/instance/init.d.ts.map +1 -0
  268. package/lib/typescript/module/core/instance/internals.d.ts +60 -0
  269. package/lib/typescript/module/core/instance/internals.d.ts.map +1 -0
  270. package/lib/typescript/module/core/instance/load.d.ts +9 -0
  271. package/lib/typescript/module/core/instance/load.d.ts.map +1 -0
  272. package/lib/typescript/module/core/instance/show.d.ts +7 -0
  273. package/lib/typescript/module/core/instance/show.d.ts.map +1 -0
  274. package/lib/typescript/module/core/instance/types.d.ts +240 -0
  275. package/lib/typescript/module/core/instance/types.d.ts.map +1 -0
  276. package/lib/typescript/module/core/placementMachine.d.ts +55 -0
  277. package/lib/typescript/module/core/placementMachine.d.ts.map +1 -0
  278. package/lib/typescript/module/core/privacyGate.d.ts +40 -0
  279. package/lib/typescript/module/core/privacyGate.d.ts.map +1 -0
  280. package/lib/typescript/module/core/requestThrottle.d.ts +99 -0
  281. package/lib/typescript/module/core/requestThrottle.d.ts.map +1 -0
  282. package/lib/typescript/module/core/retryPolicy.d.ts +66 -0
  283. package/lib/typescript/module/core/retryPolicy.d.ts.map +1 -0
  284. package/lib/typescript/module/core/rewardedSession.d.ts +37 -0
  285. package/lib/typescript/module/core/rewardedSession.d.ts.map +1 -0
  286. package/lib/typescript/module/core/timers.d.ts +135 -0
  287. package/lib/typescript/module/core/timers.d.ts.map +1 -0
  288. package/lib/typescript/module/diagnostics/correlation.d.ts +18 -0
  289. package/lib/typescript/module/diagnostics/correlation.d.ts.map +1 -0
  290. package/lib/typescript/module/diagnostics/errors.d.ts +59 -0
  291. package/lib/typescript/module/diagnostics/errors.d.ts.map +1 -0
  292. package/lib/typescript/module/diagnostics/events.d.ts +126 -0
  293. package/lib/typescript/module/diagnostics/events.d.ts.map +1 -0
  294. package/lib/typescript/module/engine/admobBanner.d.ts +10 -0
  295. package/lib/typescript/module/engine/admobBanner.d.ts.map +1 -0
  296. package/lib/typescript/module/engine/admobEngine.d.ts +71 -0
  297. package/lib/typescript/module/engine/admobEngine.d.ts.map +1 -0
  298. package/lib/typescript/module/engine/admobErrors.d.ts +9 -0
  299. package/lib/typescript/module/engine/admobErrors.d.ts.map +1 -0
  300. package/lib/typescript/module/engine/port.d.ts +171 -0
  301. package/lib/typescript/module/engine/port.d.ts.map +1 -0
  302. package/lib/typescript/module/engine/testAdUnits.d.ts +33 -0
  303. package/lib/typescript/module/engine/testAdUnits.d.ts.map +1 -0
  304. package/lib/typescript/module/index.d.ts +42 -0
  305. package/lib/typescript/module/index.d.ts.map +1 -0
  306. package/lib/typescript/module/package.json +1 -0
  307. package/lib/typescript/module/react/AdsBanner.d.ts +29 -0
  308. package/lib/typescript/module/react/AdsBanner.d.ts.map +1 -0
  309. package/lib/typescript/module/react/AdsProvider.d.ts +36 -0
  310. package/lib/typescript/module/react/AdsProvider.d.ts.map +1 -0
  311. package/lib/typescript/module/react/hooks.d.ts +27 -0
  312. package/lib/typescript/module/react/hooks.d.ts.map +1 -0
  313. package/lib/typescript/module/testing/clock.d.ts +29 -0
  314. package/lib/typescript/module/testing/clock.d.ts.map +1 -0
  315. package/lib/typescript/module/testing/conformance.d.ts +26 -0
  316. package/lib/typescript/module/testing/conformance.d.ts.map +1 -0
  317. package/lib/typescript/module/testing/index.d.ts +133 -0
  318. package/lib/typescript/module/testing/index.d.ts.map +1 -0
  319. package/package.json +48 -12
  320. package/skills/sdk-rn-ads/SKILL.md +2 -1
  321. package/src/core/adsInstance.ts +72 -1829
  322. package/src/core/consentFlow.ts +1 -20
  323. package/src/core/instance/consent.ts +162 -0
  324. package/src/core/instance/context.ts +328 -0
  325. package/src/core/instance/gate.ts +147 -0
  326. package/src/core/instance/init.ts +262 -0
  327. package/src/core/instance/internals.ts +138 -0
  328. package/src/core/instance/load.ts +426 -0
  329. package/src/core/instance/show.ts +202 -0
  330. package/src/core/instance/types.ts +198 -0
  331. package/src/core/requestThrottle.ts +11 -17
  332. package/src/diagnostics/errors.ts +24 -5
  333. package/src/engine/admobBanner.tsx +55 -0
  334. package/src/engine/admobEngine.ts +21 -12
  335. package/src/engine/admobErrors.ts +44 -0
  336. package/src/engine/port.ts +46 -1
  337. package/src/engine/testAdUnits.ts +0 -5
  338. package/src/react/AdsBanner.tsx +81 -80
  339. package/src/testing/clock.ts +75 -0
  340. package/src/testing/index.ts +43 -68
@@ -12,320 +12,37 @@
12
12
  * FR-031 a consumed ad is never reused
13
13
  * FR-035 after dispose, a late callback updates nothing and emits nothing
14
14
  *
15
- * Nothing in this file imports the provider. It talks to the engine port, which
16
- * the caller injects — the AdMob adapter in production, the fake in tests.
17
- */
18
-
19
- import {
20
- createListenerSet,
21
- isDevBuild as detectDevBuild,
22
- type ListenerSet,
23
- } from '@chipmobilesdk/rn-core';
24
-
25
- import {
26
- isFullScreenFormat,
27
- type AdFormat,
28
- type AdsConfig,
29
- type ConsentSnapshot,
30
- type Enablement,
31
- type FullScreenFormat,
32
- type InitStatus,
33
- type PlacementDefinition,
34
- type PlacementState,
35
- type PrivacyBlockReason,
36
- type PrivacyOptions,
37
- type UnavailableReason,
38
- type Unsubscribe,
39
- } from '../config/types';
40
- import { validateConfig, type ResolvedConfig } from '../config/validate';
41
- import { AdsError, normalizeProviderError } from '../diagnostics/errors';
42
- import {
43
- createEventEmitter,
44
- type AdEvent,
45
- type AdEventListener,
46
- type ImpressionSource,
47
- } from '../diagnostics/events';
48
- import { createRequestThrottle } from './requestThrottle';
49
- import { createCorrelationIssuer } from '../diagnostics/correlation';
50
- import type { AdsEngine, EngineAdEvent, EngineAdHandle } from '../engine/port';
51
- import { createFullScreenLock } from './fullScreenLock';
52
- import { canRequest, canStart, initialInit, initReducer, type InitSnapshot } from './initMachine';
53
- import {
54
- canLoad,
55
- initialPlacement,
56
- placementReducer,
57
- type PlacementSnapshot,
58
- } from './placementMachine';
59
- import {
60
- evaluateGate,
61
- requiresReinitialization,
62
- toEnginePrivacy,
63
- toEngineRequest,
64
- } from './privacyGate';
65
- import { evaluateGates, type ConsentGateInput, type GateDecision } from './gates';
66
- import { createRewardedSession, type RewardedSession } from './rewardedSession';
67
- import { createTimerSet, isStale, type Scheduler } from './timers';
68
- import { canResume, permitsRequests, type EnablementResult } from './enablement';
69
- import { canRetry, delayForAttempt } from './retryPolicy';
70
- import { createAdOriginatedTracker } from './adOriginatedReturn';
71
- import {
72
- buildConsentOptions,
73
- INITIAL_CONSENT,
74
- needsForm,
75
- normalizeConsent,
76
- runConsentStep,
77
- toConsentError,
78
- toGateInput,
79
- } from './consentFlow';
80
-
81
- export type InitResult =
82
- | { readonly status: 'ready' }
83
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
84
- | { readonly status: 'disabled' }
85
- /** The app has not decided whether ads are on. Nothing was requested (FR-115). */
86
- | { readonly status: 'undecided' }
87
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
88
- | { readonly status: 'failed'; readonly error: AdsError };
89
-
90
- export type PrivacyUpdateResult =
91
- | { readonly status: 'applied' }
92
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
93
- | { readonly status: 'requires_reinitialization' }
94
- | { readonly status: 'disabled' }
95
- | { readonly status: 'undecided' }
96
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
97
- /**
98
- * The initialization this update triggered failed (added in 0.5.0). Before,
99
- * it was reported as `applied`, so the app never retried and no ad ever
100
- * showed, with nothing to say why.
101
- */
102
- | { readonly status: 'failed'; readonly error: AdsError };
103
-
104
- export type LoadResult =
105
- | { readonly status: 'loaded'; readonly correlationId: string }
106
- | { readonly status: 'disabled' }
107
- | { readonly status: 'undecided' }
108
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
109
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
110
- | { readonly status: 'no_fill' }
111
- /**
112
- * Refused by the per-placement request throttle (FR-140), added in 0.3.0.
113
- *
114
- * Nothing was requested and nothing is broken — this placement asked too
115
- * recently. Distinct from `no_fill` on purpose: no-fill means the network had
116
- * nothing to give, this means we did not ask. An app that treats them the same
117
- * will misdiagnose the first time a banner goes quiet.
118
- */
119
- | ThrottledResult
120
- | { readonly status: 'timeout' }
121
- | { readonly status: 'failed'; readonly error: AdsError };
122
-
123
- export interface ThrottledResult {
124
- readonly status: 'throttled';
125
- readonly retryAfterMs: number;
126
- }
127
-
128
- /** A gate refusal: what a load gets when it may not ask at all. */
129
- type LoadRefusal = Extract<
130
- LoadResult,
131
- { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
132
- >;
133
-
134
- export type ShowResult =
135
- | { readonly status: 'shown'; readonly correlationId: string }
136
- | { readonly status: 'not_ready' }
137
- | { readonly status: 'busy' }
138
- | { readonly status: 'consumed' }
139
- | { readonly status: 'app_inactive' }
140
- /**
141
- * The user came back to the app from an ad, or from a browser an ad opened,
142
- * so an app-open ad would be an ad immediately after an ad. Distinct from
143
- * `busy` and `not_ready` because the app cannot detect this itself and
144
- * therefore cannot infer it from either of those (FR-128).
145
- */
146
- | { readonly status: 'ad_originated_return' }
147
- | { readonly status: 'disabled' }
148
- | { readonly status: 'undecided' }
149
- | { readonly status: 'blocked'; readonly reason: PrivacyBlockReason }
150
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason }
151
- | { readonly status: 'timeout' }
152
- | { readonly status: 'failed'; readonly error: AdsError };
153
-
154
- /**
155
- * What `consent.showPrivacyOptionsForm()` reports (FR-151), added in 0.4.0.
15
+ * This file only composes. Each responsibility lives in `core/instance/` and
16
+ * takes the shared context (ADS-10): the gate, initialization and enablement,
17
+ * full-screen load, show, consent, and the banner host's internals.
156
18
  *
157
- * Every refusal is its own member, so an app can tell "there is nothing to
158
- * offer here" from "you asked too early" from "it broke" without parsing an
159
- * error. None of them throws.
19
+ * Nothing here imports the provider. It talks to the engine port, which the
20
+ * caller injects — the AdMob adapter in production, the fake in tests.
160
21
  */
161
- export type PrivacyOptionsFormResult =
162
- /**
163
- * The provider's form was presented and has closed. `consent` is the state it
164
- * left behind, already applied to the gate. A user who closed the form without
165
- * changing anything lands here too, with the state they already had.
166
- */
167
- | { readonly status: 'shown'; readonly consent: ConsentSnapshot }
168
- /** The provider does not require privacy options here. Nothing was presented. */
169
- | { readonly status: 'not_required' }
170
- /**
171
- * The requirement is not known yet: no successful `requestInfoUpdate()` since
172
- * the instance was created or reset, or the last one failed. Nothing was
173
- * presented.
174
- */
175
- | { readonly status: 'info_update_required' }
176
- /** A consent form is already on screen. Nothing was presented. */
177
- | { readonly status: 'busy' }
178
- /**
179
- * The form could not be presented or failed while open. The consent state is
180
- * whatever the provider reports it still holds, and never a new grant.
181
- */
182
- | { readonly status: 'failed'; readonly error: AdsError }
183
- | { readonly status: 'unavailable'; readonly reason: UnavailableReason };
184
-
185
- /**
186
- * The optional consent capability (FR-101).
187
- *
188
- * Grouped under its own namespace rather than flattened onto the instance so
189
- * that FR-102's optionality stays legible: an app that never touches
190
- * `ads.consent` never starts a flow. Five loose methods on the instance would
191
- * invite the assumption that they are part of the normal path.
192
- */
193
- export interface ConsentApi {
194
- /** Refreshes consent information from the provider. */
195
- requestInfoUpdate(): Promise<ConsentSnapshot>;
196
- /** Presents the provider's form if one is required. Renders nothing itself. */
197
- showFormIfRequired(): Promise<ConsentSnapshot>;
198
- /**
199
- * Presents the provider's privacy options form, so a user can change an
200
- * answer already given (FR-151). Added in 0.4.0.
201
- *
202
- * Offer it when `getState().privacyOptionsRequirement` is `required`. Where
203
- * the entry point lives, and what it says, stay the app's. The outcome feeds
204
- * the privacy gate by itself, exactly as `showFormIfRequired` does.
205
- */
206
- showPrivacyOptionsForm(): Promise<PrivacyOptionsFormResult>;
207
- getState(): ConsentSnapshot;
208
- subscribe(listener: (snapshot: ConsentSnapshot) => void): Unsubscribe;
209
- /** Clears the stored decision. Inert in production (FR-113). */
210
- reset(): Promise<void>;
211
- }
212
-
213
- export interface AdsInstance {
214
- initialize(privacy: PrivacyOptions): Promise<InitResult>;
215
- updatePrivacy(privacy: PrivacyOptions): Promise<PrivacyUpdateResult>;
216
- /**
217
- * Turns ads on or off. Returns a result rather than `void` (FR-122).
218
- *
219
- * 0.1.0 returned nothing, which meant an app re-enabling ads after a lapsed
220
- * subscription had no way to learn that it also had to re-initialize — and
221
- * every load and show after that was refused in silence.
222
- */
223
- setEnabled(enabled: boolean): EnablementResult;
224
- /** The same, including the third state the app has not resolved yet. */
225
- setEnablement(next: Enablement): EnablementResult;
226
- setAppActive(active: boolean): void;
227
- readonly consent: ConsentApi;
228
- getStatus(): InitStatus;
229
- subscribeStatus(listener: (status: InitStatus) => void): Unsubscribe;
230
-
231
- load(placementKey: string): Promise<LoadResult>;
232
- show(placementKey: string): Promise<ShowResult>;
233
- getPlacementState(placementKey: string): PlacementState;
234
- subscribePlacement(key: string, listener: (state: PlacementState) => void): Unsubscribe;
235
-
236
- subscribeEvents(listener: AdEventListener): Unsubscribe;
237
-
238
- /** FR-011a — substitution is observable state, not only a log line. */
239
- getSubstitutedPlacements(): readonly string[];
240
- /** FR-113 — the same treatment for consent tools dropped in production. */
241
- getIgnoredConsentTools(): readonly string[];
242
- /** Internal wiring for the banner host; not part of the documented surface. */
243
- readonly internals: InstanceInternals;
244
-
245
- dispose(): void;
246
- }
247
-
248
- /** What `AdsBanner` needs. Deliberately not documented as consumer API. */
249
- export interface InstanceInternals {
250
- resolveBanner(placementKey: string):
251
- | {
252
- readonly ok: true;
253
- readonly adUnitId: string;
254
- readonly correlationId: string;
255
- /** Current at resolution time, so a banner request honors `updatePrivacy`. */
256
- readonly personalizedAds: boolean;
257
- }
258
- | { readonly ok: false; readonly result: LoadResult };
259
- /**
260
- * Takes the throttle slot for a banner cycle (FR-140).
261
- *
262
- * Called from the banner host's effect, after `resolveBanner` allowed it and
263
- * immediately before the provider component mounts. Idempotent for one
264
- * `correlationId`, so React running the effect twice charges one slot.
265
- *
266
- * Returns the refusal if the window closed between resolve and commit — rare,
267
- * but a second banner for the same placement mounting in the same tick would
268
- * do it.
269
- *
270
- * Emits nothing and sets no state: it is called during render, so the host
271
- * reports the refusal from its effect instead.
272
- */
273
- commitBannerRequest(placementKey: string, correlationId: string): ThrottledResult | null;
274
- /** The configured floor for a placement, so the host can report the cause. */
275
- throttleFloorFor(placementKey: string): number;
276
- emit(event: AdEvent): void;
277
- setPlacementState(key: string, state: PlacementState): void;
278
- isReady(): boolean;
279
- nextCorrelationId(): string;
280
- timerCount(): number;
281
- }
282
-
283
- export interface CreateAdsOptions {
284
- /** Injected in tests; the AdMob adapter is the default in production. */
285
- readonly engine?: AdsEngine;
286
- /** Injected so the FR-012 rule is testable without touching a global. */
287
- readonly isDevBuild?: boolean;
288
- readonly now?: () => number;
289
- /**
290
- * Injected so retry backoff, app-open expiry, and the undecided warning can
291
- * be driven by a test clock instead of by waiting. Production uses real
292
- * timers.
293
- */
294
- readonly scheduler?: Scheduler;
295
- }
296
-
297
- interface PlacementRuntime {
298
- snapshot: PlacementSnapshot;
299
- /**
300
- * The cycle's correlation id, held here rather than read from the snapshot:
301
- * the reducer resets the snapshot on dismissal, and events emitted around
302
- * that transition must still carry the id of the cycle they belong to.
303
- */
304
- correlationId: string | null;
305
- /**
306
- * Whether this cycle already reported a dismissal. Format-independent — the
307
- * rewarded session guards its own, but interstitials need the same guard and
308
- * would otherwise report a repeated provider callback twice.
309
- */
310
- dismissed: boolean;
311
- handle: EngineAdHandle | null;
312
- unsubscribeEngine: Unsubscribe | null;
313
- inFlightLoad: Promise<LoadResult> | null;
314
- /**
315
- * Settles the in-flight load with a given result. Only a consent withdrawal
316
- * uses it (FR-153): the caller awaiting `load()` is told `blocked` instead of
317
- * waiting on a request that has been abandoned.
318
- */
319
- abortLoad: ((result: LoadResult) => void) | null;
320
- cancelLoadTimer: Unsubscribe | null;
321
- cancelShowTimer: Unsubscribe | null;
322
- rewarded: RewardedSession | null;
323
- listeners: ListenerSet<[PlacementState]>;
324
- /** Retries already spent in this load cycle. Reset when a new cycle starts. */
325
- retryAttempts: number;
326
- /** Cancels a scheduled retry. Called by disable, undecided, and dispose. */
327
- cancelRetry: Unsubscribe | null;
328
- }
22
+ import type { AdsConfig } from '../config/types';
23
+ import type { AdsEngine } from '../engine/port';
24
+ import { createConsentApi } from './instance/consent';
25
+ import { createInstanceContext } from './instance/context';
26
+ import { cancelAllRetries, disarmUndecidedWarning } from './instance/gate';
27
+ import { createInitController } from './instance/init';
28
+ import { attachInternals, createInternals } from './instance/internals';
29
+ import { createLoader } from './instance/load';
30
+ import { createShower } from './instance/show';
31
+ import type { AdsInstance, CreateAdsOptions } from './instance/types';
32
+
33
+ export type {
34
+ AdsInstance,
35
+ ConsentApi,
36
+ CreateAdsOptions,
37
+ InitResult,
38
+ LoadRefusal,
39
+ LoadResult,
40
+ PrivacyOptionsFormResult,
41
+ PrivacyUpdateResult,
42
+ ShowResult,
43
+ ThrottledResult,
44
+ } from './instance/types';
45
+ export { getInternals, type InstanceInternals } from './instance/internals';
329
46
 
330
47
  /**
331
48
  * The instance itself. The public `createAds` (src/index.ts) passes the AdMob
@@ -337,1558 +54,84 @@ export function createAds(
337
54
  options: CreateAdsOptions = {},
338
55
  defaultEngine: () => AdsEngine = noDefaultEngine,
339
56
  ): AdsInstance {
340
- const isDevBuild = options.isDevBuild ?? detectDevBuild();
341
- const now = options.now ?? (() => Date.now());
342
-
343
- // Throws on a structural fault or the FR-012 fail-closed case. Deliberately
344
- // before anything else: a misconfigured instance should not exist at all.
345
- const resolved: ResolvedConfig = validateConfig(config, { isDevBuild });
346
-
347
- const engine: AdsEngine = options.engine ?? defaultEngine();
348
- const events = createEventEmitter();
349
- const correlation = createCorrelationIssuer();
350
- // FR-140. Account safety, not product policy — see core/requestThrottle.ts.
351
- const requestThrottle = createRequestThrottle(now);
352
- const timers = createTimerSet(options.scheduler);
353
- const lock = createFullScreenLock();
354
- const adReturn = createAdOriginatedTracker();
355
-
356
- /**
357
- * An instance told it is `undecided` says so from the moment it exists.
358
- *
359
- * Without this it reports `not_started` until `initialize()` runs, which is
360
- * indistinguishable from a disabled instance at exactly the point an app is
361
- * deciding whether to reserve the banner's space — so FR-116 would hold only
362
- * after a call the app has no reason to make yet. Found on device, not in a
363
- * unit test, because the demo screen defers initialization the way a real
364
- * entitlement-gated app does.
365
- *
366
- * `disabled` deliberately keeps 0.1.0's `not_started` here: that behavior is
367
- * observable today and BC-004 protects it. The asymmetry is the cost of not
368
- * changing something that already shipped.
369
- */
370
- let init: InitSnapshot =
371
- resolved.enablement === 'undecided'
372
- ? initReducer(initialInit, { type: 'UNDECIDE' })
373
- : initialInit;
374
- let enablement: Enablement = resolved.enablement;
375
- let appActive = true;
376
- let consent: ConsentSnapshot = INITIAL_CONSENT;
377
- let consentStarted = false;
378
- /**
379
- * Consent forms currently on screen. A count rather than a flag because
380
- * `showFormIfRequired` has never refused a concurrent call and 0.4.0 does not
381
- * start now; only the privacy options form reads it, to report `busy`.
382
- */
383
- let consentFormsOpen = 0;
384
- let privacyOptionsFormOpen = false;
385
- /**
386
- * Whether the provider has been natively initialized, ever, by this instance.
387
- *
388
- * Tracked apart from the init status because the status can leave `ready` —
389
- * a withdrawn consent moves it to `blocked` — while the native SDK stays
390
- * initialized. Re-granting must resume, not initialize again (FR-022, FR-154).
391
- */
392
- let providerInitialized = false;
393
- let undecidedWarned = false;
394
- let cancelUndecidedTimer: Unsubscribe | null = null;
395
- let appliedPrivacy: PrivacyOptions | null = null;
396
- let currentPrivacy: PrivacyOptions | null = null;
397
- let inFlightInit: Promise<InitResult> | null = null;
398
- let disposed = false;
399
- let warnedSubstitution = false;
400
-
401
- /**
402
- * Status, consent and placement listeners are consumer code. A failure is
403
- * isolated from the rest and reported the way an event listener's is: as an
404
- * `internal_error` event (FR-056).
405
- */
406
- function reportListenerFailure(error: unknown): void {
407
- events.emit({
408
- type: 'internal_error',
409
- timestamp: now(),
410
- error: new AdsError('internal_error', { message: 'consumer listener threw', cause: error }),
411
- });
412
- }
413
- const listenerOptions = { onListenerError: reportListenerFailure };
414
-
415
- const statusListeners = createListenerSet<[InitStatus]>(listenerOptions);
416
- const placements = new Map<string, PlacementRuntime>();
417
-
418
- for (const key of resolved.placements.keys()) {
419
- placements.set(key, {
420
- snapshot: initialPlacement,
421
- correlationId: null,
422
- dismissed: false,
423
- handle: null,
424
- unsubscribeEngine: null,
425
- inFlightLoad: null,
426
- abortLoad: null,
427
- cancelLoadTimer: null,
428
- cancelShowTimer: null,
429
- rewarded: null,
430
- listeners: createListenerSet(listenerOptions),
431
- retryAttempts: 0,
432
- cancelRetry: null,
433
- });
434
- }
435
-
436
- const consentListeners = createListenerSet<[ConsentSnapshot]>(listenerOptions);
437
-
438
- function setConsent(next: ConsentSnapshot): void {
439
- consent = next;
440
- consentListeners.notify(next);
441
- }
442
-
443
- /**
444
- * The consent flow's contribution to the gate, or `null` when the app never
445
- * opted in.
446
- *
447
- * `null` is what makes FR-102 structural rather than conditional: with no
448
- * flow started, the gate sees only the app's own input and behaves exactly as
449
- * 0.1.0 did. There is no code path to skip and therefore none to forget.
450
- */
451
- function consentGateInput(): ConsentGateInput {
452
- return consentStarted ? toGateInput(consent) : null;
453
- }
454
-
455
- /**
456
- * Warns once if the app leaves `undecided` unresolved (FR-119).
457
- *
458
- * Once, not repeatedly: a warning that fires every ten seconds becomes noise
459
- * that gets filtered, which defeats the diagnostic. And it only warns — the
460
- * package never resolves the state itself, because doing so would mean
461
- * guessing at exactly the business decision FR-120 forbids it from knowing.
462
- */
463
- function armUndecidedWarning(): void {
464
- if (enablement !== 'undecided' || undecidedWarned || cancelUndecidedTimer) {
465
- return;
466
- }
467
- const armedAt = now();
468
- cancelUndecidedTimer = timers.after(resolved.timeouts.undecidedWarning, () => {
469
- cancelUndecidedTimer = null;
470
- if (disposed || enablement !== 'undecided' || undecidedWarned) {
471
- return;
472
- }
473
- undecidedWarned = true;
474
- events.emit({
475
- type: 'config_warning',
476
- timestamp: now(),
477
- warning: 'enablement_undecided_timeout',
478
- placementKeys: [],
479
- elapsedMs: now() - armedAt,
480
- });
481
- });
482
- }
483
-
484
- function disarmUndecidedWarning(): void {
485
- cancelUndecidedTimer?.();
486
- cancelUndecidedTimer = null;
487
- }
488
-
489
- /** Cancels every pending retry, everywhere (FR-137). */
490
- function cancelAllRetries(): void {
491
- for (const runtime of placements.values()) {
492
- runtime.cancelRetry?.();
493
- runtime.cancelRetry = null;
494
- }
495
- }
496
-
497
- function setInit(next: InitSnapshot): void {
498
- if (next === init || next.status === init.status) {
499
- init = next;
500
- return;
501
- }
502
- init = next;
503
- events.emit({
504
- type: 'initialization',
505
- timestamp: now(),
506
- status: next.status,
507
- retryable: next.error?.retryable,
508
- });
509
- statusListeners.notify(next.status);
510
- }
511
-
512
- function setPlacement(key: string, next: PlacementSnapshot): void {
513
- const runtime = placements.get(key);
514
- if (!runtime || runtime.snapshot.state === next.state) {
515
- if (runtime) {
516
- runtime.snapshot = next;
517
- }
518
- return;
519
- }
520
- runtime.snapshot = next;
521
- runtime.listeners.notify(next.state);
522
- }
523
-
524
- function dispatchPlacement(key: string, event: Parameters<typeof placementReducer>[1]): void {
525
- const runtime = placements.get(key);
526
- const definition = resolved.placements.get(key);
527
- if (!runtime || !definition) {
528
- return;
529
- }
530
- setPlacement(key, placementReducer(runtime.snapshot, event, definition.format));
531
- }
532
-
533
- /**
534
- * The single gate evaluation (FR-140).
535
- *
536
- * Assembles the three inputs and defers the precedence entirely to
537
- * `evaluateGates`. Nothing in this file compares gate state itself — the
538
- * whole point of FR-140 is that the order is a decision made once, not four
539
- * `if` chains that happen to agree today.
540
- *
541
- * Note the order differs from 0.1.0's, which checked `enabled` before
542
- * availability. That was never observable in isolation, only when two gates
543
- * were closed at once, and the case it changes — disabled on an unsupported
544
- * platform, previously decided by whichever transition arrived last — is the
545
- * incidental ordering FR-140a exists to fix.
546
- */
547
- function currentGate(): GateDecision {
548
- return evaluateGates({
549
- available: engine.isAvailable()
550
- ? { ok: true }
551
- : { ok: false, reason: 'unsupported_platform' },
552
- enablement,
553
- appGate: (() => {
554
- const gate = evaluateGate(currentPrivacy);
555
- return gate.allowed
556
- ? ({ permitted: true } as const)
557
- : ({ permitted: false, reason: gate.reason } as const);
558
- })(),
559
- consentGate: consentGateInput(),
560
- });
561
- }
562
-
563
- /** Maps a closed gate onto the refusal shape load, show, and the banner share. */
564
- function refusalFor(
565
- decision: Extract<GateDecision, { allowed: false }>,
566
- ): Extract<LoadResult, { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }> {
567
- switch (decision.kind) {
568
- case 'unavailable':
569
- return { status: 'unavailable', reason: decision.reason };
570
- case 'disabled':
571
- return { status: 'disabled' };
572
- case 'undecided':
573
- return { status: 'undecided' };
574
- default:
575
- return { status: 'blocked', reason: decision.reason };
576
- }
577
- }
57
+ // Throws on a structural fault or the FR-012 fail-closed case, before
58
+ // anything else exists.
59
+ const context = createInstanceContext(config, options, defaultEngine);
60
+ const { state, engine, resolved, events } = context;
578
61
 
579
- /** Every refusal that applies before a placement is even looked up. */
580
- function gateCheck():
581
- | { readonly ok: true }
582
- | {
583
- readonly ok: false;
584
- readonly result: Extract<
585
- LoadResult,
586
- { status: 'disabled' | 'undecided' | 'blocked' | 'unavailable' }
587
- >;
588
- } {
589
- const decision = currentGate();
590
- if (decision.allowed) {
591
- return { ok: true };
592
- }
593
- return { ok: false, result: refusalFor(decision) };
594
- }
595
-
596
- function emitRefusal(
597
- operation: 'initialize' | 'load' | 'show',
598
- result: { readonly status: string; readonly reason?: string },
599
- placementKey?: string,
600
- format?: AdFormat,
601
- ): void {
602
- if (result.status === 'disabled' || result.status === 'undecided') {
603
- // `undecided` reuses the `disabled` event rather than minting a new
604
- // one: for an observer of the event stream the fact is the same — no
605
- // request was made — and the distinction that matters is carried by
606
- // the typed result and the init status, where the app reads it.
607
- events.emit({ type: 'disabled', timestamp: now(), operation, placementKey, format });
608
- } else if (result.status === 'unavailable' || result.status === 'blocked') {
609
- events.emit({
610
- type: 'unavailable',
611
- timestamp: now(),
612
- operation,
613
- placementKey,
614
- format,
615
- reason: (result.reason ?? 'unsupported_platform') as UnavailableReason | PrivacyBlockReason,
616
- });
617
- }
618
- }
619
-
620
- /**
621
- * Consumes a throttle slot for a placement, or reports the refusal (FR-140).
622
- *
623
- * Returns `null` when the request may proceed, so the two call sites read as a
624
- * guard clause. On refusal it moves the placement to `throttled` and emits,
625
- * which is what makes the refusal observable rather than a silent absence of
626
- * ads — the app would otherwise have no way to tell this from no-fill.
627
- *
628
- * A placement still holding a `ready` ad keeps that state: the refusal is
629
- * about asking again, and overwriting it would strand an ad that was already
630
- * paid for and is still showable.
631
- */
632
- function reportThrottled(
633
- key: string,
634
- format: AdFormat,
635
- retryAfterMs: number,
636
- minRequestIntervalMs: number,
637
- ): LoadResult {
638
- const runtime = placements.get(key);
639
- if (runtime && runtime.snapshot.state !== 'ready') {
640
- setPlacement(key, { ...runtime.snapshot, state: 'throttled' });
641
- }
642
- events.emit({
643
- type: 'request_throttled',
644
- timestamp: now(),
645
- placementKey: key,
646
- format,
647
- retryAfterMs,
648
- minRequestIntervalMs,
649
- });
650
- return { status: 'throttled', retryAfterMs };
651
- }
652
-
653
- /** Consumes a slot, or reports the refusal. `null` means proceed. */
654
- function checkThrottle(key: string, format: AdFormat, token?: string): LoadResult | null {
655
- const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(key) ?? 0;
656
- const decision = requestThrottle.tryAcquire(key, minRequestIntervalMs, token);
657
- return decision.allowed
658
- ? null
659
- : reportThrottled(key, format, decision.retryAfterMs, minRequestIntervalMs);
660
- }
661
-
662
- function warnSubstitutionOnce(): void {
663
- if (warnedSubstitution || resolved.catalog.substitutedKeys.length === 0) {
664
- return;
665
- }
666
- warnedSubstitution = true;
667
- events.emit({
668
- type: 'config_warning',
669
- timestamp: now(),
670
- warning: 'test_ad_unit_substituted',
671
- placementKeys: resolved.catalog.substitutedKeys,
672
- });
673
- }
674
-
675
- function releasePlacementResources(key: string): void {
676
- const runtime = placements.get(key);
677
- if (!runtime) {
678
- return;
679
- }
680
- runtime.cancelLoadTimer?.();
681
- runtime.cancelLoadTimer = null;
682
- runtime.cancelShowTimer?.();
683
- runtime.cancelShowTimer = null;
684
- runtime.unsubscribeEngine?.();
685
- runtime.unsubscribeEngine = null;
686
- if (runtime.handle) {
687
- engine.dispose(runtime.handle);
688
- runtime.handle = null;
689
- }
690
- runtime.inFlightLoad = null;
691
- runtime.abortLoad = null;
692
- runtime.rewarded = null;
693
- runtime.cancelRetry?.();
694
- runtime.cancelRetry = null;
695
- }
696
-
697
- /**
698
- * Stops every request a gate that just closed forbids (FR-125, FR-153,
699
- * FR-159) — ads turned off, turned undecided, or consent withdrawn.
700
- *
701
- * In-flight loads are abandoned and their resources released, a loaded ad
702
- * that may no longer be shown is released with them, and an ad already on
703
- * screen is left alone — the provider offers no safe way to close it.
704
- *
705
- * An abandoned load *settles*: its caller gets `refusal`, the same answer a
706
- * load started now would get. Until 0.4.0 disabling released the resources
707
- * but never resolved the caller's promise, so an app awaiting `load()` across
708
- * a purchase of ad removal waited forever — and a handle the provider handed
709
- * back after the disable was adopted and went on loading.
710
- */
711
- function haltPlacements(refusal: LoadRefusal): void {
712
- for (const [key, runtime] of placements) {
713
- if (runtime.snapshot.state === 'showing') {
714
- continue;
715
- }
716
- const abort = runtime.abortLoad;
717
- releasePlacementResources(key);
718
- dispatchPlacement(key, { type: 'RESET' });
719
- if (abort) {
720
- emitRefusal('load', refusal, key, resolved.placements.get(key)?.format);
721
- abort(refusal);
722
- }
723
- }
724
- }
725
-
726
- /** App-open expires on its own, shorter window (FR-130). */
727
- function validityFor(format: AdFormat): number {
728
- return format === 'app_open' ? resolved.timeouts.appOpenValidity : resolved.timeouts.adValidity;
729
- }
730
-
731
- async function doInitialize(privacy: PrivacyOptions): Promise<InitResult> {
732
- const enginePrivacy = toEnginePrivacy(privacy, resolved.testDeviceIds);
733
- try {
734
- // The adapter applies privacy configuration before initializing the
735
- // provider; that ordering is the whole reason FR-017 is enforceable.
736
- await engine.initialize(enginePrivacy);
737
- // Recorded before the status moves, and whether or not it moves: a gate
738
- // that closed while this was in flight leaves the status `blocked`, but
739
- // the provider is initialized all the same and must not be again.
740
- providerInitialized = true;
741
- appliedPrivacy = privacy;
742
- setInit(initReducer(init, { type: 'SUCCEED' }));
743
- warnSubstitutionOnce();
744
- return { status: 'ready' };
745
- } catch (error) {
746
- const normalized = normalizeProviderError(error, { operation: 'initialize' });
747
- setInit(initReducer(init, { type: 'FAIL', error: normalized }));
748
- return { status: 'failed', error: normalized };
749
- } finally {
750
- inFlightInit = null;
751
- }
752
- }
753
-
754
- function handleEngineEvent(
755
- key: string,
756
- definition: PlacementDefinition,
757
- event: EngineAdEvent,
758
- ): void {
759
- // After dispose nothing is honored: a late provider callback must not
760
- // update a consumer that has gone away (FR-035, SC-008).
761
- if (disposed) {
762
- return;
763
- }
764
- const runtime = placements.get(key);
765
- if (!runtime) {
766
- return;
767
- }
768
- const correlationId = runtime.correlationId ?? undefined;
769
- const base = { timestamp: now(), placementKey: key, format: definition.format, correlationId };
770
-
771
- switch (event.type) {
772
- case 'opened': {
773
- // The provider confirming an ad is on screen is what arms
774
- // ad-originated return detection (FR-128). Taken from here rather
775
- // than from the app's background report, which arrives too late to
776
- // be trusted — see core/adOriginatedReturn.ts.
777
- if (isFullScreenFormat(definition.format)) {
778
- adReturn.noteFullScreenShown(now());
779
- }
780
- events.emit({ ...base, type: 'display_started' });
781
- // R5: the provider exposes no impression callback for full-screen
782
- // formats, so this one is derived from the ad opening. Labelled
783
- // honestly rather than passed off as the provider's own.
784
- events.emit({ ...base, type: 'impression', source: 'derived' as ImpressionSource });
785
- break;
786
- }
787
- case 'clicked':
788
- // Recorded so that a background transition moments later is
789
- // recognized as "the ad opened a browser" — the only signal the
790
- // provider gives for that case (FR-128).
791
- adReturn.noteClick(now());
792
- events.emit({ ...base, type: 'click' });
793
- break;
794
- case 'rewarded': {
795
- const first = runtime.rewarded?.confirmReward(event.reward) ?? null;
796
- if (first) {
797
- events.emit({ ...base, type: 'reward_earned', reward: first });
798
- }
799
- break;
800
- }
801
- case 'dismissed': {
802
- const rewardedFirst = runtime.rewarded ? runtime.rewarded.dismiss() : true;
803
- if (runtime.dismissed || !rewardedFirst) {
804
- break;
805
- }
806
- runtime.dismissed = true;
807
- lock.release(key);
808
- runtime.cancelShowTimer?.();
809
- runtime.cancelShowTimer = null;
810
- events.emit({
811
- ...base,
812
- type: 'dismissed',
813
- rewardEarned: runtime.rewarded ? runtime.rewarded.rewardEarned : undefined,
814
- });
815
- dispatchPlacement(key, { type: 'DISMISSED' });
816
- // Deliberately NOT released here. FR-049 requires the reward and
817
- // the dismissal to be handled in either order, and a provider that
818
- // confirms the reward *after* closing the ad would otherwise have
819
- // its confirmation dropped by a torn-down listener — silently
820
- // costing the user a reward they earned.
821
- //
822
- // This is a retained resource, not a leak: at most one handle and
823
- // one listener per placement, released deterministically by the
824
- // next load of that placement or by dispose().
825
- break;
826
- }
827
- case 'failed': {
828
- const normalized = normalizeProviderError(event.cause, {
829
- operation: 'show',
830
- placementKey: key,
831
- format: definition.format,
832
- });
833
- lock.release(key);
834
- events.emit({ ...base, type: 'display_failed', error: normalized });
835
- dispatchPlacement(key, { type: 'SHOW_FAILED', error: normalized });
836
- releasePlacementResources(key);
837
- break;
838
- }
839
- default:
840
- break;
841
- }
842
- }
843
-
844
- function loadFullScreen(
845
- key: string,
846
- definition: PlacementDefinition,
847
- adUnitId: string,
848
- correlationId: string,
849
- ): Promise<LoadResult> {
850
- const runtime = placements.get(key)!;
851
- const format = definition.format as FullScreenFormat;
852
- const startedAt = now();
853
-
854
- return new Promise<LoadResult>(resolve => {
855
- let settled = false;
856
-
857
- const finish = (result: LoadResult) => {
858
- if (settled) {
859
- return;
860
- }
861
- settled = true;
862
- runtime.cancelLoadTimer?.();
863
- runtime.cancelLoadTimer = null;
864
- runtime.inFlightLoad = null;
865
- if (runtime.abortLoad === finish) {
866
- runtime.abortLoad = null;
867
- }
868
- resolve(result);
869
- };
870
- // `settled` is what makes this safe: a handle the provider hands back
871
- // after an abort is disposed by the `.then` below instead of adopted.
872
- runtime.abortLoad = finish;
873
-
874
- runtime.cancelLoadTimer = timers.after(resolved.timeouts.load, () => {
875
- const error = new AdsError('load_timeout', {
876
- operation: 'load',
877
- placementKey: key,
878
- format,
879
- message: `load timed out after ${resolved.timeouts.load}ms`,
880
- });
881
- events.emit({
882
- type: 'load_failed',
883
- timestamp: now(),
884
- placementKey: key,
885
- format,
886
- correlationId,
887
- error,
888
- });
889
- dispatchPlacement(key, { type: 'LOAD_FAILED', error });
890
- releasePlacementResources(key);
891
- finish({ status: 'timeout' });
892
- });
893
-
894
- engine
895
- .createFullScreen(format, adUnitId, toEngineRequest(currentPrivacy))
896
- .then(handle => {
897
- if (disposed || settled) {
898
- engine.dispose(handle);
899
- return;
900
- }
901
- runtime.handle = handle;
902
- runtime.unsubscribeEngine = engine.subscribe(handle, event => {
903
- if (event.type === 'loaded') {
904
- dispatchPlacement(key, { type: 'LOADED', atMs: now() });
905
- events.emit({
906
- type: 'loaded',
907
- timestamp: now(),
908
- placementKey: key,
909
- format,
910
- correlationId,
911
- durationMs: now() - startedAt,
912
- });
913
- finish({ status: 'loaded', correlationId });
914
- return;
915
- }
916
- if (event.type === 'failed') {
917
- const normalized = normalizeProviderError(event.cause, {
918
- operation: 'load',
919
- placementKey: key,
920
- format,
921
- });
922
- events.emit({
923
- type: 'load_failed',
924
- timestamp: now(),
925
- placementKey: key,
926
- format,
927
- correlationId,
928
- error: normalized,
929
- });
930
- dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
931
- releasePlacementResources(key);
932
- finish(
933
- normalized.code === 'no_fill'
934
- ? { status: 'no_fill' }
935
- : { status: 'failed', error: normalized },
936
- );
937
- return;
938
- }
939
- handleEngineEvent(key, definition, event);
940
- });
941
- })
942
- .catch(error => {
943
- const normalized = normalizeProviderError(error, {
944
- operation: 'load',
945
- placementKey: key,
946
- format,
947
- });
948
- events.emit({
949
- type: 'load_failed',
950
- timestamp: now(),
951
- placementKey: key,
952
- format,
953
- correlationId,
954
- error: normalized,
955
- });
956
- dispatchPlacement(key, { type: 'LOAD_FAILED', error: normalized });
957
- finish(
958
- normalized.code === 'no_fill'
959
- ? { status: 'no_fill' }
960
- : { status: 'failed', error: normalized },
961
- );
962
- });
963
- });
964
- }
965
-
966
- /**
967
- * One load, plus however many bounded retries the policy allows (FR-134).
968
- *
969
- * Off unless configured, so an upgraded 0.1.0 consumer goes through here once
970
- * and issues exactly the requests it always did. When on, only errors already
971
- * flagged `retryable` are retried — a configuration or consent failure is a
972
- * state another attempt cannot change, and retrying it would spend a billable
973
- * request on a certainty (FR-135).
974
- *
975
- * Every attempt is emitted before it is waited on, carrying the cycle's
976
- * correlation id, so the event stream can be reconciled against a bill
977
- * without inferring anything (FR-138).
978
- */
979
- async function loadWithRetry(
980
- key: string,
981
- definition: PlacementDefinition,
982
- adUnitId: string,
983
- correlationId: string,
984
- ): Promise<LoadResult> {
985
- const runtime = placements.get(key)!;
986
- const policy = resolved.retryByKey.get(key) ?? null;
987
-
988
- for (;;) {
989
- const result = await loadFullScreen(key, definition, adUnitId, correlationId);
990
- if (result.status === 'loaded' || disposed) {
991
- return result;
992
- }
993
-
994
- const error =
995
- result.status === 'failed'
996
- ? result.error
997
- : result.status === 'no_fill'
998
- ? new AdsError('no_fill', { operation: 'load', placementKey: key })
999
- : result.status === 'timeout'
1000
- ? new AdsError('load_timeout', { operation: 'load', placementKey: key })
1001
- : null;
1002
-
1003
- // A refusal — disabled, undecided, blocked, unavailable — is not a
1004
- // failure to retry. Nothing was requested, and nothing about asking
1005
- // again would change that.
1006
- if (!error || !error.retryable) {
1007
- return result;
1008
- }
1009
- // The whole gate, not just enablement: a retry is a new provider request
1010
- // and needs the same permission the first one had (FR-017).
1011
- if (!canRetry(policy, runtime.retryAttempts) || !currentGate().allowed) {
1012
- return result;
1013
- }
1014
-
1015
- runtime.retryAttempts += 1;
1016
- const delayMs = delayForAttempt(policy!, runtime.retryAttempts);
1017
- events.emit({
1018
- type: 'retry_scheduled',
1019
- timestamp: now(),
1020
- placementKey: key,
1021
- format: definition.format,
1022
- correlationId,
1023
- attempt: runtime.retryAttempts,
1024
- delayMs,
1025
- error,
1026
- });
1027
-
1028
- const waited = await new Promise<boolean>(resolve => {
1029
- runtime.cancelRetry = timers.after(delayMs, () => {
1030
- runtime.cancelRetry = null;
1031
- resolve(true);
1032
- });
1033
- const cancel = runtime.cancelRetry;
1034
- // Cancellation replaces the handle, so a disable or dispose that
1035
- // clears it resolves this wait as "do not continue" rather than
1036
- // leaving a promise nobody will settle.
1037
- runtime.cancelRetry = () => {
1038
- cancel?.();
1039
- runtime.cancelRetry = null;
1040
- resolve(false);
1041
- };
1042
- });
1043
-
1044
- if (!waited || disposed || !currentGate().allowed) {
1045
- return result;
1046
- }
1047
-
1048
- dispatchPlacement(key, { type: 'LOAD' });
1049
- }
1050
- }
1051
-
1052
- /**
1053
- * Applies a consent outcome to the gate without a second initialization.
1054
- *
1055
- * This is FR-105's "the app does not wire it by hand" in one function. When
1056
- * consent opens the gate and the instance was blocked, it initializes — and
1057
- * because `initialize` is idempotent and shares one in-flight operation, the
1058
- * total native initialization count across a whole consent flow stays at one
1059
- * (FR-022, SC-102). When consent closes the gate, requests stop and every
1060
- * pending retry is cancelled with them (FR-137).
1061
- *
1062
- * Since 0.4.0 a closed gate also moves a `ready` instance to `blocked` and
1063
- * abandons in-flight loads (FR-153). 0.3.0 left the status at `ready`, which
1064
- * was harmless while the only consent outcome that could close the gate ran
1065
- * before anything was loaded. The privacy options form changes that: a user
1066
- * can withdraw consent with a banner mounted, and a banner host keyed on
1067
- * `ready` would otherwise keep requesting. Re-granting resumes through
1068
- * `initialize`, which knows the provider is already up and does not touch it
1069
- * again (FR-154).
1070
- */
1071
- async function reevaluateAfterConsent(): Promise<void> {
1072
- if (disposed || !currentPrivacy) {
1073
- return;
1074
- }
1075
- const decision = currentGate();
1076
- if (!decision.allowed) {
1077
- cancelAllRetries();
1078
- if (decision.kind === 'blocked') {
1079
- haltPlacements({ status: 'blocked', reason: decision.reason });
1080
- setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
1081
- }
1082
- return;
1083
- }
1084
- if (init.status === 'ready' || init.status === 'initializing') {
1085
- return;
1086
- }
1087
- await instance.initialize(currentPrivacy);
1088
- }
1089
-
1090
- /**
1091
- * Answers `initialize` without a native call when the provider is already up
1092
- * (FR-022, FR-154), or returns `null` to let a real initialization proceed.
1093
- *
1094
- * The status leaves `ready` when a gate closes, but nothing is torn down, so
1095
- * the gate reopening resumes. Init-time-only flags that changed in the
1096
- * meantime fall through to a real initialization: it is the only way they can
1097
- * take effect, and it is what 0.3.0 did.
1098
- *
1099
- * Only reached once the caller has passed the whole gate, consent flow
1100
- * included, so an app that re-runs `initialize` on a consent change cannot
1101
- * reopen what the user just closed.
1102
- */
1103
- function resumeInitialized(privacy: PrivacyOptions): InitResult | null {
1104
- if (
1105
- !providerInitialized ||
1106
- !canStart(init.status) ||
1107
- requiresReinitialization(appliedPrivacy, privacy)
1108
- ) {
1109
- return null;
1110
- }
1111
- setInit(initReducer(init, { type: 'RESUME' }));
1112
- return { status: 'ready' };
1113
- }
62
+ const init = createInitController(context);
63
+ const loader = createLoader(context);
64
+ const shower = createShower(context);
1114
65
 
1115
66
  const instance: AdsInstance = {
1116
- async initialize(privacy) {
1117
- if (disposed) {
1118
- return { status: 'unavailable', reason: 'provider_missing' };
1119
- }
1120
- currentPrivacy = privacy;
1121
-
1122
- // Availability first, then enablement, then consent — the one order,
1123
- // evaluated in the one place (FR-140).
1124
- const decision = currentGate();
1125
- if (!decision.allowed && decision.kind === 'unavailable') {
1126
- setInit(initReducer(init, { type: 'UNAVAILABLE', reason: decision.reason }));
1127
- const result = { status: 'unavailable', reason: decision.reason } as const;
1128
- emitRefusal('initialize', result);
1129
- return result;
1130
- }
1131
- if (!decision.allowed && decision.kind === 'disabled') {
1132
- setInit(initReducer(init, { type: 'DISABLE' }));
1133
- const result = { status: 'disabled' } as const;
1134
- emitRefusal('initialize', result);
1135
- return result;
1136
- }
1137
- if (!decision.allowed && decision.kind === 'undecided') {
1138
- // FR-115. Nothing provider-facing happens, exactly as when
1139
- // disabled — but the status the app observes says `undecided`, so
1140
- // a banner host can hold its space instead of reclaiming it.
1141
- setInit(initReducer(init, { type: 'UNDECIDE' }));
1142
- armUndecidedWarning();
1143
- const result = { status: 'undecided' } as const;
1144
- emitRefusal('initialize', result);
1145
- return result;
1146
- }
1147
-
1148
- if (!decision.allowed && decision.kind === 'blocked') {
1149
- // FR-017, FR-158. Nothing provider-facing happens here at all — not a
1150
- // configuration call, not an initialization. The engine is untouched.
1151
- //
1152
- // `decision` is the full gate: the app's own input *and*, once the app
1153
- // has opted into it, the package's consent flow. Until 0.4.0 this
1154
- // checked the app's input alone, so a user who declined the consent
1155
- // form still got a natively initialized SDK — `load` refused, but the
1156
- // provider was up, which is exactly what the provider's own guidance
1157
- // says not to do before `canRequestAds`. The consent flow opening the
1158
- // gate later initializes it, through `reevaluateAfterConsent`.
1159
- setInit(initReducer(init, { type: 'BLOCK', reason: decision.reason }));
1160
- const result = { status: 'blocked', reason: decision.reason } as const;
1161
- emitRefusal('initialize', result);
1162
- return result;
1163
- }
1164
-
1165
- if (init.status === 'ready') {
1166
- return { status: 'ready' };
1167
- }
1168
-
1169
- const resumed = resumeInitialized(privacy);
1170
- if (resumed) {
1171
- return resumed;
1172
- }
1173
-
1174
- // FR-022, SC-005. Racing callers share one operation, so the provider
1175
- // initializes exactly once however many arrive.
1176
- if (inFlightInit) {
1177
- return inFlightInit;
1178
- }
1179
- if (!canStart(init.status)) {
1180
- return { status: 'ready' };
1181
- }
67
+ initialize: privacy => init.initialize(privacy),
68
+ updatePrivacy: privacy => init.updatePrivacy(privacy),
69
+ setEnabled: enabled => init.setEnablement(enabled ? 'enabled' : 'disabled'),
70
+ setEnablement: next => init.setEnablement(next),
71
+ setAppActive: active => shower.setAppActive(active),
72
+ consent: createConsentApi(context, () => init.reevaluateAfterConsent()),
1182
73
 
1183
- setInit(initReducer(init, { type: 'START' }));
1184
- inFlightInit = doInitialize(privacy);
1185
- return inFlightInit;
1186
- },
1187
-
1188
- async updatePrivacy(privacy) {
1189
- if (disposed) {
1190
- // The same answer initialize and load give a disposed instance.
1191
- return { status: 'unavailable', reason: 'provider_missing' };
1192
- }
1193
- if (enablement === 'undecided') {
1194
- return { status: 'undecided' };
1195
- }
1196
- if (enablement === 'disabled') {
1197
- return { status: 'disabled' };
1198
- }
1199
- if (!engine.isAvailable()) {
1200
- return { status: 'unavailable', reason: 'unsupported_platform' };
1201
- }
1202
-
1203
- // FR-020: a flag the provider only honors at initialization cannot be
1204
- // applied now. Say so, rather than accepting it and running half-old.
1205
- if (requiresReinitialization(appliedPrivacy, privacy)) {
1206
- return { status: 'requires_reinitialization' };
1207
- }
1208
-
1209
- currentPrivacy = privacy;
1210
- const gate = evaluateGate(privacy);
1211
- if (!gate.allowed) {
1212
- // FR-017, FR-137, FR-153: the same teardown as a consent outcome that
1213
- // closes the gate. Without it a retry already waiting on its backoff
1214
- // would still reach the provider after the app withdrew consent.
1215
- cancelAllRetries();
1216
- haltPlacements({ status: 'blocked', reason: gate.reason });
1217
- setInit(initReducer(init, { type: 'BLOCK', reason: gate.reason }));
1218
- return { status: 'blocked', reason: gate.reason };
1219
- }
1220
-
1221
- // FR-019: resolving consent after a block must not produce a second
1222
- // native initialization.
1223
- if (init.status === 'ready') {
1224
- return { status: 'applied' };
1225
- }
1226
- // Exhaustive on purpose (ADS-04): the old ternary mapped only `ready` and
1227
- // `blocked`, and every other outcome — `failed` included — fell through to
1228
- // `applied`. A new InitResult member now fails to compile here.
1229
- const result = await instance.initialize(privacy);
1230
- switch (result.status) {
1231
- case 'ready':
1232
- return { status: 'applied' };
1233
- case 'blocked':
1234
- case 'disabled':
1235
- case 'undecided':
1236
- case 'unavailable':
1237
- case 'failed':
1238
- return result;
1239
- default: {
1240
- const unreachable: never = result;
1241
- return unreachable;
1242
- }
1243
- }
1244
- },
74
+ getStatus: () => state.init.status,
75
+ subscribeStatus: listener => context.statusListeners.add(listener),
1245
76
 
1246
- setEnabled(next) {
1247
- return instance.setEnablement(next ? 'enabled' : 'disabled');
1248
- },
1249
-
1250
- setEnablement(next) {
1251
- if (disposed) {
1252
- return { status: 'unavailable', reason: 'provider_missing' };
1253
- }
1254
- if (enablement === next) {
1255
- return { status: 'unchanged' };
1256
- }
1257
-
1258
- const previous = enablement;
1259
- enablement = next;
1260
-
1261
- if (next === 'disabled' || next === 'undecided') {
1262
- // FR-037 and FR-125: new work is prevented and in-flight resources
1263
- // released deterministically, and moving to `undecided` from
1264
- // `enabled` follows the same rules as disabling. An ad already on
1265
- // screen is left alone — the provider offers no safe way to close
1266
- // it, and yanking it would be worse than letting it finish.
1267
- cancelAllRetries();
1268
- haltPlacements(next === 'disabled' ? { status: 'disabled' } : { status: 'undecided' });
1269
- setInit(initReducer(init, { type: next === 'disabled' ? 'DISABLE' : 'UNDECIDE' }));
1270
- if (next === 'undecided') {
1271
- armUndecidedWarning();
1272
- return { status: 'undecided' };
1273
- }
1274
- disarmUndecidedWarning();
1275
- return { status: 'disabled' };
1276
- }
1277
-
1278
- // Turning ads on. This is the path 0.1.0 got wrong: it dropped the
1279
- // status to `not_started` and returned nothing, so an app whose user
1280
- // lost their entitlement turned ads back on and then had every load
1281
- // silently refused (FR-121).
1282
- disarmUndecidedWarning();
1283
- const resumable = canResume(init.resumeTo);
1284
- setInit(initReducer(init, { type: 'ENABLE' }));
1285
-
1286
- if (init.status === 'unavailable') {
1287
- return { status: 'unavailable', reason: init.unavailableReason ?? 'unsupported_platform' };
1288
- }
1289
- if (resumable && init.status === 'ready') {
1290
- // The provider was never un-initialized, so there is genuinely
1291
- // nothing to redo — and FR-022 forbids initializing it twice.
1292
- return { status: 'resumed', initStatus: init.status };
1293
- }
1294
- if (previous === 'undecided' && init.status === 'ready') {
1295
- return { status: 'resumed', initStatus: init.status };
1296
- }
1297
- // Said out loud rather than left for the app to discover from a
1298
- // refused load. The package will not initialize implicitly: that would
1299
- // hide an asynchronous, billable operation behind a setter the caller
1300
- // may not be awaiting.
1301
- return { status: 'requires_initialization' };
1302
- },
1303
-
1304
- setAppActive(active) {
1305
- if (disposed) {
1306
- return;
1307
- }
1308
- appActive = active;
1309
- if (!active) {
1310
- // Arming happens when the provider reports an ad opened, not here:
1311
- // this event can be delivered late enough that the ad is already
1312
- // gone. All this adds is the browser case, where a background
1313
- // closely follows a click (FR-128).
1314
- adReturn.noteBackground(now());
1315
- return;
1316
- }
1317
- adReturn.noteForeground(now());
1318
- // FR-034: on return to the foreground, re-evaluate what was loaded.
1319
- // An ad that went stale while the app was away must stop reporting
1320
- // `ready` before the app offers a button that would fail on tap.
1321
- const current = now();
1322
- for (const [key, runtime] of placements) {
1323
- const definition = resolved.placements.get(key);
1324
- if (
1325
- runtime.snapshot.state === 'ready' &&
1326
- runtime.snapshot.loadedAtMs !== null &&
1327
- definition !== undefined &&
1328
- isStale(runtime.snapshot.loadedAtMs, current, validityFor(definition.format))
1329
- ) {
1330
- releasePlacementResources(key);
1331
- dispatchPlacement(key, { type: 'EXPIRE' });
1332
- }
1333
- }
1334
- },
1335
-
1336
- getStatus() {
1337
- return init.status;
1338
- },
1339
-
1340
- subscribeStatus: listener => statusListeners.add(listener),
1341
-
1342
- async load(placementKey) {
1343
- if (disposed) {
1344
- return { status: 'unavailable', reason: 'provider_missing' };
1345
- }
1346
-
1347
- const definition = resolved.placements.get(placementKey);
1348
- if (!definition) {
1349
- return {
1350
- status: 'failed',
1351
- error: new AdsError('placement_not_found', {
1352
- operation: 'load',
1353
- placementKey,
1354
- message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1355
- }),
1356
- };
1357
- }
1358
-
1359
- const gate = gateCheck();
1360
- if (!gate.ok) {
1361
- emitRefusal('load', gate.result, placementKey, definition.format);
1362
- return gate.result;
1363
- }
1364
-
1365
- if (!canRequest(init.status)) {
1366
- return {
1367
- status: 'failed',
1368
- error: new AdsError('not_ready', {
1369
- operation: 'load',
1370
- placementKey,
1371
- format: definition.format,
1372
- message: 'initialize() must complete before loading',
1373
- }),
1374
- };
1375
- }
1376
-
1377
- if (definition.format === 'banner') {
1378
- return {
1379
- status: 'failed',
1380
- error: new AdsError('format_mismatch', {
1381
- operation: 'load',
1382
- placementKey,
1383
- format: definition.format,
1384
- message: `"${placementKey}" is a banner; banners render through <AdsBanner> rather than load()`,
1385
- }),
1386
- };
1387
- }
1388
-
1389
- const runtime = placements.get(placementKey)!;
1390
-
1391
- // FR-029, SC-002's sibling guarantee: a repeat load while one is in
1392
- // flight joins it instead of creating a second provider request.
1393
- if (runtime.inFlightLoad) {
1394
- return runtime.inFlightLoad;
1395
- }
1396
-
1397
- if (runtime.snapshot.state === 'showing') {
1398
- return {
1399
- status: 'failed',
1400
- error: new AdsError('busy', { operation: 'load', placementKey }),
1401
- };
1402
- }
1403
-
1404
- if (!canLoad(runtime.snapshot.state)) {
1405
- return {
1406
- status: 'failed',
1407
- error: new AdsError('not_ready', { operation: 'load', placementKey }),
1408
- };
1409
- }
1410
-
1411
- // FR-140. Checked after the in-flight join so a caller that merely
1412
- // arrived twice for one cycle is answered by the join, not charged a
1413
- // throttle slot; after the busy and not-ready guards so a load that was
1414
- // never going to request anything does not spend one either; and before
1415
- // the correlation id and `load_started`, because a refused request is
1416
- // not a cycle and must not look like one in the event stream.
1417
- const throttled = checkThrottle(placementKey, definition.format);
1418
- if (throttled) {
1419
- return throttled;
1420
- }
1421
-
1422
- releasePlacementResources(placementKey);
1423
-
1424
- const correlationId = correlation.next();
1425
- runtime.correlationId = correlationId;
1426
- runtime.dismissed = false;
1427
- dispatchPlacement(placementKey, { type: 'LOAD' });
1428
- setPlacement(placementKey, { ...runtime.snapshot, correlationId });
1429
- events.emit({
1430
- type: 'load_started',
1431
- timestamp: now(),
1432
- placementKey,
1433
- format: definition.format,
1434
- correlationId,
1435
- });
1436
-
1437
- const adUnitId = resolved.catalog.byKey.get(placementKey)!;
1438
- runtime.retryAttempts = 0;
1439
- const promise = loadWithRetry(placementKey, definition, adUnitId, correlationId);
1440
- runtime.inFlightLoad = promise;
1441
- return promise;
1442
- },
1443
-
1444
- async show(placementKey) {
1445
- if (disposed) {
1446
- return { status: 'unavailable', reason: 'provider_missing' };
1447
- }
1448
-
1449
- const definition = resolved.placements.get(placementKey);
1450
- if (!definition) {
1451
- return {
1452
- status: 'failed',
1453
- error: new AdsError('placement_not_found', {
1454
- operation: 'show',
1455
- placementKey,
1456
- message: `unknown placement "${placementKey}"; configured keys are ${[...resolved.placements.keys()].join(', ')}`,
1457
- }),
1458
- };
1459
- }
1460
-
1461
- if (!isFullScreenFormat(definition.format)) {
1462
- return {
1463
- status: 'failed',
1464
- error: new AdsError('format_mismatch', {
1465
- operation: 'show',
1466
- placementKey,
1467
- format: definition.format,
1468
- message: `"${placementKey}" is a banner; banners are rendered, not shown`,
1469
- }),
1470
- };
1471
- }
1472
-
1473
- const gate = gateCheck();
1474
- if (!gate.ok) {
1475
- emitRefusal('show', gate.result, placementKey, definition.format);
1476
- return gate.result;
1477
- }
1478
-
1479
- // FR-028: never open an ad while the app is not in front of the user.
1480
- if (!appActive) {
1481
- return { status: 'app_inactive' };
1482
- }
1483
-
1484
- // FR-128. Only app-open is refused: the user just came back from an ad
1485
- // or from a browser an ad opened, and app-open is the format that
1486
- // would fire on exactly that return. An interstitial at the same
1487
- // moment is the app's own deliberate choice, made somewhere the
1488
- // package cannot see, so refusing it would be policy rather than
1489
- // capability.
1490
- if (definition.format === 'app_open' && adReturn.isRefusing(now())) {
1491
- return { status: 'ad_originated_return' };
1492
- }
1493
-
1494
- const runtime = placements.get(placementKey)!;
1495
-
1496
- if (runtime.snapshot.state === 'consumed') {
1497
- return { status: 'consumed' };
1498
- }
1499
-
1500
- // FR-034: a stale ad is not shown and is not reported ready.
1501
- if (
1502
- runtime.snapshot.state === 'ready' &&
1503
- runtime.snapshot.loadedAtMs !== null &&
1504
- isStale(runtime.snapshot.loadedAtMs, now(), validityFor(definition.format))
1505
- ) {
1506
- releasePlacementResources(placementKey);
1507
- dispatchPlacement(placementKey, { type: 'EXPIRE' });
1508
- return { status: 'not_ready' };
1509
- }
1510
-
1511
- if (runtime.snapshot.state !== 'ready' || !runtime.handle) {
1512
- return { status: 'not_ready' };
1513
- }
1514
-
1515
- // FR-030. Acquired before anything else can open a screen, and never
1516
- // queued: a refused show is refused now, not deferred to a moment the
1517
- // app never chose.
1518
- if (!lock.acquire(placementKey)) {
1519
- return { status: 'busy' };
1520
- }
1521
-
1522
- const correlationId = runtime.correlationId ?? correlation.next();
1523
- runtime.rewarded = definition.format === 'rewarded' ? createRewardedSession() : null;
1524
- dispatchPlacement(placementKey, { type: 'SHOW' });
1525
-
1526
- let settled = false;
1527
- const showTimeout = new Promise<ShowResult>(resolve => {
1528
- runtime.cancelShowTimer = timers.after(resolved.timeouts.show, () => {
1529
- if (settled) {
1530
- return;
1531
- }
1532
- settled = true;
1533
- const error = new AdsError('show_timeout', {
1534
- operation: 'show',
1535
- placementKey,
1536
- format: definition.format,
1537
- message: `show timed out after ${resolved.timeouts.show}ms`,
1538
- });
1539
- lock.release(placementKey);
1540
- events.emit({
1541
- type: 'display_failed',
1542
- timestamp: now(),
1543
- placementKey,
1544
- format: definition.format,
1545
- correlationId,
1546
- error,
1547
- });
1548
- dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error });
1549
- releasePlacementResources(placementKey);
1550
- resolve({ status: 'timeout' });
1551
- });
1552
- });
1553
-
1554
- const attempt = engine
1555
- .show(runtime.handle)
1556
- .then<ShowResult>(() => {
1557
- settled = true;
1558
- runtime.cancelShowTimer?.();
1559
- runtime.cancelShowTimer = null;
1560
- return { status: 'shown', correlationId };
1561
- })
1562
- .catch<ShowResult>(error => {
1563
- settled = true;
1564
- runtime.cancelShowTimer?.();
1565
- runtime.cancelShowTimer = null;
1566
- const normalized = normalizeProviderError(error, {
1567
- operation: 'show',
1568
- placementKey,
1569
- format: definition.format,
1570
- });
1571
- lock.release(placementKey);
1572
- events.emit({
1573
- type: 'display_failed',
1574
- timestamp: now(),
1575
- placementKey,
1576
- format: definition.format,
1577
- correlationId,
1578
- error: normalized,
1579
- });
1580
- dispatchPlacement(placementKey, { type: 'SHOW_FAILED', error: normalized });
1581
- releasePlacementResources(placementKey);
1582
- return { status: 'failed', error: normalized };
1583
- });
1584
-
1585
- return Promise.race([attempt, showTimeout]);
1586
- },
77
+ load: placementKey => loader.load(placementKey),
78
+ show: placementKey => shower.show(placementKey),
1587
79
 
1588
80
  getPlacementState(placementKey) {
1589
81
  if (!resolved.placements.has(placementKey)) {
1590
82
  return 'unavailable';
1591
83
  }
1592
- if (!engine.isAvailable() || enablement === 'disabled') {
84
+ if (!engine.isAvailable() || state.enablement === 'disabled') {
1593
85
  return 'unavailable';
1594
86
  }
1595
87
  // Reported distinctly from `unavailable` so the app can choose to hold
1596
- // the banner's space rather than reclaim it. That choice is the entire
1597
- // reason the third state is worth having (FR-116).
1598
- if (enablement === 'undecided') {
88
+ // the banner's space rather than reclaim it (FR-116).
89
+ if (state.enablement === 'undecided') {
1599
90
  return 'undecided';
1600
91
  }
1601
- return placements.get(placementKey)?.snapshot.state ?? 'idle';
92
+ return context.placements.get(placementKey)?.snapshot.state ?? 'idle';
1602
93
  },
1603
94
 
1604
95
  subscribePlacement(key, listener) {
1605
- const runtime = placements.get(key);
1606
- if (!runtime) {
1607
- return () => undefined;
1608
- }
1609
- return runtime.listeners.add(listener);
96
+ return context.placements.get(key)?.listeners.add(listener) ?? (() => undefined);
1610
97
  },
1611
98
 
1612
- subscribeEvents(listener) {
1613
- return events.subscribe(listener);
1614
- },
99
+ subscribeEvents: listener => events.subscribe(listener),
1615
100
 
1616
- getSubstitutedPlacements() {
1617
- return resolved.catalog.substitutedKeys;
1618
- },
1619
-
1620
- getIgnoredConsentTools() {
1621
- return resolved.ignoredConsentTools;
1622
- },
1623
-
1624
- consent: {
1625
- async requestInfoUpdate() {
1626
- if (disposed) {
1627
- return consent;
1628
- }
1629
- // Marks the flow as in use. Until this is called the gate sees no
1630
- // consent input at all, which is how an app that supplies its own
1631
- // gate keeps 0.1.0 behavior without opting out of anything
1632
- // (FR-102).
1633
- consentStarted = true;
1634
- if (resolved.consentTesting?.resetOnStart) {
1635
- try {
1636
- engine.consent.reset();
1637
- } catch {
1638
- // A reset that fails must not abort the update; the flow
1639
- // continues against whatever state is stored.
1640
- }
1641
- }
1642
- const next = await runConsentStep(
1643
- () =>
1644
- engine.consent.requestInfoUpdate(
1645
- buildConsentOptions(
1646
- resolved.consentTesting,
1647
- currentPrivacy?.underAgeOfConsent,
1648
- resolved.testDeviceIds,
1649
- ),
1650
- ),
1651
- 'consent information update failed',
1652
- );
1653
- if (disposed) {
1654
- return consent;
1655
- }
1656
- setConsent(next);
1657
- await reevaluateAfterConsent();
1658
- return next;
1659
- },
1660
-
1661
- async showFormIfRequired() {
1662
- if (disposed) {
1663
- return consent;
1664
- }
1665
- consentStarted = true;
1666
- if (!needsForm(consent) || privacyOptionsFormOpen) {
1667
- // Nothing to present. Not an error: a region that does not
1668
- // require consent is the normal case, and showing a form there
1669
- // is exactly what FR-107 forbids the package from deciding.
1670
- // Nor is a privacy options form already on screen — one
1671
- // provider form at a time.
1672
- return consent;
1673
- }
1674
- consentFormsOpen += 1;
1675
- let next: ConsentSnapshot;
1676
- try {
1677
- next = await runConsentStep(
1678
- () => engine.consent.showFormIfRequired(),
1679
- 'consent form failed',
1680
- );
1681
- } finally {
1682
- consentFormsOpen -= 1;
1683
- }
1684
- if (disposed) {
1685
- return consent;
1686
- }
1687
- setConsent(next);
1688
- await reevaluateAfterConsent();
1689
- return next;
1690
- },
1691
-
1692
- async showPrivacyOptionsForm() {
1693
- // The refusals, most immovable first — the same reading order as
1694
- // the request gates, so the answer is the most useful one rather
1695
- // than the first one checked.
1696
- if (disposed) {
1697
- return { status: 'unavailable', reason: 'provider_missing' };
1698
- }
1699
- if (!engine.isAvailable()) {
1700
- return { status: 'unavailable', reason: 'unsupported_platform' };
1701
- }
1702
- if (privacyOptionsFormOpen || consentFormsOpen > 0) {
1703
- return { status: 'busy' };
1704
- }
1705
- if (consent.privacyOptionsRequirement === 'unknown') {
1706
- return { status: 'info_update_required' };
1707
- }
1708
- if (consent.privacyOptionsRequirement !== 'required') {
1709
- // The provider's verdict, not a region guess of ours (FR-150).
1710
- return { status: 'not_required' };
1711
- }
1712
-
1713
- privacyOptionsFormOpen = true;
1714
- let next: ConsentSnapshot;
1715
- let failure: AdsError | null = null;
1716
- try {
1717
- next = normalizeConsent(await engine.consent.showPrivacyOptionsForm());
1718
- } catch (cause) {
1719
- failure = toConsentError(cause, 'privacy options form failed');
1720
- // The form did not complete, so the stored decision is the
1721
- // provider's to report — ask it rather than assume. Collapsing to
1722
- // `error` the way a failed first-time form does would revoke a
1723
- // consent the user never changed: that form runs while the gate is
1724
- // already closed, this one usually while it is open. If the
1725
- // provider cannot answer either, the gate closes; a failure is
1726
- // never consent (FR-106). Nothing is asked of a disposed instance.
1727
- next = disposed
1728
- ? consent
1729
- : await runConsentStep(
1730
- () => engine.consent.getInfo(),
1731
- 'consent information could not be read after the privacy options form failed',
1732
- );
1733
- } finally {
1734
- privacyOptionsFormOpen = false;
1735
- }
1736
- // dispose() during the form: the form is the provider's and stays
1737
- // until the user closes it, but its outcome reaches nobody (FR-157).
1738
- if (disposed) {
1739
- return { status: 'unavailable', reason: 'provider_missing' };
1740
- }
1741
- setConsent(next);
1742
- await reevaluateAfterConsent();
1743
- return failure ? { status: 'failed', error: failure } : { status: 'shown', consent: next };
1744
- },
1745
-
1746
- getState() {
1747
- return consent;
1748
- },
1749
-
1750
- subscribe: listener => consentListeners.add(listener),
1751
-
1752
- async reset() {
1753
- if (disposed) {
1754
- return;
1755
- }
1756
- // Config validation already stripped the testing tools in
1757
- // production, so this is inert there without a check here (FR-113).
1758
- if (!resolved.consentTesting) {
1759
- return;
1760
- }
1761
- try {
1762
- engine.consent.reset();
1763
- } catch {
1764
- // Nothing to report: the stored state is the provider's, and a
1765
- // failed reset leaves it as it was.
1766
- }
1767
- setConsent(INITIAL_CONSENT);
1768
- },
1769
- },
1770
-
1771
- internals: {
1772
- resolveBanner(placementKey) {
1773
- const definition = resolved.placements.get(placementKey);
1774
- if (!definition) {
1775
- return {
1776
- ok: false,
1777
- result: {
1778
- status: 'failed',
1779
- error: new AdsError('placement_not_found', {
1780
- operation: 'load',
1781
- placementKey,
1782
- message: `unknown placement "${placementKey}"`,
1783
- }),
1784
- },
1785
- };
1786
- }
1787
- if (definition.format !== 'banner') {
1788
- return {
1789
- ok: false,
1790
- result: {
1791
- status: 'failed',
1792
- error: new AdsError('format_mismatch', {
1793
- operation: 'load',
1794
- placementKey,
1795
- format: definition.format,
1796
- message: `"${placementKey}" is not a banner placement`,
1797
- }),
1798
- },
1799
- };
1800
- }
1801
- const gate = gateCheck();
1802
- if (!gate.ok) {
1803
- return { ok: false, result: gate.result };
1804
- }
1805
- if (!canRequest(init.status)) {
1806
- return { ok: false, result: { status: 'blocked', reason: 'consent_required' } };
1807
- }
1808
- // The throttle is deliberately **not** consulted here. It cannot be
1809
- // decided from configuration alone: a fresh mount must be throttled
1810
- // (that is the navigation burst the feature exists for) while a width
1811
- // re-adaptation of an already-mounted banner must not be (that is
1812
- // rotation, and refusing it would leave the user with no banner for a
1813
- // minute). Only the host knows which it is, so the host calls
1814
- // `commitBannerRequest` (FR-140).
1815
- return {
1816
- ok: true,
1817
- adUnitId: resolved.catalog.byKey.get(placementKey)!,
1818
- correlationId: correlation.next(),
1819
- personalizedAds: toEngineRequest(currentPrivacy).personalizedAds,
1820
- };
1821
- },
1822
- commitBannerRequest(placementKey, correlationId) {
1823
- if (!resolved.placements.has(placementKey)) {
1824
- return null;
1825
- }
1826
- const minRequestIntervalMs = resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1827
- const decision = requestThrottle.tryAcquire(
1828
- placementKey,
1829
- minRequestIntervalMs,
1830
- correlationId,
1831
- );
1832
- // Deliberately silent: the host calls this during render, because the
1833
- // provider component requests as part of that same render and an effect
1834
- // would be too late to stop it. Emitting an event or setting placement
1835
- // state here would mean mutating during another component's render, so
1836
- // the refusal is only returned — the host reports it from its effect.
1837
- return decision.allowed
1838
- ? null
1839
- : { status: 'throttled', retryAfterMs: decision.retryAfterMs };
1840
- },
1841
- throttleFloorFor(placementKey) {
1842
- return resolved.minRequestIntervalByKey.get(placementKey) ?? 0;
1843
- },
1844
- emit(event) {
1845
- events.emit(event);
1846
- },
1847
- setPlacementState(key, state) {
1848
- const runtime = placements.get(key);
1849
- if (runtime) {
1850
- setPlacement(key, { ...runtime.snapshot, state });
1851
- }
1852
- },
1853
- isReady() {
1854
- return permitsRequests(enablement) && engine.isAvailable() && canRequest(init.status);
1855
- },
1856
- nextCorrelationId() {
1857
- return correlation.next();
1858
- },
1859
- timerCount() {
1860
- return timers.size();
1861
- },
1862
- },
101
+ /** FR-011a — substitution is observable state, not only a log line. */
102
+ getSubstitutedPlacements: () => resolved.catalog.substitutedKeys,
103
+ /** FR-113 — the same treatment for consent tools dropped in production. */
104
+ getIgnoredConsentTools: () => resolved.ignoredConsentTools,
1863
105
 
1864
106
  dispose() {
1865
107
  // FR-005: idempotent. A second call is a no-op, not an error.
1866
- if (disposed) {
108
+ if (state.disposed) {
1867
109
  return;
1868
110
  }
1869
- disposed = true;
1870
- cancelAllRetries();
1871
- disarmUndecidedWarning();
1872
- for (const [key, runtime] of placements) {
111
+ state.disposed = true;
112
+ cancelAllRetries(context);
113
+ disarmUndecidedWarning(context);
114
+ for (const [key, runtime] of context.placements) {
1873
115
  const abort = runtime.abortLoad;
1874
- releasePlacementResources(key);
116
+ context.releasePlacementResources(key);
1875
117
  runtime.listeners.clear();
1876
118
  // Settled rather than left hanging, with the answer a `load` after
1877
- // dispose gets (FR-159). No refusal event goes with it: nothing is
1878
- // emitted after dispose (FR-035).
119
+ // dispose gets (FR-159). No refusal event: nothing is emitted after
120
+ // dispose (FR-035).
1879
121
  abort?.({ status: 'unavailable', reason: 'provider_missing' });
1880
122
  }
1881
- timers.clearAll();
1882
- requestThrottle.clear();
1883
- lock.reset();
1884
- adReturn.reset();
1885
- statusListeners.clear();
1886
- consentListeners.clear();
123
+ context.timers.clearAll();
124
+ context.requestThrottle.clear();
125
+ context.lock.reset();
126
+ context.adReturn.reset();
127
+ context.statusListeners.clear();
128
+ context.consentListeners.clear();
1887
129
  events.dispose();
1888
- inFlightInit = null;
130
+ state.inFlightInit = null;
1889
131
  },
1890
132
  };
1891
133
 
134
+ attachInternals(instance, createInternals(context));
1892
135
  return instance;
1893
136
  }
1894
137