Refactor to generated module instead of plugin
This commit is contained in:
+104
-79
@@ -1,94 +1,33 @@
|
||||
import { defineWxtPlugin } from 'wxt/sandbox';
|
||||
import { useAppConfig } from 'wxt/client';
|
||||
import { storage } from 'wxt/storage';
|
||||
import {
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import type {
|
||||
Analytics,
|
||||
AnalyticsConfig,
|
||||
AnalyticsPageViewEvent,
|
||||
AnalyticsStorageItem,
|
||||
AnalyticsTrackEvent,
|
||||
BaseAnalyticsEvent,
|
||||
} from './types';
|
||||
import uaParser from 'ua-parser-js';
|
||||
|
||||
export let analytics: Analytics;
|
||||
const ANALYTICS_PORT = 'wxt-analytics';
|
||||
const interactiveTags = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
|
||||
const interactiveRoles = new Set([
|
||||
'button',
|
||||
'link',
|
||||
'checkbox',
|
||||
'menuitem',
|
||||
'tab',
|
||||
'radio',
|
||||
]);
|
||||
const ANALYTICS_PORT = '@wxt-dev/analytics';
|
||||
|
||||
export default <any>defineWxtPlugin(() => {
|
||||
const isBackground = globalThis.window == null; // TODO: Support MV2
|
||||
analytics = isBackground
|
||||
? createBackgroundAnalytics()
|
||||
: createAnalyticsForwarder();
|
||||
});
|
||||
export function createAnalytics(config: AnalyticsConfig): Analytics {
|
||||
if (globalThis.chrome?.runtime?.id)
|
||||
throw Error(
|
||||
'Cannot use WXT analytics in contexts without access to the browser.runtime APIs',
|
||||
);
|
||||
|
||||
function createAnalyticsForwarder(): Analytics {
|
||||
const port = chrome.runtime.connect({ name: ANALYTICS_PORT });
|
||||
const sessionId = Date.now();
|
||||
const getMetadata = (): ForwardMetadata => ({
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
language: navigator.language,
|
||||
referrer: globalThis.document?.referrer || undefined,
|
||||
screen: globalThis.window
|
||||
? `${globalThis.window.screen.width}x${globalThis.window.screen.height}`
|
||||
: undefined,
|
||||
url: location.href,
|
||||
});
|
||||
// TODO: This only works for standard WXT extensions, add a more generic
|
||||
// background script detector that works with non-WXT projects.
|
||||
if (location.pathname === '/background.js')
|
||||
return createBackgroundAnalytics(config);
|
||||
|
||||
const methodForwarder =
|
||||
(fn: string) =>
|
||||
(...args: any[]) =>
|
||||
port.postMessage({ fn, args: [...args, getMetadata()] });
|
||||
|
||||
const analytics: Analytics = {
|
||||
identify: methodForwarder('identify'),
|
||||
page: methodForwarder('page'),
|
||||
track: methodForwarder('track'),
|
||||
setEnabled: methodForwarder('setEnabled'),
|
||||
autoTrack: (root) => {
|
||||
const onClick = (event: Event) => {
|
||||
const element = event.target as any;
|
||||
if (
|
||||
!element ||
|
||||
(!interactiveTags.has(element.tagName) &&
|
||||
!interactiveRoles.has(element.getAttribute('role')))
|
||||
)
|
||||
return;
|
||||
|
||||
void analytics.track('click', {
|
||||
tagName: element.tagName?.toLowerCase(),
|
||||
id: element.id || undefined,
|
||||
className: element.className || undefined,
|
||||
textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length
|
||||
href: element.href,
|
||||
});
|
||||
};
|
||||
root.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
return () => {
|
||||
root.removeEventListener('click', onClick);
|
||||
};
|
||||
},
|
||||
};
|
||||
return analytics;
|
||||
return createFrontendAnalytics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Background analytics is responsible for reporting events triggered in the
|
||||
* background, and for reporting forwarded events from the other contexts.
|
||||
* Creates an analytics client in the background responsible for uploading events to the server to avoid CORS errors.
|
||||
*/
|
||||
function createBackgroundAnalytics(): Analytics {
|
||||
const config = (useAppConfig() as any).analytics as
|
||||
| AnalyticsConfig
|
||||
| undefined;
|
||||
|
||||
function createBackgroundAnalytics(config: AnalyticsConfig): Analytics {
|
||||
// User properties storage
|
||||
const userIdStorage =
|
||||
config?.userId ?? defineStorageItem<string>('local:wxt-analytics:user-id');
|
||||
@@ -104,7 +43,7 @@ function createBackgroundAnalytics(): Analytics {
|
||||
|
||||
// Cached values
|
||||
const platformInfo = chrome.runtime.getPlatformInfo();
|
||||
const userAgent = uaParser();
|
||||
const userAgent = UAParser();
|
||||
let userId = Promise.resolve(userIdStorage.getValue()).then(
|
||||
(id) => id ?? globalThis.crypto.randomUUID(),
|
||||
);
|
||||
@@ -131,6 +70,7 @@ function createBackgroundAnalytics(): Analytics {
|
||||
screen: meta.screen,
|
||||
referrer: meta.referrer,
|
||||
language: meta.language,
|
||||
url: meta.url,
|
||||
},
|
||||
user: {
|
||||
id: await userId,
|
||||
@@ -177,7 +117,11 @@ function createBackgroundAnalytics(): Analytics {
|
||||
const baseEvent = await getBaseEvent(forwardMeta);
|
||||
const event: AnalyticsPageViewEvent = {
|
||||
...baseEvent,
|
||||
page: { url },
|
||||
page: {
|
||||
url,
|
||||
location: globalThis.location?.href,
|
||||
title: globalThis.document?.title,
|
||||
},
|
||||
};
|
||||
if (config?.debug) console.debug('[analytics] page', event);
|
||||
if (await enabled.getValue()) {
|
||||
@@ -232,6 +176,60 @@ function createBackgroundAnalytics(): Analytics {
|
||||
return analytics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an analytics client for non-background contexts.
|
||||
*/
|
||||
function createFrontendAnalytics(): Analytics {
|
||||
const port = chrome.runtime.connect({ name: ANALYTICS_PORT });
|
||||
const sessionId = Date.now();
|
||||
const getMetadata = (): ForwardMetadata => ({
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
language: navigator.language,
|
||||
referrer: globalThis.document?.referrer || undefined,
|
||||
screen: globalThis.window
|
||||
? `${globalThis.window.screen.width}x${globalThis.window.screen.height}`
|
||||
: undefined,
|
||||
url: location.href,
|
||||
});
|
||||
|
||||
const methodForwarder =
|
||||
(fn: string) =>
|
||||
(...args: any[]) =>
|
||||
port.postMessage({ fn, args: [...args, getMetadata()] });
|
||||
|
||||
const analytics: Analytics = {
|
||||
identify: methodForwarder('identify'),
|
||||
page: methodForwarder('page'),
|
||||
track: methodForwarder('track'),
|
||||
setEnabled: methodForwarder('setEnabled'),
|
||||
autoTrack: (root) => {
|
||||
const onClick = (event: Event) => {
|
||||
const element = event.target as any;
|
||||
if (
|
||||
!element ||
|
||||
(!INTERACTIVE_TAGS.has(element.tagName) &&
|
||||
!INTERACTIVE_ROLES.has(element.getAttribute('role')))
|
||||
)
|
||||
return;
|
||||
|
||||
void analytics.track('click', {
|
||||
tagName: element.tagName?.toLowerCase(),
|
||||
id: element.id || undefined,
|
||||
className: element.className || undefined,
|
||||
textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length
|
||||
href: element.href,
|
||||
});
|
||||
};
|
||||
root.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
return () => {
|
||||
root.removeEventListener('click', onClick);
|
||||
};
|
||||
},
|
||||
};
|
||||
return analytics;
|
||||
}
|
||||
|
||||
interface ForwardMetadata {
|
||||
sessionId: number | undefined;
|
||||
timestamp: number;
|
||||
@@ -240,3 +238,30 @@ interface ForwardMetadata {
|
||||
referrer: string | undefined;
|
||||
url: string | undefined;
|
||||
}
|
||||
|
||||
function defineStorageItem<T>(
|
||||
key: string,
|
||||
defaultValue?: NonNullable<T>,
|
||||
): AnalyticsStorageItem<T> {
|
||||
return {
|
||||
getValue: async () =>
|
||||
(await chrome.storage.local.get(key))[key] ?? defaultValue,
|
||||
setValue: (newValue) => chrome.storage.local.set({ [key]: newValue }),
|
||||
};
|
||||
}
|
||||
|
||||
const INTERACTIVE_TAGS = new Set([
|
||||
'A',
|
||||
'BUTTON',
|
||||
'INPUT',
|
||||
'SELECT',
|
||||
'TEXTAREA',
|
||||
]);
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button',
|
||||
'link',
|
||||
'checkbox',
|
||||
'menuitem',
|
||||
'tab',
|
||||
'radio',
|
||||
]);
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'wxt';
|
||||
import 'wxt/sandbox';
|
||||
import { addWxtPlugin, defineWxtModule } from 'wxt/modules';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { AnalyticsConfig } from './types';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { addAlias, defineWxtModule } from 'wxt/modules';
|
||||
import { resolve } from 'node:path';
|
||||
import type { AnalyticsConfig } from './types';
|
||||
|
||||
declare module 'wxt/sandbox' {
|
||||
export interface WxtAppConfig {
|
||||
@@ -11,27 +10,34 @@ declare module 'wxt/sandbox' {
|
||||
}
|
||||
}
|
||||
|
||||
const _dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const pluginId = process.env.NPM
|
||||
? '@wxt-dev/analytics/client'
|
||||
: resolve(_dirname, 'client.ts');
|
||||
|
||||
export default defineWxtModule({
|
||||
name: 'analytics',
|
||||
imports: [{ name: 'analytics', from: pluginId }],
|
||||
imports: [{ name: 'analytics', from: '#analytics' }],
|
||||
setup(wxt) {
|
||||
// Add a plugin
|
||||
addWxtPlugin(
|
||||
wxt,
|
||||
resolve(_dirname, process.env.NPM ? 'client.mjs' : 'client.ts'),
|
||||
);
|
||||
|
||||
wxt.hooks.hook('build:manifestGenerated', (_, manifest) => {
|
||||
// Add required permissions
|
||||
wxt.hook('build:manifestGenerated', (_, manifest) => {
|
||||
manifest.permissions ??= [];
|
||||
if (!manifest.permissions.includes('storage')) {
|
||||
manifest.permissions.push('storage');
|
||||
}
|
||||
});
|
||||
|
||||
// Generate #analytics module
|
||||
const analyticsModulePath = resolve(
|
||||
wxt.config.wxtDir,
|
||||
'analytics/index.ts',
|
||||
);
|
||||
const analyticsModuleCode = `
|
||||
import { createAnalytics } from '@wxt-dev/analytics/client';
|
||||
|
||||
export const analytics = createAnalytics(useAppConfig().analytics);
|
||||
`;
|
||||
addAlias(wxt, '#analytics', analyticsModulePath);
|
||||
wxt.hook('prepare:types', async (_, entries) => {
|
||||
entries.push({
|
||||
path: analyticsModulePath,
|
||||
text: analyticsModuleCode,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,18 +27,18 @@ export interface AnalyticsConfig {
|
||||
/**
|
||||
* Configure how the enabled flag is persisted
|
||||
*/
|
||||
enabled?: AnalyticsStorageItemConfig<boolean>;
|
||||
enabled?: AnalyticsStorageItem<boolean>;
|
||||
/**
|
||||
* Configure how the user Id is persisted
|
||||
*/
|
||||
userId?: AnalyticsStorageItemConfig<string>;
|
||||
userId?: AnalyticsStorageItem<string>;
|
||||
/**
|
||||
* Configure how user properties are persisted
|
||||
*/
|
||||
userProperties?: AnalyticsStorageItemConfig<Record<string, string>>;
|
||||
userProperties?: AnalyticsStorageItem<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface AnalyticsStorageItemConfig<T> {
|
||||
export interface AnalyticsStorageItem<T> {
|
||||
getValue: () => T | Promise<T>;
|
||||
setValue?: (newValue: T) => void | Promise<void>;
|
||||
}
|
||||
@@ -62,13 +62,13 @@ export interface BaseAnalyticsEvent {
|
||||
/** `Date.now()` of when the event was reported */
|
||||
timestamp: number;
|
||||
/** `"1920x1080"` */
|
||||
screen?: string;
|
||||
screen: string | undefined;
|
||||
/** `document.referrer` */
|
||||
referrer?: string;
|
||||
referrer: string | undefined;
|
||||
/** `navigator.language` */
|
||||
language?: string;
|
||||
language: string | undefined;
|
||||
/** `location.href` */
|
||||
url?: string;
|
||||
url: string | undefined;
|
||||
};
|
||||
user: {
|
||||
id: string;
|
||||
@@ -78,8 +78,8 @@ export interface BaseAnalyticsEvent {
|
||||
|
||||
export interface AnalyticsPageInfo {
|
||||
url: string;
|
||||
title?: string;
|
||||
location?: string;
|
||||
title: string | undefined;
|
||||
location: string | undefined;
|
||||
}
|
||||
|
||||
export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"extends": ["../../tsconfig.base.json", "./.wxt/tsconfig.json"],
|
||||
"compilerOptions": {
|
||||
"paths": {},
|
||||
"types": ["chrome"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user