@olefjaerestad/hmr

Hot Module Reloading server and client side scripts. All you need to get up and running with HMR in your project.

Usage no npm install needed!

<script type="module">
  import olefjaerestadHmr from 'https://cdn.skypack.dev/@olefjaerestad/hmr';
</script>

README

hmr

Hot Module Reloading server and client side scripts. All you need to get up and running with HMR in your project.

Hot module reload (automatically reload code without reloading page) for:

  • CSS
  • Images

Hot reloading (automatic full page reload) for:

  • HTML
  • JS, with experimental HMR support for JS modules (script[type="module"]).

Requirements

Browser and Node environments supporting the following:

How to use

npm i @olefjaerestad/hmr

Create a hmr-server.js and add the following. Feel free to tweak the Server parameters as you see fit:

import { Server } from '@olefjaerestad/hmr';
import { join } from 'path';
import { fileURLToPath } from 'url';

new Server({
  hostname: 'localhost',
  port: 9001,
  watch: {
    paths: [
      join(fileURLToPath(import.meta.url), '../../dist'),
      join(fileURLToPath(import.meta.url), '../../../somefolder'),
      join(fileURLToPath(import.meta.url), '/../anotherfolder'),
    ]
  }
});

Run node hmr-server.js. This will start watching the given paths recursively for file changes. The paths don't have to exist at the time the script starts running, which might be useful in some dev setups. A good idea would be to use something like Concurrently and run this script in parallel with your other dev script(s).

Note: This package is an ES module, and as such requires either setting "type": "module" in your package.json or using an .mjs extension hmr-server.mjs.

Add the following somewhere in your client side code (e.g. create a hmr-client.js and include it in your markup):

/**
 * Note: if not using a bundler or similar, the import path must point to node_modules, e.g.
 * '../node_modules/@olefjaerestad/hmr/build/client.js'.
 */
import { Client } from '@olefjaerestad/hmr/build/client.js';

new Client({
  hostname: 'localhost',
  port: 9001,
  onMessageCallback: (e, client) => {
    console.log('Client.onMessageCallback()');
    console.log(e);
    client.replaceNodesByFilename({filename: e.filename});
  },
  onOpenCallback: (e) => console.log(e),
  onCloseCallback: (e) => console.log(e),
  onErrorCallback: (e) => console.log(e),
});

This will connect your browser to the HMR Server, and you'll be notified when any of the watched files change.

Note, if you're making a separate file with this content, make sure to mark it as a module when including it in your html (<script type="module">).

General idea

  • To be used while developing.
  • Keeps a websocket server running at all times.
  • The websocket server watches for file changes and notifies connected clients.
  • The clients decide themselves what they want to do when files change (or use defaults).
  • Expose a JS API.
  • No CLI API.

Docs

Server

new Server({
  hostname: 'localhost', // string. Required.
  port: 9001, // number. Required.
  watch: { // Required.
    ignoredFileExtensions, // string[]. Optional. Example: ['.d.ts', '.tsbuildinfo']
    notifyClientsOnFileChange: true, // boolean. Optional. Notify connected clients when a file changes. Default: true.
    paths: [ // string or string[]. Required.
      join(fileURLToPath(import.meta.url), '../../dist'),
      join(fileURLToPath(import.meta.url), '../../../somefolder'),
      join(fileURLToPath(import.meta.url), '/../anotherfolder'),
      join(fileURLToPath(import.meta.url), './../yetanotherfolder/file.js'),
    ],
    verbose: false, // boolean. Optional. Outputs file events to console. Useful for debugging. Default: false.
  }
});

Server.addEventListener(eventName: string: callback: (event) => any): boolean

Run a callback on certain events. Useful e.g. if you need the filename of the changed file server side. Returns whether the callback could be added or not (a given callback can only be added once pr. eventName).

const hmrServer = new Server({...args});
function changeCallback(event) {
  console.log(event);
};
hmrServer.addEventListener('change', changeCallback);

Supported events:

  • change: When a file change is detected.

Server.emit(eventName: string, event: {[key: string]: any}): boolean

Emit an event, which triggers callbacks registered with addEventListener(). Returns false if no callbacks have been registered, true otherwise. If callbacks have been registered, true will be returned after the callbacks have been run. This is used internally, but feel free to use it however you like.

const hmrServer = new Server({...args});
hmrServer.emit('change', {
  value: {
    foo: 'bar'
  },
});

Server.notifyConnectedClients(event: IFileChangedEvent): void

