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
| Type | Description |
|---|---|
source | Connects to external data sources and creates nodes |
core | Provides 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:
| Property | Description |
|---|---|
actions | Node management actions (createNode, deleteNode) |
createNodeId | Creates unique node IDs |
createContentDigest | Creates content hashes for change detection |
options | Plugin options from configuration |
cacheDir | Directory 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
- Lifecycle Methods - Deep dive into all hooks
- Plugin Overview - How plugins are loaded