Skip to content

Codegen Plugins (Golang)

Houdini’s codegen pipeline is built in Go, and you can extend it by writing your own Go plugin. A plugin is a standalone binary that registers itself with the pipeline and responds to lifecycle hooks: schema modifications, validation, document generation, and more.

Defining a Plugin

Every plugin follows the same pattern: a struct that embeds plugins.Plugin, implements Name() and Order(), and hooks into the pipeline by implementing one or more hook interfaces.

plugin/plugin.go
package plugin
import (
"code.houdinigraphql.com/plugins"
)
type MyPlugin struct {
plugins.Plugin[MyPluginConfig]
}
func (p *MyPlugin) Name() string {
return "my-plugin"
}
func (p *MyPlugin) Order() plugins.PluginOrder {
return plugins.PluginOrderAfter
}

plugins.Plugin[PluginConfig] is a generic base that wires up the database and filesystem. Embed it and you get p.DB and p.Fs for free. The type parameter is your plugin’s config shape (see Plugin Config below). If your plugin has no config, use struct{}.

Plugin Order

The Order() method controls when your plugin runs relative to others:

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

Entry Point

The binary’s main.go is minimal: create the plugin, set the filesystem, and hand it to plugins.Run:

main.go
package main
import (
"fmt"
"os"
"github.com/spf13/afero"
"your-module/plugin"
"code.houdinigraphql.com/plugins"
)
func main() {
p := &plugin.MyPlugin{}
p.SetFilesystem(afero.NewOsFs())
if err := plugins.Run(p); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

plugins.Run handles all the plumbing: parsing flags, connecting to the shared SQLite database, registering hooks, and keeping the process alive until the pipeline is done.

Hook Interfaces

Hooks are opt-in: implement only the interfaces for the lifecycle events you care about. The pipeline inspects your plugin at startup and only invokes the hooks you’ve registered.

InterfaceWhen it fires
SchemaAdd custom directives or types to the project schema
ExtractDocumentsParse source files and write documents to the database
AfterExtractAll documents extracted; good for cross-file checks
BeforeValidateTransform documents before validation runs
ValidateReport validation errors against the extracted documents
AfterValidateDocuments are valid; last chance to transform before codegen
BeforeGenerateRuns just before artifact generation begins
GenerateDocumentsWrite per-document output files
GenerateRuntimeWrite project-wide runtime files
AfterGenerateRuns after all generation is complete
AfterLoadFires once after all plugins have loaded
EnvironmentContribute environment variables to the Vite build
IndexFileAppend exports to the generated index.tsindex.js

Implementing a hook is just adding a method to your struct:

func (p *MyPlugin) Validate(ctx context.Context) error {
// query the database for documents and report any problems
return nil
}

The pipeline uses Go’s interface system to detect which hooks you’ve implemented, so no registration step is needed.

Distributing via npm

Go produces platform-native binaries, but npm packages need to work across operating systems and architectures. The standard approach (the same one Houdini uses for its own plugins) is to cross-compile once for every target platform and split the output into per-platform npm packages, then wire them together with a JavaScript shim.

Cross-Compiling

Go makes cross-compilation straightforward: set GOOS and GOARCH before running go build and the output targets that platform regardless of where you’re building:

Terminal window
GOOS=darwin GOARCH=arm64 go build -o bin/my-plugin
GOOS=linux GOARCH=amd64 go build -o bin/my-plugin
GOOS=windows GOARCH=amd64 go build -o bin/my-plugin.exe
# ... and so on for each target

Each binary goes into its own package (my-plugin-darwin-arm64, my-plugin-linux-x64, etc.) with a package.json that declares the matching os and cpu fields. The root package lists all of them as optionalDependencies, so package managers install only the one that matches the current machine.

The Shim

The root package’s bin field points to a small Node.js shim rather than a binary directly. The shim’s job is to find the right binary for the current platform and hand off execution to it via execFileSync. As a post-install optimization, the shim replaces itself with a hard link to the actual binary so subsequent invocations skip Node entirely.

The shim also supports a HOUDINI_PLATFORM environment variable, which lets callers force a specific platform. This is useful in CI environments where the host architecture doesn’t match the target.

Static Runtimes

The StaticRuntime interface lets a plugin copy a directory of files into the project during the afterLoad phase, before document discovery and codegen run.

func (p *MyPlugin) StaticRuntime(ctx context.Context) (string, error) {
return filepath.Join(p.PluginDirectory(ctx), "static"), nil
}

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 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.

If you need to rewrite file contents during the copy (to inject the project’s schema URL, for example), implement TransformStaticRuntime alongside it:

func (p *MyPlugin) TransformStaticRuntime(ctx context.Context, source string, content string) (string, error) {
return strings.ReplaceAll(content, "__SCHEMA_URL__", p.config.SchemaURL), nil
}

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.

Houdini’s Implementation

Rather than maintaining these files by hand, Houdini generates everything through a build script. The templates and tooling live at:

These files are written as templates (my-package and my-binary are replaced with the actual package name at build time), so you can adapt them directly for your own plugin.