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.
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:
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
- 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.
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.
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.
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
- Type:
string[]
- Plugin values are concattenated together
Add extensions to the list that houdini uses to find valid source files.
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.
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.
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.
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.
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
- 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.
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).
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
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.
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.
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.
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.
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.
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.
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:
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:
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.