From 311b04594de9600d7b9cd7e74f890b10d6a44da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20=C5=A0ime=C4=8Dek?= Date: Sun, 19 Feb 2023 23:12:46 +0100 Subject: [PATCH] New plugin plugin-asset-loader --- .changeset/shy-singers-burn.md | 5 + package-lock.json | 20 ++ packages/plugin-asset-loader/.npmignore | 4 + packages/plugin-asset-loader/CHANGELOG.md | 1 + packages/plugin-asset-loader/LICENSE | 21 ++ packages/plugin-asset-loader/README.md | 30 ++ packages/plugin-asset-loader/jest.config.js | 5 + packages/plugin-asset-loader/package.json | 40 +++ .../plugin-asset-loader/src/AssetLoader.ts | 301 ++++++++++++++++++ .../src/AssetLoaderEvents.ts | 7 + packages/plugin-asset-loader/src/main.ts | 2 + packages/plugin-asset-loader/tsconfig.json | 11 + types/easy-uid/index.d.ts | 3 + types/easy-uid/package.json | 4 + 14 files changed, 454 insertions(+) create mode 100644 .changeset/shy-singers-burn.md create mode 100644 packages/plugin-asset-loader/.npmignore create mode 100644 packages/plugin-asset-loader/CHANGELOG.md create mode 100644 packages/plugin-asset-loader/LICENSE create mode 100644 packages/plugin-asset-loader/README.md create mode 100644 packages/plugin-asset-loader/jest.config.js create mode 100644 packages/plugin-asset-loader/package.json create mode 100644 packages/plugin-asset-loader/src/AssetLoader.ts create mode 100644 packages/plugin-asset-loader/src/AssetLoaderEvents.ts create mode 100644 packages/plugin-asset-loader/src/main.ts create mode 100644 packages/plugin-asset-loader/tsconfig.json create mode 100644 types/easy-uid/index.d.ts create mode 100644 types/easy-uid/package.json diff --git a/.changeset/shy-singers-burn.md b/.changeset/shy-singers-burn.md new file mode 100644 index 00000000..2a1d2497 --- /dev/null +++ b/.changeset/shy-singers-burn.md @@ -0,0 +1,5 @@ +--- +"@ima/plugin-asset-loader": major +--- + +New plugin for loading 3rd party (inline/external) assets. This effectively replaces @ima/plugin-style-loader, @ima/plugin-script-loader and @ima/plugin-resource-loader as a drop in replacement, with additional features diff --git a/package-lock.json b/package-lock.json index 6d8589f8..1e4acb1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1986,6 +1986,10 @@ "resolved": "packages/plugin-analytic-google", "link": true }, + "node_modules/@ima/plugin-asset-loader": { + "resolved": "packages/plugin-asset-loader", + "link": true + }, "node_modules/@ima/plugin-atoms": { "resolved": "packages/plugin-atoms", "link": true @@ -21318,6 +21322,22 @@ "@ima/plugin-script-loader": ">=3.1.1-rc.0" } }, + "packages/plugin-asset-loader": { + "name": "@ima/plugin-asset-loader", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "easy-uid": "^2.0.2" + }, + "peerDependencies": { + "@ima/core": ">=18.0.0 || >=19.0.0-rc.0" + } + }, + "packages/plugin-asset-loader/node_modules/easy-uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/easy-uid/-/easy-uid-2.0.2.tgz", + "integrity": "sha512-FAdGZBrhd5S0vmbYY9ZAlVw7z5oyW9DnxfO9am4ombzB6Hqi2CniawW5rb1nvuyx5tk+PiL8q94EAf8UbGhbnw==" + }, "packages/plugin-atoms": { "name": "@ima/plugin-atoms", "version": "9.0.0-rc.2", diff --git a/packages/plugin-asset-loader/.npmignore b/packages/plugin-asset-loader/.npmignore new file mode 100644 index 00000000..f9888d42 --- /dev/null +++ b/packages/plugin-asset-loader/.npmignore @@ -0,0 +1,4 @@ +* +!dist/**/* +!lib/**/* +!package.json diff --git a/packages/plugin-asset-loader/CHANGELOG.md b/packages/plugin-asset-loader/CHANGELOG.md new file mode 100644 index 00000000..420e6f23 --- /dev/null +++ b/packages/plugin-asset-loader/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/plugin-asset-loader/LICENSE b/packages/plugin-asset-loader/LICENSE new file mode 100644 index 00000000..23fc16fd --- /dev/null +++ b/packages/plugin-asset-loader/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Seznam.cz a.s. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin-asset-loader/README.md b/packages/plugin-asset-loader/README.md new file mode 100644 index 00000000..ff68022a --- /dev/null +++ b/packages/plugin-asset-loader/README.md @@ -0,0 +1,30 @@ +# @ima/plugin-resource-loader + +This is the base plugin for loading scripts and styles for the IMA.js application. +You can find the IMA.js skeleton application at +or follow link . + +## Installation + +```javascript + +npm install @ima/plugin-resource-loader --save + +``` + +```javascript +// /app/build.js + +var vendors = { + common: [ + '@ima/plugin-resource-loader' + ] +}; + +/* +Now is script loader plugin available as: + +import { ResourceLoader } from '@ima/plugin-resource-loader'; +*/ + +``` diff --git a/packages/plugin-asset-loader/jest.config.js b/packages/plugin-asset-loader/jest.config.js new file mode 100644 index 00000000..0088b226 --- /dev/null +++ b/packages/plugin-asset-loader/jest.config.js @@ -0,0 +1,5 @@ +const base = require('../../jest.config.base.js'); + +module.exports = { + ...base, +}; diff --git a/packages/plugin-asset-loader/package.json b/packages/plugin-asset-loader/package.json new file mode 100644 index 00000000..e27ca48b --- /dev/null +++ b/packages/plugin-asset-loader/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ima/plugin-asset-loader", + "version": "1.0.0", + "description": "Seznam IMA.js plugin for loading web assets and resources", + "main": "./dist/cjs/main.js", + "module": "./dist/esm/main.js", + "types": "./dist/esm/main.d.ts", + "scripts": { + "test": "../../node_modules/.bin/jest --coverage --no-watchman --config=jest.config.js", + "build": "ima-plugin build", + "dev": "ima-plugin dev", + "link": "ima-plugin link", + "lint": "eslint './**/*.{js,jsx,ts,tsx,mjs}'" + }, + "keywords": [ + "IMA.js", + "Web resource loader", + "Module", + "Javascript" + ], + "author": "Jan Šimeček ", + "repository": { + "type": "git", + "url": "https://github.com/seznam/IMA.js-plugins.git" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public" + }, + "bugs": { + "url": "https://github.com/seznam/IMA.js-plugins/issues" + }, + "license": "MIT", + "peerDependencies": { + "@ima/core": ">=18.0.0 || >=19.0.0-rc.0" + }, + "dependencies": { + "easy-uid": "^2.0.2" + } +} diff --git a/packages/plugin-asset-loader/src/AssetLoader.ts b/packages/plugin-asset-loader/src/AssetLoader.ts new file mode 100644 index 00000000..348e6148 --- /dev/null +++ b/packages/plugin-asset-loader/src/AssetLoader.ts @@ -0,0 +1,301 @@ +import { Dispatcher, GenericError, Window } from '@ima/core'; +import easyUid from 'easy-uid'; + +import { AssetLoaderEvents } from './AssetLoaderEvents'; + +export type AssetAttributes = Record; +export interface LoadedAsset { + id: string; + assetPromise: Promise; +} + +const isURLRe = /^(http:|https:)?\/\//; + +export class AssetLoader { + #window: Window; + #dispatcher: Dispatcher; + #loadedAssetsCache = new Map(); + + static get $dependencies() { + return [Window, Dispatcher]; + } + + constructor(window: Window, dispatcher: Dispatcher) { + this.#window = window; + this.#dispatcher = dispatcher; + } + + /** + * Creates elements for given assets, promisifies onload + * callbacks and injects asset elements into the page. + * + * This method also manages cache of asset promises to assure + * that every unique asset is loaded only once, even when the + * called multiple times with the same URL. + * + * @param params + * @param params.urlOrContent URL or inline content of the asset to load. + * @param params.type Asset type - script or style + * @param params.attributes Additional optional element attributes + * @param params.forceReload Set to true to force reload of existing asset. + * The existing asset is removed from DOM before injecting new one. + * @param params.injectRoot Optional different root, where asset elements + * are appended. Defaults to document.head. + */ + async #load({ + type, + urlOrContent, + attributes, + forceReload, + injectRoot, + }: { + urlOrContent: string; + type: 'script' | 'style'; + attributes: AssetAttributes | undefined; + forceReload: boolean; + injectRoot?: HTMLElement; + }): Promise { + if (!urlOrContent) { + throw new GenericError( + `URL or asset content is empty, unable to load: ${urlOrContent}.`, + { urlOrContent } + ); + } + + if (!this.#window.isClient()) { + throw new GenericError( + `The asset loader cannot be used on Server-side. Unable to load ${urlOrContent}.`, + { urlOrContent } + ); + } + + // Remove asset from DOM before reloading + if (forceReload) { + const existingAsset = this.#loadedAssetsCache.get(urlOrContent); + + if (existingAsset) { + this.#window + .getDocument() + ?.querySelector(`[data-asset-loader-id="${existingAsset.id}"`) + ?.remove(); + this.#loadedAssetsCache.delete(urlOrContent); + } + } + + // Return promise from cache + if (this.#loadedAssetsCache.has(urlOrContent)) { + return this.#loadedAssetsCache.get(urlOrContent)?.assetPromise; + } + + const uid = easyUid(); + const inline = isURLRe.test(urlOrContent) ? false : true; + const assetElement = this.#createElement( + type === 'script' ? 'script' : inline ? 'style' : 'link', + inline ? '' : urlOrContent, + uid + ); + + if (inline) { + assetElement.innerHTML = urlOrContent; + } + + if (attributes && Object.keys(attributes).length) { + for (const attribute in attributes) { + assetElement.setAttribute(attribute, attributes[attribute]); + } + } + + // Promisify script url/content loading + const assetPromise = inline + ? Promise.resolve() + : this.promisify(assetElement) + .then(() => this.#handleSuccess(urlOrContent)) + .catch(error => this.#handleError(urlOrContent, error as Error)); + + // Save asset promise to cache + this.#loadedAssetsCache.set(urlOrContent, { + id: uid, + assetPromise, + }); + + // Inject element to page + this.injectToPage(assetElement, injectRoot); + + return assetPromise; + } + + /** + * Helper for creating asset elements with pre-filled + * id data attribute (for later identification). + * + * @param tagName Tag name to create. + * @param url Asset source URL. + * @param id Asset element unique ID. + * @returns Asset element. + */ + #createElement( + tagName: E, + url: string | null, + id: string + ): HTMLScriptElement | HTMLLinkElement | HTMLStyleElement { + const element = this.#window.getDocument()!.createElement(tagName); + + // Set default attributes + element.setAttribute('data-asset-loader-id', id); + + if (tagName === 'link') { + (element as HTMLLinkElement).rel = 'stylesheet'; + } + + // Inline elements don't have url + if (!url) { + return element; + } + + // Set elements resource URL + if (tagName === 'link') { + (element as HTMLLinkElement).href = url; + } else if (url && tagName === 'script') { + (element as HTMLScriptElement).src = url; + } + + return element; + } + + /** + * Handler for successfully loaded external 3rd party assets. + * + * @param url Asset source url. + */ + #handleSuccess(url: string) { + this.#dispatcher.fire(AssetLoaderEvents.LOADED, { url }, true); + } + + /** + * Handler for 3rd party assets which failed to load. + * + * @param url Asset source url. + * @param error Rejected promise error. + */ + #handleError(url: string, error: Error) { + const handledError = new GenericError(`The ${url} script failed to load.`, { + url, + cause: error, + }); + + this.#dispatcher.fire( + AssetLoaderEvents.LOADED, + { + url, + error, + }, + true + ); + + throw handledError; + } + + /** + * Helper for injecting asset elements to document. + * + * @param element Asset element to inject to document. + * @param injectRoot Optional custom root, where assets + * are appended. + */ + injectToPage(element: HTMLElement, injectRoot?: HTMLElement) { + (injectRoot ? injectRoot : this.#window.getDocument()?.head)?.appendChild( + element + ); + } + + /** + * Promisify the resource loading for the provided asset. + * + * @param element Resource element, which onload and onerror + * event callbacks are promisified into resolve/reject methods. + * @param rejectOnAbort Set to true to also handle onabort event. + */ + async promisify(element: HTMLElement, rejectOnAbort = false): Promise { + return new Promise((resolve, reject) => { + element.onload = () => { + resolve(); + }; + + element.onerror = event => { + reject( + new GenericError(`Failed to load asset.`, { + cause: event, + }) + ); + }; + + if (rejectOnAbort) { + element.onabort = event => { + reject( + new GenericError(`Loading of a asset has been aborted`, { + cause: event, + }) + ); + }; + } + }); + } + + /** + * Load 3rd party style to the page. It can be either + * external style URL or inlined content. + * + * @param urlOrContent URL to style resource or inlined style content. + * @param options + * @param options.attributes Additional optional element attributes. + * @param options.forceReload Set to true to force reload of existing asset. + * The existing asset is removed from DOM before injecting new one. + * @param options.injectRoot Optional different root, where asset elements + * are appended. Defaults to document.head. + */ + async loadStyle( + urlOrContent: string, + options?: { + attributes?: AssetAttributes; + forceReload?: boolean; + injectRoot?: HTMLElement; + } + ): Promise { + return this.#load({ + type: 'style', + urlOrContent, + attributes: options?.attributes, + forceReload: options?.forceReload ?? false, + injectRoot: options?.injectRoot, + }); + } + + /** + * Load 3rd party script to the page. It can be either + * external style URL or inlined content. + * + * @param urlOrContent URL to script resource or inlined script. + * @param options + * @param options.attributes Additional optional element attributes. + * @param options.forceReload Set to true to force reload of existing asset. + * The existing asset is removed from DOM before injecting new one. + * @param options.injectRoot Optional different root, where asset elements + * are appended. Defaults to document.head. + */ + async loadScript( + urlOrContent: string, + options?: { + attributes?: AssetAttributes; + forceReload?: boolean; + injectRoot?: HTMLElement; + } + ): Promise { + return this.#load({ + type: 'script', + urlOrContent, + attributes: options?.attributes, + forceReload: options?.forceReload ?? false, + injectRoot: options?.injectRoot, + }); + } +} diff --git a/packages/plugin-asset-loader/src/AssetLoaderEvents.ts b/packages/plugin-asset-loader/src/AssetLoaderEvents.ts new file mode 100644 index 00000000..24f1cce7 --- /dev/null +++ b/packages/plugin-asset-loader/src/AssetLoaderEvents.ts @@ -0,0 +1,7 @@ +export enum AssetLoaderEvents { + /** + * Fired to dispatcher when loaded. If the script failed to load, + * it may included `error` in event data. + */ + LOADED = 'ima.plugin.asset.loader.loaded', +} diff --git a/packages/plugin-asset-loader/src/main.ts b/packages/plugin-asset-loader/src/main.ts new file mode 100644 index 00000000..60cc652d --- /dev/null +++ b/packages/plugin-asset-loader/src/main.ts @@ -0,0 +1,2 @@ +export { AssetLoaderEvents } from './AssetLoaderEvents'; +export { AssetLoader } from './AssetLoader'; diff --git a/packages/plugin-asset-loader/tsconfig.json b/packages/plugin-asset-loader/tsconfig.json new file mode 100644 index 00000000..2b0a1f84 --- /dev/null +++ b/packages/plugin-asset-loader/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": [ + "DOM" + ], + }, + "include": [ + "src" + ] +} diff --git a/types/easy-uid/index.d.ts b/types/easy-uid/index.d.ts new file mode 100644 index 00000000..0dc25a1b --- /dev/null +++ b/types/easy-uid/index.d.ts @@ -0,0 +1,3 @@ +declare module 'easy-uid' { + export default function (): string; +} diff --git a/types/easy-uid/package.json b/types/easy-uid/package.json new file mode 100644 index 00000000..7351ad2b --- /dev/null +++ b/types/easy-uid/package.json @@ -0,0 +1,4 @@ +{ + "name": "@types/easy-uid", + "types": "index.d.ts" +}