diff --git a/README.md b/README.md index 20c40d51..0a9aec49 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Because we are still using the native module loader the edge cases work out comp * Live bindings in ES modules * Dynamic import expressions (`import('src/' + varname')`) * Circular references, with the execption that live bindings are disabled for the first unexecuted circular parent. +* [Hot reloading extension](#hot-reloading) supporting the `import.meta.hot` API. > [Built with](https://github.com/guybedford/es-module-shims/blob/main/chompfile.toml) [Chomp](https://chompbuild.com/) @@ -134,31 +135,9 @@ If a static failure is not possible and dynamic import must be used, rather use When running in polyfill mode, it can be thought of that are effectively two loaders running on the page - the ES Module Shims polyfill loader, and the native loader. -Note that instances are not shared between these loaders for consistency and performance. +Note that instances are not shared between these loaders for consistency and performance. For this reason it is important to always ensure all modules hit the polyfill path, either by having all graphs use import maps at the top-level, or via `importShim` directly. -As a result, if you have two module graphs - one native and one polyfilled, they will not share the same dependency instance, for example: - -```html - - - -``` - -In the above, on browsers without import maps support, the `/dep.js` instance will be loaded natively by the first module, then the second import will fail. - -ES Module Shims will pick up on the second import and reexecute `/dep.js`. As a result, `/dep.js` will be executed twice on the page. - -For this reason it is important to always ensure all modules hit the polyfill path, either by having all graphs use import maps at the top-level, or via `importShim` directly. +If instance sharing really is needed, the [`subgraphPassthrough: true` option](#subgraph-passthrough) can be used, although this is not recommended in production since it results in slower network performance. #### Skip Polyfill @@ -434,10 +413,31 @@ var resolvedUrl = import.meta.resolve('dep', 'https://site.com/another/scope'); Node.js also implements a similar API, although it's in the process of shifting to a synchronous resolver. +### Hot Reloading + +Hot reloading support is provided via the separate `dist/hot.js` or `es-module-shims/hot` export. + +Load the hot reloading extension before ES Module Shims: + +test.html +```html + + + +``` + +While the hot reloading system will work with polyfill mode, it is advisable to use shim mode for hot reloading since the `import.meta.hot` API can only be created for non-native module loads. + +The hot reloader will listen to websocket events at `ws://[base]/watch`. Events are strings corresponding to changed file URLs relative to the base URL. The base URL is taken from `document.baseURI`. + +`chomp --watch` provides a local server and websocket connection that provides this hot reloading workflow out of the box given the above `test.html` ([Chomp](https://chompbuild.com) can be installed via `npm install -g chomp`). + ### Module Workers + ES Module Shims can be used in module workers in browsers that provide dynamic import in worker environments, which at the moment are Chrome(80+), Edge(80+), Safari(15+). An example of ES Module Shims usage in web workers is provided below: + ```js /** * @@ -460,6 +460,7 @@ function getWorkerScriptURL(aURL) { const worker = new Worker(getWorkerScriptURL('myEsModule.js')); ``` + > For now, in web workers must be used the non-CSP build of ES Module Shims, namely the `es-module-shim.wasm.js` output: es-module-shims/dist/es-module-shims.wasm.js. ## Init Options @@ -478,6 +479,7 @@ Provide a `esmsInitOptions` on the global scope before `es-module-shims` is load * [fetch](#fetch-hook) * [revokeBlobURLs](#revoke-blob-urls) * [mapOverrides](#overriding-import-map-entries) +* [subgraphPassthrough](#subgraph-passthrough) ```html + +``` + +In the above without `subgraphPassthrough`, `/dep.js` would be executed natively while `/main.js` would be polyfilled/ + +When the polyfilled `/main.js` imports `/dep.js` it would be executed through the polyfill loader resulting in the +`"dep execution"` log output being executed twice. + +By setting `subgraphPassthrough: true` this results in a single `"dep execution"` log - the module instance is shared +between the native loader and the polyfill loader. + ### Hooks #### Polyfill hook diff --git a/chompfile.toml b/chompfile.toml index 0cc29df7..21cfda26 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -34,9 +34,13 @@ run = ''' writeFileSync(process.env.TARGET, source); ''' +[[task]] +target = 'dist/hot.js' +run = 'cp src/hot-reload.js dist/hot.js' + [[task]] name = 'footprint' -deps = ['dist/es-module-shims.js', 'dist/es-module-shims.wasm.js'] +deps = ['dist/es-module-shims.js', 'dist/es-module-shims.wasm.js', 'dist/hot.js'] template = 'footprint' [[task]] diff --git a/package.json b/package.json index fe9a08b1..d99f83c7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "dist/es-module-shims.js", "exports": { ".": "./dist/es-module-shims.js", - "./wasm": "./dist/es-module-shims.wasm.js" + "./wasm": "./dist/es-module-shims.wasm.js", + "./hot": "./dist/hot.js" }, "types": "index.d.ts", "type": "module", diff --git a/src/env.js b/src/env.js index 399a727d..20b505cc 100644 --- a/src/env.js +++ b/src/env.js @@ -8,12 +8,18 @@ const optionsScript = hasDocument ? document.querySelector('script[type=esms-opt export const esmsInitOptions = optionsScript ? JSON.parse(optionsScript.innerHTML) : {}; Object.assign(esmsInitOptions, self.esmsInitOptions || {}); -export let shimMode = hasDocument ? !!esmsInitOptions.shimMode : true; +export let shimMode = !!esmsInitOptions.shimMode; -export const importHook = globalHook(shimMode && esmsInitOptions.onimport); -export const resolveHook = globalHook(shimMode && esmsInitOptions.resolve); -export let fetchHook = esmsInitOptions.fetch ? globalHook(esmsInitOptions.fetch) : fetch; -export const metaHook = esmsInitOptions.meta ? globalHook(shimMode && esmsInitOptions.meta) : noop; +export let importHook, resolveHook, fetchHook = fetch, metaHook; + +if (esmsInitOptions.onimport) + importHook = globalHook(esmsInitOptions.onimport); +if (esmsInitOptions.resolve) + resolveHook = globalHook(esmsInitOptions.resolve); +if (esmsInitOptions.fetch) + fetchHook = globalHook(esmsInitOptions.fetch); +if (esmsInitOptions.meta) + metaHook = globalHook(esmsInitOptions.meta); export const skip = esmsInitOptions.skip ? new RegExp(esmsInitOptions.skip) : null; @@ -31,7 +37,7 @@ export const onpolyfill = esmsInitOptions.onpolyfill ? globalHook(esmsInitOption console.log('%c^^ Module TypeError above is polyfilled and can be ignored ^^', 'font-weight:900;color:#391'); }; -export const { revokeBlobURLs, noLoadEventRetriggers, enforceIntegrity } = esmsInitOptions; +export const { revokeBlobURLs, noLoadEventRetriggers, enforceIntegrity, subgraphPassthrough } = esmsInitOptions; function globalHook (name) { return typeof name === 'string' ? self[name] : name; diff --git a/src/es-module-shims.js b/src/es-module-shims.js index ee3b2593..ad4520e9 100755 --- a/src/es-module-shims.js +++ b/src/es-module-shims.js @@ -18,6 +18,7 @@ import { skip, revokeBlobURLs, noLoadEventRetriggers, + subgraphPassthrough, cssModulesEnabled, jsonModulesEnabled, onpolyfill, @@ -48,13 +49,14 @@ async function _resolve (id, parentUrl) { }; } -const resolve = resolveHook ? async (id, parentUrl) => { +async function resolve (id, parentUrl) { + if (!resolveHook) return _resolve(id, parentUrl); let result = resolveHook(id, parentUrl, defaultResolve); // will be deprecated in next major if (result && result.then) result = await result; return result ? { r: result, b: !resolveIfNotPlainOrUrl(id, parentUrl) && !isURL(id) } : _resolve(id, parentUrl); -} : _resolve; +} // importShim('mod'); // importShim('mod', { opts }); @@ -67,7 +69,6 @@ async function importShim (id, ...args) { parentUrl = pageBaseUrl; // needed for shim check await initPromise; - if (importHook) await importHook(id, typeof args[1] !== 'string' ? args[1] : {}, parentUrl); if (acceptingImportMaps || shimMode || !baselinePassthrough) { if (hasDocument) processImportMaps(); @@ -76,7 +77,7 @@ async function importShim (id, ...args) { acceptingImportMaps = false; } await importMapPromise; - return topLevelLoad((await resolve(id, parentUrl)).r, { credentials: 'same-origin' }); + return topLevelLoad(id, parentUrl, { credentials: 'same-origin' }); } self.importShim = importShim; @@ -168,11 +169,12 @@ let importMapPromise = initPromise; let firstPolyfillLoad = true; let acceptingImportMaps = true; -async function topLevelLoad (url, fetchOpts, source, nativelyLoaded, lastStaticLoadPromise) { +async function topLevelLoad (url, parentUrl, fetchOpts, source, nativelyLoaded, lastStaticLoadPromise) { + url = (await resolve(url, parentUrl)).r; if (!shimMode) acceptingImportMaps = false; await importMapPromise; - if (importHook) await importHook(url, typeof fetchOpts !== 'string' ? fetchOpts : {}, ''); + if (importHook) await importHook(url, typeof fetchOpts !== 'string' ? fetchOpts : {}, parentUrl); // early analysis opt-out - no need to even fetch if we have feature support if (!shimMode && baselinePassthrough) { // for polyfill case, only dynamic import needs a return value here, and dynamic import will never pass nativelyLoaded @@ -187,16 +189,18 @@ async function topLevelLoad (url, fetchOpts, source, nativelyLoaded, lastStaticL lastLoad = undefined; resolveDeps(load, seen); await lastStaticLoadPromise; - if (source && !shimMode && !load.n && !self.ESMS_DEBUG) { - const module = await dynamicImport(createBlob(source), { errUrl: source }); - if (revokeBlobURLs) revokeObjectURLs(Object.keys(seen)); - return module; - } - if (firstPolyfillLoad && !shimMode && load.n && nativelyLoaded) { - onpolyfill(); - firstPolyfillLoad = false; + if (!shimMode) { + if (source && !load.n && !self.ESMS_DEBUG) { + const module = await dynamicImport(createBlob(source), { errUrl: source }); + if (revokeBlobURLs) revokeObjectURLs(Object.keys(seen)); + return module; + } + if (firstPolyfillLoad && load.n && nativelyLoaded) { + onpolyfill(); + firstPolyfillLoad = false; + } } - const module = await dynamicImport(!shimMode && !load.n && nativelyLoaded ? load.u : load.b, { errUrl: load.u }); + const module = await dynamicImport(!shimMode && !load.n && (subgraphPassthrough || nativelyLoaded) ? load.u : load.b, { errUrl: load.u }); // if the top-level load is a shell, run its update function if (load.s) (await dynamicImport(load.s)).u$_(module); @@ -207,25 +211,20 @@ async function topLevelLoad (url, fetchOpts, source, nativelyLoaded, lastStaticL } function revokeObjectURLs(registryKeys) { - let batch = 0; - const keysLength = registryKeys.length; - const schedule = self.requestIdleCallback ? self.requestIdleCallback : self.requestAnimationFrame; - schedule(cleanup); + let curIdx = 0; + const handler = self.requestIdleCallback || self.requestAnimationFrame; + handler(cleanup); function cleanup() { - const batchStartIndex = batch * 100; - if (batchStartIndex > keysLength) return - for (const key of registryKeys.slice(batchStartIndex, batchStartIndex + 100)) { + for (const key of registryKeys.slice(curIdx, curIdx += 100)) { const load = registry[key]; if (load) URL.revokeObjectURL(load.b); } - batch++; - schedule(cleanup); + if (curIdx < registryKeys.length) + handler(cleanup); } } -function urlJsString (url) { - return `'${url.replace(/'/g, "\\'")}'`; -} +const urlJsString = url => `'${url.replace(/'/g, "\\'")}'`; let lastLoad; function resolveDeps (load, seen) { @@ -236,6 +235,14 @@ function resolveDeps (load, seen) { for (const dep of load.d) resolveDeps(dep, seen); + // use direct native execution when possible + // load.n is therefore conservative + if (subgraphPassthrough && !shimMode && !load.n) { + load.b = lastLoad = load.u; + load.S = undefined; + return; + } + const [imports, exports] = load.a; // "execution" @@ -293,7 +300,7 @@ function resolveDeps (load, seen) { // import.meta else if (dynamicImportIndex === -2) { load.m = { url: load.r, resolve: metaResolve }; - metaHook(load.m, load.u); + if (metaHook) metaHook(load.m, load.u); pushStringTo(start); resolvedSource += `importShim._r[${urlJsString(load.u)}].m`; lastIndex = statementEnd; @@ -553,7 +560,7 @@ function processScript (script) { const isDomContentLoadedScript = domContentLoadedCnt > 0; if (isBlockingReadyScript) readyStateCompleteCnt++; if (isDomContentLoadedScript) domContentLoadedCnt++; - const loadPromise = topLevelLoad(script.src || pageBaseUrl, getFetchOpts(script), !script.src && script.innerHTML, !shimMode, isBlockingReadyScript && lastStaticLoadPromise).catch(throwError); + const loadPromise = topLevelLoad(script.src || pageBaseUrl, pageBaseUrl, getFetchOpts(script), !script.src && script.innerHTML, !shimMode, isBlockingReadyScript && lastStaticLoadPromise).catch(throwError); if (isBlockingReadyScript) lastStaticLoadPromise = loadPromise.then(readyStateCompleteCheck); if (isDomContentLoadedScript) diff --git a/src/es-module-shims.polyfill.js b/src/es-module-shims.polyfill.js new file mode 100644 index 00000000..677e7589 --- /dev/null +++ b/src/es-module-shims.polyfill.js @@ -0,0 +1 @@ +import './loader.js'; diff --git a/src/hot-reload.js b/src/hot-reload.js new file mode 100644 index 00000000..2f2f680a --- /dev/null +++ b/src/hot-reload.js @@ -0,0 +1,140 @@ +class Hot { + constructor (url) { + this.data = getHotData(this.url = stripVersion(url)).d; + } + accept (deps, cb) { + if (typeof deps === 'function') { + cb = deps; + deps = null; + } + const hotData = getHotData(this.url); + (hotData.a = hotData.a || []).push([typeof deps === 'string' ? defaultResolve(deps, this.url) : deps ? deps.map(d => defaultResolve(d, this.url)) : null, cb]); + } + dispose (cb) { + getHotData(this.url).u = cb; + } + decline () { + getHotData(this.url).r = true; + } + invalidate () { + invalidate(this.url); + queueInvalidationInterval(); + } +} + +const versionedRegEx = /\?v=\d+$/; +function stripVersion (url) { + const versionMatch = url.match(versionedRegEx); + if (!versionMatch) return url; + return url.slice(0, -versionMatch[0].length); +} + +const toVersioned = url => { + const { v } = getHotData(url); + return url + (v ? '?v=' + v : ''); +} + +let defaultResolve; + +if (self.importShim) + throw new Error('Hot reloading extension must be loaded before es-module-shims.js.'); + +const esmsInitOptions = self.esmsInitOptions = self.esmsInitOptions || {}; +esmsInitOptions.hot = esmsInitOptions.hot || {}; +Object.assign(esmsInitOptions, { + polyfillEnable: true, + resolve (id, parent, _defaultResolve) { + if (!defaultResolve) + defaultResolve = _defaultResolve; + const originalParent = stripVersion(parent); + const url = stripVersion(defaultResolve(id, originalParent)); + const parents = getHotData(url).p; + if (!parents.includes(originalParent)) + parents.push(originalParent); + return toVersioned(url); + }, + onimport (url) { + getHotData(url).e = true; + }, + meta (metaObj, url) { + metaObj.hot = new Hot(url); + } +}); + +let hotRegistry = {}; +let curInvalidationRoots = new Set(); +let curInvalidationInterval; + +const getHotData = url => hotRegistry[url] || (hotRegistry[url] = { + // version + v: 0, + // refresh (decline) + r: false, + // accept list ([deps, cb] pairs) + a: null, + // unload callback + u: null, + // entry point + e: false, + // hot data + d: {}, + // parents + p: [] +}); + +function invalidate (url, fromUrl, seen = []) { + if (!seen.includes(url)) { + seen.push(url); + const hotData = hotRegistry[url]; + if (hotData) { + if (hotData.r) { + location.href = location.href; + } else { + if (hotData.a && hotData.a.some(([d]) => d && (typeof d === 'string' ? d === fromUrl : d.includes(fromUrl)))) { + curInvalidationRoots.add(fromUrl); + } + else { + if (hotData.u) + hotData.u(hotData.d); + if (hotData.e || hotData.a) + curInvalidationRoots.add(url); + hotData.v++; + if (!hotData.a) { + for (const parent of hotData.p) + invalidate(parent, url, seen); + } + } + } + } + } +} + +function queueInvalidationInterval () { + curInvalidationInterval = setTimeout(() => { + const earlyRoots = new Set(); + for (const root of curInvalidationRoots) { + const promise = importShim(toVersioned(root)); + const { a, p } = hotRegistry[root]; + promise.then(m => { + if (a) a.every(([d, c]) => d === null && !earlyRoots.has(c) && c(m)); + for (const parent of p) { + const hotData = hotRegistry[parent]; + if (hotData && hotData.a) hotData.a.every(async ([d, c]) => d && !earlyRoots.has(c) && (typeof d === 'string' ? d === root && c(m) : c(await Promise.all(d.map(d => (earlyRoots.push(c), importShim(toVersioned(d)))))))); + } + }); + } + curInvalidationRoots = new Set(); + }, 100); +} + +const baseURI = document.baseURI; +const websocket = new WebSocket(`ws://${esmsInitOptions.hotHost || new URL(baseURI).host}/watch`); +websocket.onmessage = evt => { + const { data } = evt; + if (data === 'Connected') { + console.info('Hot Reload ' + data); + } else { + invalidate(new URL(data, baseURI).href); + queueInvalidationInterval(); + } +}; diff --git a/test/server.mjs b/test/server.mjs index e2bd47bf..55996ef4 100644 --- a/test/server.mjs +++ b/test/server.mjs @@ -40,7 +40,7 @@ function setBrowserTimeout () { retry += 1; if (retry > 1) { console.log('No browser requests made to server for 10s, closing.'); - process.exit(failTimeout || process.env.CI_BROWSER ? 1 : 0); + process.exit(failTimeout || 1); } else { console.log('Retrying...');