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
devtoolsPlugin = () => ({
OutputPlugin.name: string
name: "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 pluginsenforce: "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: string
code, id: string
id, options: {
ssr?: boolean | undefined;
} | undefined
options) {
if(options: {
ssr?: boolean | undefined;
} | undefined
options?.ssr?: boolean | undefined
ssr) return //Don't run in SSR mode
//Inject some code into the vite's client side entry point
if (id: string
id.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.includes("vite/dist/client/client.mjs")) {
return { code?: string | undefined
code: code: string
code + "\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: any
code, id: any
id, options: any
options) {
if (id: any
id.includes("vite/dist/client/client.mjs")) {
// How do we import our own modules?
return { code: string
code: code: any
code + "\n" + "import(????)" }
}
}
}
I offer a few solutions here:
- Export the devtools browser code from the plugin package
- Use a sub-package
- 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(): void
bootstrapDevtools() {
// 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: any
code, id: any
id, options: any
options) {
if (id: any
id.includes("vite/dist/client/client.mjs")) {
return { code: string
code: code: any
code + "\n" + 'import("my-devtools-plugin").then(module => module.bootstrapDevtools())' }
}
}
}
This works and is very simple, but it has some downsides.
- It clutters up the exports of our package.
- It mixes browser code with plugin code.
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: any
code, id: any
id, options: any
options) {
if (id: any
id.includes("vite/dist/client/client.mjs")) {
return { code: string
code: code: any
code + "\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 oftsc
, you can sidestep this problem by not generating type declarations for theinternal
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.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: \nasoo.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)
```fileURLToPath } from "node:url"
import { function normalizePath(id: string): string
normalizePath } from "vite"
function function getDevtoolsEntryPath(): string
getDevtoolsEntryPath() {
const const srcFolderPath: string
srcFolderPath = function normalizePath(id: string): string
normalizePath(function dirname(path: string): string
Return the directory name of a path. Similar to the Unix dirname command.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: \nasoo.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)
```fileURLToPath(import.meta.ImportMeta.url: string
The absolute `file:` URL of the module.url)))
return const srcFolderPath: string
srcFolderPath + "/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: string
name: "devtools",
enforce: string
enforce: "pre",
apply: string
apply: "serve",
function resolveId(id: any): any
resolveId(id: any
id) {
if (id: any
id === 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: any
code, id: any
id, options: any
options) {
if (id: any
id.includes("vite/dist/client/client.mjs")) {
return { code: string
code: code: any
code + "\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:
- “my-package:devtools/entry.js” -> “/home/user/project/node_modules/my-package/devtools/entry.js”
- “my-package:devtools/imported-by-entry.js” -> “/home/user/project/node_modules/my-package/devtools/imported-by-entry.js
// @filename: plugin.js
const const srcFolderPath: any
srcFolderPath = normalizePath(dirname(fileURLToPath(import.meta.ImportMeta.url: string
The absolute `file:` URL of the module.url)))
const const devtoolsFolderPath: string
devtoolsFolderPath = const srcFolderPath: any
srcFolderPath + "/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: string
name: "devtools",
enforce: string
enforce: "pre",
apply: string
apply: "serve",
function resolveId(id: any): any
resolveId(id: any
id) {
if (id: any
id.startsWith(const MAGIC_MODULE_PREFIX: "my-package:devtools"
MAGIC_MODULE_PREFIX)) {
return id: any
id.replace(const MAGIC_MODULE_PREFIX: "my-package:devtools"
MAGIC_MODULE_PREFIX, const devtoolsFolderPath: string
devtoolsFolderPath);
}
},
function load(path: any): any
load(path: any
path) {
if (path: any
path.startsWith(const devtoolsFolderPath: string
devtoolsFolderPath)) {
let let cleanPath: any
cleanPath = id.split("?")[0] ?? ""; //remove query params
let cleanPath: any
cleanPath = cleanId.split("#")[0] ?? ""; //remove hash
if(fs.existsSync(let cleanPath: any
cleanPath)) {return fs.readFile(let cleanPath: any
cleanPath, "utf-8") }
else { var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)
The `console.warn()` function is an alias for
{@link
error
}
.warn(`Could not find file ${let cleanPath: any
cleanPath}`) }
}
},
function transform(code: any, id: any, options: any): {
code: string;
} | undefined
transform(code: any
code, id: any
id, options: any
options) {
if (id: any
id.includes("vite/dist/client/client.mjs")) {
return { code: string
code: code: any
code + "\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.