Advanced

Creating Plugins

Build your own Universal Data Layer plugin

Overview

UDL plugins extend the data layer by connecting to external data sources. A plugin is a package that exports configuration and lifecycle hooks.

Plugin Structure

my-plugin/
├── package.json
├── udl.config.ts        # Plugin configuration and hooks
├── src/
│   └── index.ts         # Plugin logic
└── ...

Basic Configuration

Create a udl.config.ts at your plugin root:

udl.config.ts
import { defineConfig } from 'universal-data-layer';

export const config = defineConfig({
  type: 'source',
  name: 'my-plugin',
  version: '1.0.0',
});

Plugin Types

TypeDescription
sourceConnects to external data sources and creates nodes
coreProvides foundational functionality

Lifecycle Hooks

onLoad

Called when the plugin is loaded. Use for validation and setup:

udl.config.ts
import { defineConfig } from 'universal-data-layer';

export const config = defineConfig({
  type: 'source',
  name: 'my-plugin',
  version: '1.0.0',
});

export function onLoad({ options }: { options?: MyPluginOptions }) {
  if (!options?.apiKey) {
    throw new Error('apiKey is required');
  }

  console.log('[my-plugin] Initialized');
}

sourceNodes

The main hook for source plugins. Fetches data and creates nodes:

udl.config.ts
import type { SourceNodesContext } from 'universal-data-layer';
import { defineConfig } from 'universal-data-layer';

interface MyPluginOptions {
  apiKey: string;
  endpoint?: string;
}

export const config = defineConfig({
  type: 'source',
  name: 'my-plugin',
  version: '1.0.0',
});

export function onLoad({ options }: { options?: MyPluginOptions }) {
  if (!options?.apiKey) {
    throw new Error('apiKey is required');
  }
}

export async function sourceNodes({
  actions,
  createNodeId,
  createContentDigest,
  options,
}: SourceNodesContext<MyPluginOptions>): Promise<void> {
  const { createNode } = actions;

  // Fetch data from your API
  const response = await fetch(`${options.endpoint}/items`, {
    headers: { Authorization: `Bearer ${options.apiKey}` },
  });
  const items = await response.json();

  // Create nodes for each item
  for (const item of items) {
    await createNode({
      id: createNodeId(`MyItem-${item.id}`),
      internal: {
        type: 'MyItem',
        contentDigest: createContentDigest(item),
      },
      ...item,
    });
  }

  console.log(`[my-plugin] Created ${items.length} nodes`);
}

SourceNodesContext

The sourceNodes hook receives a context object:

PropertyDescription
actionsNode management actions (createNode, deleteNode)
createNodeIdCreates unique node IDs
createContentDigestCreates content hashes for change detection
optionsPlugin options from configuration
cacheDirDirectory for plugin cache files

actions.createNode

Creates or updates a node:

await actions.createNode({
  id: createNodeId(`Product-${item.id}`),
  internal: {
    type: 'Product',
    contentDigest: createContentDigest(item),
  },
  name: item.name,
  price: item.price,
});

actions.deleteNode

Removes a node by ID:

await actions.deleteNode(createNodeId(`Product-${item.id}`));

Node Structure

Every node must include:

{
  id: string;              // Unique identifier
  internal: {
    type: string;          // GraphQL type name
    contentDigest: string; // Hash for change detection
  };
  // ... your fields
}

Reference Resolver

For plugins with linked content, export a referenceResolver:

udl.config.ts
import type { ReferenceResolverConfig } from 'universal-data-layer';

// Interface for your reference objects
interface MyReference {
  _myPluginRef: true;
  refId: string;
  possibleTypes?: string[];
}

