vite-mastery

3.5 · difficulty 3/4 · 12 min read

Plugin Communication

How can multiple plugins share state? What is `this.meta`? What are the trade-offs between Vite's closure-sharing and virtual module bridge approaches?

Vite 8.1Stable

Why plugins need to communicate

A single plugin handling a single responsibility is the ideal case, but real projects sometimes require multiple plugins to collaborate:

  • Plugin A scans all route files and generates a route table
  • Plugin B injects the route table into the <head> of the HTML

Plugin B needs the data generated by Plugin A — that's plugin communication.

Another common scenario: one plugin handles development-time features while another handles build-time optimizations, and both need to share configuration or intermediate data.

Option 1: closure sharing (simplest)

The most direct approach is to wrap both plugins inside the same factory function, sharing a single closure variable:

ts
import type { Plugin } from "vite"

export function routeSystemPlugins(): Plugin[] {
  // Shared state: declared in the closure
  const routes: { path: string; component: string }[] = []

  return [
    // Plugin A: scan routes
    {
      name: "vite-plugin-route-scanner",
      enforce: "pre",
      buildStart() {
        // Scan and populate routes
        routes.push({ path: "/", component: "src/pages/index.tsx" })
        routes.push({ path: "/about", component: "src/pages/about.tsx" })
      },
    },

    // Plugin B: inject the route table
    {
      name: "vite-plugin-route-injector",
      // routes from the closure is visible to B
      resolveId(id) {
        if (id === "virtual:routes") return "\0virtual:routes"
        return null
      },
      load(id) {
        if (id !== "\0virtual:routes") return null
        return `export default ${JSON.stringify(routes)}`
      },
    },
  ]
}

Configure them together:

ts
import { defineConfig } from "vite"
import { routeSystemPlugins } from "./plugins/route-system"

export default defineConfig({
  plugins: [...routeSystemPlugins()],
})

Pros: Simple and direct, zero overhead. Cons: Both plugins must be published together and cannot be reused independently.

Option 2: passing data through a virtual module

If the two plugins come from different npm packages, you can use a virtual module as a data bridge:

ts
import type { Plugin } from "vite"

const BRIDGE_ID = "virtual:plugin-a-data"
let sharedData: Record<string, unknown> = {}

// Expose a function for Plugin B to call
export function setSharedData(data: Record<string, unknown>) {
  sharedData = data
}

export function pluginA(): Plugin {
  return {
    name: "plugin-a",
    buildStart() {
      sharedData = { timestamp: Date.now(), items: ["a", "b", "c"] }
    },
    resolveId(id) {
      if (id === BRIDGE_ID) return "\0" + BRIDGE_ID
      return null
    },
    load(id) {
      if (id !== "\0" + BRIDGE_ID) return null
      return `export default ${JSON.stringify(sharedData)}`
    },
  }
}
ts
import type { Plugin } from "vite"

export function pluginB(): Plugin {
  return {
    name: "plugin-b",
    // Plugin B retrieves data via import "virtual:plugin-a-data"
    transform(code, id) {
      if (!code.includes("virtual:plugin-a-data")) return null
      // Let Vite continue processing this import
      return null
    },
  }
}

Option 3: this.meta — Rollup plugin metadata

Rollup (and Rolldown) provides the this.meta mechanism, which lets a plugin write and read metadata at the module level:

ts
import type { Plugin } from "vite"

// Plugin A: attach metadata to a module
const pluginAMeta = {
  name: "plugin-a",
  buildStart() {
    // this.meta cannot be used for cross-plugin communication
    // this.meta is only the plugin's own metadata space
  },
  transform(code, id) {
    // Attach metadata to this module
    // Note: this.meta is private to this plugin for this module
    this.meta.processedAt = Date.now()
    return null
  },
}

Option 4: this.getModuleInfo() — reading other modules' information

In output-phase hooks like generateBundle, you can read detailed information about any module using this.getModuleInfo(id):

ts
import type { Plugin } from "vite"

export function analyzePlugin(): Plugin {
  return {
    name: "vite-plugin-analyze",
    apply: "build",
    generateBundle(_, bundle) {
      for (const [, chunk] of Object.entries(bundle)) {
        if (chunk.type !== "chunk") continue

        // Get detailed information about each module in the chunk
        for (const moduleId of Object.keys(chunk.modules)) {
          const info = this.getModuleInfo(moduleId)
          if (!info) continue

          console.log({
            id: info.id,
            importers: info.importers,
            isEntry: info.isEntry,
            hasDefaultExport: info.hasDefaultExport,
            // code contains the module's original source (null if tree-shaken)
          })
        }
      }
    },
  }
}

Practical pattern: config-sharing plugin suite

When a plugin suite needs multiple sub-plugins to share configuration:

ts
import type { Plugin } from "vite"

interface SuiteOptions {
  prefix: string
  debug: boolean
}

function createSharedContext(options: SuiteOptions) {
  // All sub-plugins share this context object
  return {
    prefix: options.prefix,
    debug: options.debug,
    cache: new Map<string, string>(),
  }
}

export function pluginSuite(options: SuiteOptions): Plugin[] {
  const ctx = createSharedContext(options)

  return [transformPlugin(ctx), injectPlugin(ctx), reportPlugin(ctx)]
}

function transformPlugin(ctx: ReturnType<typeof createSharedContext>): Plugin {
  return {
    name: "suite:transform",
    transform(code, id) {
      const result = doTransform(code, ctx.prefix)
      ctx.cache.set(id, result) // write to shared cache
      return { code: result, map: null }
    },
  }
}

function injectPlugin(ctx: ReturnType<typeof createSharedContext>): Plugin {
  return {
    name: "suite:inject",
    transform(code, id) {
      const cached = ctx.cache.get(id) // read from shared cache
      if (!cached) return null
      return injectCode(cached)
    },
  }
}

function doTransform(code: string, prefix: string) {
  return code
}
function injectCode(code: string) {
  return null
}

function reportPlugin(ctx: ReturnType<typeof createSharedContext>): Plugin {
  return {
    name: "suite:report",
    buildEnd() {
      if (ctx.debug) {
        console.log(`Processed ${ctx.cache.size} modules`)
      }
    },
  }
}

Self-check

  1. Can this.meta be used to share data between different plugins? Why or why not?
  2. When is closure sharing the right choice, and when is the virtual module bridge the better option?
  3. If Plugin A collects data in buildStart and Plugin B uses that data in transform, is the execution order guaranteed?
  4. In which hooks can this.getModuleInfo(id) be used? Why is it not well-suited for use in transform?
ts
// Implement two cooperating plugins:
// Plugin 1: scan all .md files and generate a file list
// Plugin 2: expose that list via the virtual module "virtual:md-files"
//
// Requirement: both plugins live in the same factory function and share data via closure

import type { Plugin } from "vite"
import { glob } from "node:fs/promises"
import { resolve } from "node:path"

export function mdListPlugins(): Plugin[] {
  // TODO: declare shared state
  const mdFiles: string[] = []

  return [
    // Plugin 1: scan .md files
    {
      name: "md-scanner",
      async buildStart() {
        // TODO: use glob to scan content/**/*.md
      },
    },

    // Plugin 2: expose the virtual module
    {
      name: "md-virtual",
      resolveId(id) {
        // TODO
      },
      load(id) {
        // TODO: return export default [...mdFiles]
      },
    },
  ]
}