Notify connected clients about a file change.

Server.removeEventListener(eventName: string: callback: (event) => any): boolean

Remove a callback registered with addEventListener(). Returns whether the callback could be removed or not.

const hmrServer = new Server({...args});
function changeCallback(event) {
  console.log(event);
};
hmrServer.addEventListener('change', changeCallback);
hmrServer.removeEventListener('change', changeCallback);

Server._connectedSockets

{[id: number]: WebSocket}

Server._ignoredFileExtensions

string[]

Server._lastChangedFile

{filename?: string, timestamp?: number}

Server._notifyClientsOnFileChange

boolean

Server._server

WebSocket.Server

https://github.com/websockets/ws#simple-server

Server._verbose

boolean

Client

new Client({
  hostname: 'localhost', // Must match hostname of Server. Required.
  port: 9001, // Must match port of Server. Required.
  onMessageCallback: (e: IFileChangedEvent, client: Client) => { // Run callback on file changes. Optional. If not passed, fallbacks to a default (and hopefully reasonable) behaviour.
    // client refers to the newly created instance:
    client.replaceNodesByFilename({ 
      filename: e.filename,
      includeJs: client._doJsHmr,
      verbose: client._verbose,
    });
  },
  onOpenCallback: (e: Event, client: Client) => console.log(e), // Run callback on connection to Server. Optional.
  onCloseCallback: (e: CloseEvent, client: Client) => console.log(e), // Run callback on disconnection from Server. Optional.
  onErrorCallback: (e: Event, client: Client) => console.log(e), // Run callback on connection error. Optional.
  verbose: true, // Optional. Outputs connection/file events to console. Useful for debugging. Default: false.
  doJsHmr: true, // Do HMR instead of page refresh for changes to javascript modules. This is experimental and quite buggy. Use at your own risk. Default: false.
});

Client._defaultOnMessageCallback(e: IFileChangedEvent): void:

Used on file changes if you don't pass an onMessageCallback to the Client constructor.

(e: IFileChangedEvent) => void

Client._doJsHmr

boolean

Client._socket

WebSocket

Client._verbose

boolean

Client.replaceNodesByFilename({filename: string, includeJs?: boolean = false, verbose?: boolean = false}): number

Replace nodes which reference filename (e.g. CSS <link>s). Return number of replaced nodes.

Utility functions

notify

Server side function you can use to manually notify all connected clients. This was originally created to allow web servers to notify clients whenever it restarted.

import { notify } from '@olefjaerestad/hmr';

notify({
  hostname: 'localhost', // Must match hostname of Server. Required.
  port: 9001, // Must match port of Server. Required.
  event: { // IFileChangedEvent. Required.
    type: 'restart',
  }
});

Typescript

import {
  IChangeEvent,
  IFileChangedEvent,
  TChangeCallback,
  TEventName,
} from '@olefjaerestad/hmr/build/types/types';

IFileChangedEvent is explained below, see the source code for more details.

IFileChangedEvent

Event emitted when files are changed.

interface IFileChangedEvent {
  filename?: string;
  filepath?: string;
  type: 'add' | 'addDir' | 'change' | 'restart' | 'unlink' | 'unlinkDir';
}

Dev (contributing to the project)

Branch out from master.

npm i

npm run dev

Open localhost:9000 in your browser.

Make a change to a file in src to trigger HMR. Save (no need to make any change) a file in static to trigger HMR. Save (no need to make any change) scripts/dev-server.js to trigger HMR.

A good starting point for getting to know the project is to have a look at the following files:

  • src/client.ts
  • src/server.ts
  • scripts/dev-server.js
  • scripts/hmr-server-dev.js
  • static/hmr-client-dev.js
  • package.json#scripts
  • nodemon.*.json

When you're done, create a pull request from your branch to master.

While developing, you can use the string '__ROLLUP_REPLACE_WITH_EMPTY_STRING__'. This will be replaced with an empty string when building project. Handy for tree shaking (e.g. in if statements).

Build

npm i

npm run build

npm run start

Open localhost:9000/index-prod.html in your browser.

Todo

  • Add (unit) tests.

FAQ

Why opt-in HMR for JS?

When replacing scripts, if the replaced script contains addEventListener, that event listener will fire as many times as the script has been replaced. This happens because scripts stay in memory even after they've been removed from DOM. This could also lead to huge memory leaks. This would be a less than ideal default. By opting in to JS HMR, you risk experiencing issues like these.