Codegen Plugins

Codegen plugins allow you to add additional behavior to Houdini’s static processing. This includes integrating into the core code generation pipeline as well as transforming users’ source code (usually to take advantage of what was generated).

This is an advanced topic and requires a deep understanding of Houdini. Please make sure you’ve at least seen the example at the bottom of the Architecture Guide. If something isn’t clicking or if you have any questions, please join us on discord and ask!

For information on adding a plugin to your project, checkout the Config reference.

Careful!

The codegen plugin API (the hooks signatures, field names, artifact field names, etc) is still considered unstable. We reserve the ability to change its structure with any minor version update. We recognize this isn’t proper semantic versioning but it will ultimately lead us to a better place faster.

By building a plugin, you acknowledge this and accept the responsibility of not breaking your users’ projects.

Writing a Plugin

A codegen plugin is created using the plugin function exported from houdini. It takes a name and an asynchronous function that returns an object with a fixed set of keys defining the “hooks” that you want to use:

src/plugins/custom_plugin.js
import { plugin } from 'houdini'

const generateSomething = plugin('plugin_name', async () =>  {
    return {
        generate({ documents }) {
            // generate something for every document in the project
        }
    }
})

The list of available hooks can be in the next section, beginning with Plugin Setup.

Plugin Setup

Every pipeline begins with a common set of hooks that allow you to setup and configure the system. This can include loading environment variables or updating any configuration values.

order
config
env
afterLoad

order

  • Type: "before" | "after"
  • Default is "before"

Defines whether your plugin runs before or after “core” plugins like houdini-svelte.

config

  • Type: string
  • Value must point to a module that’s globally resolvable (3rd party package, alias, etc)
  • Module value must be a function that takes an old config and returns the updated one. You are free to update the provided value just make sure to return it.
  • Plugins might overwrite each other

Used to modify any values that the user passed to their config files. In order to ensure that this file is always safe to import on the client, we ask that you define your custom config in an export of your plugin. We’ll make sure to import and apply the function.

plugin_name/src/index.js
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        config: 'plugin_name/config',
    }
})

env

  • Type: ({ env: any; config: Config }) => Promise<Record<string, string>>
  • Plugins might overwrite each other

Adds environment variables to houdini’s pipeline (ie, for schema polling headers, url, etc.). Plugins are executed in the order they are defined so they can overwrite each other.

plugin_name/src/index.js
import { loadEnv } from 'vite'
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // load .env files using vite's rules
        env() {
            return loadEnv('dev', '.', '')
        }
    }
})

afterLoad

  • Type: (config: Config) => Promise<void> | void

Invoked after all plugins have loaded and modified config values. This is the hook to use if you want to perform logic based on config values.

plugin_name/src/index.js
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        afterLoad({ config }) {
            // make sure we were given a valid value
            if (!isValid(config)) {
                throw new Error("invalid config!")
            }
        }
    }
})

Extract and Parse

These hooks are responsibile for generating a list of filepaths and finding the GraphQL documents inside.

extensions
include
exclude
extractDocuments
Parse
Documents
schema

extensions

  • Type: string[]
  • Plugin values are concattenated together

Add extensions to the list that houdini uses to find valid source files.

plugin_name/src/index.js
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        extensions: ['tsx', 'jsx']
    }
})

include

  • Type: ({ config: Config, filepath: string }) => boolean | null | undefined
  • If any plugin includes the value, the filepath is excluded

A filter for whether a file should be included in processing. This hook is useful if you generate imports that do not match your users configure include value. Return true to include the file. This is commonly used in tandem with the vite hook.

plugin_name/src/index.js
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // include any paths that start with `@` in the transforms
        include({ filepath }) {
            if (filepath.startsWith('@')) {
                return true
            }
        }
    }
})

exclude

  • Type: ({ config: Config, filepath: string }) => boolean | null | undefined
  • If any plugin excludes the value, the filepath is excluded

A filter for whether a file should be included in processing. This hook is useful if you generate imports that do not match your users configure include value. Return false to include the file.

plugin_name/src/index.js
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // exclude any server.graphql files
        exclude({ filepath }) {
            return filepath.endsWith('server.graphql')
        }
    }
})

extractDocuments

  • Type: ({ config: Config, filepath: string, content: string }) => string[] | null
  • Can return a Promise as well
  • Plugin values are concattenated together

Teaches the pipeline how to extract graphql documents out of your application source code given its filepath and the file contents. You can return null or an empty list to indicate that you didn’t find any graphql documents. This step is responsible for actually parsing your source code and extracting the string values of the graphql function.

plugin_name/src/index.ts
import { parseJS, find_graphql, plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // exclude any server.graphql files
        async extractDocuments({
            config,
            content,
            filepath
        }) {
            // only add documents for svelte files
            if (!filepath.endsWith(".svelte")) {
                return []
            }

            // parse the svelte files
            const documents = []
            let parsedFile = await parseSvelte(content)
            if (!parsedFile) {
                return documents
            }

            // look for graphql documents using houdini's utility
            await find_graphql(config, parsedFile.script, {
                tag({ tagContent }) {
                    documents.push(tagContent)
                },
            })

            // we found every document in the file
            return documents
        }
    }
})

