@uniweb/core 0.9.0 → 0.10.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.
- package/package.json +1 -1
- package/src/services.js +48 -12
- package/src/tracker.js +89 -36
package/package.json
CHANGED
package/src/services.js
CHANGED
|
@@ -180,26 +180,62 @@ export function resolveService(website, name) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/**
|
|
183
|
-
* Read a service's
|
|
183
|
+
* Read a service's options, filling each key from the first tier that declares
|
|
184
|
+
* it — **the site's value wins per key, and the host fills the gaps.**
|
|
184
185
|
*
|
|
185
186
|
* `resolveService` answers *where*; this answers *with what options*. Only the
|
|
186
187
|
* object form carries any — a shorthand string is an address and nothing else.
|
|
187
|
-
* Used by `tracking:` for `consent:`; a future service with its own options
|
|
188
|
-
* reads them the same way rather than inventing a second lookup.
|
|
189
188
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
189
|
+
* ## ⛔ Why per-key rather than all-or-nothing
|
|
190
|
+
*
|
|
191
|
+
* This used to return the site's object whole whenever the site declared
|
|
192
|
+
* *anything*, so a single authored key hid every option the host offered. That
|
|
193
|
+
* put the two readers in this file on different rules, and the disagreement was
|
|
194
|
+
* not cosmetic:
|
|
195
|
+
*
|
|
196
|
+
* - `resolveService` already falls through **per key** — a site declaration
|
|
197
|
+
* carrying no `endpoint` lets the host's endpoint answer.
|
|
198
|
+
* - `readServiceOptions` fell through **not at all**.
|
|
199
|
+
*
|
|
200
|
+
* ⇒ A site declaring only `tracking: { tags: [...] }` therefore kept sending to
|
|
201
|
+
* the **host's** endpoint while discarding the **host's** `consent` setting —
|
|
202
|
+
* using someone's collector while ignoring their gate. Not a corner case: it is
|
|
203
|
+
* what an operator gets by turning on a third-party tag while their host
|
|
204
|
+
* supplies the collector.
|
|
205
|
+
*
|
|
206
|
+
* One rule now covers both readers, and it is the one a reader of two-tier
|
|
207
|
+
* config already expects: the more specific tier wins where it speaks, and says
|
|
208
|
+
* nothing where it is silent.
|
|
209
|
+
*
|
|
210
|
+
* ⚖️ **Consequence worth stating, because it decides a question that would
|
|
211
|
+
* otherwise need its own rule:** a host's `consent` applies only when the site
|
|
212
|
+
* declared none. That is the host *filling a gap*, never overriding an
|
|
213
|
+
* operator's decision — so there is no "most restrictive wins" special case,
|
|
214
|
+
* and an operator who wants no gate on a host that asks for one writes
|
|
215
|
+
* `consent: none` and is done.
|
|
216
|
+
*
|
|
217
|
+
* ⚠️ **The merge is shallow and deliberately so.** Keys replace, they do not
|
|
218
|
+
* combine: a site's `tags` replaces a host's rather than concatenating with it.
|
|
219
|
+
* Combining would make the result depend on what a host happens to offer, which
|
|
220
|
+
* is precisely the unpredictability a site's own config should not have.
|
|
192
221
|
*
|
|
193
222
|
* @param {object} website
|
|
194
223
|
* @param {string} name
|
|
195
|
-
* @returns {object} the
|
|
224
|
+
* @returns {object} the effective options, or `{}` when no tier declares any
|
|
196
225
|
*/
|
|
197
226
|
export function readServiceOptions(website, name) {
|
|
198
227
|
const config = website?.config
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
228
|
+
return { ...asOptions(config?.services?.[name]), ...asOptions(config?.[name]) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A declaration contributes options only in its object form. A string is an
|
|
233
|
+
* address, an array is malformed, and neither carries a key worth spreading.
|
|
234
|
+
*
|
|
235
|
+
* @param {*} declaration
|
|
236
|
+
* @returns {object}
|
|
237
|
+
*/
|
|
238
|
+
function asOptions(declaration) {
|
|
239
|
+
if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)) return {}
|
|
240
|
+
return declaration
|
|
205
241
|
}
|
package/src/tracker.js
CHANGED
|
@@ -160,8 +160,11 @@ export default class Tracker {
|
|
|
160
160
|
// 'granted' | 'denied' | 'pending'. Without a consent requirement the
|
|
161
161
|
// operator's act of declaring a destination IS the decision, and the
|
|
162
162
|
// framework does not presume a jurisdiction on their behalf.
|
|
163
|
+
// ⛔ The requirement is consumed HERE and not stored. A `consentRequired`
|
|
164
|
+
// field was kept alongside and read by nothing — dead instance state in a
|
|
165
|
+
// class every site loads, which is what `destroy()` and its three fields
|
|
166
|
+
// were removed for. The starting status is the whole of what the flag means.
|
|
163
167
|
this.consent = options.consentRequired ? 'pending' : 'granted'
|
|
164
|
-
this.consentRequired = !!options.consentRequired
|
|
165
168
|
|
|
166
169
|
this.queue = []
|
|
167
170
|
this.acquisition = null
|
|
@@ -180,11 +183,17 @@ export default class Tracker {
|
|
|
180
183
|
// reports three times.
|
|
181
184
|
this.currentPath = null
|
|
182
185
|
|
|
183
|
-
this.flushIntervalId = null
|
|
184
|
-
this.onPageHide = null
|
|
185
|
-
this.onVisibilityChange = null
|
|
186
186
|
this.framed = detectFramed()
|
|
187
187
|
|
|
188
|
+
// Called once when consent moves to granted, and never otherwise. The
|
|
189
|
+
// runtime uses it to load a site's declared third-party tags at the moment
|
|
190
|
+
// they become permitted; nothing in core knows or cares what it does.
|
|
191
|
+
//
|
|
192
|
+
// ⛔ Declared HERE because the instance is sealed below — an assignment to
|
|
193
|
+
// an undeclared property throws in module code, and the caller assigns this
|
|
194
|
+
// after construction. Same reason `Uniweb.defaultInsets` is pre-declared.
|
|
195
|
+
this.onGranted = null
|
|
196
|
+
|
|
188
197
|
if (isBrowser && this.isEnabled()) {
|
|
189
198
|
this.acquisition = captureAcquisition()
|
|
190
199
|
// Minted even when consent is pending: events buffered before the visitor
|
|
@@ -200,14 +209,30 @@ export default class Tracker {
|
|
|
200
209
|
}
|
|
201
210
|
|
|
202
211
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
212
|
+
* Whether this document is one where the site's telemetry should run at all —
|
|
213
|
+
* a real visit in a browser, rather than a server render or a framed
|
|
214
|
+
* authoring preview. Says nothing about whether anything is *configured*.
|
|
215
|
+
*
|
|
216
|
+
* Split out from `isEnabled()` because a second consumer needs exactly this
|
|
217
|
+
* half: the runtime loads a site's declared third-party tags, which have no
|
|
218
|
+
* endpoint of ours to check but must be suppressed in the same contexts and
|
|
219
|
+
* for the same reason. One predicate, so the two cannot drift.
|
|
220
|
+
*
|
|
221
|
+
* @returns {boolean}
|
|
222
|
+
*/
|
|
223
|
+
isLiveDocument() {
|
|
224
|
+
return isBrowser && !this.framed
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Enabled means: a destination exists, and this is a live document. Consent
|
|
229
|
+
* is checked separately — a consent-pending tracker is *enabled* and
|
|
230
|
+
* buffering, which is a different state from off.
|
|
206
231
|
*
|
|
207
232
|
* @returns {boolean}
|
|
208
233
|
*/
|
|
209
234
|
isEnabled() {
|
|
210
|
-
return !!this.endpoint &&
|
|
235
|
+
return !!this.endpoint && this.isLiveDocument()
|
|
211
236
|
}
|
|
212
237
|
|
|
213
238
|
/** @returns {'granted'|'denied'|'pending'} */
|
|
@@ -223,15 +248,44 @@ export default class Tracker {
|
|
|
223
248
|
* decision, and the views that preceded the click are not lost. Denying
|
|
224
249
|
* discards the buffer and stops accepting.
|
|
225
250
|
*
|
|
251
|
+
* ⛔ **Recording the decision is NOT gated on `isEnabled()`, deliberately.**
|
|
252
|
+
* Consent is *the visitor's answer*; enablement is *whether we have anywhere
|
|
253
|
+
* to send*. Two different questions, and conflating them meant a decision
|
|
254
|
+
* could not be recorded **when no destination resolved** — benign while our
|
|
255
|
+
* own queue is the only thing gated on consent, a correctness bug the moment
|
|
256
|
+
* anything else is.
|
|
257
|
+
*
|
|
258
|
+
* The same conflation suppressed recording inside a framed document. That
|
|
259
|
+
* suppression exists so an authoring session cannot inflate a site's own
|
|
260
|
+
* numbers (see `detectFramed`), and it belongs on the *sending*: with
|
|
261
|
+
* `consentRequired` the status starts `'pending'` and could not move at all,
|
|
262
|
+
* so a banner following the documented pattern would render and then never
|
|
263
|
+
* dismiss.
|
|
264
|
+
*
|
|
265
|
+
* Nothing is sent as a result: `flush()` keeps its own `isEnabled()` guard,
|
|
266
|
+
* so a disabled tracker still transmits nothing no matter what is recorded
|
|
267
|
+
* here. This changes what the tracker *remembers*, never what it *emits*.
|
|
268
|
+
*
|
|
226
269
|
* @param {boolean} granted
|
|
227
270
|
*/
|
|
228
271
|
setConsent(granted) {
|
|
229
|
-
|
|
272
|
+
const wasGranted = this.consent === 'granted'
|
|
230
273
|
this.consent = granted ? 'granted' : 'denied'
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
} else {
|
|
274
|
+
|
|
275
|
+
if (!granted) {
|
|
234
276
|
this.queue = []
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.flush()
|
|
281
|
+
|
|
282
|
+
// Fires on the TRANSITION only, so a component calling grant() twice does
|
|
283
|
+
// not load a site's tags twice. The callback is cleared as it runs: this is
|
|
284
|
+
// a one-time permission, not a subscription.
|
|
285
|
+
if (!wasGranted && this.onGranted) {
|
|
286
|
+
const notify = this.onGranted
|
|
287
|
+
this.onGranted = null
|
|
288
|
+
notify()
|
|
235
289
|
}
|
|
236
290
|
}
|
|
237
291
|
|
|
@@ -330,35 +384,34 @@ export default class Tracker {
|
|
|
330
384
|
})
|
|
331
385
|
}
|
|
332
386
|
|
|
333
|
-
/**
|
|
387
|
+
/**
|
|
388
|
+
* Both of these are armed once, for the life of the document, and are never
|
|
389
|
+
* detached — so neither the interval id nor the handler references are kept.
|
|
390
|
+
*
|
|
391
|
+
* ⛔ **There is deliberately no `destroy()`.** One shipped, was called by
|
|
392
|
+
* nothing, and cost 334 bytes minified in a package **every site loads
|
|
393
|
+
* whether it tracks or not** (`@uniweb/core` is not tree-shaken — the
|
|
394
|
+
* singleton's constructor holds a `Tracker`, so the class can never be
|
|
395
|
+
* dropped). Removing it took three instance fields with it, since they
|
|
396
|
+
* existed only to serve it.
|
|
397
|
+
*
|
|
398
|
+
* ⭐ The precedent is the thing this class replaced: `analytics.js` was dead
|
|
399
|
+
* code that shipped to every site for months because nobody deleted it.
|
|
400
|
+
* Adding a never-called teardown method would have been the same mistake at
|
|
401
|
+
* smaller scale. If a lifecycle that needs teardown ever appears, it arrives
|
|
402
|
+
* *with* its call site — which is the order that keeps this honest.
|
|
403
|
+
*
|
|
404
|
+
* @private
|
|
405
|
+
*/
|
|
334
406
|
armFlushInterval() {
|
|
335
|
-
|
|
407
|
+
setInterval(() => this.flush(), this.flushInterval)
|
|
336
408
|
}
|
|
337
409
|
|
|
338
410
|
/** @private */
|
|
339
411
|
armUnloadHandlers() {
|
|
340
|
-
|
|
341
|
-
|
|
412
|
+
window.addEventListener('pagehide', () => this.flush(true))
|
|
413
|
+
window.addEventListener('visibilitychange', () => {
|
|
342
414
|
if (document.visibilityState === 'hidden') this.flush(true)
|
|
343
|
-
}
|
|
344
|
-
window.addEventListener('pagehide', this.onPageHide)
|
|
345
|
-
window.addEventListener('visibilitychange', this.onVisibilityChange)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/** Stop the interval, detach listeners, and send what is left. */
|
|
349
|
-
destroy() {
|
|
350
|
-
if (this.flushIntervalId) {
|
|
351
|
-
clearInterval(this.flushIntervalId)
|
|
352
|
-
this.flushIntervalId = null
|
|
353
|
-
}
|
|
354
|
-
if (isBrowser) {
|
|
355
|
-
if (this.onPageHide) window.removeEventListener('pagehide', this.onPageHide)
|
|
356
|
-
if (this.onVisibilityChange) {
|
|
357
|
-
window.removeEventListener('visibilitychange', this.onVisibilityChange)
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
this.onPageHide = null
|
|
361
|
-
this.onVisibilityChange = null
|
|
362
|
-
this.flush(true)
|
|
415
|
+
})
|
|
363
416
|
}
|
|
364
417
|
}
|