Auto-discovery
A trigger only matters once createTrigger(...) has run — that’s the call that registers it with the runtime. The convention in Triggery is one trigger per file, suffixed .trigger.ts. As soon as you have more than a couple of them, you stop wanting to maintain an import './triggers/foo.trigger' ladder by hand. @triggery/vite makes that import list disappear.
What the plugin does
Section titled “What the plugin does”@triggery/vite adds one virtual module to your Vite build: virtual:triggery-registry. At build / dev-server time the plugin globs every *.trigger.{ts,tsx,js,jsx} matching your configured pattern and generates a module that bare-imports each one:
You import the virtual module once at the entry point of your app. That side-effect import runs every trigger file’s top-level createTrigger(...), which registers the trigger with the default runtime.
Install
Section titled “Install”pnpm add -D @triggery/vite npm install --save-dev @triggery/vite yarn add --save-dev @triggery/vite bun add -D @triggery/vite Then wire it into vite.config.ts:
And import the virtual module exactly once, as early as possible:
That’s it. Every *.trigger.ts file is now live the moment your app boots — no more per-feature import list.
TypeScript: ambient declaration for the virtual module
Section titled “TypeScript: ambient declaration for the virtual module”Add this once so TypeScript stops complaining about the import:
A more complete declaration is unnecessary — the module exports nothing.
Plugin options
Section titled “Plugin options”The plugin takes a small options object. Today it has one field; more arrive in V1.1 as the table below indicates.
| Option | Default | Description |
|---|---|---|
glob | 'src/**/*.trigger.{ts,tsx,js,jsx}' | One pattern or an array. Anything tinyglobby accepts. Patterns are resolved relative to the Vite project root. |
Multiple globs
Section titled “Multiple globs”A monorepo often wants to discover triggers across packages, not just src/:
Patterns are matched in order; negation patterns (!**/...) exclude. The plugin sorts the final file list deterministically so the generated module is stable across machines — friendly to long-term Vite caches.
HMR — what happens when a trigger file changes
Section titled “HMR — what happens when a trigger file changes”The plugin distinguishes between two kinds of edit:
| Edit | What happens |
|---|---|
You edit an existing *.trigger.ts file | Vite re-runs the file. Its createTrigger(...) call runs again with the same id. The runtime treats this as last-mount-wins: it replaces the previous registration, aborts any in-flight handler run via signal.aborted = true, and indexes the new trigger. |
You add, rename or delete a *.trigger.ts file | The plugin invalidates virtual:triggery-registry so its import list rebuilds, then Vite re-runs the virtual module. The new file’s top-level createTrigger(...) runs; deleted files unregister automatically when the old module’s HMR cleanup runs. |
The result: edit a handler, the page doesn’t reload, the next event uses the new code, and the inspector keeps its history — snapshots are stored on the runtime, not on the trigger object. You can watch a bug in flight, edit the handler, fire again, and compare the two runs in the same panel.
Without the plugin: import.meta.glob
Section titled “Without the plugin: import.meta.glob”If you’re not on Vite, or you want a no-plugin escape, Vite ships an equivalent feature out of the box. import.meta.glob(pattern, { eager: true }) evaluates every match at boot time:
This is exactly what the plugin does, with two differences:
- You have to repeat the glob in every entry file that needs the registrations.
- HMR works as Vite’s normal module-graph HMR (no virtual module to invalidate). For a single entry point this is fine; for a monorepo it gets verbose.
For non-Vite tools, the equivalent at build time is:
- Webpack —
require.context('./triggers', true, /\.trigger\.ts$/). - Rspack — same as Webpack.
- esbuild / tsup standalone — generate the import list with a small build script. We ship one as
@triggery/cli generate-registry(see the@triggery/clipage).
Why this matters for monorepos and lazy routes
Section titled “Why this matters for monorepos and lazy routes”Two specific shapes benefit the most.
Monorepos
Section titled “Monorepos”In a workspace with packages/chat, packages/billing, packages/notifications, each package owns triggers about its own scenarios. Auto-discovery means a consuming app imports the registry once and gets all three packages’ triggers wired without knowing the file layout. New trigger in packages/chat? It shows up at boot, no consumer-side change needed.
Combine with a glob that walks workspace packages:
Lazy routes
Section titled “Lazy routes”A common reflex is to lazy-load triggers next to the route they belong to: “Show this toast only when the /chat route is loaded.” Don’t.
Triggers are passive — registering one costs nothing until an event fires. The whole registry is a few kilobytes of metadata after tree-shaking. Register all of them up-front, then let required and scope decide which ones run at any moment. This is what auto-discovery is designed for; lazy-loaded triggers are an anti-pattern that re-introduces the “who owns this rule” question we’re trying to remove.
The exception: very large chunks of code inside the handler itself (e.g. a PDF generator). In that case, leave the trigger registered eagerly and await import('./pdf-generator') inside the handler.
Verifying the wiring
Section titled “Verifying the wiring”A four-line sanity check at the entry point catches the most common foot-gun (“I imported the virtual module too late, the triggers register after their producers”):
runtime.graph() returns a JSON-friendly snapshot of the registry (id, scope, events, required, schedule, concurrency, enabled). Empty list? Either no *.trigger.ts files were matched, or the import order is wrong.