UNPKG

10.8 kBPlain TextView Raw
1/*
2 * Copyright (c) 2021 Snowplow Analytics Ltd, 2010 Anthon Pang
3 * All rights reserved.
4 *
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions are met:
7 *
8 * 1. Redistributions of source code must retain the above copyright notice, this
9 * list of conditions and the following disclaimer.
10 *
11 * 2. Redistributions in binary form must reproduce the above copyright notice,
12 * this list of conditions and the following disclaimer in the documentation
13 * and/or other materials provided with the distribution.
14 *
15 * 3. Neither the name of the copyright holder nor the names of its
16 * contributors may be used to endorse or promote products derived from
17 * this software without specific prior written permission.
18 *
19 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 */
30
31import { isStringArray, LOG } from '@snowplow/tracker-core';
32import {
33 isFunction,
34 addTracker,
35 createSharedState,
36 SharedState,
37 BrowserTracker,
38 addEventListener,
39 getTrackers,
40} from '@snowplow/browser-tracker-core';
41import * as Snowplow from '@snowplow/browser-tracker';
42import { Plugins } from './features';
43import { JavaScriptTrackerConfiguration } from './configuration';
44
45declare global {
46 interface Window {
47 [key: string]: unknown;
48 }
49}
50
51/*
52 * Proxy object
53 * This allows the caller to continue push()'ing after the Tracker has been initialized and loaded
54 */
55export interface Queue {
56 /**
57 * Allows the caller to push events
58 *
59 * @param array - parameterArray An array comprising either:
60 * [ 'functionName', optional_parameters ]
61 * or:
62 * [ functionObject, optional_parameters ]
63 */
64 push: (...args: any[]) => void;
65}
66
67interface PluginQueueItem {
68 timeout: number;
69}
70
71type FunctionParameters = [Record<string, unknown>, Array<string>] | [Array<string>];
72
73/**
74 * This allows the caller to continue push()'ing after the Tracker has been initialized and loaded
75 *
76 * @param functionName - The global function name this script has been created on
77 * @param asyncQueue - The existing queue of items to be processed
78 */
79export function InQueueManager(functionName: string, asyncQueue: Array<unknown>): Queue {
80 const windowAlias = window,
81 documentAlias = document,
82 sharedState: SharedState = createSharedState(),
83 availableTrackerIds: Array<string> = [],
84 pendingPlugins: Record<string, PluginQueueItem> = {},
85 pendingQueue: Array<[string, FunctionParameters]> = [];
86
87 let version: string, availableFunctions: Record<string, Function>;
88 ({ version, ...availableFunctions } = Snowplow);
89
90 function parseInputString(inputString: string): [string, string[] | undefined] {
91 const separatedString = inputString.split(':'),
92 extractedFunction = separatedString[0],
93 extractedNames = separatedString.length > 1 ? separatedString[1].split(';') : undefined;
94
95 return [extractedFunction, extractedNames];
96 }
97
98 function dispatch(f: string, parameters: FunctionParameters) {
99 if (availableFunctions[f]) {
100 try {
101 availableFunctions[f].apply(null, parameters);
102 } catch (ex) {
103 LOG.error(f + ' failed', ex);
104 }
105 } else {
106 LOG.warn(f + ' is not an available function');
107 }
108 }
109
110 function tryProcessQueue() {
111 if (Object.keys(pendingPlugins).length === 0) {
112 pendingQueue.forEach((q) => {
113 let fnParameters = q[1];
114 if (
115 typeof availableFunctions[q[0]] !== 'undefined' &&
116 availableFunctions[q[0]].length > fnParameters.length &&
117 Array.isArray(fnParameters[0])
118 ) {
119 fnParameters = [{}, fnParameters[0]];
120 }
121 dispatch(q[0], fnParameters);
122 });
123 }
124 }
125
126 function updateAvailableFunctions(newFunctions: Record<string, Function>) {
127 // Spread in any new methods
128 availableFunctions = {
129 ...availableFunctions,
130 ...newFunctions,
131 };
132 }
133
134 function newTracker(parameterArray: Array<unknown>) {
135 if (
136 typeof parameterArray[0] === 'string' &&
137 typeof parameterArray[1] === 'string' &&
138 (typeof parameterArray[2] === 'undefined' || typeof parameterArray[2] === 'object')
139 ) {
140 const trackerId = `${functionName}_${parameterArray[0]}`,
141 trackerConfiguration = parameterArray[2] as JavaScriptTrackerConfiguration,
142 plugins = Plugins(trackerConfiguration),
143 tracker = addTracker(trackerId, parameterArray[0], `js-${version}`, parameterArray[1], sharedState, {
144 ...trackerConfiguration,
145 plugins: plugins.map((p) => p[0]),
146 });
147
148 if (tracker) {
149 availableTrackerIds.push(tracker.id);
150 } else {
151 LOG.warn(parameterArray[0] + ' already exists');
152 return;
153 }
154
155 plugins.forEach((p) => {
156 updateAvailableFunctions(p[1]);
157 });
158 } else {
159 LOG.error('newTracker failed', new Error('Invalid parameters'));
160 }
161 }
162
163 function addPlugin(parameterArray: Array<unknown>, trackerIdentifiers: Array<string>) {
164 function postScriptHandler(scriptSrc: string) {
165 if (Object.prototype.hasOwnProperty.call(pendingPlugins, scriptSrc)) {
166 windowAlias.clearTimeout(pendingPlugins[scriptSrc].timeout);
167 delete pendingPlugins[scriptSrc];
168 tryProcessQueue();
169 }
170 }
171
172 if (
173 typeof parameterArray[0] === 'string' &&
174 isStringArray(parameterArray[1]) &&
175 (typeof parameterArray[2] === 'undefined' || Array.isArray(parameterArray[2]))
176 ) {
177 const scriptSrc = parameterArray[0],
178 constructorPath = parameterArray[1],
179 constructorParams = parameterArray[2],
180 pauseTracking = parameterArray[3] ?? true;
181
182 if (pauseTracking) {
183 const timeout = windowAlias.setTimeout(() => {
184 postScriptHandler(scriptSrc);
185 }, 5000);
186 pendingPlugins[scriptSrc] = {
187 timeout: timeout,
188 };
189 }
190 const pluginScript = documentAlias.createElement('script');
191 pluginScript.setAttribute('src', scriptSrc);
192 pluginScript.setAttribute('async', '1');
193 addEventListener(
194 pluginScript,
195 'error',
196 () => {
197 postScriptHandler(scriptSrc);
198 LOG.warn(`Failed to load plugin ${constructorPath[0]} from ${scriptSrc}`);
199 },
200 true
201 );
202 addEventListener(
203 pluginScript,
204 'load',
205 () => {
206 const [windowFn, innerFn] = constructorPath,
207 plugin = windowAlias[windowFn];
208 if (plugin && typeof plugin === 'object') {
209 const { [innerFn]: pluginConstructor, ...api } = plugin as Record<string, Function>;
210 availableFunctions['addPlugin'].apply(null, [
211 { plugin: pluginConstructor.apply(null, constructorParams) },
212 trackerIdentifiers,
213 ]);
214 updateAvailableFunctions(api);
215 }
216 postScriptHandler(scriptSrc);
217 },
218 true
219 );
220 documentAlias.head.appendChild(pluginScript);
221 return;
222 }
223
224 if (
225 typeof parameterArray[0] === 'object' &&
226 typeof parameterArray[1] === 'string' &&
227 (typeof parameterArray[2] === 'undefined' || Array.isArray(parameterArray[2]))
228 ) {
229 const plugin = parameterArray[0],
230 constructorPath = parameterArray[1],
231 constructorParams = parameterArray[2];
232 if (plugin) {
233 const { [constructorPath]: pluginConstructor, ...api } = plugin as Record<string, Function>;
234 availableFunctions['addPlugin'].apply(null, [
235 { plugin: pluginConstructor.apply(null, constructorParams) },
236 trackerIdentifiers,
237 ]);
238 updateAvailableFunctions(api);
239 return;
240 }
241 }
242
243 LOG.warn(`Failed to add Plugin: ${parameterArray[1]}`);
244 }
245
246 /**
247 * apply wrapper
248 *
249 * @param array - parameterArray An array comprising either:
250 * [ 'functionName', optional_parameters ]
251 * or:
252 * [ functionObject, optional_parameters ]
253 */
254 function applyAsyncFunction(...args: any[]) {
255 // Outer loop in case someone push'es in zarg of arrays
256 for (let i = 0; i < args.length; i += 1) {
257 let parameterArray = args[i],
258 input = Array.prototype.shift.call(parameterArray);
259
260 // Custom callback rather than tracker method, called with trackerDictionary as the context
261 if (isFunction(input)) {
262 try {
263 let fnTrackers: Record<string, BrowserTracker> = {};
264 for (const tracker of getTrackers(availableTrackerIds)) {
265 // Strip GlobalSnowplowNamespace from ID
266 fnTrackers[tracker.id.replace(`${functionName}_`, '')] = tracker;
267 }
268 input.apply(fnTrackers, parameterArray);
269 } catch (ex) {
270 LOG.error('Tracker callback failed', ex);
271 } finally {
272 continue;
273 }
274 }
275
276 let parsedString = parseInputString(input),
277 f = parsedString[0],
278 names = parsedString[1];
279
280 if (f === 'newTracker') {
281 newTracker(parameterArray);
282 continue;
283 }
284
285 const trackerIdentifiers = names ? names.map((n) => `${functionName}_${n}`) : availableTrackerIds;
286
287 if (f === 'addPlugin') {
288 addPlugin(parameterArray, trackerIdentifiers);
289 continue;
290 }
291
292 let fnParameters: FunctionParameters;
293 if (typeof parameterArray[0] !== 'undefined') {
294 fnParameters = [parameterArray[0], trackerIdentifiers];
295 } else if (typeof availableFunctions[f] !== 'undefined') {
296 fnParameters = availableFunctions[f].length === 2 ? [{}, trackerIdentifiers] : [trackerIdentifiers];
297 } else {
298 fnParameters = [trackerIdentifiers];
299 }
300
301 if (Object.keys(pendingPlugins).length > 0) {
302 pendingQueue.push([f, fnParameters]);
303 continue;
304 }
305
306 dispatch(f, fnParameters);
307 }
308 }
309
310 // We need to manually apply any events collected before this initialization
311 for (let i = 0; i < asyncQueue.length; i++) {
312 applyAsyncFunction(asyncQueue[i]);
313 }
314
315 return {
316 push: applyAsyncFunction,
317 };
318}