Skip to content

Commit

Permalink
feat(dev-server-hmr): initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
43081j authored Nov 2, 2020
1 parent b1d663c commit fe3ec35
Show file tree
Hide file tree
Showing 21 changed files with 917 additions and 16 deletions.
194 changes: 194 additions & 0 deletions docs/docs/dev-server/plugins/hmr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Dev Server >> Plugins >> Hot Module Replacement ||7

> **Warning:** this plugin is still experimental and may change until it
> reaches a stable release.
Plugin for introducing HMR (hot module replacement) support.

Modules can be written to consume the provided HMR API at development
time, allowing for them to update without reloading the page.

## Installation

Install the package:

```
npm i --save-dev @web/dev-server-hmr
```

Add the plugin to your `web-dev-server-config.mjs` or `web-test-runner.config.js`:

```ts
import { hmrPlugin } from '@web/dev-server-hmr';

export default {
plugins: [hmrPlugin()],
};
```

## Basic usage

When the plugin is loaded, any HMR-compatible module will have the HMR API
made available to it via `import.meta.hot`.

By default, it will effectively do nothing until you have written code
which consumes this API.

For example, take the following module:

```ts
/** Adds two numbers */
export let add = (a, b) => {
return a + b;
};
```

In its current state, it will _not_ be HMR-compatible as it does not reference
the HMR API. This means if our `add` module changes, the HMR plugin will
trigger a full page reload.

To make it compatible, we must use the HMR API via `import.meta.hot`:

```ts
/** Adds two numbers */
export let add = (a, b) => {
return a + b;
};

if (import.meta.hot) {
import.meta.hot.accept(({ module }) => {
add = module.add;
});
}
```

The plugin will detect that your module uses the HMR API and will make the
`import.meta.hot` object available.

Do note that in our example we wrapped this in an `if` statement. The reason
for this is to account for if the plugin has not been loaded.

## Note about production

In production it is highly recommended you remove any of these HMR related
blocks of code as they will effectively be dead code.

## Use with libraries/frameworks

This plugin exists primarily to serve as a base to other
framework/ecosystem-specific implementations of HMR.

It can be consumed directly as-is, but in future should usually be
used via another higher level plugin layered on top of this.

## API

### `import.meta.hot.accept()`

Calling `accept` without a callback will notify the plugin that your module
accepts updates, but will not deal with the updates.

This is only really useful if your module is one which has side-effects
and does not need mutating on update (i.e. has no exports).

Example:

```ts
// will be set each time the module updates as a side-effect
window.someGlobal = 303;
import.meta.hot.accept();
```

### `import.meta.hot.accept(({ module }) => { ... })`

If you pass a callback to `accept`, it will be passed the updated module
any time an update occurs.

At this point, you should usually update any exports to be those on the
new module.

Example:

```ts
export let foo = 808;
import.meta.hot.accept(({ module }) => {
foo = module.foo;
});
```

### `import.meta.hot.accept(['./dep1.js', './dep2.js'], ({ deps, module }) => { ... })`

If you specify a list of dependencies as well as a callback, your callback
will be provided with the up-to-date version of each of those modules.

This can be useful if your updates require access to dependencies of the
current module.

Example:

```ts
import { add } from './add.js';

export let foo = add(10, 10);

import.meta.hot.accept(['./add.js'], ({ deps, module }) => {
foo = deps[0].add(10, 10);
});
```

### `import.meta.hot.invalidate()`

Immediately invalidates the current module which will then lead to reloading
the page.

Example:

```ts
export let foo = 303;

import.meta.hot.accept(({ module }) => {
if (!module.foo) {
import.meta.hot.invalidate();
} else {
foo = module.foo;
}
});
```

### `import.meta.hot.decline()`

Notifies the server that you do not support updates, meaning any updates
will result in a full page reload.

This may be useful when your module makes global changes (side effects) which
cannot be re-done.

Example:

```ts
doSomethingGlobal();

import.meta.hot.decline();
```

### `import.meta.dispose(() => { ... })`

Specifies a callback to be called when the current module is disposed of,
before the new module is loaded and passed to `accept()`.

Example:

```ts
export let foo = new Server();

foo.start();

import.meta.hot.accept(({ module }) => {
foo = module.foo;
foo.start();
});

import.meta.dispose(() => {
foo.stop();
});
```
1 change: 1 addition & 0 deletions docs/docs/dev-server/plugins/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ See
- [rollup](./rollup.md)
- [import-maps](./import-maps.md)
- [legacy](./legacy.md)
- [hmr](./hmr.md)

If you have more specific needs it's best to [write your own plugin](../writing-plugins/overview.md).
1 change: 1 addition & 0 deletions packages/dev-server-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.2.6",
"@web/parse5-utils": "^1.0.0",
"chokidar": "^3.4.0",
"clone": "^2.1.2",
"es-module-lexer": "^0.3.24",
Expand Down
2 changes: 1 addition & 1 deletion packages/dev-server-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export { WebSocket };
export { DevServer } from './server/DevServer';
export { Plugin, ServerStartParams } from './Plugin';
export { DevServerCoreConfig, MimeTypeMappings } from './DevServerCoreConfig';
export { WebSocketsManager } from './web-sockets/WebSocketsManager';
export { WebSocketsManager, WebSocketData } from './web-sockets/WebSocketsManager';
export {
getRequestBrowserPath,
getRequestFilePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EventEmitter } from './EventEmitter';

