add native and web sdks

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-11-06 08:52:58 +01:00
parent ce26b24c1b
commit 4ddafcad07
33 changed files with 812 additions and 68 deletions

View File

@@ -0,0 +1,28 @@
import type { NewMixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
export class MixanNative extends Mixan {
constructor(options: NewMixanOptions) {
super(options);
}
async properties() {
return {
ip: await super.ip(),
};
}
async init(properties: Record<string, unknown>) {
super.init({
...(await this.properties()),
...(properties ?? {}),
});
}
screenView(route: string, properties?: Record<string, unknown>): void {
super.event('screen_view', {
...properties,
route: route,
});
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "@mixan/sdk-native",
"version": "0.0.1",
"module": "index.ts",
"scripts": {
"build": "rm -rf dist && tsup",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mixan/sdk": "workspace:*",
"@mixan/types": "workspace:*"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

View File

@@ -0,0 +1,6 @@
{
"extends": "@mixan/tsconfig/sdk.json",
"compilerOptions": {
"outDir": "dist"
}
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import config from '@mixan/tsconfig/tsup.config.json' assert {
type: 'json'
}
export default defineConfig(config as any);

97
packages/sdk-web/index.ts Normal file
View File

@@ -0,0 +1,97 @@
import type { NewMixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
import { parseQuery } from './src/parseQuery';
import { getDevice, getOS, getTimezone } from './src/utils';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export class MixanWeb extends Mixan {
constructor(
options: PartialBy<NewMixanOptions, 'setItem' | 'removeItem' | 'getItem'>
) {
super({
batchInterval: options.batchInterval ?? 1000,
setItem:
typeof localStorage === 'undefined'
? () => {}
: localStorage.setItem.bind(localStorage),
removeItem:
typeof localStorage === 'undefined'
? () => {}
: localStorage.removeItem.bind(localStorage),
getItem:
typeof localStorage === 'undefined'
? () => null
: localStorage.getItem.bind(localStorage),
...options,
});
}
isServer() {
return typeof document === 'undefined';
}
async properties() {
return {
ip: await super.ip(),
os: getOS(),
device: getDevice(),
ua: navigator.userAgent,
referrer: document.referrer,
language: navigator.language,
timezone: getTimezone(),
screen: {
width: window.screen.width,
height: window.screen.height,
pixelRatio: window.devicePixelRatio,
},
};
}
async init(properties?: Record<string, unknown>) {
if (this.isServer()) {
return;
}
super.init({
...(await this.properties()),
...(properties ?? {}),
});
this.screenView();
}
trackOutgoingLinks() {
if (this.isServer()) {
return;
}
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.tagName === 'A') {
const href = target.getAttribute('href');
if (href?.startsWith('http')) {
super.event('link_out', {
href,
text: target.innerText,
});
super.flush();
}
}
});
}
screenView(properties?: Record<string, unknown>): void {
if (this.isServer()) {
return;
}
super.event('screen_view', {
...properties,
route: window.location.pathname,
url: window.location.href,
query: parseQuery(window.location.search ?? ''),
});
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "@mixan/sdk-web",
"version": "0.0.1",
"module": "index.ts",
"scripts": {
"build": "rm -rf dist && tsup",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mixan/sdk": "workspace:*",
"@mixan/types": "workspace:*"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -0,0 +1,8 @@
export function parseQuery(query: string): Record<string, string> {
const params = new URLSearchParams(query);
const result: Record<string, string> = {};
for (const [key, value] of params.entries()) {
result[key] = value;
}
return result;
}

View File

@@ -0,0 +1,66 @@
export function getOS() {
if (/iPad/i.test(navigator.userAgent)) {
return 'iPad';
}
if (/iPhone/i.test(navigator.userAgent)) {
return 'iPhone';
}
if (/iPod/i.test(navigator.userAgent)) {
return 'iPod';
}
if (/Macintosh/i.test(navigator.userAgent)) {
return 'macOS';
}
if (/IEMobile|Windows/i.test(navigator.userAgent)) {
return 'Windows';
}
if (/Android/i.test(navigator.userAgent)) {
return 'Android';
}
if (/BlackBerry/i.test(navigator.userAgent)) {
return 'BlackBerry';
}
if (/EF500/i.test(navigator.userAgent)) {
return 'Bluebird';
}
if (/CrOS/i.test(navigator.userAgent)) {
return 'Chrome OS';
}
if (/DL-AXIS/i.test(navigator.userAgent)) {
return 'Datalogic';
}
if (/CT50/i.test(navigator.userAgent)) {
return 'Honeywell';
}
if (/TC70|TC55/i.test(navigator.userAgent)) {
return 'Zebra';
}
if (/Linux/i.test(navigator.userAgent)) {
return 'Generic Linux';
}
return 'Unknown';
}
export function getDevice() {
const ua = navigator.userAgent;
const t1 =
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(
ua
);
const t2 =
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(
ua.slice(0, 4)
);
if (t1 || t2) {
return 'mobile';
}
return 'desktop';
}
export function getTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
return 'unknown';
}
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@mixan/tsconfig/sdk.json",
"compilerOptions": {
"outDir": "dist"
}
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'tsup';
import config from '@mixan/tsconfig/tsup.config.json' assert {
type: 'json'
}
export default defineConfig(config as any);

View File

@@ -11,10 +11,12 @@ export interface NewMixanOptions {
batchInterval?: number;
maxBatchSize?: number;
sessionTimeout?: number;
session?: boolean;
verbose?: boolean;
saveProfileId: (profiId: string) => void;
getProfileId: () => string | null;
removeProfileId: () => void;
setItem: (key: string, profileId: string) => void;
getItem: (key: string) => string | null;
removeItem: (key: string) => void;
trackIp?: boolean;
}
export type MixanOptions = Required<NewMixanOptions>;
@@ -39,7 +41,6 @@ class Fetcher {
const url = `${this.url}${path}`;
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2));
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return fetch(url, {
headers: {
['mixan-client-id']: this.clientId,
@@ -137,19 +138,23 @@ export class Mixan {
private options: MixanOptions;
private logger: (...args: any[]) => void;
private globalProperties: Record<string, unknown> = {};
private lastEventAt?: string;
private lastScreenViewAt?: string;
private lastEventAt: string;
private promiseIp: Promise<string | null>;
constructor(options: NewMixanOptions) {
this.logger = options.verbose ? console.log : () => {};
this.options = {
sessionTimeout: 1000 * 60 * 30,
session: true,
verbose: false,
batchInterval: 10000,
maxBatchSize: 10,
trackIp: false,
...options,
};
this.lastEventAt =
this.options.getItem('@mixan:lastEventAt') ?? '1970-01-01';
this.fetch = new Fetcher(this.options);
this.eventBatcher = new Batcher(this.options, (queue) => {
this.fetch.post(
@@ -164,13 +169,26 @@ export class Mixan {
}))
);
});
this.promiseIp = this.options.trackIp
? fetch('https://api.ipify.org')
.then((res) => res.text())
.catch(() => null)
: Promise.resolve(null);
}
timestamp() {
return new Date().toISOString();
async ip() {
return this.promiseIp;
}
init() {
timestamp(modify = 0) {
return new Date(Date.now() + modify).toISOString();
}
init(properties?: Record<string, unknown>) {
if (properties) {
this.setGlobalProperties(properties);
}
this.logger('Mixan: Init');
this.setAnonymousUser();
}
@@ -178,14 +196,15 @@ export class Mixan {
event(name: string, properties: Record<string, unknown> = {}) {
const now = new Date();
const isSessionStart =
now.getTime() - new Date(this.lastEventAt ?? '1970-01-01').getTime() >
this.options.sessionTimeout;
this.options.session &&
now.getTime() - new Date(this.lastEventAt).getTime() >
this.options.sessionTimeout;
if (isSessionStart) {
this.logger('Mixan: Session start');
this.eventBatcher.add({
name: 'session_start',
time: this.timestamp(),
time: this.timestamp(-10),
properties: {},
profileId: this.profileId ?? null,
});
@@ -199,19 +218,28 @@ export class Mixan {
profileId: this.profileId ?? null,
});
this.lastEventAt = this.timestamp();
this.options.setItem('@mixan:lastEventAt', this.lastEventAt);
}
private async setAnonymousUser(retryCount = 0) {
const profileId = this.options.getProfileId();
const profileId = this.options.getItem('@mixan:profileId');
if (profileId) {
this.profileId = profileId;
await this.setUser({
properties: this.globalProperties,
});
this.logger('Mixan: Use existing profile', this.profileId);
} else {
const res = await this.fetch.post<undefined, { id: string }>('/profiles');
const res = await this.fetch.post<ProfilePayload, { id: string }>(
'/profiles',
{
properties: this.globalProperties,
}
);
if (res) {
this.profileId = res.id;
this.options.saveProfileId(res.id);
this.options.setItem('@mixan:profileId', res.id);
this.logger('Mixan: Create new profile', this.profileId);
} else if (retryCount < 2) {
setTimeout(() => {
@@ -249,8 +277,16 @@ export class Mixan {
}
setGlobalProperties(properties: Record<string, unknown>) {
if (typeof properties !== 'object') {
return this.logger(
'Mixan: Set global properties failed, properties must be an object'
);
}
this.logger('Mixan: Set global properties', properties);
this.globalProperties = properties ?? {};
this.globalProperties = {
...this.globalProperties,
...properties,
};
}
async increment(name: string, value = 1) {
@@ -291,34 +327,16 @@ export class Mixan {
);
}
screenView(route: string, _properties?: Record<string, unknown>) {
const properties = _properties ?? {};
const now = new Date();
if (this.lastScreenViewAt) {
const last = new Date(this.lastScreenViewAt);
const diff = now.getTime() - last.getTime();
this.logger(`Mixan: Screen view duration: ${diff}ms`);
properties.duration = diff;
}
this.lastScreenViewAt = now.toISOString();
this.event('screen_view', {
...properties,
route,
});
}
flush() {
this.logger('Mixan: Flushing events queue');
this.eventBatcher.send();
this.lastScreenViewAt = undefined;
}
clear() {
this.logger('Mixan: Clear, send remaining events and remove profileId');
this.eventBatcher.send();
this.options.removeProfileId();
this.options.removeItem('@mixan:profileId');
this.options.removeItem('@mixan:session');
this.profileId = undefined;
this.setAnonymousUser();
}

View File

@@ -3,6 +3,7 @@
"version": "0.0.1",
"module": "index.ts",
"scripts": {
"build": "rm -rf dist && tsup",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
@@ -13,6 +14,7 @@
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"tsup": "^7.2.0",

View File

@@ -1,21 +1,6 @@
{
"extends": "@mixan/tsconfig/sdk.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"composite": true,
"strict": true,
"downlevelIteration": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"outDir": "dist",
"allowImportingTsExtensions": false,
"noEmit": false
"outDir": "dist"
}
}

View File

@@ -1,10 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['index.ts'],
format: ['cjs', 'esm'], // Build for commonJS and ESmodules
dts: true, // Generate declaration file (.d.ts)
splitting: false,
sourcemap: true,
clean: true,
});
import config from '@mixan/tsconfig/tsup.config.json' assert {
type: 'json'
}
export default defineConfig(config as any);