Tilted sphere with longitudinal stripes Modern Web Guides Docs Blog Toggle darkmode

Writing Plugins: Hooks

Hook: serve

The serve hook can be used to serve virtual files from the server. The first plugin to respond with a body is used. It can return a Promise.

Read more

Serve an auto generated index.html:

const indexHTML = generateIndexHTML();

export default {
  plugins: [
    {
      name: 'my-plugin',
      serve(context) {
        if (context.path === '/index.html') {
          return indexHTML;
        }
      },
    },
  ],
};

Serve a virtual module:

export default {
  plugins: [
    {
      name: 'my-plugin',
      serve(context) {
        if (context.path === '/messages.js') {
          return 'export default "Hello world";';
        }
      },
    },
  ],
};

The file extension is used to infer the mime type to respond with. If you are using a non-standard file extension you need to use the type property to set it explicitly:

export default {
  plugins: [
    {
      name: 'my-plugin',
      serve(context) {
        if (context.path === '/foo.xyz') {
          return { body: 'console.log("foo bar");', type: 'js' };
        }
      },
    },
  ],
};

Hook: resolveMimeType

Browsers don't use file extensions to know how to interpret files. Instead, they use media or MIME type which is set using the content-type header.

The dev server guesses the MIME type based on the file extension. When serving virtual files with non-standard file extensions, you can set the MIME type in the returned result (see the examples above). If you are transforming code from one format to another, you need to use the resolveMimeType hook.

Read more

The returned MIME type can be a file extension, this will be used to set the corresponding default MIME type. For example js resolves to application/javascript and css to text/css.

export default {
  plugins: [
    {
      name: 'my-plugin',
      resolveMimeType(context) {
        // change all MD files to HTML
        if (context.path.endsWith('.md')) {
          return 'html';
        }
      },
    },
    {
      name: 'my-plugin',
      resolveMimeType(context) {
        // change all CSS files to JS, except for a specific file
        if (context.path.endsWith('.css') && context.path !== '/global.css') {
          return 'js';
        }
      },
    },
  ],
};

You can use a mime type shorthand, such as js or css. Koa will resolve this to the full mimetype. It is also possible to set the full mime type directly:

export default {
  plugins: [
    {
      name: 'my-plugin',
      resolveMimeType(context) {
        if (context.response.is('md')) {
          return 'text/html';
        }
      },
    },
  ],
};

Hook: transform

The transform hook is called for each file and can be used to change a file's content before it is served to the browser. Multiple plugins can transform a single file. It can return a Promise.

This hook is useful for small modifications, such as injecting environment variables, or for compiling files to JS before serving them to the browser.

In a web server, the response body is not always a string, but it can be a binary buffer or stream. If the dev server sees that the response is utf-8, it will convert the body to a string for you to make writing transform plugins easier. If you are transforming non-standard file types, you may also need to include a resolveMimeType hook. A good example of this is .ts files, which in Koa defaults to a streaming video.

Read more

Rewrite the base path of your application for local development:

export default {
  plugins: [
    {
      name: 'my-plugin',
      transform(context) {
        if (context.path === '/index.html') {
          const transformedBody = context.body.replace(/<base href=".*">/, '<base href="/foo/">');
          return transformedBody;
        }
      },
    },
  ],
};

Inject a script to set global variables during local development:

export default {
  plugins: [
    {
      name: 'my-plugin',
      transform(context) {
        if (context.path === '/index.html') {
          const transformedBody = context.body.replace(
            '</head>',
            '<script>window.process = { env: { NODE_ENV: "development" } }</script></head>',
          );
          return transformedBody;
        }
      },
    },
  ],
};

Inject environment variables into a JS module:

import fs from 'fs';

const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));

export default {
  plugins: [
    {
      name: 'my-plugin',
      transform(context) {
        if (context.path === '/src/environment.js') {
          return `export const version = '${packageJson.version}';`;
        }
      },
    },
  ],
};

Transform markdown to HTML:

import { markdownToHTML } from 'markdown-to-html-library';

export default {
  plugins: [
    {
      name: 'my-plugin',
      resolveMimeType(context) {
        // this ensures the browser interprets .md files as .html
        if (context.path.endsWith('.md')) {
          return 'html';
        }
      },

      async transform(context) {
        // this will transform all MD files. if you only want to transform certain MD files
        // you can check context.path
        if (context.path.endsWith('.md')) {
          const html = await markdownToHTML(body);

          return html;
        }
      },
    },
  ],
};