schema

  • Type: ({ config: Config }) => string

Can be used to add custom definitions to your project’s schema. Definitions added here are automatically removed from the document before they are sent to the server. It is sometimes useful to add things that can be used in connection with artifactData to embed data in the artifact.

Currently, this hook can only add directives, scalars, or enums to the schema.

plugin_name/src/index.ts
export default plugin('plugin_name', async () => {
    return {
        // add the @special directive
        schema() {
            return `
                directive @special on QUERY
            `
        }
    }
})

Validate and Transform

These hooks are responsible for transforming and validating the documents in your application. If you want to add new definitions, you can add new documents to provided list.

beforeValidate
validate
afterValidate

beforeValidate

  • Type: ({ config: Config documents: Document[] }) => void
  • Can also return a Promise

Processes documents before they has been validated. This can be useful if you are adding a layer that translates into a Houdini feature.

validate

  • Type: ({ config: Config documents: Document[] }) => void
  • Can also return a Promise

Performs any validation checks before the rest of the pipeline continue. Remember to verify as many documents as possible before erroring. For example, the uniqueNames validator makes sure that every document has a unique name so that the preprocessor can reliably import the correct artifact.

afterValidate

  • Type: ({ config: Config documents: Document[] }) => void
  • Can also return a Promise

Transforms the project’s documents after they have been validated. This is useful if you are building a feature that needs to add extra selections or documents to the list.

Generate Runtime

This pipeline is responsible for generating all of the files for your application. This includes hooks to modify the query artifact (a static respresentation of the document), hooks to customize the core runtime provided by houdini, as well as hooks to generate your own files and runtimes for your plugin.

For every document
hash
artifactData
artifactEnd
beforeGenerate
generate
includeRuntime
transformRuntime
indexFile
graphqlTagReturn
clientPlugins

beforeGenerate

  • Type: ({ config: Config documents: Document[] }) => void
  • Can also return a Promise

Transforms the documents just before static assets are generated.

artifactData

  • Type: ({ config: Config, doc: Document }) => Record<string, any> | void

Embeds metadata at the root of the artifact. You should use this to encode document-level data at build time so you don’t have to analyze the document at runtime (in a client plugin, for example).

plugin_name/src/index.ts
import * as graphql from 'graphql'
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // emebed data in the artifact if we detect the @live
        artifactData({ config, document }) {
            let live = false

            // look for the live directive
            graphql.visit(document.document, {
                Directive(node) {
                    if (node.name.value === 'live) {
                        live = true
                    }
                },
            })

            return {
                live
            }
        }
    }
})

If you don’t return anything from the hook, an empty object will be added in the pluginData field corresponding to your plugin.

hash

  • Type: ({ config: Config, document: Document }) => string
  • The first hook provided is used.

Customizes the hash generated for a given document

plugin_name/src/index.ts
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // use a custom hash function
        hash({ document }) {
            return hashDocumentName(document.name)
        }
    }
})

artifactEnd

  • Type ({ config: Config, document: Document }) => void
  • Plugins can overwrite values

Modifies the generated artifact before its written to disk. This is useful to set artifact data based on information derived from the artifact’s final state.

plugin_name/src/index.ts
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        // alert if query complexity is too high
        artifactEnd({ document }) {
            if (computeComplexity(document.originalParsed) > 0.8) {
                console.warn("High complexity query!", document.name)
            }
        }
    }
})

generate

  • Type: (args: GenerateArgs) => void
  • Can also return a Promise
type GenerateArgs = {
	config: Config
	documents: Document[]
	pluginRoot: string
}

Generates project files for each document in the application. Each plugin is assigned a root directory that you are free to place any files you want. If you want values that you generated to be included in the values exported from $houdini, you will also need to use the indexFile hook.

plugin_name/src/index.ts
import { plugin } from 'houdini'
import fs from 'fs/promises'

export default plugin('plugin_name', async () => {
    return {
        // write a file with the name of every document
        async generate({ pluginRoot, documents }) {
            const fileName = path.join(pluginRoot, 'queryname.txt')
            const contents = documents.map(doc => doc.name).join('\n')
            await fs.writeFile(fileName, contents, 'utf-8')
        }
    }
})

includeRuntime

  • Type: string | { commonjs: string, esm: string }

Instructs the runtime to copy the specified runtime to your plugin’s root directory. This is useful if you have a bunch of static values, utilities, that you want to use in transformFile. If you specify this value, you do not need to add an export in indexFile to export your runtime - an export * from ... is automatically added for the generated directory.

This value should be set to a relative path from your plugin’s root directory to the directory that should be copied. If you have different versions of your runtime for CommonJS and ESModules, you can set this to an object specifying both paths.

plugin_name/src/index.ts
import { plugin } from 'houdini'

