Loris Sigrist looking very handsome Loris Sigrist

Adding Devtools to Vite plugins

One of my favorite features in any framework is the Svelte Inspector. It allows you to click on a component and then it magically opens the relevant file in your editor.

In order to accomplish this, without the user’s having to do additional setup, they have to inject their devtool code into the browser during development. Today we will learn how to do that, so that you too can build great devtools!

Getting a Foothold - Injecting JS into the Browser

The key is to inject code into vite’s client side entry point. This is surprisingly straight forward since a vite-plugin can just modify any js file using the transform hook.

/** @returns {import('vite').Plugin} */
const const devtoolsPlugin: () => import('vite').Plugin
@returns
devtoolsPlugin
= () => ({
OutputPlugin.name: stringname: "devtools", Plugin<any>.enforce?: "pre" | "post" | undefined
Enforce plugin invocation tier similar to webpack loaders. Plugin invocation order: - alias resolution - `enforce: 'pre'` plugins - vite core plugins - normal plugins - vite build plugins - `enforce: 'post'` plugins - vite build post plugins
enforce
: "pre", //run before the vite's built-in transformations
Plugin<any>.apply?: "serve" | "build" | ((this: void, config: UserConfig, env: ConfigEnv) => boolean) | undefined
Apply the plugin only for serve or build, or on certain conditions.
apply
: "serve", //only run in dev mode
Plugin<any>.transform?: ObjectHook<(this: TransformPluginContext, code: string, id: string, options?: {
    ssr?: boolean | undefined;
} | undefined) => TransformResult | Promise<...>> | undefined
transform
(code: stringcode, id: stringid,
options: {
    ssr?: boolean | undefined;
} | undefined
options
) {
if(
options: {
    ssr?: boolean | undefined;
} | undefined
options
?.ssr?: boolean | undefinedssr) return //Don't run in SSR mode
//Inject some code into the vite's client side entry point if (id: stringid.String.includes(searchString: string, position?: number | undefined): boolean
Returns true if searchString appears as a substring of the result of converting this object to a String, at one or more positions that are greater than or equal to position; otherwise, returns false.
@paramsearchString search string@paramposition If position is undefined, 0 is assumed, so as to search all of the String.
includes
("vite/dist/client/client.mjs")) {
return { code?: string | undefinedcode: code: stringcode + "\n" + "console.log('Hello World!')" } } } })

Opening the dev-site now shows the message in the console. That’s the foothold we need.

Importing our own modules

But to ship non-trivial devtools, we need more than just a foothold. We need more than just appending some code at the end of a file. We need to import our own modules.

Unfortunately, this isn’t so straight forward. Our plugin is likely part of an external package and we don’t know where that package will be installed, so we can’t import our own modules using relative paths.

