All files / src/app/core/utils/script-loader script-loader.service.ts

69.84% Statements 44/63
50% Branches 9/18
63.63% Functions 7/11
70.68% Lines 41/58

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 15812x 12x 12x 12x                                                                 12x     29x   29x   29x 29x                           3x         3x   3x 1x       2x         2x 1x 1x 1x     1x 1x       1x 1x   1x 1x 1x 1x 1x     1x       1x         1x           1x                 1x             1x       1x 1x         3x 3x       3x       2x             2x 2x      
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core';
import { Observable, Observer } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
 
import { Attribute } from 'ish-core/models/attribute/attribute.model';
 
/**
 * Represents a script that has been loaded or is being loaded.
 */
export interface ScriptType {
  /** The source URL of the script */
  src: string;
  /** Indicates whether the script has successfully been loaded */
  loaded: boolean;
}
 
/**
 * Configuration options for loading a script element.
 */
interface ScriptLoaderOption {
  /** Type if it is not a classic Javascript file, e.g. 'module' */
  type?: string;
  /** Integrity hash and crossOrigin to 'anonymous' (parameter 'crossorigin' ignored in that case) */
  integrity?: string;
  /** Value for crossOrigin attribute in script tag */
  crossorigin?: string;
  /** Script html element (data) attributes, e.g. <script src="..." data-foo="bar"> */
  attributes?: Attribute<string>[];
}
 
/**
 * Service for dynamically loading external JavaScript files into the DOM.
 *
 */
@Injectable({ providedIn: 'root' })
export class ScriptLoaderService {
  private renderer: Renderer2;
  /** Cache of successfully loaded scripts */
  private loadedScripts = new Map<string, Observable<ScriptType>>();
  /** Cache of scripts currently being loaded */
  private loadingScripts = new Map<string, Observable<ScriptType>>();
 
  constructor(private rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document) {
    this.renderer = this.rendererFactory.createRenderer(undefined, undefined);
  }
 
  /**
   * Loads an external JavaScript file dynamically and creates and appends a new script element to the document body.
   * It prevents duplicate loading of the same script by caching the loading state and result.
   * The method supports script attributes like type, integrity, crossorigin and custom data attributes.
   * To load the same script multiple times with different attributes, provide a unique 'data-namespace' attribute.
   *
   * @param url The URL of the script to load.
   * @param options Optional configuration for the script element.
   * @returns An Observable that emits the loading state or an error if loading fails.
   */
  load(url: string, options?: ScriptLoaderOption): Observable<ScriptType> {
    Iif (!url?.trim()) {
      throw new Error('ScriptLoaderService.load: "url" parameter must be a non-empty string.');
    }
 
    // Create a cache key based on the 'data-namespace' attribute if provided, otherwise use the URL
    const cacheKey = this.createCacheKey(url, options);
    // Check if script is already loaded
    if (this.loadedScripts.has(cacheKey)) {
      return this.loadedScripts.get(cacheKey);
    }
 
    // Check if script is currently loading
    Iif (this.loadingScripts.has(cacheKey)) {
      return this.loadingScripts.get(cacheKey);
    }
 
    // Check if script is already in the DOM
    if (this.isScriptAlreadyLoaded(url, options?.attributes?.find(attr => attr.name === 'data-namespace')?.value)) {
      const loadedScript$ = new Observable<ScriptType>(observer => {
        observer.next({ src: url, loaded: true });
        observer.complete();
      }).pipe(shareReplay(1));
 
      this.loadedScripts.set(cacheKey, loadedScript$);
      return loadedScript$;
    }
 
    // Create new loading observable
    const loading$ = new Observable<ScriptType>((observer: Observer<ScriptType>) => {
      const script: ScriptType = { src: url, loaded: false };
      // Load the script
      const scriptElement = this.renderer.createElement('script');
      scriptElement.src = url;
      scriptElement.async = true;
      if (options?.type) {
        scriptElement.type = options.type;
      }
 
      Iif (options?.crossorigin) {
        scriptElement.crossOrigin = options.crossorigin;
      }
 
      Iif (options?.integrity) {
        scriptElement.integrity = options.integrity;
        scriptElement.crossOrigin = 'anonymous'; // required to be 'anonymous' if integrity is given
      }
 
      Iif (options?.attributes?.length) {
        for (const attr of options.attributes) {
          this.renderer.setAttribute(scriptElement, attr.name, attr.value);
        }
      }
 
      scriptElement.onload = () => {
        script.loaded = true;
        // Move from loading to loaded cache
        this.loadingScripts.delete(cacheKey);
        this.loadedScripts.set(cacheKey, loading$);
        observer.next(script);
        observer.complete();
      };
 
      scriptElement.onerror = () => {
        // Remove from loading cache on error
        this.loadingScripts.delete(cacheKey);
        observer.error(`Could not load script ${script.src}`);
      };
 
      // insert script as html body child
      this.renderer.appendChild(this.document.body, scriptElement);
    }).pipe(shareReplay(1));
 
    // Add to loading cache
    this.loadingScripts.set(cacheKey, loading$);
    return loading$;
  }
 
  private createCacheKey(url: string, options?: ScriptLoaderOption): string {
    // Use data-namespace attribute value as cache key
    const namespaceAttribute = options?.attributes?.find(attr => attr.name === 'data-namespace');
    Iif (namespaceAttribute?.value) {
      return namespaceAttribute.value;
    }
 
    return url;
  }
 
  private isScriptAlreadyLoaded(url: string, namespace?: string): boolean {
    Iif (namespace) {
      // When namespace is provided, check only for namespace presence in DOM
      // This prevents re-loading scripts with dynamic URLs (like PayPal with changing locale/currency)
      const scripts = this.document.querySelectorAll(`script[data-namespace="${namespace}"]`);
      return scripts.length > 0;
    }
    // For scripts without namespace, check by URL
    const scripts = this.document.querySelectorAll('script[src]');
    return Array.from(scripts).some(script => (script as HTMLScriptElement).src === url);
  }
}