feat: session replay
* wip * wip * wip * wip * final fixes * comments * fix
This commit is contained in:
committed by
GitHub
parent
38d9b65ec8
commit
aa81bbfe77
@@ -4,12 +4,14 @@ import { getInitSnippet } from '@openpanel/web';
|
||||
|
||||
type Props = Omit<OpenPanelOptions, 'filter'> & {
|
||||
profileId?: string;
|
||||
/** @deprecated Use `scriptUrl` instead. */
|
||||
cdnUrl?: string;
|
||||
scriptUrl?: string;
|
||||
filter?: string;
|
||||
globalProperties?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const { profileId, cdnUrl, globalProperties, ...options } = Astro.props;
|
||||
const { profileId, cdnUrl, scriptUrl, globalProperties, ...options } = Astro.props;
|
||||
|
||||
const CDN_URL = 'https://openpanel.dev/op1.js';
|
||||
|
||||
@@ -60,5 +62,5 @@ ${methods
|
||||
.join('\n')}`;
|
||||
---
|
||||
|
||||
<script src={cdnUrl ?? CDN_URL} async defer />
|
||||
<script src={scriptUrl ?? cdnUrl ?? CDN_URL} async defer />
|
||||
<script is:inline set:html={scriptContent} />
|
||||
@@ -1,8 +1,3 @@
|
||||
// adding .js next/script import fixes an issues
|
||||
// with esm and nextjs (when using pages dir)
|
||||
import Script from 'next/script.js';
|
||||
import React from 'react';
|
||||
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
@@ -12,6 +7,11 @@ import type {
|
||||
TrackProperties,
|
||||
} from '@openpanel/web';
|
||||
import { getInitSnippet } from '@openpanel/web';
|
||||
// adding .js next/script import fixes an issues
|
||||
// with esm and nextjs (when using pages dir)
|
||||
import Script from 'next/script.js';
|
||||
// biome-ignore lint/correctness/noUnusedImports: nextjs requires this
|
||||
import React from 'react';
|
||||
|
||||
export * from '@openpanel/web';
|
||||
|
||||
@@ -19,7 +19,9 @@ const CDN_URL = 'https://openpanel.dev/op1.js';
|
||||
|
||||
type OpenPanelComponentProps = Omit<OpenPanelOptions, 'filter'> & {
|
||||
profileId?: string;
|
||||
/** @deprecated Use `scriptUrl` instead. */
|
||||
cdnUrl?: string;
|
||||
scriptUrl?: string;
|
||||
filter?: string;
|
||||
globalProperties?: Record<string, unknown>;
|
||||
strategy?: 'beforeInteractive' | 'afterInteractive' | 'lazyOnload' | 'worker';
|
||||
@@ -42,6 +44,7 @@ const stringify = (obj: unknown) => {
|
||||
export function OpenPanelComponent({
|
||||
profileId,
|
||||
cdnUrl,
|
||||
scriptUrl,
|
||||
globalProperties,
|
||||
strategy = 'afterInteractive',
|
||||
...options
|
||||
@@ -80,10 +83,8 @@ export function OpenPanelComponent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script src={appendVersion(cdnUrl || CDN_URL)} async defer />
|
||||
<Script async defer src={appendVersion(scriptUrl || cdnUrl || CDN_URL)} />
|
||||
<Script
|
||||
id="openpanel-init"
|
||||
strategy={strategy}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `${getInitSnippet()}
|
||||
${methods
|
||||
@@ -92,6 +93,8 @@ export function OpenPanelComponent({
|
||||
})
|
||||
.join('\n')}`,
|
||||
}}
|
||||
id="openpanel-init"
|
||||
strategy={strategy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -101,25 +104,21 @@ type IdentifyComponentProps = IdentifyPayload;
|
||||
|
||||
export function IdentifyComponent(props: IdentifyComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.op('identify', ${JSON.stringify(props)});`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.op('identify', ${JSON.stringify(props)});`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetGlobalPropertiesComponent(props: Record<string, unknown>) {
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.op('setGlobalProperties', ${JSON.stringify(props)});`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
<Script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.op('setGlobalProperties', ${JSON.stringify(props)});`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,6 +136,7 @@ export function useOpenPanel() {
|
||||
clearRevenue,
|
||||
pendingRevenue,
|
||||
fetchDeviceId,
|
||||
getDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ function screenView(properties?: TrackProperties): void;
|
||||
function screenView(path: string, properties?: TrackProperties): void;
|
||||
function screenView(
|
||||
pathOrProperties?: string | TrackProperties,
|
||||
propertiesOrUndefined?: TrackProperties,
|
||||
propertiesOrUndefined?: TrackProperties
|
||||
) {
|
||||
window.op?.('screenView', pathOrProperties, propertiesOrUndefined);
|
||||
}
|
||||
@@ -172,6 +172,9 @@ function decrement(payload: DecrementPayload) {
|
||||
function fetchDeviceId() {
|
||||
return window.op.fetchDeviceId();
|
||||
}
|
||||
function getDeviceId() {
|
||||
return window.op.getDeviceId();
|
||||
}
|
||||
function clearRevenue() {
|
||||
window.op.clearRevenue();
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export type OpenPanelOptions = {
|
||||
export class OpenPanel {
|
||||
api: Api;
|
||||
profileId?: string;
|
||||
deviceId?: string;
|
||||
sessionId?: string;
|
||||
global?: Record<string, unknown>;
|
||||
queue: TrackHandlerPayload[] = [];
|
||||
|
||||
@@ -69,6 +71,16 @@ export class OpenPanel {
|
||||
this.flush();
|
||||
}
|
||||
|
||||
private shouldQueue(payload: TrackHandlerPayload): boolean {
|
||||
if (payload.type === 'replay' && !this.sessionId) {
|
||||
return true;
|
||||
}
|
||||
if (this.options.waitForProfile && !this.profileId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async send(payload: TrackHandlerPayload) {
|
||||
if (this.options.disabled) {
|
||||
return Promise.resolve();
|
||||
@@ -78,11 +90,26 @@ export class OpenPanel {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.options.waitForProfile && !this.profileId) {
|
||||
if (this.shouldQueue(payload)) {
|
||||
this.queue.push(payload);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return this.api.fetch('/track', payload);
|
||||
|
||||
// Disable keepalive for replay since it has a hard body limit and breaks the request
|
||||
const result = await this.api.fetch<
|
||||
TrackHandlerPayload,
|
||||
{ deviceId: string; sessionId: string }
|
||||
>('/track', payload, { keepalive: payload.type !== 'replay' });
|
||||
this.deviceId = result?.deviceId;
|
||||
const hadSession = !!this.sessionId;
|
||||
this.sessionId = result?.sessionId;
|
||||
|
||||
// Flush queued items (e.g. replay chunks) when sessionId first arrives
|
||||
if (!hadSession && this.sessionId) {
|
||||
this.flush();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
setGlobalProperties(properties: Record<string, unknown>) {
|
||||
@@ -149,7 +176,7 @@ export class OpenPanel {
|
||||
|
||||
async revenue(
|
||||
amount: number,
|
||||
properties?: TrackProperties & { deviceId?: string },
|
||||
properties?: TrackProperties & { deviceId?: string }
|
||||
) {
|
||||
const deviceId = properties?.deviceId;
|
||||
delete properties?.deviceId;
|
||||
@@ -160,33 +187,47 @@ export class OpenPanel {
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDeviceId(): Promise<string> {
|
||||
const result = await this.api.fetch<undefined, { deviceId: string }>(
|
||||
'/track/device-id',
|
||||
undefined,
|
||||
{ method: 'GET', keepalive: false },
|
||||
);
|
||||
return result?.deviceId ?? '';
|
||||
getDeviceId(): string {
|
||||
return this.deviceId ?? '';
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `getDeviceId()` instead. This async method is no longer needed.
|
||||
*/
|
||||
fetchDeviceId(): Promise<string> {
|
||||
return Promise.resolve(this.deviceId ?? '');
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.profileId = undefined;
|
||||
// should we force a session end here?
|
||||
this.deviceId = undefined;
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
|
||||
flush() {
|
||||
this.queue.forEach((item) => {
|
||||
this.send({
|
||||
...item,
|
||||
// Not sure why ts-expect-error is needed here
|
||||
// @ts-expect-error
|
||||
payload: {
|
||||
...item.payload,
|
||||
profileId: item.payload.profileId ?? this.profileId,
|
||||
},
|
||||
});
|
||||
});
|
||||
this.queue = [];
|
||||
const remaining: TrackHandlerPayload[] = [];
|
||||
for (const item of this.queue) {
|
||||
if (this.shouldQueue(item)) {
|
||||
remaining.push(item);
|
||||
continue;
|
||||
}
|
||||
const payload =
|
||||
item.type === 'replay'
|
||||
? item.payload
|
||||
: {
|
||||
...item.payload,
|
||||
profileId:
|
||||
'profileId' in item.payload
|
||||
? (item.payload.profileId ?? this.profileId)
|
||||
: this.profileId,
|
||||
};
|
||||
this.send({ ...item, payload } as TrackHandlerPayload);
|
||||
}
|
||||
this.queue = remaining;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openpanel/sdk": "workspace:1.0.4-local"
|
||||
"@openpanel/sdk": "workspace:1.0.4-local",
|
||||
"@rrweb/types": "2.0.0-alpha.20",
|
||||
"rrweb": "2.0.0-alpha.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
@@ -18,4 +20,4 @@
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,43 @@ import { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
||||
export type * from '@openpanel/sdk';
|
||||
export { OpenPanel as OpenPanelBase } from '@openpanel/sdk';
|
||||
|
||||
export type SessionReplayOptions = {
|
||||
enabled: boolean;
|
||||
sampleRate?: number;
|
||||
maskAllInputs?: boolean;
|
||||
maskTextSelector?: string;
|
||||
blockSelector?: string;
|
||||
blockClass?: string;
|
||||
ignoreSelector?: string;
|
||||
flushIntervalMs?: number;
|
||||
maxEventsPerChunk?: number;
|
||||
maxPayloadBytes?: number;
|
||||
/**
|
||||
* URL to the replay recorder script.
|
||||
* Only used when loading the SDK via a script tag (IIFE / op1.js).
|
||||
* When using the npm package with a bundler this option is ignored
|
||||
* because the bundler resolves the replay module from the package.
|
||||
*/
|
||||
scriptUrl?: string;
|
||||
};
|
||||
|
||||
// Injected at build time only in the IIFE (tracker) build.
|
||||
// In the library build this is `undefined`.
|
||||
declare const __OPENPANEL_REPLAY_URL__: string | undefined;
|
||||
|
||||
// Capture script element synchronously; currentScript is only set during sync execution.
|
||||
// Used by loadReplayModule() to derive the replay script URL in the IIFE build.
|
||||
const _replayScriptRef: HTMLScriptElement | null =
|
||||
typeof document !== 'undefined'
|
||||
? (document.currentScript as HTMLScriptElement | null)
|
||||
: null;
|
||||
|
||||
export type OpenPanelOptions = OpenPanelBaseOptions & {
|
||||
trackOutgoingLinks?: boolean;
|
||||
trackScreenViews?: boolean;
|
||||
trackAttributes?: boolean;
|
||||
trackHashChanges?: boolean;
|
||||
sessionReplay?: SessionReplayOptions;
|
||||
};
|
||||
|
||||
function toCamelCase(str: string) {
|
||||
@@ -66,6 +98,75 @@ export class OpenPanel extends OpenPanelBase {
|
||||
if (this.options.trackAttributes) {
|
||||
this.trackAttributes();
|
||||
}
|
||||
|
||||
if (this.options.sessionReplay?.enabled) {
|
||||
const sampleRate = this.options.sessionReplay.sampleRate ?? 1;
|
||||
const sampled = Math.random() < sampleRate;
|
||||
if (sampled) {
|
||||
this.loadReplayModule().then((mod) => {
|
||||
if (!mod) return;
|
||||
mod.startReplayRecorder(this.options.sessionReplay!, (chunk) => {
|
||||
this.send({
|
||||
type: 'replay',
|
||||
payload: {
|
||||
...chunk,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the replay recorder module.
|
||||
*
|
||||
* - **IIFE build (op1.js)**: `__OPENPANEL_REPLAY_URL__` is replaced at
|
||||
* build time with a CDN URL (e.g. `https://openpanel.dev/op1-replay.js`).
|
||||
* The user can also override it via `sessionReplay.scriptUrl`.
|
||||
* We load the IIFE replay script via a classic `<script>` tag which
|
||||
* avoids CORS issues (dynamic `import(url)` uses `cors` mode).
|
||||
* The IIFE exposes its exports on `window.__openpanel_replay`.
|
||||
*
|
||||
* - **Library build (npm)**: `__OPENPANEL_REPLAY_URL__` is `undefined`
|
||||
* (never replaced). We use `import('./replay')` which the host app's
|
||||
* bundler resolves and code-splits from the package source.
|
||||
*/
|
||||
private async loadReplayModule(): Promise<typeof import('./replay') | null> {
|
||||
try {
|
||||
// typeof check avoids a ReferenceError when the constant is not
|
||||
// defined (library build). tsup replaces the constant with a
|
||||
// string literal only in the IIFE build, so this branch is
|
||||
// dead-code-eliminated in the library build.
|
||||
if (typeof __OPENPANEL_REPLAY_URL__ !== 'undefined') {
|
||||
const scriptEl = _replayScriptRef;
|
||||
const url = this.options.sessionReplay?.scriptUrl || scriptEl?.src?.replace('.js', '-replay.js') || 'https://openpanel.dev/op1-replay.js';
|
||||
|
||||
// Already loaded (e.g. user included the script manually)
|
||||
if ((window as any).__openpanel_replay) {
|
||||
return (window as any).__openpanel_replay;
|
||||
}
|
||||
|
||||
// Load via classic <script> tag — no CORS restrictions
|
||||
return new Promise((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.onload = () => {
|
||||
resolve((window as any).__openpanel_replay ?? null);
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.warn('[OpenPanel] Failed to load replay script from', url);
|
||||
resolve(null);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
// Library / bundler context — resolved by the bundler
|
||||
return await import('./replay');
|
||||
} catch (e) {
|
||||
console.warn('[OpenPanel] Failed to load replay module', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2
packages/sdks/web/src/replay/index.ts
Normal file
2
packages/sdks/web/src/replay/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { startReplayRecorder, stopReplayRecorder } from './recorder';
|
||||
export type { ReplayChunkPayload, ReplayRecorderConfig } from './recorder';
|
||||
160
packages/sdks/web/src/replay/recorder.ts
Normal file
160
packages/sdks/web/src/replay/recorder.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { eventWithTime } from 'rrweb';
|
||||
import { record } from 'rrweb';
|
||||
|
||||
export type ReplayRecorderConfig = {
|
||||
maskAllInputs?: boolean;
|
||||
maskTextSelector?: string;
|
||||
blockSelector?: string;
|
||||
blockClass?: string;
|
||||
ignoreSelector?: string;
|
||||
flushIntervalMs?: number;
|
||||
maxEventsPerChunk?: number;
|
||||
maxPayloadBytes?: number;
|
||||
};
|
||||
|
||||
export type ReplayChunkPayload = {
|
||||
chunk_index: number;
|
||||
events_count: number;
|
||||
is_full_snapshot: boolean;
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
payload: string;
|
||||
};
|
||||
|
||||
let stopRecording: (() => void) | null = null;
|
||||
|
||||
export function startReplayRecorder(
|
||||
config: ReplayRecorderConfig,
|
||||
sendChunk: (payload: ReplayChunkPayload) => void,
|
||||
): void {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop any existing recorder before starting a new one to avoid leaks
|
||||
if (stopRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
|
||||
const maxEventsPerChunk = config.maxEventsPerChunk ?? 200;
|
||||
const flushIntervalMs = config.flushIntervalMs ?? 10_000;
|
||||
const maxPayloadBytes = config.maxPayloadBytes ?? 1_048_576; // 1MB
|
||||
|
||||
let buffer: eventWithTime[] = [];
|
||||
let chunkIndex = 0;
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function flush(isFullSnapshot: boolean): void {
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
const payloadJson = JSON.stringify(buffer);
|
||||
const payloadBytes = new TextEncoder().encode(payloadJson).length;
|
||||
|
||||
if (payloadBytes > maxPayloadBytes) {
|
||||
if (buffer.length > 1) {
|
||||
const mid = Math.floor(buffer.length / 2);
|
||||
const firstHalf = buffer.slice(0, mid);
|
||||
const secondHalf = buffer.slice(mid);
|
||||
const firstHasFullSnapshot =
|
||||
isFullSnapshot && firstHalf.some((e) => e.type === 2);
|
||||
buffer = firstHalf;
|
||||
flush(firstHasFullSnapshot);
|
||||
buffer = secondHalf;
|
||||
flush(false);
|
||||
return;
|
||||
}
|
||||
// Single event exceeds limit — drop it to avoid server rejection
|
||||
buffer = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = buffer[0]!.timestamp;
|
||||
const endedAt = buffer[buffer.length - 1]!.timestamp;
|
||||
|
||||
try {
|
||||
sendChunk({
|
||||
chunk_index: chunkIndex,
|
||||
events_count: buffer.length,
|
||||
is_full_snapshot: isFullSnapshot,
|
||||
started_at: new Date(startedAt).toISOString(),
|
||||
ended_at: new Date(endedAt).toISOString(),
|
||||
payload: payloadJson,
|
||||
});
|
||||
chunkIndex += 1;
|
||||
buffer = [];
|
||||
} catch (err) {
|
||||
console.error('[ReplayRecorder] sendChunk failed', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function flushIfNeeded(isCheckout: boolean): void {
|
||||
const isFullSnapshot =
|
||||
isCheckout ||
|
||||
buffer.some((e) => e.type === 2); /* EventType.FullSnapshot */
|
||||
if (buffer.length >= maxEventsPerChunk) {
|
||||
flush(isFullSnapshot);
|
||||
} else if (isCheckout && buffer.length > 0) {
|
||||
flush(true);
|
||||
}
|
||||
}
|
||||
|
||||
const stopFn = record({
|
||||
emit(event: eventWithTime, isCheckout?: boolean) {
|
||||
buffer.push(event);
|
||||
flushIfNeeded(!!isCheckout);
|
||||
},
|
||||
checkoutEveryNms: flushIntervalMs,
|
||||
maskAllInputs: config.maskAllInputs ?? true,
|
||||
maskTextSelector: config.maskTextSelector ?? '[data-openpanel-replay-mask]',
|
||||
blockSelector: config.blockSelector ?? '[data-openpanel-replay-block]',
|
||||
blockClass: config.blockClass,
|
||||
ignoreSelector: config.ignoreSelector,
|
||||
});
|
||||
|
||||
flushTimer = setInterval(() => {
|
||||
if (buffer.length > 0) {
|
||||
const hasFullSnapshot = buffer.some((e) => e.type === 2);
|
||||
flush(hasFullSnapshot);
|
||||
}
|
||||
}, flushIntervalMs);
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
if (document.visibilityState === 'hidden' && buffer.length > 0) {
|
||||
const hasFullSnapshot = buffer.some((e) => e.type === 2);
|
||||
flush(hasFullSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function onPageHide(): void {
|
||||
if (buffer.length > 0) {
|
||||
const hasFullSnapshot = buffer.some((e) => e.type === 2);
|
||||
flush(hasFullSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
|
||||
stopRecording = () => {
|
||||
// Flush any buffered events before tearing down (same logic as flushTimer)
|
||||
if (buffer.length > 0) {
|
||||
const hasFullSnapshot = buffer.some((e) => e.type === 2);
|
||||
flush(hasFullSnapshot);
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
stopFn?.();
|
||||
stopRecording = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function stopReplayRecorder(): void {
|
||||
if (stopRecording) {
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
6
packages/sdks/web/src/types.d.ts
vendored
6
packages/sdks/web/src/types.d.ts
vendored
@@ -14,7 +14,9 @@ type ExposedMethodsNames =
|
||||
| 'clearRevenue'
|
||||
| 'pendingRevenue'
|
||||
| 'screenView'
|
||||
| 'fetchDeviceId';
|
||||
| 'fetchDeviceId'
|
||||
| 'getDeviceId'
|
||||
| 'getSessionId';
|
||||
|
||||
export type ExposedMethods = {
|
||||
[K in ExposedMethodsNames]: OpenPanel[K] extends (...args: any[]) => any
|
||||
@@ -38,7 +40,7 @@ type OpenPanelMethodSignatures = {
|
||||
} & {
|
||||
screenView(
|
||||
pathOrProperties?: string | TrackProperties,
|
||||
properties?: TrackProperties,
|
||||
properties?: TrackProperties
|
||||
): void;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Test callable function API
|
||||
/** biome-ignore-all lint/correctness/noUnusedVariables: test */
|
||||
function testCallableAPI() {
|
||||
// ✅ Should work - correct callable syntax
|
||||
window.op('track', 'button_clicked', { location: 'header' });
|
||||
@@ -29,6 +30,7 @@ function testDirectMethodAPI() {
|
||||
window.op.flushRevenue();
|
||||
window.op.clearRevenue();
|
||||
window.op.fetchDeviceId();
|
||||
window.op.getDeviceId();
|
||||
|
||||
// ❌ Should error - wrong arguments for track
|
||||
// @ts-expect-error - track expects (name: string, properties?: TrackProperties)
|
||||
|
||||
@@ -1,11 +1,70 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['index.ts', 'src/tracker.ts'],
|
||||
format: ['cjs', 'esm', 'iife'],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
clean: true,
|
||||
minify: true,
|
||||
});
|
||||
export default defineConfig([
|
||||
// Library build (npm package) — cjs + esm + dts
|
||||
// Dynamic import('./replay') is preserved; the host app's bundler
|
||||
// will code-split it into a separate chunk automatically.
|
||||
{
|
||||
entry: ['index.ts'],
|
||||
format: ['cjs', 'esm'],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
clean: true,
|
||||
minify: true,
|
||||
},
|
||||
// IIFE build (script tag: op1.js)
|
||||
// __OPENPANEL_REPLAY_URL__ is injected at build time so the IIFE
|
||||
// knows to load the replay module from the CDN instead of a
|
||||
// relative import (which doesn't work in a standalone script).
|
||||
// The replay module is excluded via an esbuild plugin so it is
|
||||
// never bundled into op1.js — it will be loaded lazily via <script>.
|
||||
{
|
||||
entry: { 'src/tracker': 'src/tracker.ts' },
|
||||
format: ['iife'],
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
__OPENPANEL_REPLAY_URL__: JSON.stringify(
|
||||
'https://openpanel.dev/op1-replay.js'
|
||||
),
|
||||
},
|
||||
esbuildPlugins: [
|
||||
{
|
||||
name: 'exclude-replay-from-iife',
|
||||
setup(build) {
|
||||
// Intercept any import that resolves to the replay module and
|
||||
// return an empty object. The actual loading happens at runtime
|
||||
// via a <script> tag (see loadReplayModule in index.ts).
|
||||
build.onResolve(
|
||||
{ filter: /[/\\]replay([/\\]index)?(\.[jt]s)?$/ },
|
||||
() => ({
|
||||
path: 'replay-empty-stub',
|
||||
namespace: 'replay-stub',
|
||||
})
|
||||
);
|
||||
build.onLoad({ filter: /.*/, namespace: 'replay-stub' }, () => ({
|
||||
contents: 'module.exports = {}',
|
||||
loader: 'js',
|
||||
}));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Replay module — built as both ESM (npm) and IIFE (CDN).
|
||||
// ESM → consumed by the host-app's bundler via `import('./replay')`.
|
||||
// IIFE → loaded at runtime via a classic <script> tag (no CORS issues).
|
||||
// Exposes `window.__openpanel_replay`.
|
||||
// rrweb must be bundled in (noExternal) because browsers can't resolve
|
||||
// bare specifiers like "rrweb" from a standalone ES module / script.
|
||||
{
|
||||
entry: { 'src/replay': 'src/replay/index.ts' },
|
||||
format: ['esm', 'iife'],
|
||||
globalName: '__openpanel_replay',
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
noExternal: ['rrweb', '@rrweb/types'],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user