Skip to content

Repository files navigation

sri-dynamic-loader

Load third-party scripts and stylesheets at runtime with Subresource Integrity enforced, fail-closed defaults, multi-CDN fallback chains and integrity-failure telemetry. Zero runtime dependencies.

Static markup makes integrity easy: you write <script src integrity crossorigin> once and a reviewer can see the digest in the diff. Runtime injection is where it quietly disappears. A consent manager, an A/B tool or a lazily loaded payment SDK builds a <script> element in JavaScript, sets src, appends it, and ships. No integrity, no crossorigin, no linter complaint, and the page now executes whatever that origin returns today. Worse, the near-miss cases fail silently: an integrity attribute the browser cannot parse is ignored entirely, and an attribute set after src never applies to a request already in flight. Both leave you with an unverified load that looks pinned.

This library refuses to inject anything without integrity metadata unless you explicitly opt out, validates the digest before it touches the DOM, tries each source in a fallback chain in turn while retaining why every earlier one failed, and hands each failure to a telemetry hook so a compromised or drifted CDN shows up in your dashboards rather than in a user's browser.

It is a browser library. Everything it does is DOM injection plus optional fetch and WebCrypto.

Install

Not published to npm. Take it one of three ways.

Vendor the minified build. Copy dist/sri-dynamic-loader.min.js into your static assets. It is an IIFE exposing window.SRILoader:

<script src="/vendor/sri-dynamic-loader.min.js"></script>
<script>
  SRILoader.loadScript('https://cdn.example/widget.js', { integrity: 'sha384-…' });
</script>

Import the ESM bundle. dist/sri-dynamic-loader.js (readable) or dist/sri-dynamic-loader.esm.min.js (minified) are single files with no imports:

<script type="module">
  import { loadScript } from '/vendor/sri-dynamic-loader.esm.min.js';
</script>

Clone and import the source. src/ is plain ES modules with no build step, which is the easiest form to read and to patch:

$ git clone https://github.com/subresource-integrity/sri-dynamic-loader.git
$ cd sri-dynamic-loader
$ npm install       # esbuild + eslint, both dev-only
$ npm run check     # lint, test, build

TypeScript declarations live in types/index.d.ts and are referenced from package.json.

Measured sizes of the current build (npm run build prints this table and writes dist/sizes.json):

$ npm run build
file                                raw      gzip    brotli
----------------------------------  -------  ------  ------
dist/sri-dynamic-loader.js          27295 B  7887 B  6854 B
dist/sri-dynamic-loader.min.js      15489 B  6203 B  5452 B
dist/sri-dynamic-loader.esm.min.js  14987 B  5994 B  5247 B

A good share of that is error message text. Diagnostics are the point of the library, so they are not tree-shaken away; if you only need the loader, importing from src/index.js through your own bundler will drop whatever you do not reference.

Usage

A pinned load, failing closed by default

import { loadScript } from './src/index.js';

await loadScript('https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', {
  integrity: 'sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs',
});

Omit integrity and nothing is injected:

UnverifiedLoadError: refusing to load https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
without integrity metadata. Pass an integrity value, or pass allowUnverified: true if this
resource genuinely cannot be pinned.

Pass a malformed one and it is rejected before the element exists, because a browser would have ignored it and loaded the script anyway:

IntegrityFormatError: integrity digest for sha384 decodes to 32 bytes, expected 48. A hex digest
pasted in place of base64 is the usual cause; SRI uses base64 of the raw binary digest.

Fallback chains across CDNs

Each source carries its own URL and its own digest, because a mirror is usually a different build with a different hash. Sources are tried in order and the first that loads wins.

