Plugins

villus is very flexible and versatile, and as such you will need to write quite a few plugins of your own for various purposes, whether to add special headers, transform the body to a specific format or encoding, or even change the fetch function used.

Something you might not be aware of is that villus is pre-configured with a couple of plugins that are necessary to execute queries, the default plugins are:

  • fetch: used to execute queries on the network (actual fetching)
  • cache: an in-memory simple cache that comes with villus by default, and supports all cache policies
  • dedup: removes any duplicate pending queries

Furthermore, villus exposes the default plugins as defaultPlugins function. To add plugins to villus client you need to pass a use array containing the plugins you would like to have

vue<script setup>
import { useClient, defaultPlugins } from 'villus';

useClient({
  url: '/graphql',
  use: [...defaultPlugins()], // if not provided `defaultPlugins` will be used
});
</script>

In addition to the default plugins, villus also offers the following plugins but they are not enabled by default:

  • batch: used instead of fetch to execute queries in batches on the network
  • multipart: Adds File upload support
  • handleSubscriptions: Adds GraphQL subscriptions support

Plugins under the hood

Under the hood, plugins are simple callbacks that run through various life-cycles of the operation execution, the main features of villus plugins compared to other libraries are:

  • All plugins can be synchronous or asynchronous
  • They will be executed in the same order they are defined in
  • Each plugin can set anything about the current operations fetch options, like url or body or headers (ex: adding auth token to headers)
  • Each plugin can choose to set the operation result at any time without stopping other plugins (ex: cache plugins)
  • Each plugin can choose to end the operation with a specific result while skipping other plugins by setting the terminate signal (ex: fetch and batch plugins)
  • Each plugin can execute a callback that’s synchronous or asynchronous after the query is executed (ex: cache plugin)

A villus plugin has a type called ClientPlugin and it looks like this in TypeScript:

typescripttype OperationType = 'query' | 'mutation' | 'subscription';

type AfterQueryCallback = (result: OperationResult) => void | Promise<void>;

interface FetchOptions extends RequestInit {
  url?: string;
}

interface AfterQueryContext {
  response?: ParsedResponse<unknown>; // The fetch operation response except it contains a parsed `body` property
}

interface ClientPluginContext {
  useResult: (result: OperationResult<unknown>, terminate?: boolean) => void; // used to signal that the plugin found a result for the operation
  afterQuery: (cb: AfterQueryCallback, ctx: AfterQueryContext) => void; // Registers a callback to do something after the query is finished and the pipeline is done
  operation: {
    query: DocumentNode | string; // The query/mutation to be executed
    variables: Record<string, any>; // The query variables
    cachePolicy: CachePolicy; // The cache policy for this operation
    key: number; // a unique key to identify this operation (useful for cache)
    type: OperationType; // the operation type: `query` or `mutation` or `subscription`
  };
  opContext: FetchOptions; // The current operation context, contains stuff like `headers`, `body` and `url` and other fetch options
  response?: ParsedResponse<unknown>; // The fetch operation response except it contains a parsed `body` property
}

type ClientPlugin = ({ useResult, operation }: ClientPluginContext) => void | Promise<void>;

The following sections will explain the purpose of each item in the context

useResult()

The useResult function allows your plugin to resolve a value for the GraphQL operation. Plugins like fetch, batch, and cache make use of this as each of them are responsible for setting a response value for the GraphQL operation.

Non-terminating Results

There are two types of useResult calls, the first being a non-terminating resolution, meaning that while your plugin found a value, it still wants other plugins to continue executing:

jsuseResult(response); // Other plugins will still execute

This is useful for the cache plugin with the cache-and-network policy as it may decide to resolve an operation earlier (if found in cache) and leave the pipeline of plugins unaffected because it still needs to make a network request to fetch the fresh result.

Terminating Results

The other type is a terminating resolution, meaning your plugin has decided to take over the pipeline and stop executing all others. This is useful with the cache plugin because with the cache-first policy, it doesn’t want any requests to go through.

jsuseResult(response, true); // Stops all plugins after it

