@internetarchive/lazy-loader-service
Advanced tools
Comparing version 0.1.0 to 0.2.0-alpha.1
@@ -1,3 +0,3 @@ | ||
export { LazyLoaderService } from './src/lazy-loader-service'; | ||
export { LazyLoaderServiceInterface } from './src/lazy-loader-service-interface'; | ||
export { LazyLoaderService, LazyLoaderServiceOptions, } from './src/lazy-loader-service'; | ||
export { LazyLoaderServiceInterface, LazyLoaderServiceEvents, } from './src/lazy-loader-service-interface'; | ||
export { BundleType } from './src/bundle-type'; |
@@ -1,3 +0,2 @@ | ||
export { LazyLoaderService } from './src/lazy-loader-service'; | ||
export { BundleType } from './src/bundle-type'; | ||
export { LazyLoaderService, } from './src/lazy-loader-service'; | ||
//# sourceMappingURL=index.js.map |
@@ -1,4 +0,1 @@ | ||
export declare enum BundleType { | ||
Module = "module", | ||
NoModule = "nomodule" | ||
} | ||
export declare type BundleType = 'module' | 'nomodule'; |
@@ -1,6 +0,2 @@ | ||
export var BundleType; | ||
(function (BundleType) { | ||
BundleType["Module"] = "module"; | ||
BundleType["NoModule"] = "nomodule"; | ||
})(BundleType || (BundleType = {})); | ||
export {}; | ||
//# sourceMappingURL=bundle-type.js.map |
import { BundleType } from './bundle-type'; | ||
import { Unsubscribe } from 'nanoevents'; | ||
export interface LazyLoaderServiceEvents { | ||
scriptLoadRetried: (src: string, retryCount: number) => void; | ||
scriptLoadFailed: (src: string, error: string | Event) => void; | ||
} | ||
export interface LazyLoaderServiceInterface { | ||
/** | ||
* Bind to receive notifications about retry and failure events | ||
* | ||
* @template E | ||
* @param {E} event | ||
* @param {LazyLoaderServiceEvents[E]} callback | ||
* @returns {Unsubscribe} | ||
* @memberof LazyLoaderServiceInterface | ||
*/ | ||
on<E extends keyof LazyLoaderServiceEvents>(event: E, callback: LazyLoaderServiceEvents[E]): Unsubscribe; | ||
/** | ||
* Load a javascript bundle (module and nomodule pair) | ||
@@ -18,3 +33,3 @@ * | ||
nomodule?: string; | ||
}): Promise<Event | undefined>; | ||
}): Promise<void>; | ||
/** | ||
@@ -35,7 +50,4 @@ * Load a script with a Promise | ||
bundleType?: BundleType; | ||
attributes?: { | ||
key: string; | ||
value: any; | ||
}[]; | ||
}): Promise<Event>; | ||
attributes?: Record<string, string>; | ||
}): Promise<void>; | ||
} |
@@ -0,1 +1,2 @@ | ||
export {}; | ||
//# sourceMappingURL=lazy-loader-service-interface.js.map |
@@ -1,11 +0,36 @@ | ||
import { BundleType } from './bundle-type'; | ||
import { LazyLoaderServiceInterface } from './lazy-loader-service-interface'; | ||
import { Unsubscribe } from 'nanoevents'; | ||
import type { BundleType } from './bundle-type'; | ||
import { LazyLoaderServiceEvents, LazyLoaderServiceInterface } from './lazy-loader-service-interface'; | ||
export interface LazyLoaderServiceOptions { | ||
/** | ||
* The HTMLElement in which we put the script tags, defaults to document.head | ||
*/ | ||
container?: HTMLElement; | ||
/** | ||
* The number of retries we should attempt | ||
*/ | ||
retryCount?: number; | ||
/** | ||
* The retry interval in seconds | ||
*/ | ||
retryInterval?: number; | ||
} | ||
export declare class LazyLoaderService implements LazyLoaderServiceInterface { | ||
private container; | ||
constructor(container?: HTMLElement); | ||
private retryCount; | ||
private retryInterval; | ||
private emitter; | ||
/** | ||
* LazyLoaderService constructor | ||
* | ||
* @param options LazyLoaderServiceOptions | ||
*/ | ||
constructor(options?: LazyLoaderServiceOptions); | ||
/** @inheritdoc */ | ||
on<E extends keyof LazyLoaderServiceEvents>(event: E, callback: LazyLoaderServiceEvents[E]): Unsubscribe; | ||
/** @inheritdoc */ | ||
loadBundle(bundle: { | ||
module?: string; | ||
nomodule?: string; | ||
}): Promise<Event | undefined>; | ||
}): Promise<void>; | ||
/** @inheritdoc */ | ||
@@ -15,7 +40,12 @@ loadScript(options: { | ||
bundleType?: BundleType; | ||
attributes?: { | ||
key: string; | ||
value: any; | ||
}[]; | ||
}): Promise<Event>; | ||
attributes?: Record<string, string>; | ||
}): Promise<void>; | ||
private doLoad; | ||
/** | ||
* Generate a script tag with all of the proper attributes | ||
* | ||
* @param options | ||
* @returns | ||
*/ | ||
private getScriptTag; | ||
} |
@@ -1,87 +0,129 @@ | ||
import { BundleType } from './bundle-type'; | ||
import { __awaiter } from "tslib"; | ||
import { createNanoEvents } from 'nanoevents'; | ||
import { promisedSleep } from './promised-sleep'; | ||
export class LazyLoaderService { | ||
constructor(container = document.head) { | ||
this.container = container; | ||
/** | ||
* LazyLoaderService constructor | ||
* | ||
* @param options LazyLoaderServiceOptions | ||
*/ | ||
constructor(options) { | ||
var _a, _b, _c; | ||
// the emitter for consumers to listen for events like retrying a load | ||
this.emitter = createNanoEvents(); | ||
this.container = (_a = options === null || options === void 0 ? void 0 : options.container) !== null && _a !== void 0 ? _a : document.head; | ||
this.retryCount = (_b = options === null || options === void 0 ? void 0 : options.retryCount) !== null && _b !== void 0 ? _b : 2; | ||
this.retryInterval = (_c = options === null || options === void 0 ? void 0 : options.retryInterval) !== null && _c !== void 0 ? _c : 1; | ||
} | ||
/** @inheritdoc */ | ||
on(event, callback) { | ||
return this.emitter.on(event, callback); | ||
} | ||
/** @inheritdoc */ | ||
loadBundle(bundle) { | ||
let modulePromise; | ||
let nomodulePromise; | ||
/* istanbul ignore else */ | ||
if (bundle.module) { | ||
modulePromise = this.loadScript({ | ||
src: bundle.module, | ||
bundleType: BundleType.Module, | ||
}); | ||
} | ||
/* istanbul ignore else */ | ||
if (bundle.nomodule) { | ||
nomodulePromise = this.loadScript({ | ||
src: bundle.nomodule, | ||
bundleType: BundleType.NoModule, | ||
}); | ||
} | ||
return Promise.race([modulePromise, nomodulePromise]); | ||
return __awaiter(this, void 0, void 0, function* () { | ||
let modulePromise; | ||
let nomodulePromise; | ||
/* istanbul ignore else */ | ||
if (bundle.module) { | ||
modulePromise = this.loadScript({ | ||
src: bundle.module, | ||
bundleType: 'module', | ||
}); | ||
} | ||
/* istanbul ignore else */ | ||
if (bundle.nomodule) { | ||
nomodulePromise = this.loadScript({ | ||
src: bundle.nomodule, | ||
bundleType: 'nomodule', | ||
}); | ||
} | ||
return Promise.race([modulePromise, nomodulePromise]); | ||
}); | ||
} | ||
/** @inheritdoc */ | ||
loadScript(options) { | ||
return __awaiter(this, void 0, void 0, function* () { | ||
return this.doLoad(options); | ||
}); | ||
} | ||
doLoad(options) { | ||
var _a; | ||
const scriptSelector = `script[src='${options.src}'][async]`; | ||
let script = this.container.querySelector(scriptSelector); | ||
if (!script) { | ||
script = document.createElement('script'); | ||
script.setAttribute('src', options.src); | ||
script.async = true; | ||
const attributes = (_a = options.attributes) !== null && _a !== void 0 ? _a : []; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
attributes.forEach((element) => { | ||
// eslint-disable-next-line no-unused-expressions | ||
script.setAttribute(element.key, element.value); | ||
}); | ||
switch (options.bundleType) { | ||
case BundleType.Module: | ||
script.setAttribute('type', options.bundleType); | ||
break; | ||
// cannot be tested because modern browsers ignore `nomodule` | ||
/* istanbul ignore next */ | ||
case BundleType.NoModule: | ||
script.setAttribute(options.bundleType, ''); | ||
break; | ||
default: | ||
break; | ||
return __awaiter(this, void 0, void 0, function* () { | ||
const retryCount = (_a = options.retryCount) !== null && _a !== void 0 ? _a : 0; | ||
const scriptSelector = `script[src='${options.src}'][async][retryCount='${retryCount}']`; | ||
let script = this.container.querySelector(scriptSelector); | ||
if (!script) { | ||
script = this.getScriptTag(options); | ||
this.container.appendChild(script); | ||
} | ||
} | ||
return new Promise((resolve, reject) => { | ||
// if multiple requests get made for this script, just stack the onloads | ||
// and onerrors and all the callbacks will be called in-order of being received | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const originalOnLoad = script.onload; | ||
script.onload = (event) => { | ||
if (originalOnLoad) { | ||
originalOnLoad(event); | ||
return new Promise((resolve, reject) => { | ||
// script has already been loaded, just resolve | ||
if (script.getAttribute('dynamicImportLoaded')) { | ||
resolve(); | ||
return; | ||
} | ||
script.setAttribute('dynamicImportLoaded', 'true'); | ||
resolve(event); | ||
}; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const originalOnError = script.onerror; | ||
script.onerror = (error) => { | ||
if (originalOnError) { | ||
originalOnError(error); | ||
} | ||
/* istanbul ignore else */ | ||
if (script.parentNode) { | ||
script.parentNode.removeChild(script); | ||
} | ||
reject(error); | ||
}; | ||
if (script.parentNode === null) { | ||
this.container.appendChild(script); | ||
} | ||
else if (script.getAttribute('dynamicImportLoaded')) { | ||
resolve(); | ||
} | ||
const scriptBeingRetried = options.scriptBeingRetried; | ||
// If multiple requests get made for this script, just stack the `onload`s | ||
// and `onerror`s and all the callbacks will be called in-order of being received. | ||
// If we are retrying the load, we use the `onload` / `onerror` from the script being retried | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const originalOnLoad = script.onload || (scriptBeingRetried === null || scriptBeingRetried === void 0 ? void 0 : scriptBeingRetried.onload); | ||
script.onload = (event) => { | ||
originalOnLoad === null || originalOnLoad === void 0 ? void 0 : originalOnLoad(event); | ||
script.setAttribute('dynamicImportLoaded', 'true'); | ||
resolve(); | ||
}; | ||
const originalOnError = script.onerror || (scriptBeingRetried === null || scriptBeingRetried === void 0 ? void 0 : scriptBeingRetried.onerror); | ||
script.onerror = (error) => __awaiter(this, void 0, void 0, function* () { | ||
const hasBeenRetried = script.getAttribute('hasBeenRetried'); | ||
if (retryCount < this.retryCount && !hasBeenRetried) { | ||
script.setAttribute('hasBeenRetried', 'true'); | ||
yield promisedSleep(this.retryInterval * 1000); | ||
const newRetryCount = retryCount + 1; | ||
this.emitter.emit('scriptLoadRetried', options.src, newRetryCount); | ||
this.doLoad(Object.assign(Object.assign({}, options), { retryCount: newRetryCount, scriptBeingRetried: script })); | ||
} | ||
else { | ||
this.emitter.emit('scriptLoadFailed', options.src, error); | ||
originalOnError === null || originalOnError === void 0 ? void 0 : originalOnError(error); | ||
reject(error); | ||
} | ||
}); | ||
}); | ||
}); | ||
} | ||
/** | ||
* Generate a script tag with all of the proper attributes | ||
* | ||
* @param options | ||
* @returns | ||
*/ | ||
getScriptTag(options) { | ||
var _a, _b; | ||
const fixedSrc = options.src.replace("'", '"'); | ||
const script = document.createElement('script'); | ||
const retryCount = (_a = options.retryCount) !== null && _a !== void 0 ? _a : 0; | ||
script.setAttribute('src', fixedSrc); | ||
script.setAttribute('retryCount', retryCount.toString()); | ||
script.async = true; | ||
const attributes = (_b = options.attributes) !== null && _b !== void 0 ? _b : {}; | ||
Object.keys(attributes).forEach(key => { | ||
script.setAttribute(key, attributes[key]); | ||
}); | ||
switch (options.bundleType) { | ||
case 'module': | ||
script.setAttribute('type', options.bundleType); | ||
break; | ||
// cannot be tested because modern browsers ignore `nomodule` | ||
/* istanbul ignore next */ | ||
case 'nomodule': | ||
script.setAttribute(options.bundleType, ''); | ||
break; | ||
default: | ||
break; | ||
} | ||
return script; | ||
} | ||
} | ||
//# sourceMappingURL=lazy-loader-service.js.map |
import { __awaiter } from "tslib"; | ||
import { expect, fixture, html } from '@open-wc/testing'; | ||
import { LazyLoaderService } from '../src/lazy-loader-service'; | ||
import { BundleType } from '../src/bundle-type'; | ||
const testServiceUrl = '/base/dist/test/test-service.js'; | ||
@@ -16,3 +15,3 @@ describe('Lazy Loader Service', () => { | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
yield lazyLoader.loadBundle({ | ||
@@ -29,3 +28,3 @@ module: testServiceUrl, | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
yield lazyLoader.loadScript({ src: testServiceUrl }); | ||
@@ -35,17 +34,5 @@ const scripts = container.querySelectorAll('script'); | ||
})); | ||
it('Removes the script tag if the load fails', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
try { | ||
yield lazyLoader.loadScript({ src: './blahblahnotfound.js' }); | ||
} | ||
catch (_a) { | ||
// expected failure | ||
} | ||
const scripts = container.querySelectorAll('script'); | ||
expect(scripts.length).to.equal(0); | ||
})); | ||
it('Only loads scripts once if called multiple times', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
yield lazyLoader.loadScript({ src: testServiceUrl }); | ||
@@ -59,3 +46,3 @@ yield lazyLoader.loadScript({ src: testServiceUrl }); | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
yield lazyLoader.loadScript({ src: testServiceUrl }); | ||
@@ -68,6 +55,6 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
yield lazyLoader.loadScript({ | ||
src: testServiceUrl, | ||
attributes: [{ key: 'foo', value: 'bar' }], | ||
attributes: { foo: 'bar' }, | ||
}); | ||
@@ -80,6 +67,6 @@ const script = container.querySelector('script'); | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
yield lazyLoader.loadScript({ | ||
src: testServiceUrl, | ||
bundleType: BundleType.Module, | ||
bundleType: 'module', | ||
}); | ||
@@ -94,3 +81,3 @@ const script = container.querySelector('script'); | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
const count = 25; | ||
@@ -119,3 +106,6 @@ const loads = new Array(count).fill(false); | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryInterval: 0.1, | ||
}); | ||
const count = 25; | ||
@@ -145,4 +135,81 @@ const loadFailed = new Array(count).fill(false); | ||
})); | ||
// This is verifying that we emit an event on retry and failure. | ||
// This allows the consumer to respond to these events with analytics handling | ||
// anything else. | ||
it('Emits an event when a retry occurs or fails', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryCount: 1, | ||
retryInterval: 0.01, | ||
}); | ||
let testRetryCount = 0; | ||
lazyLoader.on('scriptLoadRetried', (src, retryCount) => { | ||
testRetryCount = retryCount; | ||
}); | ||
let scriptLoadEventFired = false; | ||
let failedSrc; | ||
lazyLoader.on('scriptLoadFailed', (src) => { | ||
scriptLoadEventFired = true; | ||
failedSrc = src; | ||
}); | ||
let retryFailed = false; | ||
try { | ||
yield lazyLoader.loadScript({ src: '/base/test/blahblah.js' }); | ||
} | ||
catch (err) { | ||
retryFailed = true; | ||
} | ||
expect(testRetryCount).to.equal(1); | ||
expect(scriptLoadEventFired).to.be.true; | ||
expect(failedSrc).to.equal('/base/test/blahblah.js'); | ||
expect(retryFailed).to.be.true; | ||
})); | ||
it('Retries the specified number of times', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryCount: 5, | ||
retryInterval: 0.01, | ||
}); | ||
try { | ||
yield lazyLoader.loadScript({ src: '/base/test/blahblah.js' }); | ||
} | ||
catch (_a) { } | ||
const scriptTags = container.querySelectorAll('script'); | ||
expect(scriptTags.length).to.equal(6); | ||
})); | ||
/** | ||
* This is a special test that connects to a sidecar node server to test retrying a request. | ||
* | ||
* In `npm run test`, we run `test/test-server.js` while we're running our tests. | ||
* `test-server.js` has a very specific purpose: | ||
* | ||
* The very first request will always return an HTTP 404, the second request returns a HTTP 200, | ||
* then it shuts down. | ||
* | ||
* This lets us check that a failed request gets retried successfully. | ||
*/ | ||
it('Can retry a reqest', () => __awaiter(void 0, void 0, void 0, function* () { | ||
const serverUrl = 'http://localhost:5432/'; | ||
const container = (yield fixture(html ` <div></div> `)); | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryCount: 1, | ||
retryInterval: 0.01, | ||
}); | ||
yield lazyLoader.loadScript({ src: serverUrl }); | ||
// verify we have two script tags with the expected url and a propery retryCount on each | ||
const scriptTags = container.querySelectorAll('script'); | ||
scriptTags.forEach((tag, index) => { | ||
expect(tag.src).to.equal(serverUrl); | ||
expect(tag.getAttribute('retryCount'), `${index}`); | ||
}); | ||
// verify the final load actually loaded the service | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const result = window.otherService.getResponse(); | ||
expect(result).to.equal('someotherresponse'); | ||
})); | ||
}); | ||
}); | ||
//# sourceMappingURL=lazy-loader-service.test.js.map |
10
index.ts
@@ -1,3 +0,9 @@ | ||
export { LazyLoaderService } from './src/lazy-loader-service'; | ||
export { LazyLoaderServiceInterface } from './src/lazy-loader-service-interface'; | ||
export { | ||
LazyLoaderService, | ||
LazyLoaderServiceOptions, | ||
} from './src/lazy-loader-service'; | ||
export { | ||
LazyLoaderServiceInterface, | ||
LazyLoaderServiceEvents, | ||
} from './src/lazy-loader-service-interface'; | ||
export { BundleType } from './src/bundle-type'; |
{ | ||
"name": "@internetarchive/lazy-loader-service", | ||
"version": "0.1.0", | ||
"version": "0.2.0-alpha.1", | ||
"description": "A small library to lazy load javascript with a Promise", | ||
@@ -10,3 +10,3 @@ "license": "AGPL-3.0-only", | ||
"scripts": { | ||
"start": "concurrently --kill-others --names tsc,es-dev-server \"npm run tsc:watch\" \"es-dev-server --app-index demo/index.html --node-resolve --open --watch\"", | ||
"start": "concurrently --kill-others --names tsc,wds \"npm run tsc:watch\" \"wds --app-index demo/index.html --node-resolve --open --watch\"", | ||
"docs": "typedoc", | ||
@@ -20,6 +20,5 @@ "tsc:watch": "tsc --watch", | ||
"format": "npm run format:eslint && npm run format:prettier", | ||
"test": "npm run lint && tsc && karma start --coverage", | ||
"test": "npm run lint && tsc && concurrently \"node ./test/retry-server.js\" \"karma start --coverage\"", | ||
"test:watch": "concurrently --kill-others --names tsc,karma \"npm run tsc:watch\" \"karma start --auto-watch=true --single-run=false\"" | ||
}, | ||
"dependencies": {}, | ||
"devDependencies": { | ||
@@ -32,5 +31,5 @@ "@open-wc/eslint-config": "^2.0.0", | ||
"@typescript-eslint/parser": "^2.20.0", | ||
"@web/dev-server": "^0.1.28", | ||
"concurrently": "^5.1.0", | ||
"deepmerge": "^3.2.0", | ||
"es-dev-server": "^1.23.0", | ||
"eslint": "^6.1.0", | ||
@@ -42,5 +41,5 @@ "eslint-config-prettier": "^6.11.0", | ||
"prettier": "^2.0.4", | ||
"tslib": "^1.11.0", | ||
"typedoc": "^0.17.8", | ||
"typescript": "~3.8.2" | ||
"tslib": "^2.3.1", | ||
"typedoc": "^0.22.10", | ||
"typescript": "^4.5.2" | ||
}, | ||
@@ -68,3 +67,6 @@ "eslintConfig": { | ||
] | ||
}, | ||
"dependencies": { | ||
"nanoevents": "^6.0.2" | ||
} | ||
} |
@@ -1,4 +0,1 @@ | ||
export enum BundleType { | ||
Module = 'module', | ||
NoModule = 'nomodule', | ||
} | ||
export type BundleType = 'module' | 'nomodule'; |
import { BundleType } from './bundle-type'; | ||
import { Unsubscribe } from 'nanoevents'; | ||
export interface LazyLoaderServiceEvents { | ||
scriptLoadRetried: (src: string, retryCount: number) => void; | ||
scriptLoadFailed: (src: string, error: string | Event) => void; | ||
} | ||
export interface LazyLoaderServiceInterface { | ||
/** | ||
* Bind to receive notifications about retry and failure events | ||
* | ||
* @template E | ||
* @param {E} event | ||
* @param {LazyLoaderServiceEvents[E]} callback | ||
* @returns {Unsubscribe} | ||
* @memberof LazyLoaderServiceInterface | ||
*/ | ||
on<E extends keyof LazyLoaderServiceEvents>( | ||
event: E, | ||
callback: LazyLoaderServiceEvents[E] | ||
): Unsubscribe; | ||
/** | ||
* Load a javascript bundle (module and nomodule pair) | ||
@@ -16,6 +36,3 @@ * | ||
*/ | ||
loadBundle(bundle: { | ||
module?: string; | ||
nomodule?: string; | ||
}): Promise<Event | undefined>; | ||
loadBundle(bundle: { module?: string; nomodule?: string }): Promise<void>; | ||
@@ -38,4 +55,4 @@ /** | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
attributes?: { key: string; value: any }[]; | ||
}): Promise<Event>; | ||
attributes?: Record<string, string>; | ||
}): Promise<void>; | ||
} |
@@ -1,18 +0,65 @@ | ||
import { BundleType } from './bundle-type'; | ||
import { LazyLoaderServiceInterface } from './lazy-loader-service-interface'; | ||
import { createNanoEvents, Unsubscribe } from 'nanoevents'; | ||
import type { BundleType } from './bundle-type'; | ||
import { | ||
LazyLoaderServiceEvents, | ||
LazyLoaderServiceInterface, | ||
} from './lazy-loader-service-interface'; | ||
import { promisedSleep } from './promised-sleep'; | ||
export interface LazyLoaderServiceOptions { | ||
/** | ||
* The HTMLElement in which we put the script tags, defaults to document.head | ||
*/ | ||
container?: HTMLElement; | ||
/** | ||
* The number of retries we should attempt | ||
*/ | ||
retryCount?: number; | ||
/** | ||
* The retry interval in seconds | ||
*/ | ||
retryInterval?: number; | ||
} | ||
export class LazyLoaderService implements LazyLoaderServiceInterface { | ||
// the HTMLElement in which we put the script tags, defaults to document.head | ||
private container: HTMLElement; | ||
constructor(container: HTMLElement = document.head) { | ||
this.container = container; | ||
// the number of retries we should attempt | ||
private retryCount: number; | ||
// the retry interval in seconds | ||
private retryInterval: number; | ||
// the emitter for consumers to listen for events like retrying a load | ||
private emitter = createNanoEvents<LazyLoaderServiceEvents>(); | ||
/** | ||
* LazyLoaderService constructor | ||
* | ||
* @param options LazyLoaderServiceOptions | ||
*/ | ||
constructor(options?: LazyLoaderServiceOptions) { | ||
this.container = options?.container ?? document.head; | ||
this.retryCount = options?.retryCount ?? 2; | ||
this.retryInterval = options?.retryInterval ?? 1; | ||
} | ||
/** @inheritdoc */ | ||
loadBundle(bundle: { | ||
on<E extends keyof LazyLoaderServiceEvents>( | ||
event: E, | ||
callback: LazyLoaderServiceEvents[E] | ||
): Unsubscribe { | ||
return this.emitter.on(event, callback); | ||
} | ||
/** @inheritdoc */ | ||
async loadBundle(bundle: { | ||
module?: string; | ||
nomodule?: string; | ||
}): Promise<Event | undefined> { | ||
let modulePromise: Promise<Event> | undefined; | ||
let nomodulePromise: Promise<Event> | undefined; | ||
}): Promise<void> { | ||
let modulePromise: Promise<void> | undefined; | ||
let nomodulePromise: Promise<void> | undefined; | ||
@@ -23,3 +70,3 @@ /* istanbul ignore else */ | ||
src: bundle.module, | ||
bundleType: BundleType.Module, | ||
bundleType: 'module', | ||
}); | ||
@@ -32,3 +79,3 @@ } | ||
src: bundle.nomodule, | ||
bundleType: BundleType.NoModule, | ||
bundleType: 'nomodule', | ||
}); | ||
@@ -41,9 +88,19 @@ } | ||
/** @inheritdoc */ | ||
loadScript(options: { | ||
async loadScript(options: { | ||
src: string; | ||
bundleType?: BundleType; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
attributes?: { key: string; value: any }[]; | ||
}): Promise<Event> { | ||
const scriptSelector = `script[src='${options.src}'][async]`; | ||
attributes?: Record<string, string>; | ||
}): Promise<void> { | ||
return this.doLoad(options); | ||
} | ||
private async doLoad(options: { | ||
src: string; | ||
bundleType?: BundleType; | ||
attributes?: Record<string, string>; | ||
retryCount?: number; | ||
scriptBeingRetried?: HTMLScriptElement; | ||
}): Promise<void> { | ||
const retryCount = options.retryCount ?? 0; | ||
const scriptSelector = `script[src='${options.src}'][async][retryCount='${retryCount}']`; | ||
let script = this.container.querySelector( | ||
@@ -53,63 +110,92 @@ scriptSelector | ||
if (!script) { | ||
script = document.createElement('script') as HTMLScriptElement; | ||
script.setAttribute('src', options.src); | ||
script.async = true; | ||
script = this.getScriptTag(options); | ||
this.container.appendChild(script); | ||
} | ||
const attributes = options.attributes ?? []; | ||
return new Promise((resolve, reject) => { | ||
// script has already been loaded, just resolve | ||
if (script.getAttribute('dynamicImportLoaded')) { | ||
resolve(); | ||
return; | ||
} | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
attributes.forEach((element: any) => { | ||
// eslint-disable-next-line no-unused-expressions | ||
script.setAttribute(element.key, element.value); | ||
}); | ||
const scriptBeingRetried = options.scriptBeingRetried; | ||
switch (options.bundleType) { | ||
case BundleType.Module: | ||
script.setAttribute('type', options.bundleType); | ||
break; | ||
// cannot be tested because modern browsers ignore `nomodule` | ||
/* istanbul ignore next */ | ||
case BundleType.NoModule: | ||
script.setAttribute(options.bundleType, ''); | ||
break; | ||
default: | ||
break; | ||
} | ||
} | ||
// If multiple requests get made for this script, just stack the `onload`s | ||
// and `onerror`s and all the callbacks will be called in-order of being received. | ||
// If we are retrying the load, we use the `onload` / `onerror` from the script being retried | ||
return new Promise((resolve, reject) => { | ||
// if multiple requests get made for this script, just stack the onloads | ||
// and onerrors and all the callbacks will be called in-order of being received | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const originalOnLoad: ((event: Event) => any) | null = script.onload; | ||
const originalOnLoad: ((event: Event) => any) | null | undefined = | ||
script.onload || scriptBeingRetried?.onload; | ||
script.onload = (event): void => { | ||
if (originalOnLoad) { | ||
originalOnLoad(event); | ||
} | ||
originalOnLoad?.(event); | ||
script.setAttribute('dynamicImportLoaded', 'true'); | ||
resolve(event); | ||
resolve(); | ||
}; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const originalOnError: ((error: string | Event) => any) | null = | ||
script.onerror; | ||
script.onerror = (error): void => { | ||
if (originalOnError) { | ||
originalOnError(error); | ||
} | ||
const originalOnError: OnErrorEventHandler | null | undefined = | ||
script.onerror || scriptBeingRetried?.onerror; | ||
/* istanbul ignore else */ | ||
if (script.parentNode) { | ||
script.parentNode.removeChild(script); | ||
script.onerror = async (error): Promise<void> => { | ||
const hasBeenRetried = script.getAttribute('hasBeenRetried'); | ||
if (retryCount < this.retryCount && !hasBeenRetried) { | ||
script.setAttribute('hasBeenRetried', 'true'); | ||
await promisedSleep(this.retryInterval * 1000); | ||
const newRetryCount = retryCount + 1; | ||
this.emitter.emit('scriptLoadRetried', options.src, newRetryCount); | ||
this.doLoad({ | ||
...options, | ||
retryCount: newRetryCount, | ||
scriptBeingRetried: script, | ||
}); | ||
} else { | ||
this.emitter.emit('scriptLoadFailed', options.src, error); | ||
originalOnError?.(error); | ||
reject(error); | ||
} | ||
reject(error); | ||
}; | ||
}); | ||
} | ||
if (script.parentNode === null) { | ||
this.container.appendChild(script); | ||
} else if (script.getAttribute('dynamicImportLoaded')) { | ||
resolve(); | ||
} | ||
/** | ||
* Generate a script tag with all of the proper attributes | ||
* | ||
* @param options | ||
* @returns | ||
*/ | ||
private getScriptTag(options: { | ||
src: string; | ||
retryCount?: number; | ||
bundleType?: BundleType; | ||
attributes?: Record<string, string>; | ||
}): HTMLScriptElement { | ||
const fixedSrc = options.src.replace("'", '"'); | ||
const script = document.createElement('script') as HTMLScriptElement; | ||
const retryCount = options.retryCount ?? 0; | ||
script.setAttribute('src', fixedSrc); | ||
script.setAttribute('retryCount', retryCount.toString()); | ||
script.async = true; | ||
const attributes = options.attributes ?? {}; | ||
Object.keys(attributes).forEach(key => { | ||
script.setAttribute(key, attributes[key]); | ||
}); | ||
switch (options.bundleType) { | ||
case 'module': | ||
script.setAttribute('type', options.bundleType); | ||
break; | ||
// cannot be tested because modern browsers ignore `nomodule` | ||
/* istanbul ignore next */ | ||
case 'nomodule': | ||
script.setAttribute(options.bundleType, ''); | ||
break; | ||
default: | ||
break; | ||
} | ||
return script; | ||
} | ||
} |
import { expect, fixture, html } from '@open-wc/testing'; | ||
import { LazyLoaderService } from '../src/lazy-loader-service'; | ||
import { BundleType } from '../src/bundle-type'; | ||
@@ -18,3 +17,3 @@ const testServiceUrl = '/base/dist/test/test-service.js'; | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
await lazyLoader.loadBundle({ | ||
@@ -33,3 +32,3 @@ module: testServiceUrl, | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
@@ -42,19 +41,5 @@ await lazyLoader.loadScript({ src: testServiceUrl }); | ||
it('Removes the script tag if the load fails', async () => { | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
try { | ||
await lazyLoader.loadScript({ src: './blahblahnotfound.js' }); | ||
} catch { | ||
// expected failure | ||
} | ||
const scripts = container.querySelectorAll('script'); | ||
expect(scripts.length).to.equal(0); | ||
}); | ||
it('Only loads scripts once if called multiple times', async () => { | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
@@ -71,3 +56,3 @@ await lazyLoader.loadScript({ src: testServiceUrl }); | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
await lazyLoader.loadScript({ src: testServiceUrl }); | ||
@@ -82,6 +67,6 @@ | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
await lazyLoader.loadScript({ | ||
src: testServiceUrl, | ||
attributes: [{ key: 'foo', value: 'bar' }], | ||
attributes: { foo: 'bar' }, | ||
}); | ||
@@ -96,6 +81,6 @@ | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
await lazyLoader.loadScript({ | ||
src: testServiceUrl, | ||
bundleType: BundleType.Module, | ||
bundleType: 'module', | ||
}); | ||
@@ -112,3 +97,3 @@ | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ container }); | ||
@@ -141,3 +126,6 @@ const count = 25; | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService(container); | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryInterval: 0.1, | ||
}); | ||
@@ -169,3 +157,89 @@ const count = 25; | ||
}); | ||
// This is verifying that we emit an event on retry and failure. | ||
// This allows the consumer to respond to these events with analytics handling | ||
// anything else. | ||
it('Emits an event when a retry occurs or fails', async () => { | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryCount: 1, | ||
retryInterval: 0.01, | ||
}); | ||
let testRetryCount = 0; | ||
lazyLoader.on('scriptLoadRetried', (src: string, retryCount: number) => { | ||
testRetryCount = retryCount; | ||
}); | ||
let scriptLoadEventFired = false; | ||
let failedSrc: string | undefined; | ||
lazyLoader.on('scriptLoadFailed', (src: string) => { | ||
scriptLoadEventFired = true; | ||
failedSrc = src; | ||
}); | ||
let retryFailed = false; | ||
try { | ||
await lazyLoader.loadScript({ src: '/base/test/blahblah.js' }); | ||
} catch (err) { | ||
retryFailed = true; | ||
} | ||
expect(testRetryCount).to.equal(1); | ||
expect(scriptLoadEventFired).to.be.true; | ||
expect(failedSrc).to.equal('/base/test/blahblah.js'); | ||
expect(retryFailed).to.be.true; | ||
}); | ||
it('Retries the specified number of times', async () => { | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryCount: 5, | ||
retryInterval: 0.01, | ||
}); | ||
try { | ||
await lazyLoader.loadScript({ src: '/base/test/blahblah.js' }); | ||
} catch {} | ||
const scriptTags = container.querySelectorAll('script'); | ||
expect(scriptTags.length).to.equal(6); | ||
}); | ||
/** | ||
* This is a special test that connects to a sidecar node server to test retrying a request. | ||
* | ||
* In `npm run test`, we run `test/test-server.js` while we're running our tests. | ||
* `test-server.js` has a very specific purpose: | ||
* | ||
* The very first request will always return an HTTP 404, the second request returns a HTTP 200, | ||
* then it shuts down. | ||
* | ||
* This lets us check that a failed request gets retried successfully. | ||
*/ | ||
it('Can retry a reqest', async () => { | ||
const serverUrl = 'http://localhost:5432/'; | ||
const container = (await fixture(html` <div></div> `)) as HTMLElement; | ||
const lazyLoader = new LazyLoaderService({ | ||
container, | ||
retryCount: 1, | ||
retryInterval: 0.01, | ||
}); | ||
await lazyLoader.loadScript({ src: serverUrl }); | ||
// verify we have two script tags with the expected url and a propery retryCount on each | ||
const scriptTags = container.querySelectorAll('script'); | ||
scriptTags.forEach((tag, index) => { | ||
expect(tag.src).to.equal(serverUrl); | ||
expect(tag.getAttribute('retryCount'), `${index}`); | ||
}); | ||
// verify the final load actually loaded the service | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const result = (window as any).otherService.getResponse(); | ||
expect(result).to.equal('someotherresponse'); | ||
}); | ||
}); | ||
}); |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
397785
60
5953
1
2
+ Addednanoevents@^6.0.2
+ Addednanoevents@6.0.2(transitive)