const result = await loadScript([
  { url: 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
  { url: 'https://unpkg.com/jquery@3.7.1/dist/jquery.min.js',            integrity: 'sha384-1H21…' },
  { url: '/vendor/jquery-3.7.1.min.js',                                  integrity: 'sha384-9tPm…' },
]);

result.url;       // the source that succeeded
result.attempts;  // 3
result.failures;  // the two errors from the CDNs, in order

If every source fails you get one AllSourcesFailedError holding all of them, and describe() renders the lot:

all 2 sources failed for this script
  1. https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js
     integrity-mismatch: failed to load https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js.
     The response was fetched in full but the element still errored, which means the browser
     rejected it after download. An integrity digest that does not match the bytes is by far the
     most common cause; a missing or wrong crossorigin attribute produces the same symptom because
     an opaque response can never satisfy an integrity check.
  2. https://unpkg.com/jquery@3.7.1/dist/jquery.min.js
     integrity-mismatch: failed to load https://unpkg.com/jquery@3.7.1/dist/jquery.min.js.

A source registry

Declare every pinned URL and digest in one file so rotating a hash is a one-line diff, and any stray unpinned loadScript() elsewhere stands out in review. Definitions are validated eagerly: a bad digest throws at startup, not the first time that script is needed.

import { defineSources, load } from './src/index.js';

defineSources({
  jquery: [
    { url: 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js', integrity: 'sha384-1H21…' },
    { url: '/vendor/jquery-3.7.1.min.js',                                  integrity: 'sha384-9tPm…' },
  ],
  stripe: {
    sources: [{ url: 'https://js.stripe.com/v3/', integrity: 'sha384-…', allowUnverified: false }],
    options: { timeout: 8000 },
  },
  theme: {
    kind: 'style',
    sources: [{ url: 'https://cdn.example/theme.css', integrity: 'sha384-…' }],
  },
});

await load('jquery');
await load('theme');

Stylesheets

await loadStyle('https://cdn.example/theme.css', { integrity: 'sha384-…', media: 'screen' });

<link> load and error events are the least dependable part of this area: engines have historically stayed silent for cached stylesheets, and several still fire nothing when a stylesheet is rejected by the integrity check rather than firing error. So loadStyle also polls link.sheet, which is populated only once the sheet has been fetched, integrity-checked and parsed, and stays null when the check fails. A stylesheet that never applies therefore surfaces as a LoadTimeoutError rather than hanging forever. Give stylesheet loads a timeout you are happy to wait for.

Preflight verification, for an unambiguous answer

An SRI mismatch reaches your code as a bare error event with no detail, identical to a DNS failure — the browser will not say more, because saying more would be a cross-origin information leak. verify: 'preflight' fetches the resource, hashes it with crypto.subtle, and compares before anything is injected:

try {
  await loadScript(url, { integrity: expected, verify: 'preflight' });
} catch (error) {
  const mismatch = error.errors[0];
  mismatch.reason;    // 'integrity-mismatch'
  mismatch.expected;  // ['sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs']
  mismatch.actual;    // ['sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC']
}

It costs a second request on a cold cache and it requires CORS and a secure context, so reserve it for the resources where a clear diagnosis is worth more than a few milliseconds.

Telemetry

await loadScript(sources, {
  onViolation(details) {
    navigator.sendBeacon('/telemetry/sri', JSON.stringify({
      url: details.url,
      reason: details.reason,       // integrity-mismatch | csp-blocked | network | timeout | …
      fatal: details.fatal,         // true only when no source was left to try
      position: `${details.sourceIndex + 1}/${details.sourceCount}`,
    }));
  },
});

The hook fires once per failed source, including non-fatal ones, so a chain that silently falls back to a mirror every time still shows up. A throwing hook is swallowed: broken telemetry must not turn a recoverable CDN failure into an unhandled rejection.

Runnable demo

$ npm run example
sri-dynamic-loader example: http://localhost:8000/
press ctrl-c to stop

The page demonstrates a successful pinned load, a deliberate hash mismatch falling back to a mirror, preflight verification naming the expected and actual digests, a stylesheet load, the fail-closed default, and the telemetry hook accumulating the lot. It has to be served over HTTP rather than opened as a file:// URL: integrity, CORS and crypto.subtle all behave differently on the file scheme, and localhost counts as a secure context.

How it works

Attribute order is load-bearing. The element is fully configured before src/href is set, which is the last mutation in every path. Setting integrity or crossorigin afterwards has no effect on a request already in flight, and that is a quiet way to lose enforcement entirely. The test suite asserts that src is the final attribute written.

Validation before injection. The integrity string is parsed into algorithm/digest pairs, the algorithm is checked against the three SRI defines (sha256, sha384, sha512 — not sha1, not md5, not sha3), and the base64 is decoded to confirm it is the right length for that algorithm: 32, 48 or 64 bytes. A hex digest pasted where base64 belongs is the single most common mistake and it is caught here. A browser given an unparseable integrity attribute treats the resource as unpinned, so failing loudly at this point is the whole safety property.

Only the strongest algorithm counts. Browsers pick the strongest algorithm present in the attribute and require at least one value of that algorithm to match; weaker values alongside it are ignored. Preflight verification applies the same rule rather than accepting a sha256 the browser would have discarded.

Failure diagnosis without extra traffic. Three signals narrow a bare error event:

  1. securitypolicyviolation events on the document. If CSP blocked the URL, an event names it, and that is a certain diagnosis — the integrity attribute was never evaluated.
  2. Resource Timing. A resource that was fetched and then rejected by the integrity check still produces a PerformanceResourceTiming entry with a completed response; one that never connected does not. An entry with responseEnd > 0 plus an error event means the bytes arrived and something downstream refused them, which is overwhelmingly an integrity mismatch — or a missing crossorigin, which looks identical because an opaque response can never satisfy an integrity check.
  3. Elapsed time, as a weak tiebreaker only.

Each error carries reason and a confidence of certain, likely or unknown, so nothing overstates what the platform actually told us. When preflight has already proved the digest correct, an error event is explicitly not blamed on integrity.

Deduplication. Loads are cached by kind plus absolute URL, so two concurrent calls for the same script share one promise and one DOM element, and a URL that already succeeded resolves immediately with cached: true. Failures are never cached, so a retry after a CDN recovers can succeed and a fallback chain works. reload: true bypasses the cache.

Cleanup. Timeout and abort both remove the pending element from the document before rejecting. An abort stops the whole chain rather than advancing to the next mirror: it was the caller's decision, not a property of the source.

Options

Set per call, or per source inside a chain, where per-source wins. Rows marked · may be set on an individual source.

Option · Type Default Purpose
integrity · string SRI metadata, e.g. sha384-…. Space-separated values allowed. Required unless allowUnverified.
allowUnverified · boolean false Escape hatch for a resource that genuinely cannot be pinned. Without it, an unpinned load throws.
crossOrigin · 'anonymous' | 'use-credentials' | null 'anonymous' crossorigin attribute. null omits it, which is refused for a cross-origin URL that has integrity.
verify · 'none' | 'preflight' 'none' preflight hashes the bytes with WebCrypto before injecting. Requires CORS and a secure context.
module · boolean false Inject the script as type="module".
nonce · string CSP nonce, set as both content and IDL attribute.
attributes · Record<string, string> {} Extra attributes, written before src/href.
referrerPolicy · string referrerpolicy attribute.
media · string media attribute, stylesheets only.
timeout number 15000 Milliseconds before the element is removed and the load rejected. 0 disables it.
signal AbortSignal Cancels the load and stops the chain.
reload boolean false Bypass the dedupe cache and inject a fresh element.
onViolation (details) => void Called once per failed source, fatal or not.
document Document globalThis.document Target document; useful for iframes and tests.
performance Performance globalThis.performance Injectable, used for failure diagnosis.
fetchImpl / cryptoImpl globals Injectable, used by preflight verification.

Failure reasons

reason Meaning
unverified No integrity value and no allowUnverified. Nothing was injected.
integrity-format The integrity string was malformed, or used a non-SRI algorithm or a wrong-length digest.
integrity-mismatch Preflight computed a different digest, or the element errored after a complete download.
csp-blocked A securitypolicyviolation event named this URL. Certain.
network The request did not complete: DNS, offline, connection reset, blocking extension.
load-error The element errored and the signals were inconclusive.
timeout Exceeded timeout; the element was removed.
aborted Cancelled via AbortSignal.
all-sources-failed Aggregate wrapper; see .errors and .describe().
missing-crossorigin A cross-origin source has integrity but crossOrigin: null, which can never pass.

Continuous integration

.github/workflows/ci.yml runs lint, tests and the build on Node 20, 22 and 24, and fails if the committed dist/ no longer matches its sources — the vendored bundle is what most consumers copy, so it must not drift.

$ npm test
ℹ tests 60
ℹ pass 60
ℹ fail 0

Tests use Node's built-in node:test runner against a small DOM stub in test/dom-stub.js. jsdom would work, but it neither fetches resources nor implements SRI, so the interesting case — an element that errors after a full download — would have to be simulated by hand regardless. The stub keeps the suite dependency-free and makes the DOM surface the library touches explicit: createElement, setAttribute, appendChild, removeChild and addEventListener, and nothing else. The preflight verifier is tested against Node's real crypto.subtle, cross-checked with node:crypto.

Further reading

Background on the problems this library is built around:

Licence

MIT. See LICENSE.

Maintained alongside the Subresource Integrity & supply chain hardening reference.

About

Load third-party scripts and stylesheets at runtime with Subresource Integrity enforced, fail-closed defaults, multi-CDN fallback chains and integrity-failure telemetry. Zero dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages