Skip to content

Commit 6aa9ec2

Browse files
committed
feat(browser): add lazyLoadIntegration utility
This can be used to lazy load a pluggable integration.
1 parent 2389646 commit 6aa9ec2

File tree

9 files changed

+264
-0
lines changed

9 files changed

+264
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from '@sentry/browser';
2+
import { httpClientIntegration } from '@sentry/browser';
3+
4+
window.Sentry = {
5+
...Sentry,
6+
// This would be done by the CDN bundle otherwise
7+
httpClientIntegration: httpClientIntegration,
8+
};
9+
10+
Sentry.init({
11+
dsn: 'https://[email protected]/1337',
12+
integrations: [],
13+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
window._testLazyLoadIntegration = async function run() {
2+
const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration');
3+
4+
window.Sentry.getClient()?.addIntegration(integration());
5+
6+
window._integrationLoaded = true;
7+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest('it bails if the integration is already loaded', async ({ getLocalTestUrl, page }) => {
6+
const url = await getLocalTestUrl({ testDir: __dirname });
7+
8+
await page.goto(url);
9+
10+
const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
11+
expect(hasIntegration).toBe(false);
12+
13+
const scriptTagsBefore = await page.evaluate('document.querySelectorAll("script").length');
14+
15+
await page.evaluate('window._testLazyLoadIntegration()');
16+
await page.waitForFunction('window._integrationLoaded');
17+
18+
const scriptTagsAfter = await page.evaluate('document.querySelectorAll("script").length');
19+
20+
const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
21+
expect(hasIntegration2).toBe(true);
22+
23+
expect(scriptTagsAfter).toBe(scriptTagsBefore);
24+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = {
4+
...Sentry,
5+
// Ensure this is _not_ set
6+
httpClientIntegration: undefined,
7+
};
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
integrations: [],
12+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
window._testLazyLoadIntegration = async function run() {
2+
const integration = await window.Sentry.lazyLoadIntegration('httpClientIntegration');
3+
4+
window.Sentry.getClient()?.addIntegration(integration());
5+
6+
window._integrationLoaded = true;
7+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest('it allows to lazy load an integration', async ({ getLocalTestUrl, page }) => {
6+
const url = await getLocalTestUrl({ testDir: __dirname });
7+
8+
page.on('console', msg => console.log(msg.text()));
9+
10+
await page.goto(url);
11+
12+
const hasIntegration = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
13+
expect(hasIntegration).toBe(false);
14+
15+
const scriptTagsBefore = await page.evaluate<number>('document.querySelectorAll("script").length');
16+
17+
await page.evaluate('window._testLazyLoadIntegration()');
18+
await page.waitForFunction('window._integrationLoaded');
19+
20+
const scriptTagsAfter = await page.evaluate<number>('document.querySelectorAll("script").length');
21+
22+
const hasIntegration2 = await page.evaluate('!!window.Sentry.getClient()?.getIntegrationByName("HttpClient")');
23+
expect(hasIntegration2).toBe(true);
24+
25+
expect(scriptTagsAfter).toBe(scriptTagsBefore + 1);
26+
});

packages/browser/src/exports.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,5 @@ export { globalHandlersIntegration } from './integrations/globalhandlers';
9696
export { httpContextIntegration } from './integrations/httpcontext';
9797
export { linkedErrorsIntegration } from './integrations/linkederrors';
9898
export { browserApiErrorsIntegration } from './integrations/browserapierrors';
99+
100+
export { lazyLoadIntegration } from './utils/lazyLoadIntegration';
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { SDK_VERSION } from '@sentry/core';
2+
import type { IntegrationFn } from '@sentry/types';
3+
import { WINDOW } from '../helpers';
4+
5+
// This is a map of integration function method to bundle file name.
6+
const LazyLoadableIntegrations = {
7+
replayIntegration: 'replay',
8+
replayCanvasIntegration: 'replay-canvas',
9+
feedbackIntegration: 'feedback',
10+
captureConsoleIntegration: 'captureconsole',
11+
contextLinesIntegration: 'contextlines',
12+
linkedErrorsIntegration: 'linkederrors',
13+
debugIntegration: 'debug',
14+
dedupeIntegration: 'dedupe',
15+
extraErrorDataIntegration: 'extraerrordata',
16+
httpClientIntegration: 'httpclient',
17+
reportingObserverIntegration: 'reportingobserver',
18+
rewriteFramesIntegration: 'rewriteframes',
19+
sessionTimingIntegration: 'sessiontiming',
20+
} as const;
21+
22+
const WindowWithMaybeIntegration = WINDOW as typeof WINDOW & {
23+
Sentry?: Partial<Record<keyof typeof LazyLoadableIntegrations, IntegrationFn>>;
24+
};
25+
26+
/**
27+
* Lazy load an integration from the CDN.
28+
* Rejects if the integration cannot be loaded.
29+
*/
30+
export async function lazyLoadIntegration(name: keyof typeof LazyLoadableIntegrations): Promise<IntegrationFn> {
31+
const bundle = LazyLoadableIntegrations[name];
32+
33+
if (!bundle || !WindowWithMaybeIntegration.Sentry) {
34+
throw new Error(`Cannot lazy load integration: ${name}`);
35+
}
36+
37+
// Bail if the integration already exists
38+
const existing = WindowWithMaybeIntegration.Sentry[name];
39+
console.log({ existing });
40+
if (typeof existing === 'function') {
41+
return existing;
42+
}
43+
44+
const url = `https://browser.sentry-cdn.com/${SDK_VERSION}/${bundle}.min.js`;
45+
46+
const script = WINDOW.document.createElement('script');
47+
script.src = url;
48+
script.crossOrigin = 'anonymous';
49+
50+
console.log(url);
51+
52+
const waitForLoad = new Promise<void>((resolve, reject) => {
53+
script.addEventListener(
54+
'load',
55+
() => {
56+
console.log('LOADED!');
57+
resolve();
58+
},
59+
{ once: true, passive: true },
60+
);
61+
script.addEventListener(
62+
'error',
63+
error => {
64+
console.error(error);
65+
reject(error);
66+
},
67+
{ once: true, passive: true },
68+
);
69+
});
70+
71+
WINDOW.document.body.appendChild(script);
72+
73+
console.log(WINDOW.document.body.innerHTML);
74+
75+
console.log('start waiting....');
76+
77+
try {
78+
await waitForLoad;
79+
} catch {
80+
throw new Error(`Error when loading integration: ${name}`);
81+
}
82+
83+
const integrationFn = WindowWithMaybeIntegration.Sentry[name];
84+
85+
if (typeof integrationFn !== 'function') {
86+
throw new Error(`Could not load integration: ${name}`);
87+
}
88+
89+
return integrationFn;
90+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { TextDecoder, TextEncoder } from 'util';
2+
import { SDK_VERSION, lazyLoadIntegration } from '../../../src';
3+
import * as Sentry from '../../../src';
4+
const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true;
5+
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails (deleted in afterAll)
6+
const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true;
7+
8+
import { JSDOM } from 'jsdom';
9+
10+
const globalDocument = global.document;
11+
const globalWindow = global.window;
12+
const globalLocation = global.location;
13+
14+
describe('lazyLoadIntegration', () => {
15+
beforeEach(() => {
16+
const dom = new JSDOM('<body></body>', {
17+
runScripts: 'dangerously',
18+
resources: 'usable',
19+
});
20+
21+
global.document = dom.window.document;
22+
// @ts-expect-error need to override global document
23+
global.window = dom.window;
24+
global.location = dom.window.location;
25+
// @ts-expect-error For testing sake
26+
global.Sentry = undefined;
27+
});
28+
29+
// Reset back to previous values
30+
afterEach(() => {
31+
global.document = globalDocument;
32+
global.window = globalWindow;
33+
global.location = globalLocation;
34+
});
35+
36+
afterAll(() => {
37+
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails
38+
patchedEncoder && delete global.window.TextEncoder;
39+
// @ts-expect-error patch the encoder on the window, else importing JSDOM fails
40+
patchedDecoder && delete global.window.TextDecoder;
41+
});
42+
43+
test('it rejects invalid name', async () => {
44+
// @ts-expect-error For testing sake - otherwise this bails out anyhow
45+
global.Sentry = Sentry;
46+
47+
// @ts-expect-error we want to test this
48+
await expect(() => lazyLoadIntegration('invalid!!!')).rejects.toThrow('Cannot lazy load integration: invalid!!!');
49+
});
50+
51+
test('it rejects without global Sentry variable', async () => {
52+
await expect(() => lazyLoadIntegration('httpClientIntegration')).rejects.toThrow(
53+
'Cannot lazy load integration: httpClientIntegration',
54+
);
55+
});
56+
57+
test('it does not inject a script tag if integration already exists', async () => {
58+
// @ts-expect-error For testing sake
59+
global.Sentry = Sentry;
60+
61+
const integration = await lazyLoadIntegration('httpClientIntegration');
62+
63+
expect(integration).toBe(Sentry.httpClientIntegration);
64+
expect(global.document.querySelectorAll('script')).toHaveLength(0);
65+
});
66+
67+
test('it injects a script tag if integration is not yet loaded xxx', async () => {
68+
// @ts-expect-error For testing sake
69+
global.Sentry = {
70+
...Sentry,
71+
httpClientIntegration: undefined,
72+
};
73+
74+
// We do not await here, as this this does not seem to work with JSDOM :(
75+
// We have browser integration tests to check that this actually works
76+
void lazyLoadIntegration('httpClientIntegration');
77+
78+
expect(global.document.querySelectorAll('script')).toHaveLength(1);
79+
expect(global.document.querySelector('script')?.src).toEqual(
80+
`https://browser.sentry-cdn.com/${SDK_VERSION}/httpclient.min.js`,
81+
);
82+
});
83+
});

0 commit comments

Comments
 (0)