import type { ErrorPayload, HMRPayload, Update } from 'types/hmrPayload' import type { ViteHotContext } from 'types/hot' import type { InferCustomEventPayload } from 'types/customEvent' import { ErrorOverlay, overlayId } from './overlay' // eslint-disable-next-line node/no-missing-import import '@vite/env' // injected by the hmr plugin when served declare const __BASE__: string declare const __HMR_PROTOCOL__: string declare const __HMR_HOSTNAME__: string declare const __HMR_PORT__: string declare const __HMR_TIMEOUT__: number declare const __HMR_ENABLE_OVERLAY__: boolean console.log('[vite] connecting...') // use server configuration, then fallback to inference const socketProtocol = __HMR_PROTOCOL__ || (location.protocol === 'https:' ? 'wss' : 'ws') const socketHost = `${__HMR_HOSTNAME__ || location.hostname}:${__HMR_PORT__}` const socket = new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr') const base = __BASE__ || '/' const messageBuffer: string[] = [] function warnFailedFetch(err: Error, path: string | string[]) { if (!err.message.match('fetch')) { console.error(err) } console.error( `[hmr] Failed to reload ${path}. ` + `This could be due to syntax errors or importing non-existent ` + `modules. (see errors above)` ) } function cleanUrl(pathname: string): string { const url = new URL(pathname, location.toString()) url.searchParams.delete('direct') return url.pathname + url.search } // Listen for messages socket.addEventListener('message', async ({ data }) => { handleMessage(JSON.parse(data)) }) let isFirstUpdate = true async function handleMessage(payload: HMRPayload) { switch (payload.type) { case 'connected': console.log(`[vite] connected.`) sendMessageBuffer() // proxy(nginx, docker) hmr ws maybe caused timeout, // so send ping package let ws keep alive. setInterval(() => socket.send('{"type":"ping"}'), __HMR_TIMEOUT__) break case 'update': notifyListeners('vite:beforeUpdate', payload) // if this is the first update and there's already an error overlay, it // means the page opened with existing server compile error and the whole // module script failed to load (since one of the nested imports is 500). // in this case a normal update won't work and a full reload is needed. if (isFirstUpdate && hasErrorOverlay()) { window.location.reload() return } else { clearErrorOverlay() isFirstUpdate = false } payload.updates.forEach((update) => { if (update.type === 'js-update') { queueUpdate(fetchUpdate(update)) } else { // css-update // this is only sent when a css file referenced with is updated const { path, timestamp } = update const searchUrl = cleanUrl(path) // can't use querySelector with `[href*=]` here since the link may be // using relative paths so we need to use link.href to grab the full // URL for the include check. const el = Array.from( document.querySelectorAll('link') ).find((e) => cleanUrl(e.href).includes(searchUrl)) if (el) { const newPath = `${base}${searchUrl.slice(1)}${ searchUrl.includes('?') ? '&' : '?' }t=${timestamp}` // rather than swapping the href on the existing tag, we will // create a new link tag. Once the new stylesheet has loaded we // will remove the existing link tag. This removes a Flash Of // Unstyled Content that can occur when swapping out the tag href // directly, as the new stylesheet has not yet been loaded. const newLinkTag = el.cloneNode() as HTMLLinkElement newLinkTag.href = new URL(newPath, el.href).href const removeOldEl = () => el.remove() newLinkTag.addEventListener('load', removeOldEl) newLinkTag.addEventListener('error', removeOldEl) el.after(newLinkTag) } console.log(`[vite] css hot updated: ${searchUrl}`) } }) break case 'custom': { notifyListeners(payload.event, payload.data) break } case 'full-reload': notifyListeners('vite:beforeFullReload', payload) if (payload.path && payload.path.endsWith('.html')) { // if html file is edited, only reload the page if the browser is // currently on that page. const pagePath = decodeURI(location.pathname) const payloadPath = base + payload.path.slice(1) if ( pagePath === payloadPath || (pagePath.endsWith('/') && pagePath + 'index.html' === payloadPath) ) { location.reload() } return } else { location.reload() } break case 'prune': notifyListeners('vite:beforePrune', payload) // After an HMR update, some modules are no longer imported on the page // but they may have left behind side effects that need to be cleaned up // (.e.g style injections) // TODO Trigger their dispose callbacks. payload.paths.forEach((path) => { const fn = pruneMap.get(path) if (fn) { fn(dataMap.get(path)) } }) break case 'error': { notifyListeners('vite:error', payload) const err = payload.err if (enableOverlay) { createErrorOverlay(err) } else { console.error( `[vite] Internal Server Error\n${err.message}\n${err.stack}` ) } break } default: { const check: never = payload return check } } } function notifyListeners( event: T, data: InferCustomEventPayload ): void function notifyListeners(event: string, data: any): void { const cbs = customListenersMap.get(event) if (cbs) { cbs.forEach((cb) => cb(data)) } } const enableOverlay = __HMR_ENABLE_OVERLAY__ function createErrorOverlay(err: ErrorPayload['err']) { if (!enableOverlay) return clearErrorOverlay() document.body.appendChild(new ErrorOverlay(err)) } function clearErrorOverlay() { document .querySelectorAll(overlayId) .forEach((n) => (n as ErrorOverlay).close()) } function hasErrorOverlay() { return document.querySelectorAll(overlayId).length } let pending = false let queued: Promise<(() => void) | undefined>[] = [] /** * buffer multiple hot updates triggered by the same src change * so that they are invoked in the same order they were sent. * (otherwise the order may be inconsistent because of the http request round trip) */ async function queueUpdate(p: Promise<(() => void) | undefined>) { queued.push(p) if (!pending) { pending = true await Promise.resolve() pending = false const loading = [...queued] queued = [] ;(await Promise.all(loading)).forEach((fn) => fn && fn()) } } async function waitForSuccessfulPing(ms = 1000) { // eslint-disable-next-line no-constant-condition while (true) { try { const pingResponse = await fetch(`${base}__vite_ping`) // success - 2xx status code if (pingResponse.ok) break // failure - non-2xx status code else throw new Error() } catch (e) { // wait ms before attempting to ping again await new Promise((resolve) => setTimeout(resolve, ms)) } } } // ping server socket.addEventListener('close', async ({ wasClean }) => { if (wasClean) return console.log(`[vite] server connection lost. polling for restart...`) await waitForSuccessfulPing() location.reload() }) // https://wicg.github.io/construct-stylesheets const supportsConstructedSheet = (() => { // TODO: re-enable this try block once Chrome fixes the performance of // rule insertion in really big stylesheets // try { // new CSSStyleSheet() // return true // } catch (e) {} return false })() const sheetsMap = new Map() export function updateStyle(id: string, content: string): void { let style = sheetsMap.get(id) if (supportsConstructedSheet && !content.includes('@import')) { if (style && !(style instanceof CSSStyleSheet)) { removeStyle(id) style = undefined } if (!style) { style = new CSSStyleSheet() style.replaceSync(content) // @ts-expect-error: using experimental API document.adoptedStyleSheets = [...document.adoptedStyleSheets, style] } else { style.replaceSync(content) } } else { if (style && !(style instanceof HTMLStyleElement)) { removeStyle(id) style = undefined } if (!style) { style = document.createElement('style') style.setAttribute('type', 'text/css') style.innerHTML = content document.head.appendChild(style) } else { style.innerHTML = content } } sheetsMap.set(id, style) } export function removeStyle(id: string): void { const style = sheetsMap.get(id) if (style) { if (style instanceof CSSStyleSheet) { // @ts-expect-error: using experimental API document.adoptedStyleSheets = document.adoptedStyleSheets.filter( (s: CSSStyleSheet) => s !== style ) } else { document.head.removeChild(style) } sheetsMap.delete(id) } } async function fetchUpdate({ path, acceptedPath, timestamp }: Update) { const mod = hotModulesMap.get(path) if (!mod) { // In a code-splitting project, // it is common that the hot-updating module is not loaded yet. // https://github.com/vitejs/vite/issues/721 return } const moduleMap = new Map() const isSelfUpdate = path === acceptedPath // make sure we only import each dep once const modulesToUpdate = new Set() if (isSelfUpdate) { // self update - only update self modulesToUpdate.add(path) } else { // dep update for (const { deps } of mod.callbacks) { deps.forEach((dep) => { if (acceptedPath === dep) { modulesToUpdate.add(dep) } }) } } // determine the qualified callbacks before we re-import the modules const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => { return deps.some((dep) => modulesToUpdate.has(dep)) }) await Promise.all( Array.from(modulesToUpdate).map(async (dep) => { const disposer = disposeMap.get(dep) if (disposer) await disposer(dataMap.get(dep)) const [path, query] = dep.split(`?`) try { const newMod = await import( /* @vite-ignore */ base + path.slice(1) + `?import&t=${timestamp}${query ? `&${query}` : ''}` ) moduleMap.set(dep, newMod) } catch (e) { warnFailedFetch(e, dep) } }) ) return () => { for (const { deps, fn } of qualifiedCallbacks) { fn(deps.map((dep) => moduleMap.get(dep))) } const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}` console.log(`[vite] hot updated: ${loggedPath}`) } } function sendMessageBuffer() { if (socket.readyState === 1) { messageBuffer.forEach((msg) => socket.send(msg)) messageBuffer.length = 0 } } interface HotModule { id: string callbacks: HotCallback[] } interface HotCallback { // the dependencies must be fetchable paths deps: string[] fn: (modules: object[]) => void } const hotModulesMap = new Map() const disposeMap = new Map void | Promise>() const pruneMap = new Map void | Promise>() const dataMap = new Map() const customListenersMap = new Map void)[]>() const ctxToListenersMap = new Map< string, Map void)[]> >() export function createHotContext(ownerPath: string): ViteHotContext { if (!dataMap.has(ownerPath)) { dataMap.set(ownerPath, {}) } // when a file is hot updated, a new context is created // clear its stale callbacks const mod = hotModulesMap.get(ownerPath) if (mod) { mod.callbacks = [] } // clear stale custom event listeners const staleListeners = ctxToListenersMap.get(ownerPath) if (staleListeners) { for (const [event, staleFns] of staleListeners) { const listeners = customListenersMap.get(event) if (listeners) { customListenersMap.set( event, listeners.filter((l) => !staleFns.includes(l)) ) } } } const newListeners = new Map() ctxToListenersMap.set(ownerPath, newListeners) function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) { const mod: HotModule = hotModulesMap.get(ownerPath) || { id: ownerPath, callbacks: [] } mod.callbacks.push({ deps, fn: callback }) hotModulesMap.set(ownerPath, mod) } const hot: ViteHotContext = { get data() { return dataMap.get(ownerPath) }, accept(deps?: any, callback?: any) { if (typeof deps === 'function' || !deps) { // self-accept: hot.accept(() => {}) acceptDeps([ownerPath], ([mod]) => deps && deps(mod)) } else if (typeof deps === 'string') { // explicit deps acceptDeps([deps], ([mod]) => callback && callback(mod)) } else if (Array.isArray(deps)) { acceptDeps(deps, callback) } else { throw new Error(`invalid hot.accept() usage.`) } }, acceptDeps() { throw new Error( `hot.acceptDeps() is deprecated. ` + `Use hot.accept() with the same signature instead.` ) }, dispose(cb) { disposeMap.set(ownerPath, cb) }, // @ts-expect-error untyped prune(cb: (data: any) => void) { pruneMap.set(ownerPath, cb) }, // TODO // eslint-disable-next-line @typescript-eslint/no-empty-function decline() {}, invalidate() { // TODO should tell the server to re-perform hmr propagation // from this module as root location.reload() }, // custom events on(event, cb) { const addToMap = (map: Map) => { const existing = map.get(event) || [] existing.push(cb) map.set(event, existing) } addToMap(customListenersMap) addToMap(newListeners) }, send(event, data) { messageBuffer.push(JSON.stringify({ type: 'custom', event, data })) sendMessageBuffer() } } return hot } /** * urls here are dynamic import() urls that couldn't be statically analyzed */ export function injectQuery(url: string, queryToInject: string): string { // skip urls that won't be handled by vite if (!url.startsWith('.') && !url.startsWith('/')) { return url } // can't use pathname from URL since it may be relative like ../ const pathname = url.replace(/#.*$/, '').replace(/\?.*$/, '') const { search, hash } = new URL(url, 'http://vitejs.dev') return `${pathname}?${queryToInject}${search ? `&` + search.slice(1) : ''}${ hash || '' }` } export { ErrorOverlay }