tip

Calling useResult multiple times in the pipeline (by multiple plugins) won’t have an effect on the end result as the very first useResult call will set the operation response, and any subsequent calls are ignored.

afterQuery()

The afterQuery function allows you to run a callback after the query is finished, the callback receives the GraphQL response as the first argument.

For example, the cache plugin makes use of this to cache the operation response, here is a snippet of what happens in the cache plugin:

jsfunction cachePlugin({ afterQuery, useResult, operation }) {
  // ...
  afterQuery(result => {
    // Set the cache result after query is resolved
    setCacheResult(operation, result);
  });
  // ...
}

Additionally, you can have access to the actual response returned by the fetch API, the second argument is an object that contains the response property:

jsfunction somePlugin({ afterQuery, useResult, operation }) {
  // ...
  afterQuery((result, { response }) => {
    // do something with the response
    console.log(response);
  });
  // ...
}

operation

The operation field contains useful information about the GraphQL operation being executed.

FieldTypeDescription
querystring | DocumentNodeThe query/mutation being executed
variablesRecord<string, any>The query variables passed with the operation
cachePolicy'cache-first' | 'network-only' | 'cache-and-network' | 'cache-only'The cache policy for this operation, which is useful if you are building a custom cache plugin
keynumberA unique identifier to use for this operation, useful for cache and dedup plugins
type'query' | 'mutation' | 'subscription'The operation type

opContext

The opContext field is the fetch options that will be passed to the fetch or batch plugins or your custom plugin that makes the actual request, it has the same shape as RequestInit interface. This is particularly useful if you are building a fetch plugin or some kind of authentication plugin with headers or cookies.

Here are a few useful snippets:

jsfunction myPlugin({ opContext, operation }) {
  // Add auth headers
  opContext.headers.Authorization = 'Bearer <token>';

  // Encode additional information in the body
  opContext.body = JSON.stringify({ query: operation.query, variables: operation.variables, mutationKey: 39933 });

  // Change the URL dynamically
  opContext.url = '/other/graphql';
}

tip

All plugins are processed in the same order they were added in, and as you can imagine the order of these plugins is critical as some plugins may override options for others and some may resolve the value too early. So keep the order in mind when defining the plugins

Plugin Configuration

You might want to create a configurable plugin to publish or re-use in various ways. villus doesn’t offer any API for that but good old higher-order functions can be used to achieve that:

jsfunction myPluginWithConfig({ prefix }) {
  return ({ opContext, operation }) => {
    // Add auth headers with configurable prefix
    opContext.headers.Authorization = `${prefix} <token>`;
  };
}

TypeScript Support

While villus exports the ClientPlugin type, you can use the definePlugin helper to get automatic types for your plugins:

typescriptimport { definePlugin } from 'villus';

// opContext will be automatically typed
const myPlugin = definePlugin(({ opContext }) => {
  opContext.headers.Authorization = 'Bearer <token>';
});

const myPluginWithConfig = (config: { prefix: string }) => {
  // opContext will be automatically typed
  return definePlugin(({ opContext }) => {
    // Add auth headers with configurable prefix
    opContext.headers.Authorization = `${config.prefix} <token>`;
  });
};

Example - Adding Authorization Headers

You likely have an authentication header you would like to add to your queries to be able to execute protected queries/mutations. A very common header is Authorization header which contains an auth token. Here is a snippet that shows how to add such headers to your queries:

jsfunction authPlugin({ opContext }) {
  opContext.headers.Authorization = 'Bearer <token>';
}

// later in your setup
import { useClient, defaultPlugins } from 'villus';

useClient({
  url: '/graphql',
  use: [authPlugin, ...defaultPlugins()], // add the auth plugin alongside the default plugins
});

And that’s it,

Example - Persistent Cache

You might want to create a custom cache especially since the villus default cache plugin does not persist when the page is reloaded or when the client is destroyed, this is because villus default cache is a simple object in memory that keeps track of queries during runtime and each time the page is reloaded or when the client is initialized, it will start with a new object each time. This is convenient for most cases but you might want to leverage a more permanent cache solution.

