@segment/browser-destination-runtime 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +66 -0
- package/src/__tests__/plugin.test.ts +62 -0
- package/src/load-script.ts +47 -0
- package/src/plugin.ts +87 -0
- package/src/resolve-when.ts +19 -0
- package/src/shim.ts +16 -0
- package/src/types.ts +69 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@segment/browser-destination-runtime",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "yarn build:esm && yarn build:cjs",
|
|
6
|
+
"build:esm": "tsc --outDir ./dist/esm",
|
|
7
|
+
"build:cjs": "tsc --module commonjs --outDir ./dist/cjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"require": "./dist/cjs/index.js",
|
|
12
|
+
"default": "./dist/esm/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./load-script": {
|
|
15
|
+
"require": "./dist/cjs/load-script.js",
|
|
16
|
+
"default": "./dist/esm/load-script.js"
|
|
17
|
+
},
|
|
18
|
+
"./plugin": {
|
|
19
|
+
"require": "./dist/cjs/plugin.js",
|
|
20
|
+
"default": "./dist/esm/plugin.js"
|
|
21
|
+
},
|
|
22
|
+
"./resolve-when": {
|
|
23
|
+
"require": "./dist/cjs/resolve-when.js",
|
|
24
|
+
"default": "./dist/esm/resolve-when.js"
|
|
25
|
+
},
|
|
26
|
+
"./shim": {
|
|
27
|
+
"require": "./dist/cjs/shim.js",
|
|
28
|
+
"default": "./dist/esm/shim.js"
|
|
29
|
+
},
|
|
30
|
+
"./types": {
|
|
31
|
+
"require": "./dist/cjs/types.js",
|
|
32
|
+
"default": "./dist/esm/types.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"typesVersions": {
|
|
36
|
+
"*": {
|
|
37
|
+
"*": [
|
|
38
|
+
"dist/esm/index.d.ts"
|
|
39
|
+
],
|
|
40
|
+
"load-script": [
|
|
41
|
+
"dist/esm/load-script.d.ts"
|
|
42
|
+
],
|
|
43
|
+
"plugin": [
|
|
44
|
+
"dist/esm/plugin.d.ts"
|
|
45
|
+
],
|
|
46
|
+
"resolve-when": [
|
|
47
|
+
"dist/esm/resolve-when.d.ts"
|
|
48
|
+
],
|
|
49
|
+
"shim": [
|
|
50
|
+
"dist/esm/shim.d.ts"
|
|
51
|
+
],
|
|
52
|
+
"types": [
|
|
53
|
+
"dist/esm/types.d.ts"
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@segment/actions-core": "^3.71.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@segment/analytics-next": "*"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@segment/analytics-next": "*"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { BrowserDestinationDefinition } from '../types'
|
|
2
|
+
import { generatePlugins } from '../plugin'
|
|
3
|
+
|
|
4
|
+
describe('generatePlugins', () => {
|
|
5
|
+
const initializeSpy = jest.fn()
|
|
6
|
+
const destinationDefinition: BrowserDestinationDefinition<{}, unknown> = {
|
|
7
|
+
name: 'Test destination',
|
|
8
|
+
slug: 'test-web-destination',
|
|
9
|
+
mode: 'device',
|
|
10
|
+
settings: {},
|
|
11
|
+
initialize: async () => {
|
|
12
|
+
initializeSpy()
|
|
13
|
+
},
|
|
14
|
+
actions: {
|
|
15
|
+
trackEventA: {
|
|
16
|
+
title: 'Track Event',
|
|
17
|
+
description: 'Tests track events',
|
|
18
|
+
platform: 'web',
|
|
19
|
+
defaultSubscription: 'type = "track"',
|
|
20
|
+
fields: {},
|
|
21
|
+
perform: () => {}
|
|
22
|
+
},
|
|
23
|
+
trackEventB: {
|
|
24
|
+
title: 'Track Event',
|
|
25
|
+
description: 'Tests track events',
|
|
26
|
+
platform: 'web',
|
|
27
|
+
defaultSubscription: 'type = "track"',
|
|
28
|
+
fields: {},
|
|
29
|
+
perform: () => {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
jest.resetAllMocks()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('only loads once', async () => {
|
|
39
|
+
const plugins = generatePlugins(destinationDefinition, {}, [
|
|
40
|
+
{
|
|
41
|
+
enabled: true,
|
|
42
|
+
mapping: {},
|
|
43
|
+
name: 'a',
|
|
44
|
+
partnerAction: 'trackEventA',
|
|
45
|
+
subscribe: 'type = "track"'
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
enabled: true,
|
|
49
|
+
mapping: {},
|
|
50
|
+
name: 'b',
|
|
51
|
+
partnerAction: 'trackEventB',
|
|
52
|
+
subscribe: 'type = "track"'
|
|
53
|
+
}
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
expect(plugins.length).toBe(2)
|
|
57
|
+
|
|
58
|
+
await Promise.all(plugins.map((p) => p.load({} as any, {} as any)))
|
|
59
|
+
|
|
60
|
+
expect(initializeSpy).toHaveBeenCalledTimes(1)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export async function loadScript(src: string, attributes?: Record<string, string>): Promise<HTMLScriptElement> {
|
|
2
|
+
const scripts = Array.from(window.document.querySelectorAll('script'))
|
|
3
|
+
const found = scripts.find((s) => s.src === src)
|
|
4
|
+
|
|
5
|
+
if (found !== undefined) {
|
|
6
|
+
const status = found?.getAttribute('status')
|
|
7
|
+
|
|
8
|
+
if (status === 'loaded') {
|
|
9
|
+
return found
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (status === 'loading') {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
found.addEventListener('load', () => resolve(found))
|
|
15
|
+
found.addEventListener('error', (err) => reject(err))
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const script = window.document.createElement('script')
|
|
22
|
+
|
|
23
|
+
script.type = 'text/javascript'
|
|
24
|
+
script.src = src
|
|
25
|
+
script.async = true
|
|
26
|
+
|
|
27
|
+
script.setAttribute('status', 'loading')
|
|
28
|
+
for (const [k, v] of Object.entries(attributes ?? {})) {
|
|
29
|
+
script.setAttribute(k, v)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
script.onload = (): void => {
|
|
33
|
+
script.onerror = script.onload = null
|
|
34
|
+
script.setAttribute('status', 'loaded')
|
|
35
|
+
resolve(script)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
script.onerror = (): void => {
|
|
39
|
+
script.onerror = script.onload = null
|
|
40
|
+
script.setAttribute('status', 'error')
|
|
41
|
+
reject(new Error(`Failed to load ${src}`))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tag = window.document.getElementsByTagName('script')[0]
|
|
45
|
+
tag.parentElement?.insertBefore(script, tag)
|
|
46
|
+
})
|
|
47
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Analytics, Context, Plugin } from '@segment/analytics-next'
|
|
2
|
+
import type { JSONObject } from '@segment/actions-core'
|
|
3
|
+
import { transform } from '@segment/actions-core/mapping-kit'
|
|
4
|
+
import { parseFql, validate } from '@segment/destination-subscriptions'
|
|
5
|
+
import { ActionInput, BrowserDestinationDefinition, Subscription } from './types'
|
|
6
|
+
import { loadScript } from './load-script'
|
|
7
|
+
import { resolveWhen } from './resolve-when'
|
|
8
|
+
|
|
9
|
+
type MaybePromise<T> = T | Promise<T>
|
|
10
|
+
|
|
11
|
+
export function generatePlugins<S, C>(
|
|
12
|
+
def: BrowserDestinationDefinition<S, C>,
|
|
13
|
+
settings: S,
|
|
14
|
+
subscriptions: Subscription[]
|
|
15
|
+
): Plugin[] {
|
|
16
|
+
let hasInitialized = false
|
|
17
|
+
let client: C
|
|
18
|
+
let analytics: Analytics
|
|
19
|
+
let initializing: Promise<C> | undefined
|
|
20
|
+
|
|
21
|
+
const load: Plugin['load'] = async (_ctx, analyticsInstance) => {
|
|
22
|
+
if (hasInitialized) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (initializing) {
|
|
27
|
+
await initializing
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
analytics = analyticsInstance
|
|
32
|
+
initializing = def.initialize?.({ settings, analytics }, { loadScript, resolveWhen })
|
|
33
|
+
client = await initializing
|
|
34
|
+
hasInitialized = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return Object.entries(def.actions).reduce((acc, [key, action]) => {
|
|
38
|
+
// Grab all the enabled subscriptions that invoke this action
|
|
39
|
+
const actionSubscriptions = subscriptions.filter((s) => s.enabled && s.partnerAction === key)
|
|
40
|
+
if (actionSubscriptions.length === 0) return acc
|
|
41
|
+
|
|
42
|
+
async function evaluate(ctx: Context): Promise<Context> {
|
|
43
|
+
const invocations: Array<MaybePromise<unknown>> = []
|
|
44
|
+
|
|
45
|
+
for (const sub of actionSubscriptions) {
|
|
46
|
+
const isSubscribed = validate(parseFql(sub.subscribe), ctx.event)
|
|
47
|
+
if (!isSubscribed) continue
|
|
48
|
+
|
|
49
|
+
const mapping = (sub.mapping ?? {}) as JSONObject
|
|
50
|
+
const payload = transform(mapping, ctx.event as unknown as JSONObject)
|
|
51
|
+
|
|
52
|
+
const input: ActionInput<S, unknown> = {
|
|
53
|
+
payload,
|
|
54
|
+
mapping,
|
|
55
|
+
settings,
|
|
56
|
+
analytics,
|
|
57
|
+
context: ctx
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
invocations.push(action.perform(client, input))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await Promise.all(invocations)
|
|
64
|
+
// TODO: some sort of error handling
|
|
65
|
+
return ctx
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const plugin = {
|
|
69
|
+
name: `${def.name} ${key}`,
|
|
70
|
+
type: action.lifecycleHook ?? 'destination',
|
|
71
|
+
version: '0.1.0',
|
|
72
|
+
ready: () => Promise.resolve(),
|
|
73
|
+
|
|
74
|
+
isLoaded: () => hasInitialized,
|
|
75
|
+
load,
|
|
76
|
+
|
|
77
|
+
track: evaluate,
|
|
78
|
+
page: evaluate,
|
|
79
|
+
alias: evaluate,
|
|
80
|
+
identify: evaluate,
|
|
81
|
+
group: evaluate
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
acc.push(plugin)
|
|
85
|
+
return acc
|
|
86
|
+
}, [] as Plugin[])
|
|
87
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function resolveWhen(condition: () => boolean, timeout?: number): Promise<void> {
|
|
2
|
+
return new Promise((resolve, _reject) => {
|
|
3
|
+
if (condition()) {
|
|
4
|
+
resolve()
|
|
5
|
+
return
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const check = () =>
|
|
9
|
+
setTimeout(() => {
|
|
10
|
+
if (condition()) {
|
|
11
|
+
resolve()
|
|
12
|
+
} else {
|
|
13
|
+
check()
|
|
14
|
+
}
|
|
15
|
+
}, timeout)
|
|
16
|
+
|
|
17
|
+
check()
|
|
18
|
+
})
|
|
19
|
+
}
|
package/src/shim.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { BrowserDestinationDefinition, PluginFactory, Subscription } from './types'
|
|
2
|
+
|
|
3
|
+
export function browserDestination<S, C>(definition: BrowserDestinationDefinition<S, C>): PluginFactory {
|
|
4
|
+
const factory = (async (settings: S & { subscriptions?: Subscription[] }) => {
|
|
5
|
+
const plugin = await import(
|
|
6
|
+
/* webpackChunkName: "actions-plugin" */
|
|
7
|
+
/* webpackMode: "lazy-once" */
|
|
8
|
+
'./plugin'
|
|
9
|
+
)
|
|
10
|
+
return plugin.generatePlugins(definition, settings, settings.subscriptions || [])
|
|
11
|
+
}) as unknown as PluginFactory
|
|
12
|
+
|
|
13
|
+
factory.pluginName = definition.name
|
|
14
|
+
|
|
15
|
+
return factory
|
|
16
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Analytics, Context, Plugin } from '@segment/analytics-next'
|
|
2
|
+
import type {
|
|
3
|
+
BaseDefinition,
|
|
4
|
+
BaseActionDefinition,
|
|
5
|
+
ExecuteInput,
|
|
6
|
+
JSONLikeObject,
|
|
7
|
+
GlobalSetting,
|
|
8
|
+
JSONValue
|
|
9
|
+
} from '@segment/actions-core'
|
|
10
|
+
|
|
11
|
+
export type ActionInput<Settings, Payload> = ExecuteInput<Settings, Payload> & {
|
|
12
|
+
analytics: Analytics
|
|
13
|
+
context: Context
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
export interface BrowserActionDefinition<Settings, Client, Payload = any> extends BaseActionDefinition {
|
|
18
|
+
/** The operation to perform when this action is triggered */
|
|
19
|
+
perform: (client: Client, data: ActionInput<Settings, Payload>) => Promise<unknown> | unknown
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The target platform for the action
|
|
23
|
+
*/
|
|
24
|
+
platform: 'web'
|
|
25
|
+
|
|
26
|
+
/** Which step in the Analytics.js lifecycle this action should run */
|
|
27
|
+
lifecycleHook?: Plugin['type']
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BrowserDestinationDependencies {
|
|
31
|
+
loadScript: (src: string, attributes?: Record<string, string>) => Promise<HTMLScriptElement>
|
|
32
|
+
resolveWhen: (condition: () => boolean, timeout?: number) => Promise<void>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type InitializeOptions<Settings> = { settings: Settings; analytics: Analytics }
|
|
36
|
+
|
|
37
|
+
export interface BrowserDestinationDefinition<Settings = unknown, Client = unknown> extends BaseDefinition {
|
|
38
|
+
mode: 'device'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The function called when the destination has loaded and is ready to be initialized
|
|
42
|
+
* Typically you would configure an SDK or API client here.
|
|
43
|
+
* The return value is injected to your actions as the `client`
|
|
44
|
+
*/
|
|
45
|
+
initialize: (options: InitializeOptions<Settings>, dependencies: BrowserDestinationDependencies) => Promise<Client>
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Top-level settings that should be available across all actions
|
|
49
|
+
* This is often where you would put initialization settings,
|
|
50
|
+
* SDK keys, API subdomains, etc.
|
|
51
|
+
*/
|
|
52
|
+
settings?: Record<string, GlobalSetting>
|
|
53
|
+
|
|
54
|
+
/** Actions */
|
|
55
|
+
actions: Record<string, BrowserActionDefinition<Settings, Client>>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Subscription {
|
|
59
|
+
partnerAction: string
|
|
60
|
+
name: string
|
|
61
|
+
enabled: boolean
|
|
62
|
+
subscribe: string
|
|
63
|
+
mapping: JSONLikeObject
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PluginFactory {
|
|
67
|
+
(settings: JSONValue): Plugin | Plugin[] | Promise<Plugin | Plugin[]>
|
|
68
|
+
pluginName: string
|
|
69
|
+
}
|