const 
const plugin: {
    transform(code: any, id: any, options: any): {
        code: string;
    } | undefined;
}
plugin
= {
function transform(code: any, id: any, options: any): {
    code: string;
} | undefined
transform
(code: anycode, id: anyid, options: anyoptions) {
if (id: anyid.includes("vite/dist/client/client.mjs")) { // How do we import our own modules? return { code: stringcode: code: anycode + "\n" + "import(????)" } } } }

I offer a few solutions here:

  1. Export the devtools browser code from the plugin package
  2. Use a sub-package
  3. Magic Module Resolution (preferred)

Option 1: Exporting the runtime code from the package

This one is very straight forward. We just export the entry point of our devtools from our package. This way all we need to do is to inject an import statement to it in the client side js.

// @filename: entry.js
export function function bootstrapDevtools(): voidbootstrapDevtools() {
    // Devtool Browser code here
}

// @filename: plugin.js
const 
const plugin: {
    transform(code: any, id: any, options: any): {
        code: string;
    } | undefined;
}
plugin
= {
function transform(code: any, id: any, options: any): {
    code: string;
} | undefined
transform
(code: anycode, id: anyid, options: anyoptions) {
if (id: anyid.includes("vite/dist/client/client.mjs")) { return { code: stringcode: code: anycode + "\n" + 'import("my-devtools-plugin").then(module => module.bootstrapDevtools())' } } } }

This works and is very simple, but it has some downsides.

It’s probably possible to hide the export from the IDE by modifying the package’s type definitions, but that’s more work than the other solutions.

Option 2: Using a sub-package

Sub packages are a feature of npm that allow you to have multiple entry points in a single package. For example, the svelte package has a sub-package svelte/stores which contains store implementations.

In this approach, we still export the runtime code from our package, but we give it it’s own entry point. This way we don’t clutter up the exports and we don’t mix concerns.

Here is the setup:

src
|- plugin.js
|- devtools
   |- entry.js

Then, in the package.json, add an exports field with two entries: one for the plugin and one for the devtools.

{
  "name": "my-devtools-plugin",
  "exports": {
    ".": {
        "import": "./plugin.js",
        "types": "./plugin.d.ts"
    },
    "./internal": {
        "import": "./devtools/entry.js"
    }
  }
}

You can then inject an import statement to my-devtools-plugin/internal in the client side js.

const 
const plugin: {
    transform(code: any, id: any, options: any): {
        code: string;
    } | undefined;
}
plugin
= {
function transform(code: any, id: any, options: any): {
    code: string;
} | undefined
transform
(code: anycode, id: anyid, options: anyoptions) {
if (id: anyid.includes("vite/dist/client/client.mjs")) { return { code: stringcode: code: anycode + "\n" + 'import("my-devtools-plugin/internal").then(module => module.bootstrap())' } } } }

This eliminates the code-mixing problem, but does not quite eliminated the import cluttering. While the my-devtools-plugin package does not have private exports, IDEs might still suggest my-devtools-plugin/internal as an import option. Developers are unlikely to use it, but it’s still a bit annoying.

If you generate your type definitions using dts-buddy instead of tsc, you can sidestep this problem by not generating type declarations for the internal sub-package. Otherwise use Option 3.

Option 3: Magic Module Resolution (preferred)

If you really don’t want to clutter your exports, this is the best way to go, but it’s a bit of work to set up.

The idea is to define a magic module-id that our plugin resolves to the absolute path of our entry point.

(Eg: import("my-package:devtools") resolves to import("/home/user/project/node_modules/my-package/devtools/entry.js") or whatever)

But how can we know the absolute path of our entry point? The trick is that we know the relative path to the entry point from our plugin file.

We can get the absolute path of our plugin’s file using import.meta.url. We can then combine that with the relative path to our entry point to get the absolute path to our entry point.

src
|- plugin.js
|- devtools
   |- entry.js
import { function (method) dirname(path: string): string
Return the directory name of a path. Similar to the Unix dirname command.
@parampath the path to evaluate.@throws{TypeError} if `path` is not a string.
dirname
} from "node:path"
import { function fileURLToPath(url: string | URL, options?: FileUrlToPathOptions): string
This function ensures the correct decodings of percent-encoded characters as well as ensuring a cross-platform valid absolute path string. ```js import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); new URL('file:///C:/path/').pathname; // Incorrect: /C:/path/ fileURLToPath('file:///C:/path/'); // Correct: C:path (Windows) new URL('file://nas/foo.txt').pathname; // Incorrect: /foo.txt fileURLToPath('file://nas/foo.txt'); // Correct: \nas oo.txt (Windows) new URL('file:///你好.txt').pathname; // Incorrect: /%E4%BD%A0%E5%A5%BD.txt fileURLToPath('file:///你好.txt'); // Correct: /你好.txt (POSIX) new URL('file:///hello world').pathname; // Incorrect: /hello%20world fileURLToPath('file:///hello world'); // Correct: /hello world (POSIX) ```
@sincev10.12.0@paramurl The file URL string or URL object to convert to a path.@returnThe fully-resolved platform-specific Node.js file path.
fileURLToPath
} from "node:url"
import { function normalizePath(id: string): stringnormalizePath } from "vite" function function getDevtoolsEntryPath(): stringgetDevtoolsEntryPath() { const const srcFolderPath: stringsrcFolderPath = function normalizePath(id: string): stringnormalizePath(function dirname(path: string): string
Return the directory name of a path. Similar to the Unix dirname command.
@parampath the path to evaluate.@throws{TypeError} if `path` is not a string.
dirname
(function fileURLToPath(url: string | URL, options?: FileUrlToPathOptions | undefined): string
This function ensures the correct decodings of percent-encoded characters as well as ensuring a cross-platform valid absolute path string. ```js import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); new URL('file:///C:/path/').pathname; // Incorrect: /C:/path/ fileURLToPath('file:///C:/path/'); // Correct: C:path (Windows) new URL('file://nas/foo.txt').pathname; // Incorrect: /foo.txt fileURLToPath('file://nas/foo.txt'); // Correct: \nas oo.txt (Windows) new URL('file:///你好.txt').pathname; // Incorrect: /%E4%BD%A0%E5%A5%BD.txt fileURLToPath('file:///你好.txt'); // Correct: /你好.txt (POSIX) new URL('file:///hello world').pathname; // Incorrect: /hello%20world fileURLToPath('file:///hello world'); // Correct: /hello world (POSIX) ```
@sincev10.12.0@paramurl The file URL string or URL object to convert to a path.@returnThe fully-resolved platform-specific Node.js file path.
fileURLToPath
(import.meta.ImportMeta.url: string
The absolute `file:` URL of the module.
url
)))
return const srcFolderPath: stringsrcFolderPath + "/devtools/entry.js" }

Using this, we can then resolve our magic module id to the absolute path of our entry point.

const const MAGIC_MODULE_ID: "my-package:devtools"MAGIC_MODULE_ID = "my-package:devtools"

export const 
const devtoolsPlugin: () => {
    name: string;
    enforce: string;
    apply: string;
    resolveId(id: any): any;
    transform(code: any, id: any, options: any): {
        code: string;
    } | undefined;
}
devtoolsPlugin
= () => ({
name: stringname: "devtools", enforce: stringenforce: "pre", apply: stringapply: "serve", function resolveId(id: any): anyresolveId(id: anyid) { if (id: anyid === const MAGIC_MODULE_ID: "my-package:devtools"MAGIC_MODULE_ID) { return getDevtoolsEntryPath() } },
function transform(code: any, id: any, options: any): {
    code: string;
} | undefined
transform
(code: anycode, id: anyid, options: anyoptions) {
if (id: anyid.includes("vite/dist/client/client.mjs")) { return { code: stringcode: code: anycode + "\n" + `import("${const MAGIC_MODULE_ID: "my-package:devtools"MAGIC_MODULE_ID}").then(module => module.bootstrapDevtools())` } } } })

If you’re going to use this, make sure that the relative path to your entry point is correct. Compiling or Bundling your plugin code may change the relative path.

Addendum: Dealing with fs.allow

vite has a configuration option called fs.allow. It decides which paths vite’s file-imports are allowed to read. This prevents path-traversal attacks. If your user’s use this and haven’t allowed paths inside your package folder the above code will break. You could just instruct them to allow these paths, but that’s not very user friendly.

We can sidestep this by loading the code ourselves using the load hook and fs.readFile. We need to do this for all devtool files, not just the entry point.

To do this, we will not use a magic id, but a magic prefix. We will check if an import id starts with the prefix, and if it does, replace the prefix with the path to our src/devtools/ folder and load the files ourselves.

src
|- plugin.js
|- devtools
   |- entry.js
   |- imported-by-entry.js

Eg:

// @filename: plugin.js 
const const srcFolderPath: anysrcFolderPath = normalizePath(dirname(fileURLToPath(import.meta.ImportMeta.url: stringurl)))
const const devtoolsFolderPath: stringdevtoolsFolderPath = const srcFolderPath: anysrcFolderPath + "/devtools"

const const MAGIC_MODULE_PREFIX: "my-package:devtools"MAGIC_MODULE_PREFIX = "my-package:devtools"

export const 
const devtoolsPlugin: () => {
    name: string;
    enforce: string;
    apply: string;
    resolveId(id: any): any;
    load(path: any): any;
    transform(code: any, id: any, options: any): {
        code: string;
    } | undefined;
}
devtoolsPlugin
= () => ({
name: stringname: "devtools", enforce: stringenforce: "pre", apply: stringapply: "serve", function resolveId(id: any): anyresolveId(id: anyid) { if (id: anyid.startsWith(const MAGIC_MODULE_PREFIX: "my-package:devtools"MAGIC_MODULE_PREFIX)) { return id: anyid.replace(const MAGIC_MODULE_PREFIX: "my-package:devtools"MAGIC_MODULE_PREFIX, const devtoolsFolderPath: stringdevtoolsFolderPath); } }, function load(path: any): anyload(path: anypath) { if (path: anypath.startsWith(const devtoolsFolderPath: stringdevtoolsFolderPath)) { let let cleanPath: anycleanPath = id.split("?")[0] ?? ""; //remove query params let cleanPath: anycleanPath = cleanId.split("#")[0] ?? ""; //remove hash if(fs.existsSync(let cleanPath: anycleanPath)) {return fs.readFile(let cleanPath: anycleanPath, "utf-8") } else { var console: Consoleconsole.Console.warn(...data: any[]): voidwarn(`Could not find file ${let cleanPath: anycleanPath}`) } } },
function transform(code: any, id: any, options: any): {
    code: string;
} | undefined
transform
(code: anycode, id: anyid, options: anyoptions) {
if (id: anyid.includes("vite/dist/client/client.mjs")) { return { code: stringcode: code: anycode + "\n" + `import("${MAGIC_MODULE_ID}/entry.js").then(module => module.bootstrap())` } } } })

In Conclusion

It’s not hard, but it’s a hassle. Fortunately, you only need to do this once.