@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 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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "esnext",
5
+ "removeComments": false,
6
+ "baseUrl": "."
7
+ },
8
+ "exclude": ["dist"]
9
+ }