@segment/analytics-next
Version:
Analytics Next (aka Analytics 2.0) is the latest version of Segment’s JavaScript SDK - enabling you to send your data to any tool without having to learn, test, or use a new API every time.
404 lines (362 loc) • 11.6 kB
text/typescript
import { Analytics } from '../analytics'
import { Context } from '../context'
import { isThenable } from '../../lib/is-thenable'
import { AnalyticsBrowserCore } from '../analytics/interfaces'
import { version } from '../../generated/version'
import { getGlobalAnalytics } from '../../lib/global-analytics-helper'
import {
isBufferedPageContext,
BufferedPageContext,
getDefaultBufferedPageContext,
createPageContext,
PageContext,
} from '../page'
import { getVersionType } from '../../lib/version-type'
/**
* The names of any AnalyticsBrowser methods that also exist on Analytics
*/
export type PreInitMethodName =
| 'screen'
| 'register'
| 'deregister'
| 'user'
| 'trackSubmit'
| 'trackClick'
| 'trackLink'
| 'trackForm'
| 'pageview'
| 'identify'
| 'reset'
| 'group'
| 'track'
| 'ready'
| 'alias'
| 'debug'
| 'page'
| 'once'
| 'off'
| 'on'
| 'addSourceMiddleware'
| 'setAnonymousId'
| 'addDestinationMiddleware'
// Union of all analytics methods that _do not_ return a Promise
type SyncPreInitMethodName = {
[MethodName in PreInitMethodName]: ReturnType<
Analytics[MethodName]
> extends Promise<any>
? never
: MethodName
}[PreInitMethodName]
const flushSyncAnalyticsCalls = (
name: SyncPreInitMethodName,
analytics: Analytics,
buffer: PreInitMethodCallBuffer
): void => {
buffer.getAndRemove(name).forEach((c) => {
// While the underlying methods are synchronous, the callAnalyticsMethod returns a promise,
// which normalizes success and error states between async and non-async methods, with no perf penalty.
callAnalyticsMethod(analytics, c).catch(console.error)
})
}
export const flushAddSourceMiddleware = async (
analytics: Analytics,
buffer: PreInitMethodCallBuffer
) => {
for (const c of buffer.getAndRemove('addSourceMiddleware')) {
await callAnalyticsMethod(analytics, c).catch(console.error)
}
}
/**
* Flush register plugin
*/
export const flushRegister = async (
analytics: Analytics,
buffer: PreInitMethodCallBuffer
) => {
for (const c of buffer.getAndRemove('register')) {
await callAnalyticsMethod(analytics, c).catch(console.error)
}
}
export const flushOn = flushSyncAnalyticsCalls.bind(this, 'on')
export const flushSetAnonymousID = flushSyncAnalyticsCalls.bind(
this,
'setAnonymousId'
)
export const flushAnalyticsCallsInNewTask = (
analytics: Analytics,
buffer: PreInitMethodCallBuffer
): void => {
;(Object.keys(buffer.calls) as (keyof typeof buffer.calls)[]).forEach((m) => {
buffer.getAndRemove(m).forEach((c) => {
// No one remembers why this event loop optimization is/was neccessary. Lost to history.
setTimeout(() => {
callAnalyticsMethod(analytics, c).catch(console.error)
}, 0)
})
})
}
export const popPageContext = (args: unknown[]): PageContext | undefined => {
if (hasBufferedPageContextAsLastArg(args)) {
const ctx = args.pop() as BufferedPageContext
return createPageContext(ctx)
}
}
export const hasBufferedPageContextAsLastArg = (
args: unknown[]
): args is [...unknown[], BufferedPageContext] | [BufferedPageContext] => {
const lastArg = args[args.length - 1]
return isBufferedPageContext(lastArg)
}
/**
* Represents a buffered method call that occurred before initialization.
*/
export class PreInitMethodCall<
MethodName extends PreInitMethodName = PreInitMethodName
> {
method: MethodName
args: PreInitMethodParams<MethodName>
called: boolean
resolve: (v: ReturnType<Analytics[MethodName]>) => void
reject: (reason: any) => void
constructor(
method: PreInitMethodCall<MethodName>['method'],
args: PreInitMethodParams<MethodName>,
resolve: PreInitMethodCall<MethodName>['resolve'] = () => {},
reject: PreInitMethodCall<MethodName>['reject'] = console.error
) {
this.method = method
this.resolve = resolve
this.reject = reject
this.called = false
this.args = args
}
}
export type PreInitMethodParams<MethodName extends PreInitMethodName> =
| [...Parameters<Analytics[MethodName]>, BufferedPageContext]
| Parameters<Analytics[MethodName]>
/**
* Infer return type; if return type is promise, unwrap it.
*/
type ReturnTypeUnwrap<Fn> = Fn extends (...args: any[]) => infer ReturnT
? ReturnT extends PromiseLike<infer Unwrapped>
? Unwrapped
: ReturnT
: never
type MethodCallMap = Partial<Record<PreInitMethodName, PreInitMethodCall[]>>
type SnippetWindowBufferedMethodCall<
MethodName extends PreInitMethodName = PreInitMethodName
> = [MethodName, ...PreInitMethodParams<MethodName>]
/**
* A list of the method calls before initialization for snippet users
* For example, [["track", "foo", {bar: 123}], ["page"], ["on", "ready", function(){..}]
*/
type SnippetBuffer = SnippetWindowBufferedMethodCall[]
/**
* Represents any and all the buffered method calls that occurred before initialization.
*/
export class PreInitMethodCallBuffer {
private _callMap: MethodCallMap = {}
constructor(...calls: PreInitMethodCall[]) {
this.add(...calls)
}
/**
* Pull any buffered method calls from the window object, and use them to hydrate the instance buffer.
*/
public get calls() {
this._pushSnippetWindowBuffer()
return this._callMap
}
private set calls(calls: MethodCallMap) {
this._callMap = calls
}
get<T extends PreInitMethodName>(methodName: T): PreInitMethodCall<T>[] {
return (this.calls[methodName] ?? []) as PreInitMethodCall<T>[]
}
/**
* Get all buffered method calls for a given method name, and clear them from the buffer.
*/
getAndRemove<T extends PreInitMethodName>(
methodName: T
): PreInitMethodCall<T>[] {
const calls = this.get(methodName)
this.calls[methodName] = []
return calls
}
add(...calls: PreInitMethodCall[]): void {
calls.forEach((call) => {
const eventsExpectingPageContext: PreInitMethodName[] = [
'track',
'screen',
'alias',
'group',
'page',
'identify',
]
if (
eventsExpectingPageContext.includes(call.method) &&
!hasBufferedPageContextAsLastArg(call.args)
) {
call.args = [...call.args, getDefaultBufferedPageContext()]
}
if (this.calls[call.method]) {
this.calls[call.method]!.push(call)
} else {
this.calls[call.method] = [call]
}
})
}
clear(): void {
// clear calls in the global snippet buffered array.
this._pushSnippetWindowBuffer()
// clear calls in this instance
this.calls = {}
}
toArray(): PreInitMethodCall[] {
return ([] as PreInitMethodCall[]).concat(...Object.values(this.calls))
}
/**
* Fetch the buffered method calls from the window object,
* normalize them, and use them to hydrate the buffer.
* This removes existing buffered calls from the window object.
*/
private _pushSnippetWindowBuffer(): void {
// if this is the npm version, we don't want to read from the window object.
// This avoids namespace conflicts if there is a seperate analytics library on the page.
if (getVersionType() === 'npm') {
return undefined
}
const wa = getGlobalAnalytics()
if (!Array.isArray(wa)) return undefined
const buffered: SnippetBuffer = wa.splice(0, wa.length)
const calls = buffered.map(
([methodName, ...args]) => new PreInitMethodCall(methodName, args)
)
this.add(...calls)
}
}
/**
* Call method and mark as "called"
* This function should never throw an error
*/
export async function callAnalyticsMethod<T extends PreInitMethodName>(
analytics: Analytics,
call: PreInitMethodCall<T>
): Promise<void> {
try {
if (call.called) {
return undefined
}
call.called = true
const result: ReturnType<Analytics[T]> = (
analytics[call.method] as Function
)(...call.args)
if (isThenable(result)) {
// do not defer for non-async methods
await result
}
call.resolve(result)
} catch (err) {
call.reject(err)
}
}
export type AnalyticsLoader = (
preInitBuffer: PreInitMethodCallBuffer
) => Promise<[Analytics, Context]>
export class AnalyticsBuffered
implements PromiseLike<[Analytics, Context]>, AnalyticsBrowserCore
{
instance?: Analytics
ctx?: Context
private _preInitBuffer: PreInitMethodCallBuffer
private _promise: Promise<[Analytics, Context]>
constructor(loader: AnalyticsLoader) {
this._preInitBuffer = new PreInitMethodCallBuffer()
this._promise = loader(this._preInitBuffer)
this._promise
.then(([ajs, ctx]) => {
this.instance = ajs
this.ctx = ctx
})
.catch(() => {
// intentionally do nothing...
// this result of this promise will be caught by the 'catch' block on this class.
})
}
then<T1, T2 = never>(
...args: [
onfulfilled:
| ((instance: [Analytics, Context]) => T1 | PromiseLike<T1>)
| null
| undefined,
onrejected?: (reason: unknown) => T2 | PromiseLike<T2>
]
) {
return this._promise.then(...args)
}
catch<TResult = never>(
...args: [
onrejected?:
| ((reason: any) => TResult | PromiseLike<TResult>)
| undefined
| null
]
) {
return this._promise.catch(...args)
}
finally(...args: [onfinally?: (() => void) | undefined | null]) {
return this._promise.finally(...args)
}
trackSubmit = this._createMethod('trackSubmit')
trackClick = this._createMethod('trackClick')
trackLink = this._createMethod('trackLink')
pageView = this._createMethod('pageview')
identify = this._createMethod('identify')
reset = this._createMethod('reset')
group = this._createMethod('group') as AnalyticsBrowserCore['group']
track = this._createMethod('track')
ready = this._createMethod('ready')
alias = this._createMethod('alias')
debug = this._createChainableMethod('debug')
page = this._createMethod('page')
once = this._createChainableMethod('once')
off = this._createChainableMethod('off')
on = this._createChainableMethod('on')
addSourceMiddleware = this._createMethod('addSourceMiddleware')
setAnonymousId = this._createMethod('setAnonymousId')
addDestinationMiddleware = this._createMethod('addDestinationMiddleware')
screen = this._createMethod('screen')
register = this._createMethod('register')
deregister = this._createMethod('deregister')
user = this._createMethod('user')
readonly VERSION = version
private _createMethod<T extends PreInitMethodName>(methodName: T) {
return (
...args: Parameters<Analytics[T]>
): Promise<ReturnTypeUnwrap<Analytics[T]>> => {
if (this.instance) {
const result = (this.instance[methodName] as Function)(...args)
return Promise.resolve(result)
}
return new Promise((resolve, reject) => {
this._preInitBuffer.add(
new PreInitMethodCall(methodName, args, resolve as any, reject)
)
})
}
}
/**
* These are for methods that where determining when the method gets "flushed" is not important.
* These methods will resolve when analytics is fully initialized, and return type (other than Analytics)will not be available.
*/
private _createChainableMethod<T extends PreInitMethodName>(methodName: T) {
return (...args: Parameters<Analytics[T]>): AnalyticsBuffered => {
if (this.instance) {
void (this.instance[methodName] as Function)(...args)
return this
} else {
this._preInitBuffer.add(new PreInitMethodCall(methodName, args))
}
return this
}
}
}