export const NAME_WEB_SOCKET_IMPORT = '/__web-dev-server__web-socket.js';

type WebSocketData = { type: string } & Record<string, unknown>;
export type WebSocketData = { type: string } & Record<string, unknown>;

export interface Events {
message: { webSocket: WebSocket; data: WebSocketData };
Expand Down
16 changes: 2 additions & 14 deletions packages/dev-server-core/src/web-sockets/webSocketsPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { parse as parseHtml } from 'parse5';
import { query, predicates } from '../dom5';
import { Plugin } from '../Plugin';
import { NAME_WEB_SOCKET_IMPORT } from './WebSocketsManager';
import { appendToDocument } from '@web/parse5-utils';

export const webSocketScript = `<!-- injected by web-dev-server -->
<script type="module" src="${NAME_WEB_SOCKET_IMPORT}"></script>`;
Expand Down Expand Up @@ -90,18 +89,7 @@ if (webSocket) {

async transform(context) {
if (context.response.is('html')) {
const documentAst = parseHtml(context.body, { sourceCodeLocationInfo: true });
const htmlNode = query(documentAst, predicates.hasTagName('html'));
const bodyNode = query(documentAst, predicates.hasTagName('body'));
if (!htmlNode?.sourceCodeLocation || !bodyNode?.sourceCodeLocation) {
// if html or body tag does not have a source code location it was generated
return;
}

const { startOffset } = bodyNode.sourceCodeLocation.endTag;
const start = context.body.substring(0, startOffset);
const end = context.body.substring(startOffset);
return `${start}\n\n${webSocketScript}\n\n${end}`;
return appendToDocument(context.body, webSocketScript);
}
},
};
Expand Down
5 changes: 5 additions & 0 deletions packages/dev-server-hmr/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Dev server HMR

Plugin for using HMR (hot module reload) in the dev server.

See [our website](https://modern-web.dev/docs/dev-server/plugins/hmr/) for full documentation.
48 changes: 48 additions & 0 deletions packages/dev-server-hmr/demo/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const renderedComponents = new Set();

export class MyComponent extends HTMLElement {
static styles = `
h1 { color: hotpink; }
p { color: olivegreen; }
`;

static template = `
<h1>Foo</h1>
<p>Bar</p>
`;

constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
renderedComponents.add(this);
this.render();
}

disconnectedCallback() {
renderedComponents.delete(this);
}

render() {
this.shadowRoot.innerHTML = `
<style>${MyComponent.styles}</style>
${MyComponent.template}
`;
}
}

if (import.meta.hot) {
import.meta.hot.accept(({ module }) => {
MyComponent.styles = module.MyComponent.styles;
MyComponent.template = module.MyComponent.template;
for (const component of renderedComponents) {
component.render();
}
});
}

if (!window.customElements.get('my-component')) {
window.customElements.define('my-component', MyComponent);
}
8 changes: 8 additions & 0 deletions packages/dev-server-hmr/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<html>
<head></head>

<body>
<my-component></my-component>
<script type="module" src="./component.js"></script>
</body>
</html>
6 changes: 6 additions & 0 deletions packages/dev-server-hmr/demo/server.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const { hmrPlugin } = require('../dist/index');

module.exports = {
rootDir: '.',
plugins: [hmrPlugin()],
};
2 changes: 2 additions & 0 deletions packages/dev-server-hmr/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// this file is autogenerated with the generate-mjs-dts-entrypoints script
export * from './dist/index';
6 changes: 6 additions & 0 deletions packages/dev-server-hmr/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// this file is autogenerated with the generate-mjs-dts-entrypoints script
import cjsEntrypoint from './dist/index.js';

const { hmrPlugin } = cjsEntrypoint;

export { hmrPlugin };
43 changes: 43 additions & 0 deletions packages/dev-server-hmr/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@web/dev-server-hmr",
"version": "0.0.1",
"publishConfig": {
"access": "public"
},
"description": "Plugin for using HMR in @web/dev-server",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/modernweb-dev/web.git",
"directory": "packages/dev-server-hmr"
},
"author": "modern-web",
"homepage": "https://github.com/modernweb-dev/web/tree/master/packages/dev-server-hmr",
"main": "dist/index.js",
"engines": {
"node": ">=10.0.0"
},
"scripts": {
"build": "tsc",
"start:demo": "web-dev-server --config demo/server.config.js",
"test": "mocha \"test/**/*.test.ts\" --require ts-node/register --reporter dot",
"test:watch": "mocha \"test/**/*.test.ts\" --require ts-node/register --watch --watch-files src,test --reporter dot"
},
"files": [
"*.d.ts",
"*.js",
"*.mjs",
"dist",
"src"
],
"dependencies": {
"@web/dev-server-core": "^0.2.2"
},
"devDependencies": {},
"exports": {
".": {
"import": "./index.mjs",
"require": "./dist/index.js"
}
}
}
Loading

0 comments on commit fe3ec35

Please sign in to comment.