@segment/analytics-react-native
Version:
The hassle-free way to add Segment analytics to your React-Native app.
289 lines (247 loc) • 7.42 kB
text/typescript
import { NativeModule, NativeModules, Platform } from 'react-native';
import type { EventPlugin } from './plugin';
import type { Timeline } from './timeline';
const sizeOf = (obj: unknown): number => {
const size = encodeURI(JSON.stringify(obj)).split(/%..|./).length - 1;
return size / 1024;
};
export const warnMissingNativeModule = () => {
const MISSING_NATIVE_MODULE_WARNING =
"The package 'analytics-react-native' can't access a custom native module. Make sure: \n\n" +
Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
'- You rebuilt the app after installing the package\n' +
'- You are not using Expo managed workflow\n';
console.warn(MISSING_NATIVE_MODULE_WARNING);
};
export const getNativeModule = (moduleName: string) => {
const module = (NativeModules[moduleName] as NativeModule) ?? undefined;
if (module === undefined) {
warnMissingNativeModule();
}
return module;
};
export const chunk = <T>(array: T[], count: number, maxKB?: number): T[][] => {
if (!array.length || !count) {
return [];
}
let currentChunk = 0;
let rollingKBSize = 0;
const result: T[][] = array.reduce(
(chunks: T[][], item: T, index: number) => {
if (maxKB !== undefined) {
rollingKBSize += sizeOf(item);
// If we overflow chunk until the previous index, else keep going
if (rollingKBSize >= maxKB) {
chunks[++currentChunk] = [item];
return chunks;
}
}
if (index !== 0 && index % count === 0) {
chunks[++currentChunk] = [item];
} else {
if (chunks[currentChunk] === undefined) {
chunks[currentChunk] = [];
}
chunks[currentChunk].push(item);
}
return chunks;
},
[]
);
return result;
};
export const getAllPlugins = (timeline: Timeline) => {
const allPlugins = Object.values(timeline.plugins);
if (allPlugins.length) {
return allPlugins.reduce((prev = [], curr = []) => prev.concat(curr));
}
return [];
};
export const getPluginsWithFlush = (timeline: Timeline) => {
const allPlugins = getAllPlugins(timeline);
// checking for the existence of .flush()
const eventPlugins = allPlugins?.filter(
(f) => (f as EventPlugin).flush !== undefined
) as EventPlugin[];
return eventPlugins;
};
export const getPluginsWithReset = (timeline: Timeline) => {
const allPlugins = getAllPlugins(timeline);
// checking for the existence of .reset()
const eventPlugins = allPlugins?.filter(
(f) => (f as EventPlugin).reset !== undefined
) as EventPlugin[];
return eventPlugins;
};
type PromiseResult<T> =
| {
status: 'fulfilled';
value: T;
}
| {
status: 'rejected';
reason: unknown;
};
const settlePromise = async <T>(
promise: Promise<T> | T
): Promise<PromiseResult<T>> => {
try {
const result = await promise;
return {
status: 'fulfilled',
value: result,
};
} catch (error) {
return {
status: 'rejected',
reason: error,
};
}
};
export const allSettled = async <T>(
promises: (Promise<T> | T)[]
): Promise<PromiseResult<T>[]> => {
return Promise.all(promises.map(settlePromise));
};
export function isNumber(x: unknown): x is number {
return typeof x === 'number';
}
export function isString(x: unknown): x is string {
return typeof x === 'string';
}
export function isBoolean(x: unknown): x is boolean {
return typeof x === 'boolean';
}
export function isDate(value: unknown): value is Date {
return (
value instanceof Date ||
(typeof value === 'object' &&
Object.prototype.toString.call(value) === '[object Date]')
);
}
export function objectToString(value: object, json = true): string | undefined {
// If the object has a custom toString we well use that
if (value.toString !== Object.prototype.toString) {
return value.toString();
}
if (json) {
return JSON.stringify(value);
}
return undefined;
}
export function unknownToString(
value: unknown,
stringifyJSON = true,
replaceNull: string | undefined = '',
replaceUndefined: string | undefined = ''
): string | undefined {
if (value === null) {
if (replaceNull !== undefined) {
return replaceNull;
} else {
return undefined;
}
}
if (value === undefined) {
if (replaceUndefined !== undefined) {
return replaceUndefined;
} else {
return undefined;
}
}
if (isNumber(value) || isBoolean(value) || isString(value)) {
return value.toString();
}
if (isObject(value)) {
return objectToString(value, stringifyJSON);
}
if (stringifyJSON) {
return JSON.stringify(value);
}
return undefined;
}
/**
* Checks if value is a dictionary like object
* @param value unknown object
* @returns typeguard, value is dicitonary
*/
export const isObject = (value: unknown): value is Record<string, unknown> =>
value !== null &&
value !== undefined &&
typeof value === 'object' &&
!Array.isArray(value);
/**
* Utility to deeply compare 2 objects
* @param a unknown object
* @param b unknown object
* @returns true if both objects have the same keys and values
*/
export function deepCompare<T>(a: T, b: T): boolean {
// Shallow compare first, just in case
if (a === b) {
return true;
}
// If not objects then compare values directly
if (!isObject(a) || !isObject(b)) {
return a === b;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) {
return false;
}
for (const key of keysA) {
if (!keysB.includes(key) || !deepCompare(a[key], b[key])) {
return false;
}
}
return true;
}
export const createPromise = <T>(
timeout: number | undefined = undefined,
_errorHandler: (err: Error) => void = (_: Error) => {
//
}
): { promise: Promise<T>; resolve: (value: T) => void } => {
let resolver: (value: T) => void;
const promise = new Promise<T>((resolve, reject) => {
resolver = resolve;
if (timeout !== undefined) {
setTimeout(() => {
reject(new Error('Promise timed out'));
}, timeout);
}
});
promise.catch(_errorHandler);
return {
promise: promise,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolve: resolver!,
};
};
export function getURL(host: string, path: string) {
if (!host.startsWith('https://') && !host.startsWith('http://')) {
host = 'https://' + host;
}
const s = `${host}${path}`;
if (!validateURL(s)) {
console.error('Invalid URL has been passed');
console.log(`Invalid Url passed is ${s}`);
throw new Error('Invalid URL has been passed');
}
return s;
}
export function validateURL(url: string): boolean {
const urlRegex = new RegExp(
'^(?:https?:\\/\\/)' + // Protocol (http or https)
'(?:\\S+(?::\\S*)?@)?' + // Optional user:pass@
'(?:(localhost|\\d{1,3}(?:\\.\\d{1,3}){3})|' + // Localhost or IP address
'(?:(?!-)[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:\\.[a-zA-Z]{2,})))' + // Domain validation (supports hyphens)
'(?::\\d{2,5})?' + // Optional port
'(\\/[^\\s?#]*)?' + // Path (allows `/projects/yup/settings`)
'(\\?[a-zA-Z0-9_.-]+=[a-zA-Z0-9_.-]+(&[a-zA-Z0-9_.-]+=[a-zA-Z0-9_.-]+)*)?' + // Query params
'(#[^\\s]*)?$', // Fragment (optional)
'i' // Case-insensitive
);
return urlRegex.test(url);
}