UNPKG

@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.

318 lines (269 loc) 8.97 kB
import type { Integrations } from '../../core/events/interfaces' import { CDNSettings } from '../../browser' import { JSONObject, JSONValue } from '../../core/events' import { Plugin, InternalPluginWithAddMiddleware } from '../../core/plugin' import { loadScript } from '../../lib/load-script' import { getCDN } from '../../lib/parse-cdn' import { applyDestinationMiddleware, DestinationMiddlewareFunction, } from '../middleware' import { Context, ContextCancelation } from '../../core/context' import { recordIntegrationMetric } from '../../core/stats/metric-helpers' import { Analytics, InitOptions } from '../../core/analytics' import { createDeferred } from '@segment/analytics-generic-utils' export interface RemotePlugin { /** The name of the remote plugin */ name: string /** The creation name of the remote plugin */ creationName: string /** The url of the javascript file to load */ url: string /** The UMD/global name the plugin uses. Plugins are expected to exist here with the `PluginFactory` method signature */ libraryName: string /** The settings related to this plugin. */ settings: JSONObject } export class ActionDestination implements InternalPluginWithAddMiddleware { name: string // destination name version = '1.0.0' /** * The lifecycle name of the wrapped plugin. * This does not need to be 'destination', and can be 'enrichment', etc. */ type: Plugin['type'] alternativeNames: string[] = [] private loadPromise = createDeferred<unknown>() middleware: DestinationMiddlewareFunction[] = [] action: Plugin constructor(name: string, action: Plugin) { this.action = action this.name = name this.type = action.type this.alternativeNames.push(action.name) } addMiddleware(...fn: DestinationMiddlewareFunction[]): void { /** Make sure we only apply destination filters to actions of the "destination" type to avoid causing issues for hybrid destinations */ if (this.type === 'destination') { this.middleware.push(...fn) } } private async transform(ctx: Context): Promise<Context> { const modifiedEvent = await applyDestinationMiddleware( this.name, ctx.event, this.middleware ) if (modifiedEvent === null) { ctx.cancel( new ContextCancelation({ retry: false, reason: 'dropped by destination middleware', }) ) } return new Context(modifiedEvent!) } private _createMethod( methodName: 'track' | 'page' | 'identify' | 'alias' | 'group' | 'screen' ) { return async (ctx: Context): Promise<Context> => { if (!this.action[methodName]) return ctx let transformedContext: Context = ctx // Transformations only allowed for destination plugins. Other plugin types support mutating events. if (this.type === 'destination') { transformedContext = await this.transform(ctx) } try { if (!(await this.ready())) { throw new Error( 'Something prevented the destination from getting ready' ) } recordIntegrationMetric(ctx, { integrationName: this.action.name, methodName, type: 'action', }) await this.action[methodName]!(transformedContext) } catch (error) { recordIntegrationMetric(ctx, { integrationName: this.action.name, methodName, type: 'action', didError: true, }) throw error } return ctx } } alias = this._createMethod('alias') group = this._createMethod('group') identify = this._createMethod('identify') page = this._createMethod('page') screen = this._createMethod('screen') track = this._createMethod('track') /* --- PASSTHROUGH METHODS --- */ isLoaded(): boolean { return this.action.isLoaded() } async ready(): Promise<boolean> { try { await this.loadPromise.promise return true } catch { return false } } async load(ctx: Context, analytics: Analytics): Promise<unknown> { if (this.loadPromise.isSettled()) { return this.loadPromise.promise } try { recordIntegrationMetric(ctx, { integrationName: this.action.name, methodName: 'load', type: 'action', }) const loadP = this.action.load(ctx, analytics) this.loadPromise.resolve(await loadP) return loadP } catch (error) { recordIntegrationMetric(ctx, { integrationName: this.action.name, methodName: 'load', type: 'action', didError: true, }) this.loadPromise.reject(error) throw error } } unload(ctx: Context, analytics: Analytics): Promise<unknown> | unknown { return this.action.unload?.(ctx, analytics) } } export type PluginFactory = { (settings: JSONValue): Plugin | Plugin[] | Promise<Plugin | Plugin[]> pluginName: string } function validate(pluginLike: unknown): pluginLike is Plugin[] { if (!Array.isArray(pluginLike)) { throw new Error('Not a valid list of plugins') } const required = ['load', 'isLoaded', 'name', 'version', 'type'] pluginLike.forEach((plugin) => { required.forEach((method) => { if (plugin[method] === undefined) { throw new Error( `Plugin: ${ plugin.name ?? 'unknown' } missing required function ${method}` ) } }) }) return true } function isPluginDisabled( userIntegrations: Integrations, remotePlugin: RemotePlugin ) { const creationNameEnabled = userIntegrations[remotePlugin.creationName] const currentNameEnabled = userIntegrations[remotePlugin.name] // Check that the plugin isn't explicitly enabled when All: false if ( userIntegrations.All === false && !creationNameEnabled && !currentNameEnabled ) { return true } // Check that the plugin isn't explicitly disabled if (creationNameEnabled === false || currentNameEnabled === false) { return true } return false } async function loadPluginFactory( remotePlugin: RemotePlugin, obfuscate?: boolean ): Promise<void | PluginFactory> { try { const defaultCdn = new RegExp('https://cdn.segment.(com|build)') const cdn = getCDN() if (obfuscate) { const urlSplit = remotePlugin.url.split('/') const name = urlSplit[urlSplit.length - 2] const obfuscatedURL = remotePlugin.url.replace( name, btoa(name).replace(/=/g, '') ) try { await loadScript(obfuscatedURL.replace(defaultCdn, cdn)) } catch (error) { // Due to syncing concerns it is possible that the obfuscated action destination (or requested version) might not exist. // We should use the unobfuscated version as a fallback. await loadScript(remotePlugin.url.replace(defaultCdn, cdn)) } } else { await loadScript(remotePlugin.url.replace(defaultCdn, cdn)) } // @ts-expect-error if (typeof window[remotePlugin.libraryName] === 'function') { // @ts-expect-error return window[remotePlugin.libraryName] as PluginFactory } } catch (err) { console.error('Failed to create PluginFactory', remotePlugin) throw err } } export async function remoteLoader( settings: CDNSettings, userIntegrations: Integrations, mergedIntegrations: Record<string, JSONObject>, options?: InitOptions, routingMiddleware?: DestinationMiddlewareFunction, pluginSources?: PluginFactory[] ): Promise<Plugin[]> { const allPlugins: Plugin[] = [] const routingRules = settings.middlewareSettings?.routingRules ?? [] const pluginPromises = (settings.remotePlugins ?? []).map( async (remotePlugin) => { if (isPluginDisabled(userIntegrations, remotePlugin)) return try { const pluginFactory = pluginSources?.find( ({ pluginName }) => pluginName === remotePlugin.name ) || (await loadPluginFactory(remotePlugin, options?.obfuscate)) if (pluginFactory) { const plugin = await pluginFactory({ ...remotePlugin.settings, ...mergedIntegrations[remotePlugin.name], }) const plugins = Array.isArray(plugin) ? plugin : [plugin] validate(plugins) const routing = routingRules.filter( (rule) => rule.destinationName === remotePlugin.creationName ) plugins.forEach((plugin) => { const wrapper = new ActionDestination( remotePlugin.creationName, plugin ) if (routing.length && routingMiddleware) { wrapper.addMiddleware(routingMiddleware) } allPlugins.push(wrapper) }) } } catch (error) { console.warn('Failed to load Remote Plugin', error) } } ) await Promise.all(pluginPromises) return allPlugins.filter(Boolean) }