Skip to content

Commit 88f2ff2

Browse files
committed
barrel
1 parent 3a1a33a commit 88f2ff2

2 files changed

Lines changed: 111 additions & 71 deletions

File tree

src/client/europlate.client.ts

Lines changed: 110 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -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 */
241249
const inFlightScripts = new Map<string, Promise<void>>();
242250
const 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+
249282
export 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
305352
export 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 */
355415
type 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");

src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// src/client/index.ts
12
// Esponi SOLO l’API pubblica del client
23
export type {
34
I18nCode,

0 commit comments

Comments
 (0)