use-m
Version:
use-m: dynamically import any JavaScript module
176 lines (165 loc) • 7.37 kB
JavaScript
// load.cjs — robust loader for use-m (https://github.com/link-foundation/use-m).
//
// The naive bootstrap recommended for loading use-m anywhere:
//
// const { use } = eval(await (await fetch('https://unpkg.com/use-m/src/use.js')).text());
//
// crashes with a confusing error whenever the CDN hiccups. When unpkg (or its
// upstream) returns an error body such as the plain text "Internal Server Error"
// instead of the module source, eval() tries to parse that text as JavaScript and
// throws `SyntaxError: Unexpected identifier 'Server'` — pointing at the eval line
// with no hint that the real problem is a transient network/CDN failure.
// See https://github.com/link-foundation/use-m/issues/58.
//
// `loadUseM()` makes the bootstrap resilient:
// - it validates the HTTP status and the response body before eval(),
// - it retries each source and falls back across multiple CDN mirrors, and
// - it fails with a clear, actionable error listing every attempt instead of a
// cryptic SyntaxError.
//
// `loadUseM()` reuses the SAME generic retry/fallback engine (`loadWithFallback`)
// that powers use-m's resilient per-package CDN loading, so the bootstrap and the
// rest of the codebase share one mechanism instead of duplicating it.
//
// NOTE: This is unrelated to `loader.js`, which is a Node.js `--loader` hook used
// to resolve bare npm specifiers; this file loads the use-m module itself.
const { loadWithFallback } = require('./use.cjs');
// Ordered list of CDN mirrors that serve the evaluable `use.js` build.
const DEFAULT_SOURCES = [
'https://unpkg.com/use-m/src/use.js',
'https://cdn.jsdelivr.net/npm/use-m/src/use.js',
'https://esm.sh/use-m/src/use.js',
];
// Common CDN/proxy plain-text error bodies that would otherwise be eval()'d as code.
const ERROR_BODY_PATTERN =
/^(internal server error|bad gateway|service unavailable|gateway timeout|request timeout|not found|forbidden|too many requests|origin is unreachable|cannot find package[^]*)\.?$/i;
/**
* Heuristic check that the fetched text is the use-m module source rather than a
* CDN error page. Error responses are typically HTML or short plain-text bodies
* like "Internal Server Error"; the real module is tens of kilobytes of
* JavaScript that defines a `use` factory. This is a cheap sanity check that
* yields a clear error message — eval() ultimately validates the rest.
*
* @param {unknown} source
* @returns {boolean}
*/
function looksLikeUseModule(source) {
if (typeof source !== 'string') return false;
const trimmed = source.trim();
if (trimmed.length === 0) return false;
// The real module is large; CDN error bodies are short. A conservative length
// floor rejects "Internal Server Error" and friends without coupling to the
// exact module size (use.js is tens of KB).
if (trimmed.length < 256) return false;
// HTML error pages (e.g. Cloudflare / nginx) start with a tag.
if (trimmed.startsWith('<')) return false;
// Common plain-text error bodies.
if (ERROR_BODY_PATTERN.test(trimmed)) return false;
// The module references `use` and contains JavaScript syntax.
return trimmed.includes('use') && /[{(=]/.test(trimmed);
}
/**
* Load use-m, returning the object it exports (with a `use` function).
*
* @param {object} [options]
* @param {typeof fetch} [options.fetch] - fetch implementation (defaults to globalThis.fetch)
* @param {string[]} [options.sources] - ordered list of CDN URLs to try
* @param {number} [options.maxAttemptsPerSource] - attempts per source (default 3)
* @param {number} [options.retryDelayMs] - base delay between retries, linear backoff (default 250)
* @param {number} [options.timeoutMs] - per-attempt timeout in ms, 0 to disable (default 10000)
* @param {(source: string) => unknown} [options.evaluate] - evaluator for the module source (defaults to eval)
* @returns {Promise<{ use: Function }>}
*/
async function loadUseM(options = {}) {
const {
fetch: fetchImpl = (typeof globalThis !== 'undefined' ? globalThis.fetch : undefined),
sources = DEFAULT_SOURCES,
maxAttemptsPerSource = 3,
retryDelayMs = 250,
timeoutMs = 10000,
evaluate = (source) => eval(source),
} = options;
if (typeof fetchImpl !== 'function') {
throw new Error(
'Cannot load use-m: `fetch` is not available in this runtime. ' +
'Use Node.js >= 18, Bun, Deno, or a browser, or pass a `fetch` implementation via options.'
);
}
if (!Array.isArray(sources) || sources.length === 0) {
throw new Error('Cannot load use-m: `sources` must be a non-empty array of URLs.');
}
// Delegate the retry/fallback loop to the shared engine; the per-source work
// (fetch → validate body → eval → check `use` export) is the only loader-specific
// part. The engine aggregates failures into the same clear, actionable error.
return loadWithFallback(
sources,
async (url) => {
const source = await fetchSource(fetchImpl, url, timeoutMs);
if (!looksLikeUseModule(source)) {
const preview = source.slice(0, 80).replace(/\s+/g, ' ').trim();
throw new Error(`response was not the use-m module (got: "${preview}")`);
}
const exported = await evaluate(source);
if (!exported || typeof exported.use !== 'function') {
throw new Error('evaluated module did not export a `use` function');
}
return exported;
},
{
maxAttemptsPerSource,
retryDelayMs,
label: 'load use-m from every CDN mirror',
hint: 'This is usually a transient network or CDN outage — please check your connection and try again.',
}
);
}
// Fetch a single source with an optional per-attempt timeout, validating the
// HTTP status before returning the body text.
async function fetchSource(fetchImpl, url, timeoutMs) {
const timeout = createTimeout(timeoutMs);
let response;
try {
response = await fetchImpl(url, timeout.signal ? { signal: timeout.signal } : undefined);
} catch (error) {
if (timeout.timedOut()) {
throw new Error(`request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
timeout.clear();
}
if (!response || !response.ok) {
const status = response
? `${response.status} ${response.statusText || ''}`.trim()
: 'no response';
throw new Error(`HTTP ${status}`);
}
return response.text();
}
// Build an AbortController-backed timeout. Degrades gracefully where
// AbortController is unavailable (returns a no-op).
function createTimeout(timeoutMs) {
if (!timeoutMs || typeof AbortController === 'undefined') {
return { signal: undefined, clear: () => {}, timedOut: () => false };
}
const controller = new AbortController();
let didTimeout = false;
const timer = setTimeout(() => {
didTimeout = true;
controller.abort();
}, timeoutMs);
// Deliberately NOT unref()'d: this is a guard that must be allowed to fire so
// the timeout actually triggers, and it is always cleared on the fast path via
// `clear()`. unref()'ing it would let the runtime exit before the guard fires
// (and trips Deno's "promise resolution is still pending" op sanitizer).
return {
signal: controller.signal,
clear: () => clearTimeout(timer),
timedOut: () => didTimeout,
};
}
module.exports = {
loadUseM,
looksLikeUseModule,
DEFAULT_SOURCES,
};