Polyfill CSS modules in JS:

export default {
  plugins: [
    {
      name: 'my-plugin',
      resolveMimeType(context) {
        if (context.path.endsWith('.css')) {
          return 'js';
        }
      },

      async transform(context) {
        if (context.path.endsWith('.css')) {
          const stylesheet = `
            const stylesheet = new CSSStyleSheet();
            stylesheet.replaceSync(${JSON.stringify(body)});
            export default stylesheet;
          `;

          return stylesheet;
        }
      },
    },
  ],
};

Set custom HTTP headers (e.g. for COOP/COEP, CSP, etc.):

export default {
  plugins: [
    {
      name: 'my-plugin',
      transform(context) {
        if (context.path === '/index.html') {
          context.set('X-My-Custom-Header', 'hello, world');
        }
      },
    },
  ],
};

Hook: resolveImport

The resolveImport hook is called for each module import. It can be used to resolve module imports before it reaches the browser. When a one plugin returns a resolved, further resolve hooks are not called.

Read more

The dev server already resolves module imports when the --node-resolve flag is turned on. You can do the resolving yourself, or overwrite it for some files.

The hook receives the import string and should return the string to replace it with. This should be a browser-compatible path, not a file path.

export default {
  plugins: [
    {
      name: 'my-plugin',
      async resolveImport({ source, context }) {
        const resolvedImport = fancyResolveLibrary(source);
        return resolvedImport;
      },
    },
  ],
};

Hook: transformImport

The transformImport hook is called for each module import. It can be used to transform module imports before they reach the browser. The difference from resolveImport is that this hook is always called for all plugins.

Read more

The hook receives the import string and should return the string to replace it with. This should be a browser-compatible path, not a file path.

export default {
  plugins: [
    {
      name: 'my-plugin',
      async transformImport({ source, context }) {
        return `${source}?foo=bar;
      },
    },
  ],
};

Hook: serverStart

The serverStart hook is called when the server starts. It is the ideal location to boot up other servers you will proxy to. It receives the server config, which you can use if plugins need access to general information such as the rootDir or appIndex. It also receives the HTTP server, Koa app, and chokidar file watcher instance. These can be used for more advanced plugins. This hook can be async, and it awaited before actually booting the server and opening the browser.

Read more

Accessing the serverStart parameters:

import _glob from 'glob';
import { promisify } from 'util';
const glob = promisify(_glob);

function myFancyPlugin() {
  let rootDir;

  return {
    name: 'my-plugin',
    serverStart({ config, app, server, fileWatcher }) {
      // take the rootDir to access it later
      rootDir = config.rootDir;

      // register a koa middleware directly
      app.use((context, next) => {
        console.log(context.path);
        return next();
      });

      // register a single file to be watched
      fileWatcher.add('/README.md');

      // register multiple files to be watched
      const files = await glob('{elements}/**/*.{ts,css,html}', { cwd: process.cwd() });
      for (const file of files) {
        fileWatcher.add(file);
      }
    },
  };
}

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

Boot up another server for proxying in serverStart:

import proxy from 'koa-proxies';

export default {
  plugins: [
    {
      name: 'my-plugin',
      async serverStart({ app }) {
        // set up a proxy for certain requests
        app.use(
          proxy('/api', {
            target: 'http://localhost:9001',
          }),
        );

        // boot up the other server because it is awaited the dev server will also wait for it
        await startOtherServer({ port: 9001 });
      },
    },
  ],
};

Hook: serverStop

The serverStop hook is called when the server stops. You can use this to do cleanup work, such as closing connections

Read more
function myFancyPlugin() {
  return {
    name: 'my-plugin',
    serverStop() {
      // cleanup
    },
  };
}

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

Boot up another server for proxying in serverStart:

Koa Context

The plugin hooks receive the raw Koa Context object. This contains information about the server's request and response. Check the Koa documentation to learn more about this.

To transform specific kinds of files we don't recommend relying on file extensions. Other plugins may be using non-standard file extensions. Instead, you should use the server's MIME type or content-type header. You can check by using the context.response.is() function. This is used a lot in the examples above.

Because files can be requested with query parameters and hashes, we recommend using context.path for reading the path segment of the URL only. If you do need to access search parameters, we recommend using context.URL.searchParams.get('my-parameter').