UNPKG

@jsenv/core

Version:

Tool to develop, test and build js projects

432 lines (423 loc) • 13.9 kB
import { parseSrcSet, stringifySrcSet, } from "@jsenv/ast/src/html/html_src_set.js"; import { dispatchAfterPartialReload, dispatchBeforeFullReload, dispatchBeforePartialReload, dispatchBeforePrune, urlHotMetas, } from "../../import_meta_hot/client/import_meta_hot.js"; export const initAutoreload = ({ mainFilePath }) => { let debug = false; const reloader = { urlHotMetas, status: { value: "idle", onchange: () => {}, goTo: (value) => { reloader.status.value = value; reloader.status.onchange(); }, }, autoreload: { enabled: ["1", null].includes(window.localStorage.getItem("autoreload")), onchange: () => {}, enable: () => { reloader.autoreload.enabled = true; window.localStorage.setItem("autoreload", "1"); reloader.autoreload.onchange(); }, disable: () => { reloader.autoreload.enabled = false; window.localStorage.setItem("autoreload", "0"); reloader.autoreload.onchange(); }, }, changes: { value: [], onchange: () => {}, add: (reloadMessage) => { if (debug) { console.debug("received reload message", reloadMessage); } reloader.changes.value.push(reloadMessage); reloader.changes.onchange(); if (reloader.autoreload.enabled) { reloader.reload(); } else { reloader.status.goTo("can_reload"); } }, remove: (reloadMessage) => { const index = reloader.changes.value.indexOf(reloadMessage); if (index > -1) { reloader.changes.value.splice(index, 1); if (reloader.changes.value.length === 0) { reloader.status.goTo("idle"); } reloader.changes.onchange(); } }, }, currentExecution: null, reload: () => { const someEffectIsFullReload = reloader.changes.value.some( (reloadMessage) => { if (reloadMessage.type === "full") { return true; } if (reloadMessage.type === "hot") { for (const reloadInstruction of reloadMessage.hotInstructions) { if (reloadInstruction.type === "html") { const acceptedByUrl = new URL( reloadInstruction.acceptedBy, `${window.location.origin}/`, ).href; const isCurrentHtmlFile = compareTwoUrlPaths( acceptedByUrl, window.location.href, ); if (isCurrentHtmlFile) { return true; } } } } return false; }, ); if (someEffectIsFullReload) { dispatchBeforeFullReload(); reloadHtmlPage(); return; } dispatchBeforePartialReload(); reloader.status.goTo("reloading"); const onApplied = (reloadMessage) => { reloader.changes.remove(reloadMessage); }; const setReloadMessagePromise = (reloadMessage, promise) => { promise.then( () => { onApplied(reloadMessage); reloader.currentExecution = null; dispatchAfterPartialReload(); }, (e) => { reloader.status.goTo("failed"); if (typeof window.reportError === "function") { window.reportError(e); } else { console.error(e); } console.error( `[jsenv] Hot reload failed after ${reloadMessage.reason}. This could be due to syntax errors or importing non-existent modules (see errors in console)`, ); reloader.currentExecution = null; }, ); }; for (const reloadMessage of reloader.changes.value) { if (reloadMessage.type === "hot") { const promise = addToHotQueue(() => { return applyHotReload(reloadMessage); }); setReloadMessagePromise(reloadMessage, promise); } else { setReloadMessagePromise(reloadMessage, Promise.resolve()); } } }, }; let pendingCallbacks = []; let running = false; const addToHotQueue = async (callback) => { pendingCallbacks.push(callback); dequeue(); }; const dequeue = async () => { if (running) { return; } const callbacks = pendingCallbacks.slice(); pendingCallbacks = []; running = true; try { await callbacks.reduce(async (previous, callback) => { await previous; await callback(); }, Promise.resolve()); } finally { running = false; if (pendingCallbacks.length) { dequeue(); } } }; const applyHotReload = async ({ cause, hotInstructions }) => { for (const instruction of hotInstructions) { const { type, boundary, acceptedBy } = instruction; const hot = Date.now(); const urlToFetch = new URL(boundary, `${window.location.origin}/`).href; const urlHotMeta = urlHotMetas[urlToFetch]; // there is no url hot meta when: // - code was not executed (code splitting with dynamic import) // - import.meta.hot.accept() is not called (happens for HTML and CSS) if (type === "prune") { if (!urlHotMeta) { // code not executed for this url, no need to prune continue; } dispatchBeforePrune(); delete urlHotMetas[urlToFetch]; if (urlHotMeta.disposeCallback) { console.log( `[jsenv] cleanup ${boundary} (no longer referenced by ${acceptedBy})`, ); if (debug) { console.log(`call dispose callback`); } await urlHotMeta.disposeCallback(); } continue; } if (type === "js_module") { if (!urlHotMeta) { // code not yet executed for this url, no need to re-execute it continue; } if (acceptedBy === boundary) { console.log(`[jsenv] hot reload ${boundary} (${cause})`); } else { console.log( `[jsenv] hot reload ${acceptedBy} usage in ${boundary} (${cause})`, ); } if (urlHotMeta.disposeCallback) { if (debug) { console.log(`call dispose callback`); } await urlHotMeta.disposeCallback(); } if (debug) { console.log(`importing js module`); } reloader.currentExecution = { type: "dynamic_import", url: urlToFetch, }; const namespace = await reloadJsImport(urlToFetch, hot); if (urlHotMeta.acceptCallback) { await urlHotMeta.acceptCallback(namespace); } if (debug) { console.log(`js module import done`); } continue; } if (type === "html") { let isRootHtmlFile; if (window.location.pathname === "/") { if (new URL(urlToFetch).pathname.slice(1).indexOf("/") === -1) { isRootHtmlFile = true; } else if (new URL(urlToFetch).pathname === mainFilePath) { isRootHtmlFile = true; } } if ( !isRootHtmlFile && !compareTwoUrlPaths(urlToFetch, window.location.href) ) { if (debug) { console.log( `[jsenv] skip ${acceptedBy} hot reload because we are not in that html page`, ); } // we are not in that HTML page continue; } if (acceptedBy === boundary) { console.log(`[jsenv] hot reload ${boundary} (${cause})`); } else { console.log( `[jsenv] hot reload ${acceptedBy} usage in ${boundary} (${cause})`, ); } const urlToReload = new URL(acceptedBy, `${window.location.origin}/`) .href; const domNodesUsingUrl = getDOMNodesUsingUrl(urlToReload); const domNodesCount = domNodesUsingUrl.length; if (domNodesCount === 0) { console.log(`no dom node using ${acceptedBy}`); } else if (domNodesCount === 1) { console.log(`reloading`, domNodesUsingUrl[0].node); domNodesUsingUrl[0].reload(hot); } else { console.log(`reloading ${domNodesCount} nodes using ${acceptedBy}`); domNodesUsingUrl.forEach((domNodesUsingUrl) => { domNodesUsingUrl.reload(hot); }); } continue; } console.warn(`unknown update type: "${type}"`); } }; window.__reloader__ = reloader; window.__server_events__.listenEvents({ reload: (reloadServerEvent) => { reloader.changes.add(reloadServerEvent.data); }, }); }; const reloadHtmlPage = () => { window.location.reload(true); }; // This function can consider everything as hot reloadable: // - no need to check [hot-accept]and [hot-decline] attributes for instance // This is because if something should full reload, we receive "full_reload" // from server and this function is not called const getDOMNodesUsingUrl = (urlToReload) => { const nodes = []; const shouldReloadUrl = (urlCandidate) => { return compareTwoUrlPaths(urlCandidate, urlToReload); }; const visitNodeAttributeAsUrl = (node, attributeName) => { let attribute = node[attributeName]; if (!attribute) { return; } if (SVGAnimatedString && attribute instanceof SVGAnimatedString) { attribute = attribute.animVal; } if (!shouldReloadUrl(attribute)) { return; } nodes.push({ node, reload: (hot) => { if (node.nodeName === "SCRIPT") { const copy = document.createElement("script"); Array.from(node.attributes).forEach((attribute) => { copy.setAttribute(attribute.nodeName, attribute.nodeValue); }); copy.src = injectQuery(node.src, { hot }); if (node.parentNode) { node.parentNode.replaceChild(copy, node); } else { document.body.appendChild(copy); } } else { node[attributeName] = injectQuery(attribute, { hot }); } }, }); }; Array.from(document.querySelectorAll(`link[rel="stylesheet"]`)).forEach( (link) => { visitNodeAttributeAsUrl(link, "href"); }, ); Array.from(document.querySelectorAll(`link[rel="icon"]`)).forEach((link) => { visitNodeAttributeAsUrl(link, "href"); }); Array.from(document.querySelectorAll("script")).forEach((script) => { visitNodeAttributeAsUrl(script, "src"); const inlinedFromSrc = script.getAttribute("inlined-from-src"); if (inlinedFromSrc) { const inlinedFromUrl = new URL(inlinedFromSrc, window.location.origin) .href; if (shouldReloadUrl(inlinedFromUrl)) { nodes.push({ node: script, reload: () => window.__supervisor__.reloadSupervisedScript(inlinedFromSrc), }); } } }); // There is no real need to update a.href because the resource will be fetched when clicked. // But in a scenario where the resource was already visited and is in browser cache, adding // the dynamic query param ensure the cache is invalidated Array.from(document.querySelectorAll("a")).forEach((a) => { visitNodeAttributeAsUrl(a, "href"); }); // About iframes: // - By default iframe itself and everything inside trigger a parent page full-reload // - Adding [hot-accept] on the iframe means parent page won't reload when iframe full/hot reload // In that case and if there is code in the iframe and parent doing post message communication: // you must put import.meta.hot.decline() for code involved in communication. // (both in parent and iframe) Array.from(document.querySelectorAll("img")).forEach((img) => { visitNodeAttributeAsUrl(img, "src"); const srcset = img.srcset; if (srcset) { nodes.push({ node: img, reload: (hot) => { const srcCandidates = parseSrcSet(srcset); srcCandidates.forEach((srcCandidate) => { const url = new URL( srcCandidate.specifier, `${window.location.href}`, ); if (shouldReloadUrl(url)) { srcCandidate.specifier = injectQuery(url, { hot }); } }); img.srcset = stringifySrcSet(srcCandidates); }, }); } }); Array.from(document.querySelectorAll("source")).forEach((source) => { visitNodeAttributeAsUrl(source, "src"); }); // svg image tag Array.from(document.querySelectorAll("image")).forEach((image) => { visitNodeAttributeAsUrl(image, "href"); }); // svg use Array.from(document.querySelectorAll("use")).forEach((use) => { visitNodeAttributeAsUrl(use, "href"); }); return nodes; }; const reloadJsImport = async (url, hot) => { const urlWithHotSearchParam = injectQuery(url, { hot }); const namespace = await import(urlWithHotSearchParam); return namespace; }; // const reloadAllCss = () => { // const links = Array.from(document.getElementsByTagName("link")); // links.forEach((link) => { // if (link.rel === "stylesheet") { // link.href = injectQuery(link.href, { hot: Date.now() }); // } // }); // }; const compareTwoUrlPaths = (url, otherUrl) => { if (url === otherUrl) { return true; } const urlObject = new URL(url); const otherUrlObject = new URL(otherUrl); if (urlObject.origin !== otherUrlObject.origin) { return false; } if (urlObject.pathname !== otherUrlObject.pathname) { return false; } return true; }; const injectQuery = (url, query) => { const urlObject = new URL(url); const { searchParams } = urlObject; Object.keys(query).forEach((key) => { searchParams.set(key, query[key]); }); return String(urlObject); };