In our example we will use localStorage as our storage to cache queries, you are free to use anything else as storage like indexedDB which should offer more powerful capabilities and flexibility.

Here is an example of such a cache:

jsfunction localStorageCache({ afterQuery, useResult, operation }) {
  // avoid caching mutations or subscriptions, also avoid caching queries with `network-only` policy
  if (operation.type !== 'query' || operation.cachePolicy === 'network-only') {
    return;
  }

  // Set the cache result after query is resolved
  // Using the `operation.key` is very handy here, it is a unique value that identifies this operation
  // The key is calculated from the query itself and it's variables
  afterQuery(result => {
    localStorage.setItem(operation.key, result);
  });

  // Get cached item
  const cachedResult = localStorage.getItem(operation.key);

  // if exists in cache, terminate with result
  if (cachedResult) {
    // The first argument of `useResult` is the final value of the operation
    // The second argument is optional, it allows the plugin to terminate the operation
    // and stop all other plugins from executing, the last plugin must terminate with `true`
    return useResult(cachedResult, true);
  }
}

// later in your setup
import { useClient, fetch } from 'villus';

useClient({
  url: '/graphql',
  use: [localStorageCache, fetch()], // add the local storage plugin along with the fetch plugin
});

In the previous sample, our cache plugin does not handle cache-and-network policy because it terminates the operation once it finds a cached value in local storage. To simply support it you could check if the cache policy is cache-first which doesn’t require any cache invalidations/update after resolving the value. So you need to terminate the operation conditionally if the policy is cache-first or not.

js// ...
// Terminate operation only if the cache policy is cache-first
return useResult(cachedResult, operation.cachePolicy === 'cache-first');

For reference you may look at the implementation of the cache plugin

Example - Response Headers

You might want to do something after response headers, for example refreshing a user’s token after each response to keep them signed in. You can do so by using the plugin context’s response property which is set after either fetch or batch plugins are done executing.

To make sure you access the response, you need to do so in the afterQuery callback:

vue<script setup>
let token = `TOKEN`;

function authPluginWithRefresh({ opContext, afterQuery }) {
  opContext.headers.Authorization = `Bearer $<token>`;

  afterQuery((result, { response }) => {
    // if no response, then the fetch plugin failed with a fatal error
    if (!response) {
      return;
    }

    // Update the access token
    token = response.headers['access-token'];
  });
}

// later in your setup
import { useClient, defaultPlugins } from 'villus';

useClient({
  url: '/graphql',
  use: [authPluginWithRefresh, ...defaultPlugins()], // add the auth plugin alongside the default plugins
});
</script>

danger

It is important that you don’t use ES6 destructing if you plan to use the response property as it will be set after the query is executed, destructing it at the function level will always yield undefined.

Example - Global Error Handler

If you are using an error reporting or bug tracking service like Sentry, it can become tedious to handle each query and mutation errors all over your app.

It would be useful to handle most of the errors in a global handler while leaving the specific errors (e.g: validation) to the component that used that query/mutation.

In this example, a global error handler is created where it reports all 500 (and unknown) errors to Sentry.

tsimport { captureException } from '@sentry/browser';

/**
 * Reports unknown errors to Sentry to avoid having to do that everywhere.
 */
const sentryReportPlugin = definePlugin(({ operation, afterQuery }) => {
  afterQuery(({ error }, { response }) => {
    // collect some information about the query that failed
    const operationContext = {
      query: operation.query,
      type: operation.type,
      variables: operation.variables,
    };

    if ((response?.status || 200) >= 500) {
      captureException(error, {
        contexts: {
          info: {
            description: 'received 500 response code from API',
          },
          operation: operationContext,
        },
      });
    }

    // Other kinds of error codes should be handled by the consuming component
    if (error && isUnknownError(error)) {
      captureException(error, {
        contexts: {
          operation: operationContext,
        },
      });
    }

    // Notify user about the error
    // ...
  });
});