export const referenceResolver: ReferenceResolverConfig = {
  id: 'my-plugin',
  markerField: '_myPluginRef',
  lookupField: 'externalId',
  isReference: (value): value is MyReference => {
    return typeof value === 'object' && value !== null && '_myPluginRef' in value;
  },
  getLookupValue: (ref: unknown) => (ref as MyReference).refId,
  getPossibleTypes: (ref: unknown) => (ref as MyReference).possibleTypes ?? [],
  priority: 10,
};

This tells UDL how to resolve linked content at query time.

Entity Key Configuration

Define how nodes are uniquely identified:

udl.config.ts
import type { EntityKeyConfig } from 'universal-data-layer';

export const entityKeyConfig: EntityKeyConfig = {
  idField: 'externalId',
  priority: 10,
};

Best Practices

Validate Options Early

export function onLoad({ options }: { options?: MyPluginOptions }) {
  const required = ['apiKey', 'endpoint'] as const;

  for (const key of required) {
    if (!options?.[key]) {
      throw new Error(`Missing required option: ${key}`);
    }
  }
}

Handle Errors Gracefully

export async function sourceNodes({ actions, options }: SourceNodesContext) {
  try {
    const items = await fetchItems(options);
    for (const item of items) {
      try {
        await actions.createNode(transformItem(item));
      } catch (error) {
        console.warn(`Failed to create node for item ${item.id}:`, error);
      }
    }
  } catch (error) {
    console.error('Failed to fetch items:', error);
    throw error;
  }
}

Use Descriptive Logging

const LOG_PREFIX = '[my-plugin]';

console.log(`${LOG_PREFIX} Fetching data...`);
console.log(`${LOG_PREFIX} Created ${count} nodes`);
console.warn(`${LOG_PREFIX} Warning: ${message}`);

Export TypeScript Types

types.ts
export interface MyPluginOptions {
  apiKey: string;
  endpoint?: string;
  timeout?: number;
}

export interface MyItem {
  id: string;
  name: string;
  // ...
}

Publishing

As npm Package

package.json
{
  "name": "udl-plugin-myservice",
  "version": "1.0.0",
  "main": "./udl.config.js",
  "types": "./udl.config.d.ts",
  "files": ["dist", "udl.config.js", "udl.config.d.ts"],
  "peerDependencies": {
    "universal-data-layer": "^1.0.0"
  }
}

As Local Plugin

udl.config.ts
import { defineConfig } from 'universal-data-layer';

export const config = defineConfig({
  plugins: [
    './plugins/my-local-plugin',
  ],
});

Example: Complete Source Plugin

udl.config.ts
import type {
  SourceNodesContext,
  ReferenceResolverConfig,
} from 'universal-data-layer';
import { defineConfig } from 'universal-data-layer';

interface TodoPluginOptions {
  apiUrl?: string;
}

interface Todo {
  id: number;
  title: string;
  completed: boolean;
  userId: number;
}

const LOG_PREFIX = '[todo-plugin]';

export const config = defineConfig({
  type: 'source',
  name: 'todo-plugin',
  version: '1.0.0',
});

export function onLoad({ options }: { options?: TodoPluginOptions }) {
  console.log(`${LOG_PREFIX} Initialized`);
}

export async function sourceNodes({
  actions,
  createNodeId,
  createContentDigest,
  options,
}: SourceNodesContext<TodoPluginOptions>): Promise<void> {
  const apiUrl = options?.apiUrl ?? 'https://jsonplaceholder.typicode.com';

  console.log(`${LOG_PREFIX} Fetching todos from ${apiUrl}...`);

  const response = await fetch(`${apiUrl}/todos`);
  const todos: Todo[] = await response.json();

  for (const todo of todos) {
    await actions.createNode({
      id: createNodeId(`Todo-${todo.id}`),
      internal: {
        type: 'Todo',
        contentDigest: createContentDigest(todo),
      },
      todoId: todo.id,
      title: todo.title,
      completed: todo.completed,
      userId: todo.userId,
    });
  }

  console.log(`${LOG_PREFIX} Created ${todos.length} Todo nodes`);
}

Next Steps