Enhancers

With a system like Uniform Canvas that stores layout information with elements from many data sources, it's important to be able to control the flow of data from the linked data sources through the layout data and into a final object that's ready to render in the system of your choice. Enhancers are how Canvas aggregates the data from linked systems with the layout information.

tip

For more details on the enhancer API, see the package reference.

Uniform supports a number of different types of enhancers.

Canvas components have parameters attached to them, which contain data values that can be of any type - text, numbers, CMS content IDs, JSON product query definitions, etc. A parameter enhancer is a function that's passed a parameter and can manipulate the parameter data into a new value. For example, a parameter enhancer might transform a text value of the ID of a CMS item into a JSON object that's the content of that item from the CMS's API.

Parameter enhancers operate within the scope of a single parameter, and can't change any other aspects of a component.

Sometimes you may want to simply add arbitrary data to a component that's not tied to a parameter, or crosses multiple parameters. A data enhancer is a function that's assigned a data key and then returns the value that will be the targeted component's data value for that key.

Often it's desirable to scope enhancers to run only for a subset of components. Enhancers are registered using the EnhancerBuilder, a fluent interface that lets you bind enhancers to specific components, specific parameter types, or all of the above. Within the EnhancerBuilder, only the first matching enhancer will be run for a given parameter or data key. This lets you craft overrides to more generic enhancers by placing them before the more generic enhancers.

const enhancers = new EnhancerBuilder() // Target the `promo` component (by public ID) with a sub-selection of enhancers .component( 'promo', (promo) => promo // target the 'foo' parameter on the 'promo' component .parameterName('foo', promoFooEnhancer) // target the 'crm' data key on the 'promo' component .data('crm', crmEnhancer) // NOTE: any non-targeted parameters will continue to use non-component-targeted enhancers if present // (for example if promo had a parameter called `towel`, it would use the towel enhancer defined below). // For data enhancers, keys get merged into the data object - with specifically component-targeted keys overriding root keys. ) // target any component parameter of type 'contentfulEntry' .parameterType('contentfulEntry', contentfulEnhancer) // target any component parameter with name 'towel' .parameterName('towel', findYourTowelEnhancer) // adds a 'joke' data key to every component .data('joke', jokeEnhancer) // enhances _any_ component parameter (that has not matched another enhancer, since it's last) .parameter(allTheThingsEnhancer)

Note that because only the first matching enhancer is run, in the above example if the promo component's foo parameter was of type contentfulEntry, the contentfulEnhancer would not be run because the promoFooEnhancer matched first.

Parameter enhancers are simple functions that take the matched parameter and return a new value for it. For example, this is a valid parameter enhancer that would change the value of any matched parameter to 👋🌎.

function helloWorldParameterEnhancer() { return '👋🌎' }

Now let's wire up the enhancer to an EnhancerBuilder and actually run it:

import { enhance, EnhancerBuilder } from '@uniformdev/canvas' // hard-coded fake composition data; this is what would come from the Canvas API const fakeComposition = { type: 'page', parameters: { title: { type: 'text', value: 'Hello World' }, headline: { type: 'text', value: 'Hello! World!' }, }, } // execute the enhancement await enhance({ composition: fakeComposition, // the enhancer builder binds your enhancer to _any_ parameter enhancers: new EnhancerBuilder().parameter(helloWorldParameterEnhancer), }) console.log(fakeComposition) // now every parameter is 👋🌎 // fakeComposition = { // type: 'page', // parameters: { // title: { type: 'text', value: '👋🌎' }, // headline: { type: 'text', value: '👋🌎' }, // }, // }

tip

The enhance function mutates the input composition. See the Immutability section below if you don't like that.

In reality you'd want to do more than just replace any parameter with a fixed string, so here's a more complex parameter enhancer that does something useful. Note that you can return promises and gain access to the parent component, parameter name, and parameter value.

