Skip to content

Commit b6ae65b

Browse files
authored
Allow use of integrations directly from NPM (#669)
This patch allows customers to import classic integrations directly from NPM, and use those instead of them being loaded from CDN. This can be useful in a case where one can't afford to have too many concurrent network requests (due to limits imposed by browsers) , and would rather prefer few larger requests instead.
1 parent 6e42f6e commit b6ae65b

File tree

10 files changed

+562
-88
lines changed

10 files changed

+562
-88
lines changed

.changeset/khaki-ducks-lick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@segment/analytics-next': minor
3+
---
4+
5+
Allow use of integrations directly from NPM

packages/browser/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
"size-limit": [
4444
{
4545
"path": "dist/umd/index.js",
46-
"limit": "27.2 KB"
46+
"limit": "27.3 KB"
4747
}
4848
],
4949
"dependencies": {
@@ -61,6 +61,7 @@
6161
},
6262
"devDependencies": {
6363
"@internal/config": "0.0.0",
64+
"@segment/analytics.js-integration-amplitude": "^3.3.3",
6465
"@segment/inspector-webext": "^2.0.3",
6566
"@size-limit/preset-big-lib": "^7.0.8",
6667
"@types/flat": "^5.0.1",

packages/browser/src/browser/__tests__/integration.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { PriorityQueue } from '../../lib/priority-queue'
1919
import { getCDN, setGlobalCDNUrl } from '../../lib/parse-cdn'
2020
import { clearAjsBrowserStorage } from '../../test-helpers/browser-storage'
2121
import { ActionDestination } from '@/plugins/remote-loader'
22+
import { ClassicIntegrationBuilder } from '../../plugins/ajs-destination/types'
2223

2324
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2425
let fetchCalls: Array<any>[] = []
@@ -975,6 +976,11 @@ describe('.Integrations', () => {
975976
windowSpy.mockImplementation(
976977
() => jsd.window as unknown as Window & typeof globalThis
977978
)
979+
980+
const documentSpy = jest.spyOn(global, 'document', 'get')
981+
documentSpy.mockImplementation(
982+
() => jsd.window.document as unknown as Document
983+
)
978984
})
979985

980986
it('lists all legacy destinations', async () => {
@@ -1032,6 +1038,58 @@ describe('.Integrations', () => {
10321038
}
10331039
`)
10341040
})
1041+
1042+
it('uses directly provided classic integrations without fetching them from cdn', async () => {
1043+
const amplitude = // @ts-ignore
1044+
(await import('@segment/analytics.js-integration-amplitude')).default
1045+
1046+
const intializeSpy = jest.spyOn(amplitude.prototype, 'initialize')
1047+
const trackSpy = jest.spyOn(amplitude.prototype, 'track')
1048+
1049+
const [analytics] = await AnalyticsBrowser.load(
1050+
{
1051+
writeKey,
1052+
classicIntegrations: [
1053+
amplitude as unknown as ClassicIntegrationBuilder,
1054+
],
1055+
},
1056+
{
1057+
integrations: {
1058+
Amplitude: {
1059+
apiKey: 'abc',
1060+
},
1061+
},
1062+
}
1063+
)
1064+
1065+
await analytics.ready()
1066+
expect(intializeSpy).toHaveBeenCalledTimes(1)
1067+
1068+
await analytics.track('test event')
1069+
1070+
expect(trackSpy).toHaveBeenCalledTimes(1)
1071+
})
1072+
1073+
it('ignores directly provided classic integrations if settings for them are unavailable', async () => {
1074+
const amplitude = // @ts-ignore
1075+
(await import('@segment/analytics.js-integration-amplitude')).default
1076+
1077+
const intializeSpy = jest.spyOn(amplitude.prototype, 'initialize')
1078+
const trackSpy = jest.spyOn(amplitude.prototype, 'track')
1079+
1080+
const [analytics] = await AnalyticsBrowser.load({
1081+
writeKey,
1082+
classicIntegrations: [amplitude as unknown as ClassicIntegrationBuilder],
1083+
})
1084+
1085+
await analytics.ready()
1086+
1087+
expect(intializeSpy).not.toHaveBeenCalled()
1088+
1089+
await analytics.track('test event')
1090+
1091+
expect(trackSpy).not.toHaveBeenCalled()
1092+
})
10351093
})
10361094

10371095
describe('Options', () => {

packages/browser/src/browser/index.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '../core/buffer'
2525
import { popSnippetWindowBuffer } from '../core/buffer/snippet'
2626
import { inspectorHost } from '../core/inspector'
27+
import { ClassicIntegrationSource } from '../plugins/ajs-destination/types'
2728

2829
export interface LegacyIntegrationConfiguration {
2930
/* @deprecated - This does not indicate browser types anymore */
@@ -152,7 +153,8 @@ async function registerPlugins(
152153
analytics: Analytics,
153154
opts: InitOptions,
154155
options: InitOptions,
155-
plugins: Plugin[]
156+
plugins: Plugin[],
157+
legacyIntegrationSources: ClassicIntegrationSource[]
156158
): Promise<Context> {
157159
const tsubMiddleware = hasTsubMiddleware(legacySettings)
158160
? await import(
@@ -164,18 +166,20 @@ async function registerPlugins(
164166
})
165167
: undefined
166168

167-
const legacyDestinations = hasLegacyDestinations(legacySettings)
168-
? await import(
169-
/* webpackChunkName: "ajs-destination" */ '../plugins/ajs-destination'
170-
).then((mod) => {
171-
return mod.ajsDestinations(
172-
legacySettings,
173-
analytics.integrations,
174-
opts,
175-
tsubMiddleware
176-
)
177-
})
178-
: []
169+
const legacyDestinations =
170+
hasLegacyDestinations(legacySettings) || legacyIntegrationSources.length > 0
171+
? await import(
172+
/* webpackChunkName: "ajs-destination" */ '../plugins/ajs-destination'
173+
).then((mod) => {
174+
return mod.ajsDestinations(
175+
legacySettings,
176+
analytics.integrations,
177+
opts,
178+
tsubMiddleware,
179+
legacyIntegrationSources
180+
)
181+
})
182+
: []
179183

180184
if (legacySettings.legacyVideoPluginsEnabled) {
181185
await import(
@@ -274,6 +278,7 @@ async function loadAnalytics(
274278
inspectorHost.attach?.(analytics as any)
275279

276280
const plugins = settings.plugins ?? []
281+
const classicIntegrations = settings.classicIntegrations ?? []
277282
Context.initMetrics(legacySettings.metrics)
278283

279284
// needs to be flushed before plugins are registered
@@ -284,7 +289,8 @@ async function loadAnalytics(
284289
analytics,
285290
opts,
286291
options,
287-
plugins
292+
plugins,
293+
classicIntegrations
288294
)
289295

290296
const search = window.location.search ?? ''

packages/browser/src/core/analytics/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import { CookieOptions, Group, ID, User, UserOptions } from '../user'
2828
import autoBind from '../../lib/bind-all'
2929
import { PersistedPriorityQueue } from '../../lib/priority-queue/persisted'
3030
import type { LegacyDestination } from '../../plugins/ajs-destination'
31-
import type { LegacyIntegration } from '../../plugins/ajs-destination/types'
31+
import type {
32+
LegacyIntegration,
33+
ClassicIntegrationSource,
34+
} from '../../plugins/ajs-destination/types'
3235
import type {
3336
DestinationMiddlewareFunction,
3437
MiddlewareFunction,
@@ -58,6 +61,7 @@ export interface AnalyticsSettings {
5861
writeKey: string
5962
timeout?: number
6063
plugins?: Plugin[]
64+
classicIntegrations?: ClassicIntegrationSource[]
6165
}
6266

6367
export interface InitOptions {

packages/browser/src/plugins/ajs-destination/index.ts

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,19 @@ import {
1717
applyDestinationMiddleware,
1818
DestinationMiddlewareFunction,
1919
} from '../middleware'
20-
import { loadIntegration, resolveVersion, unloadIntegration } from './loader'
21-
import { LegacyIntegration } from './types'
20+
import {
21+
buildIntegration,
22+
loadIntegration,
23+
resolveIntegrationNameFromSource,
24+
resolveVersion,
25+
unloadIntegration,
26+
} from './loader'
27+
import { LegacyIntegration, ClassicIntegrationSource } from './types'
28+
import { isPlainObject } from '@segment/analytics-core'
29+
import {
30+
isDisabledIntegration as shouldSkipIntegration,
31+
isInstallableIntegration,
32+
} from './utils'
2233

2334
export type ClassType<T> = new (...args: unknown[]) => T
2435

@@ -67,6 +78,7 @@ export class LegacyDestination implements Plugin {
6778
private onInitialize: Promise<unknown> | undefined
6879
private disableAutoISOConversion: boolean
6980

81+
integrationSource?: ClassicIntegrationSource
7082
integration: LegacyIntegration | undefined
7183

7284
buffer: PriorityQueue<Context>
@@ -76,12 +88,14 @@ export class LegacyDestination implements Plugin {
7688
name: string,
7789
version: string,
7890
settings: JSONObject = {},
79-
options: InitOptions
91+
options: InitOptions,
92+
integrationSource?: ClassicIntegrationSource
8093
) {
8194
this.name = name
8295
this.version = version
8396
this.settings = { ...settings }
8497
this.disableAutoISOConversion = options.disableAutoISOConversion || false
98+
this.integrationSource = integrationSource
8599

86100
// AJS-Renderer sets an extraneous `type` setting that clobbers
87101
// existing type defaults. We need to remove it if it's present
@@ -110,13 +124,19 @@ export class LegacyDestination implements Plugin {
110124
return
111125
}
112126

113-
this.integration = await loadIntegration(
114-
ctx,
115-
analyticsInstance,
116-
this.name,
117-
this.version,
127+
const integrationSource =
128+
this.integrationSource ??
129+
(await loadIntegration(
130+
ctx,
131+
this.name,
132+
this.version,
133+
this.options.obfuscate
134+
))
135+
136+
this.integration = buildIntegration(
137+
integrationSource,
118138
this.settings,
119-
this.options.obfuscate
139+
analyticsInstance
120140
)
121141

122142
this.onReady = new Promise((resolve) => {
@@ -304,7 +324,8 @@ export function ajsDestinations(
304324
settings: LegacySettings,
305325
globalIntegrations: Integrations = {},
306326
options: InitOptions = {},
307-
routingMiddleware?: DestinationMiddlewareFunction
327+
routingMiddleware?: DestinationMiddlewareFunction,
328+
legacyIntegrationSources?: ClassicIntegrationSource[]
308329
): LegacyDestination[] {
309330
if (isServer()) {
310331
return []
@@ -316,47 +337,47 @@ export function ajsDestinations(
316337
}
317338

318339
const routingRules = settings.middlewareSettings?.routingRules ?? []
319-
340+
const remoteIntegrationsConfig = settings.integrations
341+
const localIntegrationsConfig = options.integrations
320342
// merged remote CDN settings with user provided options
321343
const integrationOptions = mergedOptions(settings, options ?? {}) as Record<
322344
string,
323345
JSONObject
324346
>
325347

326-
return Object.entries(settings.integrations)
327-
.map(([name, integrationSettings]) => {
328-
if (name.startsWith('Segment')) {
329-
return
330-
}
331-
332-
const allDisableAndNotDefined =
333-
globalIntegrations.All === false &&
334-
globalIntegrations[name] === undefined
335-
336-
if (globalIntegrations[name] === false || allDisableAndNotDefined) {
337-
return
338-
}
348+
const adhocIntegrationSources = legacyIntegrationSources?.reduce(
349+
(acc, integrationSource) => ({
350+
...acc,
351+
[resolveIntegrationNameFromSource(integrationSource)]: integrationSource,
352+
}),
353+
{} as Record<string, ClassicIntegrationSource>
354+
)
339355

340-
const { type, bundlingStatus, versionSettings } = integrationSettings
341-
// We use `!== 'unbundled'` (versus `=== 'bundled'`) to be inclusive of
342-
// destinations without a defined value for `bundlingStatus`
343-
const deviceMode =
344-
bundlingStatus !== 'unbundled' &&
345-
(type === 'browser' ||
346-
versionSettings?.componentTypes?.includes('browser'))
347-
348-
// checking for iterable is a quick fix we need in place to prevent
349-
// errors showing Iterable as a failed destiantion. Ideally, we should
350-
// fix the Iterable metadata instead, but that's a longer process.
351-
if ((!deviceMode && name !== 'Segment.io') || name === 'Iterable') {
352-
return
353-
}
356+
const installableIntegrations = new Set([
357+
// Remotely configured installable integrations
358+
...Object.keys(remoteIntegrationsConfig).filter((name) =>
359+
isInstallableIntegration(name, remoteIntegrationsConfig[name])
360+
),
361+
362+
// Directly provided integration sources are only installable if settings for them are available
363+
...Object.keys(adhocIntegrationSources || {}).filter(
364+
(name) =>
365+
isPlainObject(remoteIntegrationsConfig[name]) ||
366+
isPlainObject(localIntegrationsConfig?.[name])
367+
),
368+
])
369+
370+
return Array.from(installableIntegrations)
371+
.filter((name) => !shouldSkipIntegration(name, globalIntegrations))
372+
.map((name) => {
373+
const integrationSettings = remoteIntegrationsConfig[name]
354374
const version = resolveVersion(integrationSettings)
355375
const destination = new LegacyDestination(
356376
name,
357377
version,
358378
integrationOptions[name],
359-
options
379+
options,
380+
adhocIntegrationSources?.[name]
360381
)
361382

362383
const routing = routingRules.filter(
@@ -368,5 +389,4 @@ export function ajsDestinations(
368389

369390
return destination
370391
})
371-
.filter((xt) => xt !== undefined) as LegacyDestination[]
372392
}

0 commit comments

Comments
 (0)