export default plugin('plugin_name', async () => {
    return {
        includeRuntime: {
            commonjs: "../runtime-commonjs",
            esm: "../runtime-esm",
        }
    }
})

transformRuntime

  • Type: Record<string, ({ config: Config; content: string }) => string> or (docs: Document[]) => Record<string, ({ config: Config; content: string }) => string>

Transforms the plugin’s runtime while houdini is copying it. The keys of the object are paths in your runtime (relative to the includeRuntime setting). The values are functions that set the new file value.

This hook only has an effect if you have passed a value for includeRuntime.

plugin_name/src/index.ts
import { plugin } from 'houdini'

const plugin_variable = "1234"

export default plugin('plugin_name', async () => {
    return {
        // this must be set
        includeRuntime: "../runtime",

        // replace value in {pluginRoot}/lib/constants.js
        transformRuntime: {
            ['lib/constants.js']: ({ content }) {
                return content.replace("THIS", plugin_variable)
            }
        }
    }
})

If you need to transform files based on the documents in your application, you can also pass a function that returns an object. This function will be called with the list of documents in your application.

plugin_name/src/index.ts
import { plugin } from 'houdini'

const plugin_variable = "1234"

export default plugin('plugin_name', async () => {
    return {
        // this must be set
        includeRuntime: "../runtime",

        // replace value in {pluginRoot}/lib/constants.js
        transformRuntime: (docs) => {
            ['lib/constants.js']: ({ content }) {
                return content.replace("LENGTH", docs.length)
            }
        }
    }
})

indexFile

  • Type: (args: IndexFileArgs) => string
type IndexFileArgs = {
	config: Config
	content: string
	exportDefaultAs(args: { module: string; as: string }): string
	exportStarFrom(args: { module: string }): string
	pluginRoot: string
	typedef: boolean
	documents: Document[]
}

Modifies the root index.js and index.d.ts files in the generated runtime. If you want to add any exports, make sure to use the exportDefaultAs and exportStarFrom utilities which will make sure your type definitions are up to date.

plugin_name/src/index.ts
import { plugin } from 'houdini'

const plugin_variable = "1234"

export default plugin('plugin_name', async () => {
    return {
        // export everything in the stores directory
        // this one function handles .js and .d.ts
        indexFile({ content, exportStarFrom, pluginRoot }) {
            return content + exportStarFrom({
                module: path.join('.', pluginRoot, 'stores')
            })
        },
    }
})

graphqlTagReturn

  • Type: ({ config, document, ensure_import }) => string | undefined
  • The first value provided by a plugin is used

Customizes the return type of the graphql function. If you need to add an import to the file in order to resolve the import, you can use the ensureImport utility. Here is an example from houdini-svelte which maps the result of graphql to the store that was generated for the document:

custom_plugin.js
export default {
    graphqlTagReturn({ ensureImport, document }) {
        const { artifact, name } = document

        // if we're supposed to generate a store then add
        // an overloaded declaration
        if (artifact.pluginData['houdini-svelte'].generate) {
            // use the name of the store as the return value
            const store = store_name({ name })

            // make sure we are importing the store
            // this won't add an import if its already been imported
            ensureImport({
                // identifier specifies the local variable created
                // by the import
                identifier: store,
                // the module you are importing from
                module: store_import_path({
                    name,
                }),
            })

            // and use the store as the return value
            return store
        }

        // if we got this far, we dont want to add
        // an overloaded return type
    }
}

clientPlugins

  • Type: Record<string, any>

Adds plugins to the application’s default list of plugins to add to HoudiniClient. This can be useful for client plugins that want to add a generated portion. For example, a plugin for something like Live Queries could check for the @live directive at build time (using something like the artifactData hook) and then check for the persisted value in a client plugin.

plugin('houdini-plugin-custom', async () => {
	return {
		clientPlugins: {
			'houdini-plugin-custom/client': null
		}
	}
})

The key of this object should be a globally resolvable module (third-party package, aliased local path, etc). The generated runtime assumes that the default export of this module is a function that returns a client plugin. The value of the clientPlugins hook object is passed to that function.

The above codeblock is equivalent to:

src/client.ts
import { HoudiniClient } from '$houdini'
import customPlugin from 'houdini-plugin-custom/client'

export default new HoudiniClient({
    plugins: [
        customPlugin(null)
    ]
})

Transform Source

transformFile

  • Type: (page: TransformPage) => { code: string }
  • Can also return a Promise
type TransformInput = {
	config: Config
	content: string
	filepath: string
	/* Adds the filepath to the dev server's watch list */
	watch_file: (path: string) => void
}

Transforms the user’s source code from an ergonomic API to the the actual implementation under the hood. For more information, please review the example at the bottom of the Architecture Guide.

Miscellaneous hooks

vite

  • Type: same { resolveId, load } from vite except with the config file passed to the appropriate object.

Allows a plugin to configure the vite plugin exported from houdini/vite. This hook is useful if you generate files that are imported or need to hook into vite for any reason.