@farmfe/runtime-plugin-hmr
Version:
Runtime hmr plugin of Farm
262 lines • 11.7 kB
JavaScript
import { logger } from './logger.js';
import { ErrorOverlay, overlayId } from './overlay.js';
// Inject during compile time
const usingClientHost = typeof FARM_HMR_HOST === 'boolean'; // using client host/port by default
const hmrPort = usingClientHost ? window.location.port : Number(FARM_HMR_PORT);
const hmrHost = usingClientHost ? window.location.hostname : FARM_HMR_HOST;
const socketProtocol = FARM_HMR_PROTOCOL || (location.protocol === 'https:' ? 'wss' : 'ws');
const socketHostUrl = `${hmrHost}:${hmrPort}${FARM_HMR_PATH}`;
export class HmrClient {
constructor(moduleSystem) {
this.moduleSystem = moduleSystem;
this.registeredHotModulesMap = new Map();
this.disposeMap = new Map();
this.pruneMap = new Map();
this.customListenersMap = new Map();
}
connect() {
logger.debug('connecting to the server...');
// setup websocket connection
const socket = new WebSocket(`${socketProtocol}://${socketHostUrl}`, 'farm_hmr');
this.socket = socket;
// listen for the message from the server
// when the user save the file, the server will recompile the file(and its dependencies as long as its dependencies are changed)
// after the file is recompiled, the server will generated a update resource and send its id to the client
// the client will apply the update
socket.addEventListener('message', (event) => {
const result = new Function(`return (${event.data})`)();
if (result?.type === 'closing') {
this.closeConnectionGracefully();
return;
}
this.handleMessage(result);
});
socket.addEventListener('open', () => {
this.notifyListeners('vite:ws:connect', { webSocket: socket });
this.notifyListeners('farm:ws:connect', { webSocket: socket });
}, { once: true });
socket.addEventListener('close', async () => {
// TODO Do you want to do an elegant cleaning?
// if (wasClean) return;
this.notifyListeners('vite:ws:disconnect', { webSocket: socket });
this.notifyListeners('farm:ws:disconnect', { webSocket: socket });
logger.debug('disconnected from the server, please reload the page.');
await waitForSuccessfulPing(socketProtocol, `${socketHostUrl}`);
location.reload();
});
return socket;
}
closeConnectionGracefully() {
if (this.socket.readyState === WebSocket.CLOSING ||
this.socket.readyState === WebSocket.CLOSED) {
return;
}
this.socket.close(1000, 'Client closing connection');
}
async applyHotUpdates(result, moduleSystem) {
result.changed.forEach((id) => {
logger.debug(`${id} updated`);
});
for (const id of result.removed) {
const prune = this.pruneMap.get(id);
if (prune) {
const hotContext = this.registeredHotModulesMap.get(id);
await prune(hotContext.data);
}
moduleSystem.delete(id);
this.registeredHotModulesMap.delete(id);
}
for (const id of result.added) {
moduleSystem.register(id, result.modules[id]);
}
for (const id of result.changed) {
moduleSystem.update(id, result.modules[id]);
if (!result.boundaries[id]) {
// do not found boundary module, reload the window
location.reload();
}
}
if (result.dynamicResources && result) {
moduleSystem.setDynamicModuleResourcesMap(result.dynamicResources, result.dynamicModuleResourcesMap);
}
for (const chains of Object.values(result.boundaries)) {
for (const chain of chains) {
// clear the cache of the boundary module and its dependencies
for (const id of chain) {
moduleSystem.clearCache(id);
}
try {
// require the boundary module
const boundary = chain[chain.length - 1];
const hotContext = this.registeredHotModulesMap.get(boundary);
const acceptedDep = chain.length > 1 ? chain[chain.length - 2] : undefined;
if (!hotContext) {
logger.debug(`hot context is empty for boundary ${boundary}. Hot update of ${boundary} is skipped.`);
// location.reload();
// fix multi page application hmr
continue;
}
// get all the accept callbacks of the boundary module that accepts the updated module
const selfAcceptedCallbacks = hotContext.acceptCallbacks.filter(({ deps }) => deps.includes(boundary));
const depsAcceptedCallbacks = hotContext.acceptCallbacks.filter(({ deps }) => deps.includes(acceptedDep));
// when there are both self accept callbacks and deps accept callbacks in a boundary module, only the deps accept callbacks will be called
for (const [acceptedId, acceptedCallbacks] of Object.entries({
[acceptedDep]: depsAcceptedCallbacks,
[boundary]: selfAcceptedCallbacks
})) {
if (acceptedCallbacks.length > 0) {
const acceptHotContext = this.registeredHotModulesMap.get(acceptedId);
const disposer = this.disposeMap.get(acceptedId);
if (disposer)
await disposer(acceptHotContext.data);
const acceptedExports = moduleSystem.require(acceptedId);
for (const { deps, fn } of acceptedCallbacks) {
fn(deps.map((dep) => dep === acceptedId ? acceptedExports : undefined));
}
// break the loop, only the first accept callback will be called
break;
}
}
}
catch (err) {
// The boundary module's dependencies may not present in current module system for a multi-page application. We should reload the window in this case.
// See https://github.com/farm-fe/farm/issues/383
logger.error(err);
location.reload();
}
}
}
}
async notifyListeners(event, data) {
const callbacks = this.customListenersMap.get(event);
if (callbacks) {
await Promise.allSettled(callbacks.map((cb) => cb(data)));
}
}
/**
* handle vite HMR message, except farm-update which is handled by handleFarmUpdate, other messages are handled the same as vite
* @param payload Vite HMR payload
*/
async handleMessage(payload) {
switch (payload.type) {
case 'farm-update':
this.notifyListeners('farm:beforeUpdate', payload);
this.handleFarmUpdate(payload.result);
this.notifyListeners('farm:afterUpdate', payload);
break;
case 'error': {
this.notifyListeners('vite:error', payload);
this.notifyListeners('farm:error', payload);
if (payload.overlay)
createOverlay(payload.err);
break;
}
case 'connected':
logger.debug('connected to the server');
break;
case 'update':
this.notifyListeners('vite:beforeUpdate', payload);
await Promise.all(payload.updates.map(async (update) => {
if (update.type === 'js-update') {
this.socket.send(JSON.stringify(update));
return;
}
logger.warn('css link update is not supported yet');
}));
this.notifyListeners('vite:afterUpdate', payload);
break;
case 'custom':
this.notifyListeners(payload.event, payload.data);
break;
case 'full-reload':
this.notifyListeners('vite:beforeFullReload', payload);
location.reload();
break;
case 'prune':
this.notifyListeners('vite:beforePrune', payload);
this.notifyListeners('farm:beforePrune', payload);
break;
default:
logger.warn(`unknown message payload: ${payload}`);
}
}
handleFarmUpdate(result) {
hasErrorOverlay() && clearOverlay();
const immutableModules = new Function(`return ${result.immutableModules}`)();
const mutableModules = new Function(`return ${result.mutableModules}`)();
const modules = { ...immutableModules, ...mutableModules };
this.applyHotUpdates({
added: result.added,
changed: result.changed,
removed: result.removed,
boundaries: result.boundaries,
modules,
dynamicResources: result.dynamicResources,
dynamicModuleResourcesMap: result.dynamicModuleResourcesMap
}, this.moduleSystem);
}
}
export function createOverlay(err) {
clearOverlay();
document.body.appendChild(new ErrorOverlay(err));
}
function clearOverlay() {
document.querySelectorAll(overlayId).forEach((n) => n.close());
}
function hasErrorOverlay() {
return document.querySelectorAll(overlayId).length;
}
export function waitForWindowShow() {
return new Promise((resolve) => {
const onChange = async () => {
if (document.visibilityState === 'visible') {
resolve();
document.removeEventListener('visibilitychange', onChange);
}
};
document.addEventListener('visibilitychange', onChange);
});
}
async function waitForSuccessfulPing(socketProtocol, hostAndPath, ms = 1000) {
const pingHostProtocol = socketProtocol === 'wss' ? 'https' : 'http';
const ping = async () => {
// A fetch on a websocket URL will return a successful promise with status 400,
// but will reject a networking error.
// When running on middleware mode, it returns status 426, and an cors error happens if mode is not no-cors
try {
await fetch(`${pingHostProtocol}://${hostAndPath}`, {
mode: 'no-cors',
headers: {
// Custom headers won't be included in a request with no-cors so (ab)use one of the
// safelisted headers to identify the ping request
Accept: 'text/x-farm-ping'
}
});
return true;
}
catch {
/* empty */
}
return false;
};
if (await ping()) {
return;
}
await wait(ms);
// eslint-disable-next-line no-constant-condition
while (true) {
if (document.visibilityState === 'visible') {
if (await ping()) {
break;
}
await wait(ms);
}
else {
await waitForWindowShow();
}
}
}
export function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
//# sourceMappingURL=hmr-client.js.map