-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsmartpixel.ts
369 lines (317 loc) · 13 KB
/
smartpixel.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
// SPDX-License-Identifier: MIT-0
// Copyright 2024 Amazon
/// <reference path="types.ts"/>
/* Settings for the Options object:
* fullReferrer: boolean (defaults to false)
opt-in to passing the full page url to TQ, otherwise only domain
* disabledFeatures: Array of strings (defaults to null)
Permissions Policy features to disable
*/
type SmartPixelOptions = {
fullReferrer?: boolean;
disabledFeatures?: string[];
};
const smartpixel: (url: string, options: SmartPixelOptions) => void = (
(window, document, fetch) => {
// added to the option object
const VERSION = '1.2.6';
// returns true with given probability
const sample = (p: number): boolean => {
try {
return crypto.getRandomValues(new Uint32Array(1))[0] / 0xffffffff < p;
} catch (_) {
return false; // anicent browser, just return false
}
}
// encodes an object as a string that can be used as a uri parameter
const encodeObject = (obj: any): EncodedURIComponent => {
// first, we need the object as a utf8-encoded json string
const string = unescape(encodeURIComponent(JSON.stringify(obj)));
// return it base64 encoded
return btoa(string).replace(/\//g,'_').replace(/\+/g,'-').replace(/=/g,'') as EncodedURIComponent;
};
const addUrlParameter = (urlParts: RegExpExecArray, key: EncodedURIComponent, value: EncodedURIComponent): void => {
urlParts[3] += (urlParts[3] ? '&' : '?') + '_' + key + '=' + value;
};
// wrap a function to make it run once only
const once = <F extends AnyFn>(fn: F): VoidWrapper<F> => {
return function() {
fn.apply(null, Array.prototype.slice.call(arguments));
(fn as any) = () => {}; // update the function to do nothing
};
};
let stopEstablish = false, messageQueue: RegExpExecArray[] = [], port = {} as MessagePort;
// returns a function that sends its argument to the target iframe
const messageSender = (target: HTMLIFrameElement) => {
// attempt to establish a connection with the target frame
const tryEstablish = () => {
// stop once the channel is open
if (!stopEstablish) {
// clean up event listener on old port so we don't leak memory
port.onmessage = void(0);
// create new message channel
const mc = new MessageChannel();
// save a reference to port1 for later
port = mc.port1;
// set up message handler for reply
port.onmessage = once((evt) => {
// on the first message, mark port open and send all queued messages
stopEstablish = true;
while (messageQueue.length) {
port.postMessage(messageQueue.shift().join());
}
});
// try to transfer port2 to target frame
try {
target.contentWindow.postMessage(null, '*', [mc.port2]);
} catch(e) {}
// periodically retry until port is open
setTimeout(tryEstablish, 100);
}
};
// start trying to establish the connection
tryEstablish();
// return a function to queue/send messages to the target frame
return (urlParts: RegExpExecArray) => {
if (stopEstablish) {
port.postMessage(urlParts.join(''));
} else {
messageQueue.push(urlParts);
}
};
};
// create a new element to be appended to target
const createElement: ElementCreator = <K,>(target, type) => {
return (target.ownerDocument || document).createElement(type);
};
// style an element to be invisible and not affect layout
const hide = (element: HTMLIFrameElement) => {
const style = element.style;
style.display = 'none';
style.visibility = 'hidden';
style.opacity = '0';
style.height = style.width = '1px';
style.position = 'absolute';
style.left = '-9999px';
// avoid issues with screen readers
element.setAttribute('aria-hidden', 'true');
};
let pageLoaded = false;
const onloadQueue: NoArgsFn[] = [];
window.addEventListener('load', once(() => {
// run all tasks from the queue (FIFO order)
while (onloadQueue.length) {
setTimeout(onloadQueue.shift(), 0);
}
}));
// run a function when the page is fully loaded, returns current state
const whenLoaded = (task: NoArgsFn) => {
if (pageLoaded = (pageLoaded || document.readyState === 'complete')) {
setTimeout(task, 0); // trigger now if page is already fully loaded
} else {
onloadQueue.push(task);
}
return pageLoaded;
};
// preload a url - starts resource download without blocking page load event
const preload = (url: URLString, as: LinkAs, onload: AnyFn) => {
// url load triggers after page page load event regardless
if (!whenLoaded(onload)) {
// do a preload if the page load event hadn't already been triggered
const parent = (document.head || document.documentElement || document);
let link = createElement(parent, 'link');
link.onload = link.onerror = once((ev) => {
if (ev.type === 'load' && onload) { onload(); }
// cleanup
try { parent.removeChild(link); } catch(e) {}
link = void(0);
});
// feature test for `preload`
if (!link.relList || !link.relList.supports('preload')) {
// trigger a synthetic error event
link.dispatchEvent(new Event('error'));
} else {
// trigger a synthetic error event after timeout
setTimeout(() => {
link.dispatchEvent(new Event('error'));
}, 10*1000 /* 10 second timeout */);
// configure the link element
link.rel = 'preload';
link.as = as;
link.href = url;
// add the link element to the DOM so that download can start
parent.appendChild(link);
}
}
};
let loadIFrame = (urlParts: RegExpExecArray, options: SmartPixelOptions) => {
const url = urlParts.join('') as URLString;
// settings
const target = (document.body || document.documentElement || document);
const allowedFeatures = [];
let disabledFeatures = options.disabledFeatures || null;
// create the iframe
const iframe = createElement(target, 'iframe');
// set up sandbox
if ('sandbox' in iframe) {
const sandboxAllowed = [
"scripts", // needed for javascript execution
"modals", // needed for the 'beforeunload' event
"same-origin", // needed for some checks, gives no access to parent page
"forms" // may be used by future checks
];
// set the allow flags
for (let i = 0; i < sandboxAllowed.length; ++i) {
try { // sandbox.add throws errors for unknown flags
iframe.sandbox.add('allow-' + sandboxAllowed[i]);
} catch (e) { }
}
}
// CONFIG: set up permissions policy.
// This is disabled by default, and may cause issues depending on which
// features are restricted.
if (disabledFeatures) {
// warn if invalid data passed in the disabledFeatures option
if (!Array.isArray(disabledFeatures)) {
console.warn('Invalid value for options.disabledFeatures!');
disabledFeatures = [];
}
// check if permissions policy is supported and features should be disabled
if (disabledFeatures.length && 'allow' in iframe && iframe.featurePolicy && iframe.featurePolicy.features) {
// Permissions Policy (formerly "Feature Policy") allows for various
// browser features to be disabled selectively.
// regular expression of required features that can't be disabled
// TQ requires:
// * execution-while-out-of-viewport (needed because iframe is never in viewport)
// * execution-while-not-rendered (needed because iframe is never rendered)
// * ch-* ("client hints" - used to detect misrepresentation)
const requiredFeatureRegexp = /^(execution-while|ch)-.*/;
// filter disabled features, and allow all other available features
const tmp = new Set(iframe.featurePolicy.features());
for (let i = 0, _len = disabledFeatures.length; i < _len; ++i) {
if (tmp.has(disabledFeatures[i])) {
// warn of attempts to disable required features
if (requiredFeatureRegexp.test(disabledFeatures[i])) {
console.warn("Ignored request to disable required feature '" + disabledFeatures[i] + "'.");
} else {
allowedFeatures.push(disabledFeatures[i] + " 'none'");
}
}
}
// actually set the allow property
iframe.allow = allowedFeatures.join('; ');
}
}
// CONFIG: opt-in to full referrer
if (options.fullReferrer && 'referrerPolicy' in iframe) {
iframe.referrerPolicy = 'no-referrer-when-downgrade';
}
preload(url, 'document', once(() => {
// make the iframe invisible, then add it to the DOM
hide(iframe);
iframe.src = url;
// add iframe to DOM so it loads - deferred as a performance optimization
target.appendChild(iframe);
// switch to fallback if iframe doesn't load for a while
setTimeout(() => {
// stop trying to establish comms with iframe if we haven't succeeded
if (!stopEstablish) {
stopEstablish = true;
// further impressions should use loadFallback
reportImpression = loadFallback;
// use fallback method for queued messages
while (messageQueue.length) {
reportImpression(messageQueue.shift(), options);
}
}
}, 10*1000 /* 10 second timeout */);
}));
// further impressions should be sent as messages to the iframe
reportImpression = messageSender(iframe);
};
let loadFallback = (urlParts: RegExpExecArray, options: SmartPixelOptions) => {
// after the first impression, add a reference to the first impression URL
if (impCount > 1) {
addUrlParameter(urlParts, 'fiu' as EncodedURIComponent, firstImpressionUrl);
}
urlParts[2] = '/noop';
const url = urlParts.join('') as URLString;
// use fetch if available and not obviously monkeypatched, otherwise pixel
if (fetch && /native code/.test(fetch as any as string)) {
const fetchOptions: RequestInitExt = {
priority: "low",
mode: "no-cors", // don't error if server doesn't send cors headers
keepalive: true, // finish request even after page closes
headers: { accept: 'image/*,*/*;q=0.1' }
};
// CONFIG: opt-in to full referrer
if (options.fullReferrer) {
fetchOptions.referrerPolicy = 'no-referrer-when-downgrade';
}
// no attempt is made to read the result, but switch to pixel on failure
fetch(url, fetchOptions).then(() => {}, () => {
fetch = void(0);
loadFallback(urlParts, options);
});
} else {
let pixel = new Image();
// CONFIG: opt-in to full referrer
if (options.fullReferrer && 'referrerPolicy' in pixel) {
pixel.referrerPolicy = 'no-referrer-when-downgrade';
}
// safe to trigger image load once the preload is done
preload(url, 'image', once(() => {
// cleanup when done
pixel.onload = pixel.onerror = () => { pixel = void(0); };
// trigger load
pixel.src = url;
}));
}
// further impressions should use loadFallback
reportImpression = loadFallback;
};
let firstImpressionUrl: EncodedURIComponent, impCount: number = 0;
let reportImpression = (urlParts: RegExpExecArray, options) => {
if (urlParts[2] === '/noop') {
loadFallback(urlParts, options);
} else {
loadIFrame(urlParts, options);
}
};
return (url: string, options: SmartPixelOptions) => {
options = options || {};
/* validate url
* only https and protocol-relative urls are allowed for security reasons
^ start of string
(?:https:)? (optional) literal string `https:`
\/\/ literal string `//`
(.*?) capture group 1: non-greedy match of any characters
(\/\w*)) capture group 2: `/` followed by 0 or more word characters
\/? (optional) literal string `/`
( capture group 3:
(?:[?][^#]*)? (optional) `?` followed by any characters except `#`
)
( capture group 4:
(?:#.*)? (optional) `#` followed by any characters
)
$ end of string */
const urlParts = /^(?:https:)?\/\/(.*?)(\/\w*)\/?((?:[?][^#]*)?)((?:#.*)?)$/.exec(url);
if (urlParts) {
// urlParts[0] is the entire matched string, replace it with `https://`
urlParts[0] = 'https://';
// save first url for future impressions
if (!(impCount++)) {
firstImpressionUrl = encodeURIComponent(urlParts.join('')) as EncodedURIComponent;
}
// for debugging, add the encoded options to the url 1% of the time
if (sample(0.01)) {
(options as SmartPixelOptions & {version?: string}).version = VERSION;
addUrlParameter(urlParts, 'opts' as EncodedURIComponent, encodeObject(options));
}
reportImpression(urlParts, options);
} else {
console.error('Invalid URL:', url);
}
};
}
)(window, document, window.fetch);