UNPKG

@web/dev-server-hmr

Version:
339 lines (285 loc) 9.53 kB
import type { Plugin, WebSocketsManager, Logger, WebSocketData, ServerStartParams, DevServerCoreConfig, } from '@web/dev-server-core'; import WebSocket from 'ws'; import type { Context } from 'koa'; import path, { posix as pathUtil } from 'path'; import fs from 'fs'; const hmrClientScriptPath = require.resolve('../scripts/hmrClientScript.js'); let hmrClientScript = fs.readFileSync(hmrClientScriptPath, 'utf-8'); export interface HmrReloadMessage { type: 'hmr:reload'; } export interface HmrUpdateMessage { type: 'hmr:update'; url: string; } export interface HmrModuleOptions { bubbles: boolean; } export interface HmrAcceptMessage extends WebSocketData { type: 'hmr:accept'; id: string; options?: Partial<HmrModuleOptions>; } export type HmrMessage = HmrReloadMessage | HmrUpdateMessage; export type HmrClientMessage = HmrAcceptMessage; export interface HmrModule { dependencies: Set<string>; dependents: Set<string>; hmrAccepted: boolean; hmrEnabled: boolean; options?: Partial<HmrModuleOptions>; needsReplacement: boolean; replacementRequests: number; } export const NAME_HMR_CLIENT_IMPORT = '/__web-dev-server__/hmr.js'; /** * Dev server plugin to provide hot module reloading */ export class HmrPlugin implements Plugin { name = 'hmr'; injectWebSocket = true; protected _dependencyTree: Map<string, HmrModule> = new Map(); protected _webSockets?: WebSocketsManager; protected _logger?: Logger; protected _config?: DevServerCoreConfig; /** @inheritDoc */ async serverStart({ webSockets, fileWatcher, logger, config }: ServerStartParams) { if (!fileWatcher) { throw new Error('Cannot use HMR when watch mode is disabled.'); } if (!webSockets) { throw new Error('Cannot use HMR when web sockets are disabled.'); } hmrClientScript = hmrClientScript.replace('__WEBSOCKET_IMPORT__', webSockets.webSocketImport); this._config = config; this._webSockets = webSockets; this._logger = logger; webSockets.on('message', ({ webSocket, data }) => this._onMessage(webSocket, data)); fileWatcher.on('change', path => this._onFileChanged(path)); fileWatcher.on('unlink', path => this._onFileChanged(path)); this._logger?.debug('[hmr] Listening for HMR messages'); } /** @inheritDoc */ async serve(context: Context) { // Someone is requesting the injected client script if (context.path === NAME_HMR_CLIENT_IMPORT) { if (!this._webSockets) { return; } return hmrClientScript; } } resolveImport({ source }: { source: string }) { if (source === '/__web-dev-server__/hmr.js') { return source; } } transformCacheKey(context: Context) { const mod = this._getOrCreateModule(context.path); if (mod.needsReplacement) { const marker = context.URL.searchParams.get('m') ?? Date.now(); return `${context.path}${marker}`; } } /** @inheritDoc */ async transformImport({ source, context }: { source: string; context: Context }) { if ( source.startsWith('/__web-dev-server__') || context.path.startsWith('/__web-dev-server__') ) { return source; } const importPath = pathUtil.resolve(pathUtil.dirname(context.path), source); const mod = this._getOrCreateModule(context.path); const dependencyMod = this._getOrCreateModule(importPath); mod.dependencies.add(importPath); dependencyMod.dependents.add(context.path); this._logger?.debug(`[hmr] Added dependency from ${context.path} -> ${importPath}`); if (dependencyMod.needsReplacement) { const marker = context.URL.searchParams.get('m') ?? Date.now(); const divider = source.includes('?') ? '&' : '?'; return `${source}${divider}m=${marker}`; } } /** @inheritDoc */ async transform(context: Context) { // Don't want to handle the hmr plugin itself if (context.path === NAME_HMR_CLIENT_IMPORT) { return; } // If the module references import.meta.hot it can be assumed it // supports hot reloading const hmrEnabled = (context.body as string).includes('import.meta.hot') === true; const mod = this._getOrCreateModule(context.path); mod.hmrEnabled = hmrEnabled; this._logger?.debug(`[hmr] Setting hmrEnabled=${hmrEnabled} for ${context.path}`); if (context.URL.searchParams.has('m')) { this._setNeedsReplacement(context.path, mod, false); } if (hmrEnabled && context.response.is('js')) { return ( `import {create as __WDS_HMR__} from '${NAME_HMR_CLIENT_IMPORT}';` + 'import.meta.hot = __WDS_HMR__(import.meta.url);' + context.body ); } } /** * Clears the dependency cache/tree of a particular module * @param path Module path to clear */ protected _clearDependencies(path: string): void { const mod = this._getModule(path); if (!mod) { return; } for (const dep of mod.dependencies) { const depMod = this._getModule(dep); if (depMod) { depMod.dependents.delete(path); } } mod.dependencies = new Set(); } /** @inheritDoc */ serverStop() { this._webSockets = undefined; } /** * Fired when a file has changed. * @param path Module path which has changed */ protected _onFileChanged(filePath: string): void { if (!this._config?.rootDir) { return; } const relativePath = path.relative(this._config.rootDir, filePath); const browserPath = relativePath.split(path.sep).join('/'); this._triggerUpdate(`/${browserPath}`); } /** * Triggers an update for a given module. * This will result in the client being sent a message to tell them * how to deal with this module updating. * @param path Module path to update * @param visited Modules already updated (cache) */ protected _triggerUpdate(path: string, visited: Set<string> = new Set()): void { // We already visited this module if (visited.has(path)) { return; } const mod = this._getModule(path); visited.add(path); this._clearDependencies(path); this._logger?.debug(`[hmr] Cleared dependency tree cache of ${path}`); // We're not aware of this module so can't handle it if (!mod) { this._broadcast({ type: 'hmr:reload' }); return; } this._setNeedsReplacement(path, mod, true); const dependents = new Set<string>(mod.dependents); // The module supports HMR so lets tell it to update if (mod.hmrEnabled) { this._broadcast({ type: 'hmr:update', url: path }); } if (mod.options?.bubbles || !mod.hmrEnabled) { // Trigger an update for every module that depends on this one for (const dep of dependents) { this._triggerUpdate(dep, visited); } } // If this module doesn't support HMR and it has no dependents, // nothing will handle this. So we must reload. if (!mod.hmrEnabled && dependents.size === 0) { this._broadcast({ type: 'hmr:reload' }); } } private _setNeedsReplacement(path: string, module: HmrModule, needsReplacement: boolean) { if (needsReplacement) { module.replacementRequests += 1; } else { module.replacementRequests = Math.max(0, module.replacementRequests - 1); } module.needsReplacement = module.replacementRequests > 0; } /** * Broadcasts a HMR message to the client * @param message HMR message to emit */ protected _broadcast(message: HmrMessage): void { if (!this._webSockets) { return; } this._logger?.debug(`[hmr] emitting ${message.type} message`); this._webSockets.send(JSON.stringify(message)); } /** * Determines if the dependency tree already has a given module * @param path Module path to check */ protected _hasModule(path: string): boolean { return this._dependencyTree.has(path); } /** * Fired when a message is received from a client * @param socket Socket the message was received on * @param message Message received */ protected _onMessage(socket: WebSocket, data: WebSocketData): void { const message = data as HmrClientMessage; // Only handle HMR requests if (!message.type.startsWith('hmr:')) { return; } if (message.type === 'hmr:accept') { const mod = this._getOrCreateModule(message.id); mod.options = message.options; mod.hmrAccepted = true; mod.hmrEnabled = true; } } /** * Retrieves a module from the cache and creates it if it does not * exist already. * @param path Module path to retrieve */ protected _getOrCreateModule(path: string): HmrModule { // TODO (43081j): some kind of normalisation of the paths? const mod = this._getModule(path); return mod ?? this._createModule(path); } /** * Retrieves a module from the cache if it exists * @param path Module path to retrieve */ protected _getModule(path: string): HmrModule | null { const mod = this._dependencyTree.get(path); return mod ?? null; } /** * Creates a module and initialises the dependency tree cache entry * for it. * @param path Module path to create an entry for */ protected _createModule(path: string): HmrModule { const mod: HmrModule = { hmrAccepted: false, hmrEnabled: false, dependencies: new Set(), dependents: new Set(), needsReplacement: false, replacementRequests: 0, }; this._dependencyTree.set(path, mod); return mod; } }