const parameterEnhancer = async ({ component, parameter, parameterName }) => { console.log( `Enhancing ${component.type}::${parameterName} (${parameter.type})` ) // faux fetch from an API const apiResult = await fetch(`https://my-api/items/${parameter.value}`) if (!apiResult.ok) { // returning null will cause the parameter to be removed from the component // (return undefined if you wish to leave the value unchanged) return null } return await apiResult.json() }

Data enhancers are simple functions that return a value for a data key on the component. Data keys are properties on the component's data property. For example, this is a valid data enhancer that would set the value of its assigned data key to 👋🌎.

function helloWorldDataEnhancer() { return '👋🌎' }

Now wire up the enhancer to an EnhancerBuilder and actually run it:

import { enhance, EnhancerBuilder } from '@uniformdev/canvas' // hard-coded fake composition data; this is what would come from the Canvas API const fakeComposition = { type: 'page', } // execute the enhancement await enhance({ composition: fakeComposition, // the enhancer builder binds your data enhancer to any component's `greeting` data key enhancers: new EnhancerBuilder().data('greeting', helloWorldDataEnhancer), }) console.log(fakeComposition) // now the 'greeting' data key is 👋🌎 // fakeComposition = { // type: 'page', // data: { // greeting: '👋🌎' // }, // }

In reality you'd want to do more than just greet every component, so here's a more complex data enhancer that does something useful. Note that you can return promises and gain access to the parent component.

const dataEnhancer = async ({ component }) => { console.log(`Enhancing ${component.type}`) const apiResult = await fetch( `https://my-api/items/${component.type}?variant=${component.variant ?? ''}` ) if (!apiResult.ok) { return null } return await apiResult.json() }

The enhance function mutates the input composition. Usually this is fine, as enhancement would occur immediately on a Canvas API response that would not be used without enhancement; mutability is in this case a performance enhancement. However it's possible to simply make the enhance function behave immutably if this isn't desirable by using immer:

import produce from 'immer' const composition = { type: 'test', } const enhancedComposition = await produce(composition, (draft) => enhance({ composition: draft, enhancers: new EnhancerBuilder(), // ... context: {}, }) ) // now `composition` is unchanged

Sometimes it makes sense to compose multiple enhancers together in a chain. A common reason for this would be to modify the output of some other enhancer: perhaps your CMS enhancer fetches more data than you need, and you wish to remove it to reduce data size. Since regular enhancers only execute the first enhancer matched, this pattern impossible with the EnhancerBuilder directly; subsequent enhancers would simply be ignored. But you can use the compose() function to do exactly this within the EnhancerBuilder:

// note that compose() only works with parameter enhancers - not data enhancers, which due to their unary nature can be composed using a wrapper enhancer instead. import { compose, EnhancerBuilder } from '@uniformdev/canvas' const composeDemo = new EnhancerBuilder().parameterType( 'contentfulEntry', // the `contentfulEnhancer` runs first, // then the `contentfulRichTextToHtmlEnhancer` takes its results and transforms rich text fields to HTML (from JSON) // finally the `removeSysEnhancer` deletes the `sys` property from the HTML-enhanced result to reduce data size. compose( contentfulEnhancer, contentfulRichTextToHtmlEnhancer, removeSysEnhancer ) )

tip

Composing enhancers together with the compose() function is different than a Canvas composition (a collection of components in a layout) despite the shared language.

Another tactic for composing enhancers is multi-phasic enhancement. With this pattern you take advantage of the fact that the enhance() function is simply a function that mutates a composition - there is no reason you can't enhance more than once in sequence, on the same composition object. Choosing this technique is appropriate when you want to split enhancement itself into several phases, for example preprocessing, fetch data, postprocessing. Sometimes this can make composed enhancers make more sense.

// hard-coded fake composition data; this is what would come from the Canvas API const fakeComposition = { type: 'page', parameters: { foo: { type: 'test', value: 'It is a good day to', }, }, } // execute enhancement phase 1 await enhance({ composition: fakeComposition, // now all parameter values have 'hello' appended enhancers: new EnhancerBuilder().parameter( ({ parameter }) => parameter.value + ' hello' ), }) // execute enhancement phase 2, which acts on the result of phase 1 await enhance({ composition: fakeComposition, // now all parameter values have 'world' appended enhancers: new EnhancerBuilder().parameter( ({ parameter }) => parameter.value + ' world' ), }) // ... any more enhancement phases needed // fakeComposition = { // type: 'page', // parameters: { // foo: { // type: 'test', // value: 'It is a good day to hello world' // } // } // };

Asynchronous enhancers are allowed to run in parallel where possible. Because only the first matching enhancer is executed, this enables the enhancer engine to queue all the necessary data fetches at once, without creating a waterfall effect that would impact performance. Advanced enhancers can also take advantage of batching. A batched enhancer has a different shape than a simple function, as it stores up all the things it needs to fetch during a walk of the components in the composition and then once the walk is complete the batch function is invoked to enable fetching multiple data results with one request for APIs that support that. An example of this is the Contentful enhancer; it can find all referenced Contentful entry IDs on a composition and then make a single call to Contentful requesting all the referenced IDs in one query.

import { ComponentParameterEnhancerOptions, createBatchEnhancer, UniqueBatchEntries, } from '@uniformdev/canvas' // the input type of the batch enhancer (type of the value from Canvas API) type EnhancerInputType = string // the output type of the batch enhancer type EnhancerResultType = { id: string; value: string } // fake implementation of a function that would fetch a batch of things by ID from somewhere, like a CMS async function fetchMultipleIdsFromSomewhere( ids: string[] ): Promise<Array<EnhancerResultType>> { return ids.map((id) => ({ id, value: `${id}-value` })) } // createBatchEnhancer() handles collecting each matching parameter that is enhanced and giving you an array to process const batchEnhancer = createBatchEnhancer< ComponentParameterEnhancerOptions<string>, EnhancerResultType | null >({ handleBatch: async (queuedTasks) => { // queuedTasks is an array of all the components that matched the enhancer, with `args` (arguments to the enhancer), // and resolve/reject functions to complete that queued task. // `UniqueBatchEntries` helps you filter out multiple values that may have the same data need, // so you can fetch them once // (i.e. if you have two component parameters that point to the same Contentful entry) const uniqueQueuedTasks = new UniqueBatchEntries( queuedTasks, // the value to determine the batch data uniqueness (task) => task.parameter.value ) // do something to fetch all the queued values const uniqueValues = Object.keys(uniqueQueuedTasks.groups) const results = await fetchMultipleIdsFromSomewhere(uniqueValues) // receive the results and close the tasks with data for (const result of results) { uniqueQueuedTasks.resolveKey(result.id, result) } // if any of the queued tasks did not receive a value, resolve the rest with a null value // for example broken links to CMS entries that no longer exist // (this will remove the parameter from the component) uniqueQueuedTasks.resolveRemaining(null) // NOTE: if any unresolved tasks exist (resolve or reject was not called), an error will be thrown to prevent an infinite wait. }, })

Under load an enhancer can potentially make a large number of parallel requests to a remote API. Most APIs have rate limits which prevent this, but it's even better to avoid being rate limited entirely. As such, enhancers support providing a limit policy which allows control over the flow of requests to data source as well as error retrying logic. A limit policy is an asynchronous function that's passed the enhancer function to run as a parameter. Every enhance call for that enhancer is routed through the limit policy if it's defined, which enables you to use any logic you desire to control the flow of requests to the remote API. Uniform provides a default createLimitPolicy function that uses p-limit and p-retry to handle rate limiting and retrying. Enhancers provided by Uniform will define a default limit policy internally based on the API they're calling, but also accept a custom limit policy passed into their creation function. This is useful as it allows you to both customize the limit policy (if you have a high rate limit plan) but also to tie several enhancers with different configurations to the same limit policy to prevent them from having parallel rate limits.

import { createLimitPolicy } from '@uniformdev/canvas' import { createContentfulEnhancer } from '@uniformdev/canvas-contentful' // simple default limit policy const limitPolicy = createLimitPolicy({ // configure up to 2 retries (exponential backoff) // see p-retry docs for options; pass false to disable retrying retry: { retries: 2, }, // configure up to 10 requests per second throttling // see p-throttle docs for options; pass false to disable throttling throttle: { interval: 1000, limit: 10, }, }) const enhancer = { enhanceOne: ({ parameter }) => { return parameter.value + ' is enhanced' }, // this enhancer will now retry exceptions/rejections twice, and not call enhanceOne more than 10x per second limitPolicy: limitPolicy, } // you can pass the limit policy to Uniform enhancers such as this contentful enhancer as well // since there is one instance of limitPolicy, this would _share_ the throttling with the above enhancer // (i.e. no more than 10x per second between the two) const contentfulEnhancer = createContentfulEnhancer({ // ...other options limitPolicy: limitPolicy, })

You can also implement a custom limit policy if you need advanced functionality. Here is a simple example which uses p-limit, which allows only a certain number of promises in flight at any given time.

import pLimit from 'p-limit' // custom limit policy using p-limit (up to 2 promises active at a time) const limit = pLimit(2) const plimitPolicy = (enhanceFunc) => limit(enhanceFunc) const enhancer = { enhanceOne: ({ parameter }) => { return parameter.value + ' is enhanced' }, // this enhancer will now have at most 2 promises in flight at any given time limitPolicy: plimitPolicy, } // technically `limit` could itself be used as the policy here; // the explicit `plimitPolicy` function is used for clarity

The enhance function supports passing arbitrary context data to the enhancers. This can be used to provide data from the top level down to enhancers without relying on closures. The preview property is always present on the context as most enhancers need to know if they should fetch preview data or not, but other properties can be added to the context as needed.

function contextyEnhancer({ context }) { return context.greeting; } await enhance({ composition, enhancers: new EnhancerBuilder().parameter(contextyEnhancer) context: { preview: false, greeting: '👋🌎', }, });

tip

Many enhancers provide some sort of function to enable customizing their data fetching options. These functions are also passed the enhancer context, which enables tasks like localization of the data query based on application state.

Data enhancers can be executed anywhere you possess composition data, not just within a terminal web application. Uniform has also developed an enhancer proxy architecture that can be used if you wish to separate enhancement from your applications, for example to support identical enhancement configuration for several applications, apply custom cache routines, or separate development work between frontend and back end teams. In an enhancer proxy configuration, data flows more like this:

The enhancer proxy acts as a purpose built reverse proxy server that performs enhancement on the composition data before forwarding it on to the consumer. It can be run anywhere where Node.js can run - in a container, on a server-less function, or on bare metal.