Skip to content

Codegen Plugins (Node)

Node plugins let you hook into Houdini’s codegen pipeline from JavaScript or TypeScript. They follow the same lifecycle as Go plugins but are written as plain Node.js scripts and distributed as normal npm packages.

Defining a Plugin

A Node plugin is a script that calls plugin() from houdini/node. Pass it a name, an order, and an object of hook handlers:

src/index.ts
import { plugin } from 'houdini/node'
plugin({
name: 'my-plugin',
order: 'after',
hooks: {
async validate(ctx, payload) {
// inspect documents and throw PluginError to surface problems
},
async generateRuntime(ctx, payload) {
// write project-wide output files
},
},
})

plugin() handles all the transport plumbing (registering with the orchestrator, receiving hook invocations, and sending results back) so the script itself stays minimal.

Plugin Order

  • 'before': runs before houdini-core
  • 'core': reserved for houdini-core and framework plugins
  • 'after': runs after houdini-core (the right choice for most plugins)

Entry Point

The script is the entry point. Wire it up in package.json’s bin field so the orchestrator can launch it:

package.json
{
"name": "my-plugin",
"bin": "dist/index.js"
}

Hook Handlers

Each hook is an async function that receives a PluginContext and a payload, and can return a result object:

type HookHandler = (
ctx: PluginContext,
payload: Record<string, any>
) => Promise<Record<string, any> | undefined> | Record<string, any> | undefined

The available hooks mirror the Go pipeline exactly:

HookWhen it fires
configPlugin is loading; return default config values
afterLoadAll plugins have loaded
schemaAdd custom directives or types to the project schema
extractDocumentsParse source files; receives { filepaths: string[] }
afterExtractAll documents extracted
beforeValidateTransform documents before validation
validateReport validation errors
afterValidateDocuments are valid; last chance to transform before codegen
beforeGenerateRuns just before artifact generation
generateDocumentsWrite per-document output files
generateRuntimeWrite project-wide runtime files
afterGenerateRuns after all generation is complete

Errors

Throw PluginError from any hook to surface a structured error to the user:

import { plugin, PluginError } from 'houdini/node'
plugin({
name: 'my-plugin',
order: 'after',
hooks: {
async validate(ctx, payload) {
throw new PluginError({
message: 'document must have a name',
kind: 'validation',
locations: [{ filepath: 'src/routes/+page.svelte', line: 12 }],
})
},
},
})

Plain Error throws are also caught and forwarded, but without file locations or a kind.

Plugin Context

Every hook receives a PluginContext as its first argument:

type PluginContext = {
taskId: string
pluginDirectory: string
db: Db
invokeHook(
hook: string,
payload?: Record<string, any>,
options?: { parallel?: boolean }
): Promise<Record<string, any>>
}
  • pluginDirectory: absolute path to the directory containing your plugin’s entry point. Use it to resolve bundled assets.
  • db: a connection to the shared SQLite database. Exposes get, all, and run for reading and writing pipeline data directly:
async validate(ctx, payload) {
const docs = ctx.db.all(`SELECT name, filepath FROM documents WHERE kind = 'query'`)
for (const doc of docs) {
if (!doc.name.endsWith('Query')) {
throw new PluginError({
message: `query name must end with 'Query'`,
kind: 'validation',
locations: [{ filepath: doc.filepath }],
})
}
}
},
  • invokeHook: call another pipeline hook from within your handler. Results are keyed by plugin name.

Optional Fields

The plugin config accepts a few additional fields for plugins that need to ship runtime code:

  • includeRuntime: a path (relative to your plugin entry point) to a directory that Houdini will copy into the project’s generated runtime
  • staticRuntime: a path (relative to your plugin entry point) to a directory whose contents are copied before codegen runs (see below)
  • configModule: a path to a JavaScript module that exports config values to merge into the project config
  • clientPlugins: an object of client-side plugins to inject into the user’s HoudiniClient
plugin({
name: 'my-plugin',
order: 'after',
includeRuntime: './runtime',
clientPlugins: {
'my-plugin/runtime/clientPlugin': null,
},
hooks: { /* ... */ },
})

Static Runtimes

staticRuntime points to a directory (relative to your plugin entry point) whose contents are copied into the project during the afterLoad phase, before document discovery and codegen run.

plugin({
name: 'my-plugin',
order: 'after',
staticRuntime: './static',
hooks: { /* ... */ },
})

Because the copy happens before document discovery, any .graphql files in that directory are treated as project documents. That makes staticRuntime the right place to ship GraphQL fragments or queries that users can reference directly in their own operations:

static/UserFields.graphql
fragment UserFields on User {
id
name
email
}

A user can then spread ...UserFields in their own queries without defining the fragment themselves. The plugin owns the definition; the project just consumes it.

The main distinction from includeRuntime is timing: includeRuntime is copied during the generateRuntime phase (after documents are already collected), so its .graphql files arrive too late to be discovered. Use staticRuntime whenever the content needs to participate in the document graph, and includeRuntime for TypeScript runtime code that doesn’t.

Vite Integration

If your plugin needs to add transforms to the user's Vite build, export a /vite sub-module from your npm package. Houdini picks it up automatically and includes it in the project's Vite config, with no manual wiring required on the user's end.

The sub-module just needs to export a default function that returns a Vite plugin:

src/vite.ts
import type { Plugin } from 'vite'
export default function myPlugin(): Plugin {
return {
name: 'my-plugin',
transform(code, id) {
// ...
},
}
}

Wire it up in package.json's exports field:

package.json
{
"exports": {
".": "./dist/index.js",
"./vite": "./dist/vite.js"
}
}

Any project that installs your plugin will get the Vite integration automatically.