@@ -192,6 +192,10 @@ export type EuroPlateInstance = {
192192
193193/* ============================================================
194194 * Interni: CDN + loaders + getters + ensure deps
195+ * Puoi continuare a passare le opzioni avanzate quando servono (SRI, media, timeout, ecc.). Esempio:
196+ * @example ts
197+ * await loadScriptOnce(urlIM, { integrity: "...", timeoutMs: 20000 });
198+ * await loadCssOnce(cssToastr, { media: "all", timeoutMs: 10000 });
195199 * Extra opzionali (se servono più avanti)
196200 * **Preload**: prima di `appendChild` puoi verificare e/o aggiungere un `<link rel="preload" as="script">` / `as="style"`.
197201 * **AbortSignal**: se vuoi abortire manualmente, estendi le opzioni con `signal?: AbortSignal` e fai `signal.addEventListener("abort", …rej…)`.
@@ -219,6 +223,8 @@ type LoadScriptOptions = {
219223 attrs ?: Record < string , string > ;
220224 /** Timeout hard-fail (ms). 0 = no timeout. Default: 15000 */
221225 timeoutMs ?: number ;
226+ /** opzionale: id fisso per dedup */
227+ id ?: string ;
222228} ;
223229
224230/** @internal */
@@ -235,65 +241,106 @@ type LoadCssOptions = {
235241 attrs ?: Record < string , string > ;
236242 /** Timeout hard-fail (ms). 0 = no timeout. Default: 15000 */
237243 timeoutMs ?: number ;
244+ /** opzionale: id fisso per dedup */
245+ id ?: string ;
238246} ;
239247
240248/** Cache per prevenire doppi insert e coalescare chiamate concorrenti */
241249const inFlightScripts = new Map < string , Promise < void > > ( ) ;
242250const inFlightCss = new Map < string , Promise < void > > ( ) ;
243251
252+ // ---------- util comuni ----------
253+
254+ /** Rileva il CSP nonce dall'ambiente (override con opt.nonce). */
255+ function detectCspNonce ( explicit ?: string ) : string | undefined {
256+ if ( explicit ) return explicit ;
257+ const winNonce = ( window as any ) . __CSP_NONCE__ ;
258+ if ( typeof winNonce === "string" && winNonce ) return winNonce ;
259+ const meta = document . querySelector ( 'meta[name="csp-nonce"]' ) as HTMLMetaElement | null ;
260+ const metaNonce = meta ?. getAttribute ( "content" ) || meta ?. getAttribute ( "value" ) ;
261+ return metaNonce || undefined ;
262+ }
263+
264+ function applyAttrs < T extends HTMLElement > ( el : T , attrs ?: Record < string , string > ) {
265+ if ( ! attrs ) return ;
266+ for ( const [ k , v ] of Object . entries ( attrs ) ) el . setAttribute ( k , v ) ;
267+ }
268+
269+ /** Chiave di dedup: preferisci id, altrimenti URL normalizzato. */
270+ function buildKey ( kind : "js" | "css" , url : string , id ?: string ) : string {
271+ return id ? `${ kind } #${ id } ` : `${ kind } :${ new URL ( url , document . baseURI ) . href } ` ;
272+ }
273+
274+ // ---------- loader script ----------
275+
244276/** Carica uno <script> esterno una sola volta (idempotente+concurrency-safe).
245277 * @internal
246278 * @param src URL assoluto/relativo dello script
247279 * @returns Promise risolta quando `onload` fires (o noop se già presente)
248280 */
281+
249282export function loadScriptOnce ( src : string , opt : LoadScriptOptions = { } ) : Promise < void > {
250283 if ( ! src || typeof document === "undefined" ) return Promise . resolve ( ) ;
251284
252- // già nel DOM?
285+ // dedup 1: elemento già presente in DOM (per src o id)
286+ if ( opt . id && document . getElementById ( opt . id ) ) return Promise . resolve ( ) ;
253287 if ( document . querySelector ( `script[src="${ src } "]` ) ) return Promise . resolve ( ) ;
254288
255- // chiamata già in corso?
256- const pending = inFlightScripts . get ( src ) ;
257- if ( pending ) return pending ;
289+ // dedup 2: chiamate concorrenti
290+ const key = buildKey ( "js" , src , opt . id ) ;
291+ const existing = inFlightScripts . get ( key ) ;
292+ if ( existing ) return existing ;
258293
259294 const p = new Promise < void > ( ( res , rej ) => {
260295 const s = document . createElement ( "script" ) ;
261-
262- // base attrs
263296 s . src = src ;
264- s . async = true ;
265- s . crossOrigin = opt . crossOrigin ?? "anonymous" ;
297+
298+ // type="module" opzionale
266299 if ( opt . module ) s . type = "module" ;
300+
301+ // crossorigin (default "anonymous" se non vuoto)
302+ if ( opt . crossOrigin !== undefined ) {
303+ if ( opt . crossOrigin ) s . crossOrigin = opt . crossOrigin ;
304+ } else {
305+ s . crossOrigin = "anonymous" ;
306+ }
307+
267308 if ( opt . integrity ) s . integrity = opt . integrity ;
268- if ( opt . nonce ) s . nonce = opt . nonce ;
309+
310+ const nonce = detectCspNonce ( opt . nonce ) ;
311+ if ( nonce ) s . setAttribute ( "nonce" , nonce ) ;
312+
313+ if ( opt . id ) s . id = opt . id ;
314+
315+ applyAttrs ( s , opt . attrs ) ;
316+ s . async = true ;
269317 s . setAttribute ( "data-loaded-by" , "EuroPlate" ) ;
270- if ( opt . attrs ) for ( const [ k , v ] of Object . entries ( opt . attrs ) ) s . setAttribute ( k , v ) ;
271-
272- let t : number | undefined ;
273- if ( ( opt . timeoutMs ?? 15000 ) > 0 ) {
274- t = window . setTimeout ( ( ) => {
275- s . onload = null ;
276- s . onerror = null ;
277- try {
278- s . remove ( ) ;
279- } catch { }
318+
319+ let to : number | undefined ;
320+ const timeoutMs = opt . timeoutMs ?? 15000 ;
321+ if ( timeoutMs > 0 ) {
322+ to = window . setTimeout ( ( ) => {
323+ s . onerror = null ! ;
324+ s . onload = null ! ;
280325 rej ( new Error ( `Timeout loading script: ${ src } ` ) ) ;
281- } , opt . timeoutMs ?? 15000 ) ;
326+ } , timeoutMs ) ;
282327 }
283328
284329 s . onload = ( ) => {
285- if ( t ) clearTimeout ( t ) ;
330+ if ( to ) clearTimeout ( to ) ;
286331 res ( ) ;
287332 } ;
288333 s . onerror = ( ) => {
289- if ( t ) clearTimeout ( t ) ;
334+ if ( to ) clearTimeout ( to ) ;
290335 rej ( new Error ( `Failed ${ src } ` ) ) ;
291336 } ;
292337
293338 document . head . appendChild ( s ) ;
294- } ) . finally ( ( ) => inFlightScripts . delete ( src ) ) ;
339+ } ) . finally ( ( ) => {
340+ inFlightScripts . delete ( key ) ;
341+ } ) ;
295342
296- inFlightScripts . set ( src , p ) ;
343+ inFlightScripts . set ( key , p ) ;
297344 return p ;
298345}
299346
@@ -305,52 +352,65 @@ export function loadScriptOnce(src: string, opt: LoadScriptOptions = {}): Promis
305352export function loadCssOnce ( href : string , opt : LoadCssOptions = { } ) : Promise < void > {
306353 if ( ! href || typeof document === "undefined" ) return Promise . resolve ( ) ;
307354
308- // già nel DOM?
355+ // dedup 1: elemento già presente in DOM (per href o id)
356+ if ( opt . id && document . getElementById ( opt . id ) ) return Promise . resolve ( ) ;
309357 if ( document . querySelector ( `link[rel="stylesheet"][href="${ href } "]` ) ) return Promise . resolve ( ) ;
310358
311- // chiamata già in corso?
312- const pending = inFlightCss . get ( href ) ;
313- if ( pending ) return pending ;
359+ // dedup 2: chiamate concorrenti
360+ const key = buildKey ( "css" , href , opt . id ) ;
361+ const existing = inFlightCss . get ( key ) ;
362+ if ( existing ) return existing ;
314363
315364 const p = new Promise < void > ( ( res , rej ) => {
316365 const l = document . createElement ( "link" ) ;
317366 l . rel = "stylesheet" ;
318367 l . href = href ;
319- l . crossOrigin = opt . crossOrigin ?? "anonymous" ;
320- if ( opt . integrity ) l . integrity = opt . integrity ;
321- if ( opt . nonce ) l . nonce = opt . nonce ;
368+
322369 if ( opt . media ) l . media = opt . media ;
370+
371+ if ( opt . crossOrigin !== undefined ) {
372+ if ( opt . crossOrigin ) l . crossOrigin = opt . crossOrigin ;
373+ } else {
374+ l . crossOrigin = "anonymous" ;
375+ }
376+
377+ if ( opt . integrity ) l . integrity = opt . integrity ;
378+
379+ const nonce = detectCspNonce ( opt . nonce ) ;
380+ if ( nonce ) l . setAttribute ( "nonce" , nonce ) ;
381+
382+ if ( opt . id ) l . id = opt . id ;
383+
384+ applyAttrs ( l , opt . attrs ) ;
323385 l . setAttribute ( "data-loaded-by" , "EuroPlate" ) ;
324- if ( opt . attrs ) for ( const [ k , v ] of Object . entries ( opt . attrs ) ) l . setAttribute ( k , v ) ;
325-
326- let t : number | undefined ;
327- if ( ( opt . timeoutMs ?? 15000 ) > 0 ) {
328- t = window . setTimeout ( ( ) => {
329- l . onload = null ;
330- l . onerror = null ;
331- try {
332- l . remove ( ) ;
333- } catch { }
386+
387+ let to : number | undefined ;
388+ const timeoutMs = opt . timeoutMs ?? 15000 ;
389+ if ( timeoutMs > 0 ) {
390+ to = window . setTimeout ( ( ) => {
391+ l . onerror = null ! ;
392+ l . onload = null ! ;
334393 rej ( new Error ( `Timeout loading css: ${ href } ` ) ) ;
335- } , opt . timeoutMs ?? 15000 ) ;
394+ } , timeoutMs ) ;
336395 }
337396
338397 l . onload = ( ) => {
339- if ( t ) clearTimeout ( t ) ;
398+ if ( to ) clearTimeout ( to ) ;
340399 res ( ) ;
341400 } ;
342401 l . onerror = ( ) => {
343- if ( t ) clearTimeout ( t ) ;
402+ if ( to ) clearTimeout ( to ) ;
344403 rej ( new Error ( `Failed ${ href } ` ) ) ;
345404 } ;
346405
347406 document . head . appendChild ( l ) ;
348- } ) . finally ( ( ) => inFlightCss . delete ( href ) ) ;
407+ } ) . finally ( ( ) => {
408+ inFlightCss . delete ( key ) ;
409+ } ) ;
349410
350- inFlightCss . set ( href , p ) ;
411+ inFlightCss . set ( key , p ) ;
351412 return p ;
352413}
353-
354414/** @internal */
355415type Lang = "it" | "en" ;
356416
@@ -403,14 +463,7 @@ async function ensureInputmask(opts: EuroPlateOptions, log: Logger) {
403463
404464 const url = opts . cdn ?. inputmask ?? cdnURLs . base + cdnURLs . inputmask . v + cdnURLs . inputmask . JS ;
405465 try {
406- const cspNonce =
407- ( window as any ) . __CSP_NONCE__ ||
408- document . querySelector ( 'meta[name="csp-nonce"]' ) ?. getAttribute ( "content" ) ||
409- undefined ;
410-
411- await loadScriptOnce ( url , { module : false , nonce : cspNonce } ) . then ( ( ) =>
412- log . debug ?.( "Inputmask loaded" )
413- ) ;
466+ await loadScriptOnce ( url , { module : false } ) . then ( ( ) => log . debug ?.( "Inputmask loaded" ) ) ;
414467 } catch {
415468 log . warn ?.( "Failed to load Inputmask from CDN" ) ;
416469 }
@@ -434,14 +487,7 @@ async function ensureJQuery(opts: EuroPlateOptions, log: Logger) {
434487 const url = opts . cdn ?. jquery ?? cdnURLs . base + cdnURLs . jquery . v + cdnURLs . jquery . JS ;
435488
436489 try {
437- const cspNonce =
438- ( window as any ) . __CSP_NONCE__ ||
439- document . querySelector ( 'meta[name="csp-nonce"]' ) ?. getAttribute ( "content" ) ||
440- undefined ;
441-
442- await loadScriptOnce ( url , { module : false , nonce : cspNonce } ) . then ( ( ) =>
443- log . debug ?.( "jQuery loaded" )
444- ) ;
490+ await loadScriptOnce ( url , { module : false } ) . then ( ( ) => log . debug ?.( "jQuery loaded" ) ) ;
445491 } catch {
446492 log . warn ?.( "Failed to load jQuery from CDN" ) ;
447493 }
@@ -469,16 +515,9 @@ async function ensureToastr(opts: EuroPlateOptions, log: Logger) {
469515 const js = opts . cdn ?. toastrJs ?? cdnURLs . base + cdnURLs . toastr . v + cdnURLs . toastr . JS ;
470516
471517 try {
472- const cspNonce =
473- ( window as any ) . __CSP_NONCE__ ||
474- document . querySelector ( 'meta[name="csp-nonce"]' ) ?. getAttribute ( "content" ) ||
475- undefined ;
476-
477518 await Promise . all ( [
478519 loadCssOnce ( css , { media : "all" } ) . then ( ( ) => log . debug ?.( "toastr CSS loaded" ) ) ,
479- loadScriptOnce ( js , { module : false , nonce : cspNonce } ) . then ( ( ) =>
480- log . debug ?.( "toastr loaded" )
481- ) ,
520+ loadScriptOnce ( js , { module : false } ) . then ( ( ) => log . debug ?.( "toastr loaded" ) ) ,
482521 ] ) ;
483522 } catch {
484523 log . warn ?.( "Failed to load toastr from CDN" ) ;
0 commit comments